diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index e62f71082..2cb697980 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -45,7 +45,7 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install libx11-dev libxtst-dev libadwaita-1-dev libgtk-4-dev + sudo apt-get install libx11-dev libxtst-dev libadwaita-1-dev libgtk-4-dev libdbus-1-dev - name: Install macOS dependencies if: runner.os == 'macOS' run: brew install gtk4 libadwaita imagemagick diff --git a/Cargo.lock b/Cargo.lock index d3a6a96db..c678d13de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -55,6 +61,15 @@ dependencies = [ "libc", ] +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + [[package]] name = "anstream" version = "1.0.0" @@ -111,6 +126,27 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -147,7 +183,7 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 1.0.69", @@ -241,6 +277,17 @@ dependencies = [ "syn", ] +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -319,12 +366,24 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -452,6 +511,21 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim 0.8.0", + "textwrap", + "unicode-width", + "vec_map", +] + [[package]] name = "clap" version = "4.6.0" @@ -471,7 +545,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -492,6 +566,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -592,12 +675,27 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -662,6 +760,37 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-codegen" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a49da9fdfbe872d4841d56605dc42efa5e6ca3291299b87f44e1cde91a28617c" +dependencies = [ + "clap 2.34.0", + "dbus", + "xml-rs", +] + +[[package]] +name = "dbus-tree" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f456e698ae8e54575e19ddb1f9b7bce2298568524f215496b248eb9498b4f508" +dependencies = [ + "dbus", +] + [[package]] name = "der" version = "0.7.10" @@ -681,7 +810,7 @@ checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" dependencies = [ "asn1-rs", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -849,6 +978,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.4.1" @@ -876,6 +1011,21 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "ff" version = "0.13.1" @@ -908,6 +1058,22 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -1145,6 +1311,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1415,6 +1591,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1436,6 +1623,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -1653,6 +1849,20 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + [[package]] name = "indexmap" version = "2.13.1" @@ -1699,6 +1909,7 @@ dependencies = [ name = "input-capture" version = "0.3.0" dependencies = [ + "arboard", "ashpd", "async-trait", "bitflags 2.11.0", @@ -1709,11 +1920,16 @@ dependencies = [ "futures-core", "input-event", "keycode", + "lan-mouse-ipc", "libc", "log", "memmap", + "objc2", + "objc2-app-kit", + "objc2-foundation", "once_cell", "reis", + "serde_json", "tempfile", "thiserror 2.0.18", "tokio", @@ -1723,12 +1939,14 @@ dependencies = [ "wayland-protocols-wlr", "windows 0.61.3", "x11", + "x11rb", ] [[package]] name = "input-emulation" version = "0.3.0" dependencies = [ + "arboard", "ashpd", "async-trait", "bitflags 2.11.0", @@ -1875,11 +2093,23 @@ dependencies = [ "libc", ] +[[package]] +name = "ksni" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4934310bdd016e55725482b8d35ac0c16fd058c1b955d8959aa2d953b918c85b" +dependencies = [ + "dbus", + "dbus-codegen", + "dbus-tree", + "thiserror 1.0.69", +] + [[package]] name = "lan-mouse" version = "0.10.0" dependencies = [ - "clap", + "clap 4.6.0", "env_logger", "futures", "hostname", @@ -1918,7 +2148,7 @@ dependencies = [ name = "lan-mouse-cli" version = "0.2.0" dependencies = [ - "clap", + "clap 4.6.0", "futures", "lan-mouse-ipc", "thiserror 2.0.18", @@ -1933,6 +2163,8 @@ dependencies = [ "glib-build-tools", "gtk4", "hostname", + "input-capture", + "ksni", "lan-mouse-ipc", "libadwaita", "log", @@ -2011,6 +2243,15 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libgit2-sys" version = "0.18.3+1.9.2" @@ -2146,6 +2387,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -2158,6 +2409,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "netdev" version = "0.43.0" @@ -2277,6 +2538,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "notify" version = "8.2.0" @@ -2378,6 +2648,49 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -2391,6 +2704,54 @@ dependencies = [ "objc2", ] +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-core-wlan" version = "0.3.2" @@ -2424,6 +2785,28 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-security" version = "0.3.2" @@ -2496,6 +2879,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "p256" version = "0.13.2" @@ -2604,6 +2997,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2645,6 +3049,19 @@ dependencies = [ "time", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -2653,7 +3070,7 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi", + "hermit-abi 0.5.2", "pin-project-lite", "rustix 1.1.4", "windows-sys 0.61.2", @@ -2747,6 +3164,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.39.2" @@ -2926,7 +3355,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -3169,6 +3598,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -3227,6 +3662,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + [[package]] name = "strsim" version = "0.11.1" @@ -3314,6 +3755,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3354,6 +3804,20 @@ dependencies = [ "syn", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -3573,6 +4037,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "typenum" version = "1.19.0" @@ -3622,6 +4097,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -3685,6 +4166,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "version-compare" version = "0.2.1" @@ -3952,6 +4439,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -4453,6 +4946,24 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.3" @@ -4469,6 +4980,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -4491,13 +5019,19 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry", "rusticata-macros", "thiserror 1.0.69", "time", ] +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "yasna" version = "0.5.2" @@ -4686,6 +5220,21 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.10.0" diff --git a/README.md b/README.md index 78834c3f4..ca6902f52 100644 --- a/README.md +++ b/README.md @@ -415,6 +415,50 @@ port = 4242 Where `left` can be either `left`, `right`, `top` or `bottom`. +## Clipboard Sync + +Optional bi-directional clipboard text sync between paired peers. Disabled by default; enable per pair from the GUI or by editing `config.toml`. + +### Per-pair gates + +Each direction is independently gated. Both ends must opt in for clipboard text to flow that way: + +- **Outgoing**: `clipboard_send` on each `[[clients]]` entry — when true, copies on this device propagate to that peer. +- **Incoming**: `clipboard_receive` on each `[authorized_fingerprints]` entry — when true, clipboard text from that peer is applied to this device's clipboard. + +Defaults are `false`. Existing pairs see no behavior change on upgrade. + +### Limitations + +- **Text only**. No images, files, RTF/HTML, or multi-format pasteboard. +- **4 KiB max payload** (originator fingerprint + content + length prefixes, conservative against typical UDP MTU). Larger copies are dropped at the sender with a debug log; the local clipboard is unaffected. +- **UTF-8 only**. Invalid byte sequences are rejected. +- Polling-based change detection (no native pasteboard event API exists on macOS), so very rapid recopies within a single poll tick may be coalesced. + +### App-source suppression + +A per-OS suppression list lets you mark applications whose clipboard contents must never propagate (password managers, sensitive editors, Apple Messages, etc.). The frontmost app at the moment of copy is checked against the host-OS slot of `clipboard_suppress_apps`: + +```toml +[clipboard_suppress_apps] +macos = ["com.1password.1password", "com.apple.MobileSMS"] +windows = ["1Password.exe"] +linux_wayland = ["org.keepassxc.KeePassXC"] +linux_x11 = ["KeePassXC"] +``` + +Each machine reads/writes only the slot matching its own OS — the others round-trip untouched, so a single config can be shared across machines (dotfiles / Syncthing / etc.) without any one machine bleeding identifiers into the wrong section. + +The GUI exposes this via **Clipboard Privacy → Manage**: a searchable picker of the apps currently running on this device (with name + icon), plus a list of currently-suppressed apps with one-click removal. + +### Automatic suppression on macOS + +In addition to the user list, macOS clipboards stamped with the [`org.nspasteboard.ConcealedType`](https://nspasteboard.org/) UTI (the community convention used by 1Password, Bitwarden, KeePassXC, and most modern password managers) are auto-suppressed without needing a manual entry. + +### Loop prevention for 3+ peer fan-out + +The wire frame carries the originator's TLS certificate fingerprint. The Service tracks `(originator_fp, content_hash)` for 1 second to prevent rebroadcast cycles in N-peer topologies (A→B→C won't echo back to A). + ## Roadmap - [x] Graphical frontend (gtk + libadwaita) - [x] respect xdg-config-home for config file location. @@ -429,7 +473,7 @@ Where `left` can be either `left`, `right`, `top` or `bottom`. - [ ] X11 Input Capture - [ ] Latency measurement and visualization - [ ] Bandwidth usage measurement and visualization -- [ ] Clipboard support +- [x] Clipboard support (text, per-pair, with app-source suppression) ## Detailed OS Support diff --git a/flake.nix b/flake.nix index 5cf260f1a..19058ce25 100644 --- a/flake.nix +++ b/flake.nix @@ -83,6 +83,7 @@ ++ lib.optionals pkgs.stdenv.isLinux [ libX11 libXtst + dbus ]; env.RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library"; }; diff --git a/input-capture/Cargo.toml b/input-capture/Cargo.toml index bcdeb3246..92e2f409e 100644 --- a/input-capture/Cargo.toml +++ b/input-capture/Cargo.toml @@ -28,6 +28,9 @@ tokio = { version = "1.32.0", features = [ once_cell = "1.19.0" async-trait = "0.1.81" tokio-util = "0.7.11" +arboard = { version = "3.4", features = ["wayland-data-control"] } +lan-mouse-ipc = { path = "../lan-mouse-ipc", version = "0.2.0" } +serde_json = "1.0" [target.'cfg(all(unix, not(target_os="macos")))'.dependencies] @@ -46,6 +49,11 @@ ashpd = { version = "0.13.9", default-features = false, features = [ "tokio", ], optional = true } reis = { version = "0.5.0", features = ["tokio"], optional = true } +# Used unconditionally on Linux for frontmost-app detection in +# `frontmost_app::linux_x11`. Already pulled in transitively via +# arboard's wayland-data-control feature, so listing it here just +# pins it as a direct dep. +x11rb = "0.13" [target.'cfg(target_os="macos")'.dependencies] core-graphics = { version = "0.25.0", features = ["highsierra"] } @@ -54,6 +62,12 @@ core-foundation-sys = "0.8.6" libc = "0.2.155" keycode = "1.0.0" bitflags = "2.6.0" +# Used by `frontmost_app::macos` and `clipboard::is_concealed_clipboard` +# to call NSWorkspace / NSPasteboard. Already pulled in transitively +# via arboard, so listed here as direct deps. +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSWorkspace", "NSRunningApplication", "NSPasteboard", "NSImage", "NSImageRep", "NSBitmapImageRep", "NSGraphics"] } +objc2-foundation = { version = "0.3", features = ["NSString", "NSArray", "NSData", "NSDictionary", "NSGeometry", "NSURL"] } [target.'cfg(windows)'.dependencies] windows = { version = "0.61.2", features = [ diff --git a/input-capture/src/clipboard.rs b/input-capture/src/clipboard.rs new file mode 100644 index 000000000..6b8b4abf9 --- /dev/null +++ b/input-capture/src/clipboard.rs @@ -0,0 +1,533 @@ +use arboard::Clipboard; +use input_event::{ClipboardEvent, Event}; +use lan_mouse_ipc::AppIdent; +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc::{self, Receiver, Sender}; +use tokio::task::spawn_blocking; +use tokio::time::interval; + +use crate::frontmost_app; +use crate::{CaptureError, CaptureEvent}; + +/// Shared, mutable suppression list. Service owns the canonical +/// `Arc>>` and clones the handle into each +/// freshly-spawned [`ClipboardMonitor`]; mutations from +/// `Add/RemoveSuppressedApp` requests take effect immediately on +/// the next clipboard poll. +pub type SuppressionList = Arc>>; + +/// Clipboard monitor that watches for clipboard changes +pub struct ClipboardMonitor { + event_rx: Receiver, + _event_tx: Sender, + last_content: Arc>>, + last_change: Arc>>, + enabled: Arc>, +} + +impl ClipboardMonitor { + /// Construct without app-source suppression. Equivalent to + /// `with_suppression(Default::default())` — provided as a + /// convenience for callers that don't care about suppression + /// (CLI smoke tests, future per-platform unit tests). + pub fn new() -> Result { + Self::with_suppression(SuppressionList::default()) + } + + /// Construct a monitor that consults `suppression` on every + /// detected clipboard change and skips both the emit AND the + /// `last_content` update when [`frontmost_app::frontmost_app()`] + /// reports an app whose [`AppIdent`] is in the list. Skipping + /// the `last_content` update is intentional: it keeps the + /// monitor "blind" to the suppressed content so a later non- + /// suppressed copy of the same string still emits normally. + pub fn with_suppression(suppression: SuppressionList) -> Result { + let (event_tx, event_rx) = mpsc::channel(16); + let last_content: Arc>> = Arc::new(Mutex::new(None)); + let last_change: Arc>> = Arc::new(Mutex::new(None)); + let enabled = Arc::new(Mutex::new(true)); + + let last_content_clone = last_content.clone(); + let last_change_clone = last_change.clone(); + let enabled_clone = enabled.clone(); + let event_tx_clone = event_tx.clone(); + let suppression_clone = suppression.clone(); + + // Spawn monitoring task. Cadence: 100 ms on macOS (cheap + // because `pasteboard_change_count_advanced` short-circuits + // 99% of ticks via a single integer compare); 500 ms + // elsewhere (no cheap precheck, full content read every + // tick). Tighter cadence on macOS shrinks the + // frontmost-app suppression race window from 500 ms → + // 100 ms — the user would have to Cmd+Tab faster than + // human reaction time after copying to defeat the check. + #[cfg(target_os = "macos")] + const POLL_MS: u64 = 100; + #[cfg(not(target_os = "macos"))] + const POLL_MS: u64 = 500; + + tokio::spawn(async move { + let mut check_interval = interval(Duration::from_millis(POLL_MS)); + + loop { + check_interval.tick().await; + + // Check if enabled + let is_enabled = { + let enabled = enabled_clone.lock().unwrap(); + *enabled + }; + + if !is_enabled { + continue; + } + + // macOS: skip the expensive content-read entirely + // when NSPasteboard.changeCount hasn't advanced + // since last tick. This is the canonical clipboard- + // monitor optimization (Maccy / Alfred / Paste all + // do it). Single integer compare per idle tick. + #[cfg(target_os = "macos")] + if !pasteboard_change_count_advanced() { + continue; + } + + // Read clipboard in blocking task + let last_content_clone2 = last_content_clone.clone(); + let last_change_clone2 = last_change_clone.clone(); + let event_tx_clone2 = event_tx_clone.clone(); + let suppression_clone2 = suppression_clone.clone(); + + let _ = spawn_blocking(move || { + // Create clipboard instance + let mut clipboard = match Clipboard::new() { + Ok(c) => c, + Err(e) => { + log::debug!("Failed to create clipboard: {e}"); + return; + } + }; + + // Get current clipboard text + let current_text = match clipboard.get_text() { + Ok(text) => { + log::trace!("Clipboard text read: {} bytes", text.len()); + text + } + Err(e) => { + // Clipboard might be empty or contain non-text data + log::trace!("Failed to get clipboard text: {e}"); + return; + } + }; + + // Check if content changed + let mut last_content = last_content_clone2.lock().unwrap(); + let mut last_change = last_change_clone2.lock().unwrap(); + + let last_change_elapsed = last_change.map(|t| t.elapsed()); + // App-source suppression. Frontmost-app lookup + // and the macOS concealed-pasteboard probe both + // run only after we've decided the content + // actually changed (see PollDecision::classify). + // Pre-compute the inputs here so the classifier + // stays a pure function. + let needs_decision = PollDecision::content_might_emit( + ¤t_text, + last_content.as_deref(), + last_change_elapsed, + ); + let (concealed, suppressed) = if needs_decision { + let concealed = is_concealed_clipboard(); + let suppressed = if concealed { + None + } else { + is_suppressed(&suppression_clone2) + }; + (concealed, suppressed) + } else { + (false, None) + }; + let decision = PollDecision::classify( + ¤t_text, + last_content.as_deref(), + last_change_elapsed, + concealed, + suppressed.is_some(), + ); + // Always advance `last_content` after a state- + // changing decision (Suppressed or Emit), + // regardless of which suppression branch + // fired. The earlier "blind to suppressed + // value" approach left `last_content` at the + // previous emitted value, which made every + // subsequent 500ms poll see the SAME + // suppressed content as "changed" and re-run + // the suppression check. Any focus shift + // between polls (user alt-tabs after copying + // a password) would then find a non- + // suppressed frontmost and leak the password. + // PollDecision::advances_state pins this + // contract — see the unit tests at the + // bottom of this file. + if decision.advances_state() { + *last_content = Some(current_text.clone()); + *last_change = Some(Instant::now()); + } + match decision { + PollDecision::Unchanged => {} + PollDecision::Debounced => { + log::trace!("Clipboard changed but debounced (too recent)"); + } + PollDecision::Suppressed if concealed => { + log::debug!("clipboard change suppressed (concealed pasteboard type)"); + } + PollDecision::Suppressed => { + if let Some(app) = suppressed.as_ref() { + log::debug!( + "clipboard change suppressed (frontmost app `{}`)", + app.label() + ); + } + } + PollDecision::Emit => { + log::info!("Clipboard changed, length: {} bytes", current_text.len()); + let event = CaptureEvent::Input(Event::Clipboard( + ClipboardEvent::Text(current_text), + )); + let _ = event_tx_clone2.blocking_send(event); + } + } + }) + .await; + } + }); + + Ok(Self { + event_rx, + _event_tx: event_tx, + last_content, + last_change, + enabled, + }) + } + + /// Receive the next clipboard event + pub async fn recv(&mut self) -> Option { + self.event_rx.recv().await + } + + /// Enable clipboard monitoring + pub fn enable(&self) { + let mut enabled = self.enabled.lock().unwrap(); + *enabled = true; + log::info!("Clipboard monitoring enabled"); + } + + /// Disable clipboard monitoring + pub fn disable(&self) { + let mut enabled = self.enabled.lock().unwrap(); + *enabled = false; + log::info!("Clipboard monitoring disabled"); + } + + /// Update the last known clipboard content (called when we set the clipboard) + /// This prevents detecting our own clipboard changes as external changes + pub fn update_last_content(&self, content: String) { + let mut last_content = self.last_content.lock().unwrap(); + let mut last_change = self.last_change.lock().unwrap(); + *last_content = Some(content); + *last_change = Some(Instant::now()); + } +} + +/// macOS password managers stamp `org.nspasteboard.ConcealedType` +/// on the general pasteboard so cooperating apps skip syncing +/// passwords. Returns `true` when that UTI is present on the +/// current pasteboard contents. Always `false` on non-macOS. +/// +/// This is the standard "nspasteboard.com" convention — see +/// . +#[cfg(target_os = "macos")] +fn is_concealed_clipboard() -> bool { + use objc2_app_kit::NSPasteboard; + use objc2_foundation::NSString; + + let pasteboard = NSPasteboard::generalPasteboard(); + let Some(types) = pasteboard.types() else { + return false; + }; + let concealed = NSString::from_str("org.nspasteboard.ConcealedType"); + types.iter().any(|t| t.isEqualToString(&concealed)) +} + +#[cfg(not(target_os = "macos"))] +fn is_concealed_clipboard() -> bool { + false +} + +/// If [`frontmost_app::frontmost_app()`] reports an app whose ident +/// is in the suppression list, return that ident. Otherwise return +/// `None`. Snapshotting the lock guard short keeps us from holding +/// the mutex across the platform call (which on Linux can shell +/// out to hyprctl/swaymsg). +fn is_suppressed(list: &SuppressionList) -> Option { + let snapshot: Vec = { + let Ok(guard) = list.lock() else { + log::debug!("clipboard suppression: lock poisoned"); + return None; + }; + if guard.is_empty() { + log::debug!("clipboard suppression: list is empty"); + return None; + } + guard.iter().cloned().collect() + }; + let active = frontmost_app::frontmost_app(); + log::debug!("clipboard suppression check: list={snapshot:?} active={active:?}"); + let active = active?; + snapshot.into_iter().find(|s| s.matches(&active)) +} + +/// Returns `true` the first time it's called, and on every later +/// call where `NSPasteboard.generalPasteboard.changeCount` has +/// advanced since the previous call. Used as a cheap precheck so +/// the polling loop only invokes `arboard::Clipboard::get_text` +/// (which round-trips through `pboardd` via XPC) on ticks where +/// the pasteboard actually mutated. +/// +/// `changeCount` reads are an Apple-blessed background-thread +/// operation — the property is designed for exactly this kind of +/// polling. No autorelease pool needed: the return value is a +/// primitive `NSInteger`, not an Objective-C object. +#[cfg(target_os = "macos")] +fn pasteboard_change_count_advanced() -> bool { + use objc2_app_kit::NSPasteboard; + use std::sync::atomic::{AtomicI64, Ordering}; + + // Initial sentinel `i64::MIN` ensures the first call always + // returns `true` so we read once at startup to seed the + // diff-against-`last_content` machinery downstream. + static LAST: AtomicI64 = AtomicI64::new(i64::MIN); + + let pb = NSPasteboard::generalPasteboard(); + let now = pb.changeCount() as i64; + let prev = LAST.swap(now, Ordering::Relaxed); + prev != now +} + +/// Outcome of a single clipboard-poll cycle. Pulled into a pure +/// function so the focus-race invariant — "advance `last_content` +/// on every state-changing decision, even when we suppress" — is +/// pinned by unit tests rather than implicit in the closure +/// body. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PollDecision { + /// Current text equals last recorded — nothing to do. + Unchanged, + /// Content changed but the 200 ms debounce window from the + /// previous advance hasn't elapsed yet. + Debounced, + /// Content changed, debounce passed, but suppression + /// (concealed pasteboard or app-list match) tells us not to + /// emit. State must still advance so subsequent polls don't + /// re-check the same content and leak on a focus shift. + Suppressed, + /// Content changed, debounce passed, suppression cleared. + /// Caller emits the event and advances state. + Emit, +} + +impl PollDecision { + /// Cheap pre-flight: `true` when the next call to + /// [`PollDecision::classify`] could return `Suppressed` or + /// `Emit`. The poll loop uses this to skip the relatively + /// expensive `is_concealed_clipboard()` / `frontmost_app()` / + /// `is_suppressed()` calls when the content hasn't changed + /// or the debounce window blocks emission anyway. + fn content_might_emit( + current_text: &str, + last_content: Option<&str>, + last_change_elapsed: Option, + ) -> bool { + if last_content == Some(current_text) { + return false; + } + last_change_elapsed.is_none_or(|d| d > Duration::from_millis(200)) + } + + /// Decide what to do with this poll cycle. Pure function — no + /// I/O, no global state — so the focus-race invariant is + /// expressible as a series of `assert_eq!` calls. + fn classify( + current_text: &str, + last_content: Option<&str>, + last_change_elapsed: Option, + concealed: bool, + suppressed_match: bool, + ) -> Self { + if last_content == Some(current_text) { + return Self::Unchanged; + } + let debounce_passed = last_change_elapsed.is_none_or(|d| d > Duration::from_millis(200)); + if !debounce_passed { + return Self::Debounced; + } + if concealed || suppressed_match { + Self::Suppressed + } else { + Self::Emit + } + } + + /// True when this decision must advance `last_content` / + /// `last_change` in the caller. Both `Suppressed` and `Emit` + /// are state-changing — the focus-race fix lives here. + fn advances_state(self) -> bool { + matches!(self, Self::Suppressed | Self::Emit) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_unchanged_when_text_matches_last_content() { + let d = PollDecision::classify( + "secret", + Some("secret"), + Some(Duration::from_secs(60)), + false, + false, + ); + assert_eq!(d, PollDecision::Unchanged); + assert!(!d.advances_state()); + } + + #[test] + fn classify_debounced_when_recent_change() { + // Content differs but only 100ms since last advance — + // hold off so a peer's set_clipboard echo doesn't bounce + // back as a fresh local emit. + let d = PollDecision::classify( + "new", + Some("old"), + Some(Duration::from_millis(100)), + false, + false, + ); + assert_eq!(d, PollDecision::Debounced); + assert!(!d.advances_state()); + } + + #[test] + fn classify_emit_when_changed_unsuppressed() { + let d = PollDecision::classify( + "new", + Some("old"), + Some(Duration::from_secs(1)), + false, + false, + ); + assert_eq!(d, PollDecision::Emit); + assert!(d.advances_state()); + } + + #[test] + fn classify_emit_on_first_change() { + // No prior advance: the first poll always falls through + // to Emit (assuming nothing's suppressed) so the very + // first clipboard read after startup is broadcast. + let d = PollDecision::classify("first", None, None, false, false); + assert_eq!(d, PollDecision::Emit); + } + + #[test] + fn classify_suppressed_when_concealed() { + let d = PollDecision::classify( + "password", + Some("plain"), + Some(Duration::from_secs(1)), + true, + false, + ); + assert_eq!(d, PollDecision::Suppressed); + } + + #[test] + fn classify_suppressed_when_app_list_matches() { + let d = PollDecision::classify( + "password", + Some("plain"), + Some(Duration::from_secs(1)), + false, + true, + ); + assert_eq!(d, PollDecision::Suppressed); + } + + /// The focus-race invariant: a Suppressed decision MUST tell + /// the caller to advance `last_content`. If this assert + /// fails, the regression we hit live (1Password password + /// leaks on Ghostty alt-tab after copy) is back. + #[test] + fn suppressed_decision_advances_state() { + let d = PollDecision::classify( + "password", + Some("plain"), + Some(Duration::from_secs(1)), + false, + true, + ); + assert!( + d.advances_state(), + "Suppressed must advance last_content; otherwise the \ + same content gets re-checked on the next poll and a \ + focus shift between polls will leak the password." + ); + } + + /// Companion: Unchanged and Debounced must NOT advance state. + /// Advancing on Debounced would defeat the 200 ms window and + /// turn every peer-driven clipboard sync into a fresh local + /// emit (echo loop). + #[test] + fn non_state_changing_decisions_do_not_advance() { + for (current, last, elapsed) in [ + ("same", Some("same"), Some(Duration::from_secs(1))), + ("new", Some("old"), Some(Duration::from_millis(50))), + ] { + let d = PollDecision::classify(current, last, elapsed, false, false); + assert!( + !d.advances_state(), + "{d:?} should not advance state (current={current:?} last={last:?} elapsed={elapsed:?})" + ); + } + } + + #[test] + fn content_might_emit_skips_unchanged_and_debounced() { + // Used by the poll loop to short-circuit the expensive + // suppression probes. Same matrix as `classify`'s "no + // emission possible" cases. + assert!(!PollDecision::content_might_emit( + "same", + Some("same"), + None + )); + assert!(!PollDecision::content_might_emit( + "new", + Some("old"), + Some(Duration::from_millis(50)) + )); + assert!(PollDecision::content_might_emit( + "new", + Some("old"), + Some(Duration::from_secs(1)) + )); + assert!(PollDecision::content_might_emit("new", None, None)); + } +} diff --git a/input-capture/src/desktop_entries.rs b/input-capture/src/desktop_entries.rs new file mode 100644 index 000000000..17ac8f82b --- /dev/null +++ b/input-capture/src/desktop_entries.rs @@ -0,0 +1,628 @@ +//! Discover and parse freedesktop `.desktop` files for the +//! clipboard-suppression picker on Linux. +//! +//! Two responsibilities: +//! +//! 1. Build a map from a runtime identifier (Hyprland `class`, +//! Sway `app_id`, X11 `WM_CLASS` — all lowercased) to a +//! [`DesktopAppMetadata`] record. The map is keyed both by the +//! `.desktop` filename stem and by `StartupWMClass=` so the +//! common cases — `firefox.desktop` matching a `firefox` class +//! and `1password.desktop` (StartupWMClass=`1Password`) matching +//! a `1Password` class — both resolve. +//! +//! 2. Resolve a freedesktop icon *name* (e.g. `firefox`) into +//! raster bytes that GTK can load via `gdk::Texture::from_bytes`. +//! PNG is preferred; SVG falls through to gdk-pixbuf's librsvg +//! loader on the GTK side. The picker target is ~64–128 px so +//! we prefer those sizes and degrade gracefully when only +//! larger or scalable variants exist. +//! +//! Scope is intentionally narrow: this module exists to make the +//! suppression-list modal show "Firefox" with its real icon +//! instead of `firefox` as bare text. It does NOT replace the +//! runtime suppression check itself, which still keys on +//! [`crate::frontmost_app::frontmost_app`] returning a host-OS +//! identifier. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +/// One installed application learned from a `.desktop` file. +#[derive(Debug, Clone)] +pub struct DesktopAppMetadata { + /// `Name=` field — the human-readable display name. Falls back + /// to the .desktop filename stem when `Name=` is absent or + /// empty. + pub display_name: String, + /// `Icon=` field. May be a bare freedesktop icon name (typical: + /// `firefox`) or an absolute path. `None` if the .desktop file + /// has no `Icon=` line or the value is empty. + pub icon_name: Option, +} + +/// Result of [`discover_apps`]: two indices over the same set of +/// installed `.desktop` entries. +/// +/// - `by_identifier`: keyed by lowercased filename stem AND +/// `StartupWMClass` so a runtime class / app_id resolves directly +/// to its installed metadata (the common case). +/// - `by_webapp_host`: keyed by lowercased hostname extracted from +/// any `https?://…` token in the entry's `Exec=` line. Used to +/// resolve Chrome / Chromium `--app=URL` PWAs whose runtime class +/// is `chrome-__-`. The omarchy +/// `omarchy-launch-webapp ` flow falls into this bucket: the +/// `.desktop` has Name + Icon + Exec=URL but no `StartupWMClass`, +/// so the direct index can't see it. +#[derive(Debug, Default)] +pub struct AppDirectory { + pub by_identifier: HashMap, + pub by_webapp_host: HashMap, +} + +impl AppDirectory { + /// Resolve a runtime class / app_id to its installed metadata. + /// Tries the direct index first, then falls back to parsing + /// the class as a Chrome `--app=` PWA and matching the host + /// against `by_webapp_host`. Returns `None` when nothing + /// matches; the caller renders the raw identifier as the + /// display name. + pub fn lookup(&self, identifier: &str) -> Option { + let lower = identifier.to_lowercase(); + if let Some(m) = self.by_identifier.get(&lower) { + return Some(m.clone()); + } + if let Some(host) = parse_chrome_pwa_host(&lower) { + if let Some(m) = self.by_webapp_host.get(host) { + return Some(m.clone()); + } + } + None + } +} + +/// Scan every standard `.desktop` location, returning the two-way +/// [`AppDirectory`]. Apps with `Type != Application` / +/// `Hidden=true` / `NoDisplay=true` are dropped so the picker +/// doesn't fill up with `xdg-open`-style helper entries the user +/// can't actually focus. +pub fn discover_apps() -> AppDirectory { + let mut by_identifier: HashMap = HashMap::new(); + let mut by_webapp_host: HashMap = HashMap::new(); + for dir in standard_app_dirs() { + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("desktop") { + continue; + } + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let Ok(contents) = fs::read_to_string(&path) else { + continue; + }; + let Some(parsed) = parse_desktop_entry(&contents) else { + continue; + }; + let display_name = parsed + .name + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| stem.to_owned()); + let metadata = DesktopAppMetadata { + display_name, + icon_name: parsed.icon.filter(|s| !s.is_empty()), + }; + // Index by .desktop filename stem (matches the common + // case where WM_CLASS / app_id matches the app's binary + // name — `firefox.desktop` ↔ `firefox`). + by_identifier + .entry(stem.to_lowercase()) + .or_insert_with(|| metadata.clone()); + // ALSO index by StartupWMClass when present — that's + // the explicit hint the .desktop author published for + // matching against window classes that disagree with + // the filename stem (`1password.desktop` → + // `StartupWMClass=1Password`). + if let Some(wmclass) = parsed.startup_wm_class.as_deref().filter(|s| !s.is_empty()) { + by_identifier + .entry(wmclass.to_lowercase()) + .or_insert_with(|| metadata.clone()); + } + // Webapp index: every host that appears in an http(s):// + // URL token in the Exec= line. Lets Chrome `--app=URL` + // PWAs fall back to this entry's Name + Icon when the + // direct index misses (typical of .desktop files that + // don't bother setting `StartupWMClass`). + if let Some(exec) = parsed.exec.as_deref() { + for host in extract_hosts_from_exec(exec) { + by_webapp_host + .entry(host) + .or_insert_with(|| metadata.clone()); + } + } + } + } + AppDirectory { + by_identifier, + by_webapp_host, + } +} + +/// Resolve an icon name to PNG or SVG bytes. Prefers raster sizes +/// in the 64–128 px window where the picker actually displays them; +/// falls through to scalable SVG and finally to `/usr/share/pixmaps` +/// when the freedesktop hicolor theme doesn't have an entry. +/// +/// Absolute paths bypass the search and read directly. Returns +/// `None` when no matching file is found or the read fails. +pub fn icon_bytes_for_name(icon_name: &str) -> Option> { + if icon_name.is_empty() { + return None; + } + // Absolute path → just read it. + let direct = Path::new(icon_name); + if direct.is_absolute() { + return fs::read(direct).ok(); + } + // Preferred raster sizes, picker-friendly first. Larger sizes + // serve HiDPI; smaller are the last raster fallback before SVG. + const RASTER_SIZES: &[&str] = &[ + "128x128", "256x256", "64x64", "96x96", "192x192", "48x48", "32x32", + ]; + for base in icon_search_dirs() { + for size in RASTER_SIZES { + let p = base + .join(size) + .join("apps") + .join(format!("{icon_name}.png")); + if let Ok(bytes) = fs::read(&p) { + return Some(bytes); + } + } + // Scalable (SVG) fallback. gdk-pixbuf with librsvg loaded + // can render this directly via gdk::Texture::from_bytes. + let svg = base + .join("scalable") + .join("apps") + .join(format!("{icon_name}.svg")); + if let Ok(bytes) = fs::read(&svg) { + return Some(bytes); + } + } + // /usr/share/pixmaps/.{png,svg} as a final fallback — + // the legacy "no theme" icon directory. + for ext in ["png", "svg"] { + let p = PathBuf::from("/usr/share/pixmaps").join(format!("{icon_name}.{ext}")); + if let Ok(bytes) = fs::read(&p) { + return Some(bytes); + } + } + None +} + +/// Application directories per the XDG Base Directory spec, in +/// lookup-priority order: user-local first, then system. Apps in +/// later directories are silently shadowed by earlier ones with +/// matching `.desktop` filenames (`HashMap::entry().or_insert_with`). +fn standard_app_dirs() -> Vec { + let mut dirs: Vec = Vec::new(); + if let Some(home) = std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) { + dirs.push(PathBuf::from(home).join("applications")); + } else if let Some(home) = std::env::var_os("HOME") { + dirs.push(PathBuf::from(home).join(".local/share/applications")); + } + let data_dirs = std::env::var("XDG_DATA_DIRS") + .ok() + .filter(|v| !v.is_empty()); + let data_dirs = data_dirs.unwrap_or_else(|| "/usr/local/share:/usr/share".to_owned()); + for d in data_dirs.split(':').filter(|s| !s.is_empty()) { + dirs.push(PathBuf::from(d).join("applications")); + } + // Flatpak system & user exports — these aren't always present + // in $XDG_DATA_DIRS depending on distro / flatpak version. + if let Some(home) = std::env::var_os("HOME") { + dirs.push(PathBuf::from(&home).join(".local/share/flatpak/exports/share/applications")); + } + dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications")); + dirs +} + +/// Hicolor theme search roots. We don't consult the user's selected +/// theme on purpose — the suppression picker works just fine with +/// the universal hicolor fallback, and per-theme lookup adds cost +/// (parse `index.theme`, walk inheritance) that doesn't pay back +/// for a one-shot list of apps. +fn icon_search_dirs() -> Vec { + let mut dirs: Vec = Vec::new(); + if let Some(home) = std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) { + dirs.push(PathBuf::from(home).join("icons/hicolor")); + } else if let Some(home) = std::env::var_os("HOME") { + dirs.push(PathBuf::from(home).join(".local/share/icons/hicolor")); + } + let data_dirs = std::env::var("XDG_DATA_DIRS") + .ok() + .filter(|v| !v.is_empty()); + let data_dirs = data_dirs.unwrap_or_else(|| "/usr/local/share:/usr/share".to_owned()); + for d in data_dirs.split(':').filter(|s| !s.is_empty()) { + dirs.push(PathBuf::from(d).join("icons/hicolor")); + } + dirs +} + +#[derive(Default, Debug)] +struct ParsedDesktopEntry { + name: Option, + icon: Option, + startup_wm_class: Option, + exec: Option, +} + +/// Parse the `[Desktop Entry]` section of a `.desktop` file. Stops +/// at the first blank line or at the first non-`[Desktop Entry]` +/// section header — we don't need locale-specific `Name[xx]=` +/// variants for the picker's English-only display today. +/// +/// Returns `None` when the entry is `Type=Application`-incompatible +/// (anything other than Application, including missing Type), +/// `Hidden=true`, or `NoDisplay=true`. The caller treats that as +/// "skip this app" rather than rendering an unfocusable shell. +fn parse_desktop_entry(contents: &str) -> Option { + let mut in_section = false; + let mut entry = ParsedDesktopEntry::default(); + let mut entry_type: Option = None; + let mut hidden = false; + let mut no_display = false; + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if line.starts_with('[') && line.ends_with(']') { + // First [Desktop Entry] header switches us in; any other + // header (e.g. [Desktop Action xyz]) ends parsing for our + // purposes. + in_section = line == "[Desktop Entry]"; + if !in_section { + break; + } + continue; + } + if !in_section { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + let value = value.trim(); + match key { + "Name" => entry.name = Some(value.to_owned()), + "Icon" => entry.icon = Some(value.to_owned()), + "StartupWMClass" => entry.startup_wm_class = Some(value.to_owned()), + "Exec" => entry.exec = Some(value.to_owned()), + "Type" => entry_type = Some(value.to_owned()), + "Hidden" => hidden = value.eq_ignore_ascii_case("true"), + "NoDisplay" => no_display = value.eq_ignore_ascii_case("true"), + _ => {} + } + } + if entry_type.as_deref() != Some("Application") || hidden || no_display { + return None; + } + Some(entry) +} + +/// Extract the host portion of every `https?://…` token in an +/// `Exec=` line. Hosts are lowercased to match Chrome's class- +/// derivation convention. A single Exec line can have multiple +/// URLs (rare but legal); we capture them all so a future tab/PWA +/// launch with any of those URLs still resolves. +fn extract_hosts_from_exec(exec: &str) -> Vec { + let mut hosts = Vec::new(); + for token in exec.split_whitespace() { + // Strip surrounding quotes (Exec= can have quoted args). + let token = token.trim_matches(|c| c == '"' || c == '\''); + let after = match token + .strip_prefix("https://") + .or_else(|| token.strip_prefix("http://")) + { + Some(s) => s, + None => continue, + }; + // Host runs until the first /, ?, #, : (port), or + // end-of-token. + let host_end = after.find(['/', '?', '#', ':']).unwrap_or(after.len()); + let host = &after[..host_end]; + if host.is_empty() || !host.contains('.') { + continue; + } + hosts.push(host.to_lowercase()); + } + hosts +} + +/// Parse a Chrome / Chromium `--app=URL` window class string and +/// return the host portion of the URL it was derived from. +/// +/// Chrome's class encoding for `--app=https:///` is +/// `chrome-__-`. The +/// `__` always separates host from path; everything before it is +/// the host. For URLs without a path +/// (`--app=https:///`), the class collapses to +/// `chrome--` so we also strip a trailing +/// `-default` / `-profile_N` segment as a fallback. +/// +/// Returns `None` for non-Chrome classes, classes whose host has +/// no `.` (almost certainly an extension ID — `crx_…` / +/// `chrome--default` already get matched by the +/// direct .desktop index, so the webapp fallback is redundant for +/// them), or empty hosts. +fn parse_chrome_pwa_host(class_lowercased: &str) -> Option<&str> { + let after = class_lowercased.strip_prefix("chrome-")?; + // Path-bearing form: `__-`. The path + // separator is unambiguous so we stop at the first `__`. + let host = if let Some(idx) = after.find("__") { + &after[..idx] + } else { + // Path-less form: `-`. Strip a single + // known profile suffix; default and `profile_N` cover the + // standard Chrome multi-profile setup. `-default` is also + // what Brave / Vivaldi / Edge use. + let mut trimmed = after; + for suffix in [ + "-default", + "-profile_1", + "-profile_2", + "-profile_3", + "-profile_4", + ] { + if let Some(s) = trimmed.strip_suffix(suffix) { + trimmed = s; + break; + } + } + // If we couldn't identify a profile suffix the class is + // probably not a webapp — bail. + if trimmed == after { + return None; + } + trimmed + }; + if host.is_empty() || !host.contains('.') { + return None; + } + Some(host) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_minimal_application_entry() { + let raw = "[Desktop Entry]\nName=Firefox\nIcon=firefox\nType=Application\n"; + let parsed = parse_desktop_entry(raw).expect("application entry"); + assert_eq!(parsed.name.as_deref(), Some("Firefox")); + assert_eq!(parsed.icon.as_deref(), Some("firefox")); + assert_eq!(parsed.startup_wm_class, None); + } + + #[test] + fn captures_startup_wm_class() { + let raw = "[Desktop Entry]\nName=1Password\nIcon=1password\n\ + Type=Application\nStartupWMClass=1Password\n"; + let parsed = parse_desktop_entry(raw).expect("application entry"); + assert_eq!(parsed.startup_wm_class.as_deref(), Some("1Password")); + } + + #[test] + fn rejects_link_type() { + let raw = "[Desktop Entry]\nName=Some Bookmark\nType=Link\nURL=https://x/\n"; + assert!(parse_desktop_entry(raw).is_none()); + } + + #[test] + fn rejects_missing_type() { + // Missing Type= is equivalent to "this isn't an + // Application", so the picker should skip it. + let raw = "[Desktop Entry]\nName=Whatever\nIcon=x\n"; + assert!(parse_desktop_entry(raw).is_none()); + } + + #[test] + fn rejects_hidden_entry() { + let raw = "[Desktop Entry]\nName=Hidden\nIcon=x\nType=Application\nHidden=true\n"; + assert!(parse_desktop_entry(raw).is_none()); + } + + #[test] + fn rejects_no_display_entry() { + let raw = "[Desktop Entry]\nName=Helper\nIcon=x\nType=Application\nNoDisplay=true\n"; + assert!(parse_desktop_entry(raw).is_none()); + } + + #[test] + fn stops_at_subsequent_section_header() { + // Locale-specific Name[de_DE]= keys are interleaved between + // [Desktop Entry] and [Desktop Action xyz] headers in some + // .desktop files. We only care about the primary section. + let raw = "[Desktop Entry]\nName=Foo\nType=Application\n\ + [Desktop Action New]\nName=New Window\n"; + let parsed = parse_desktop_entry(raw).unwrap(); + assert_eq!(parsed.name.as_deref(), Some("Foo")); + } + + #[test] + fn ignores_comments_and_blank_lines() { + let raw = "# leading comment\n\n[Desktop Entry]\n# inside\nName=Foo\n\nType=Application\n"; + let parsed = parse_desktop_entry(raw).unwrap(); + assert_eq!(parsed.name.as_deref(), Some("Foo")); + } + + #[test] + fn discover_apps_smoke_test() { + // Best-effort: this test runs anywhere `cargo test` runs, so + // we only assert the function doesn't panic. On a desktop + // box it'll typically return dozens of entries; on CI it + // may be empty. + let _ = discover_apps(); + } + + #[test] + fn extract_hosts_handles_simple_https_url() { + let hosts = + extract_hosts_from_exec("omarchy-launch-webapp https://discord.com/channels/@me"); + assert_eq!(hosts, vec!["discord.com"]); + } + + #[test] + fn extract_hosts_handles_quoted_and_multiple_urls() { + let hosts = extract_hosts_from_exec( + "browser \"https://gmail.com/u/0\" https://calendar.google.com/r", + ); + assert_eq!(hosts, vec!["gmail.com", "calendar.google.com"]); + } + + #[test] + fn extract_hosts_skips_non_url_tokens() { + let hosts = extract_hosts_from_exec("firefox %U"); + assert!(hosts.is_empty()); + } + + #[test] + fn extract_hosts_strips_port_and_query() { + let hosts = extract_hosts_from_exec("open https://example.com:8080/path?q=1#frag"); + assert_eq!(hosts, vec!["example.com"]); + } + + #[test] + fn parse_chrome_pwa_host_handles_path_form() { + // The omarchy --app=URL case the user reported: Hyprland + // class `chrome-discord.com__channels_@me-Default`. + // (We're matching against the lowercased form.) + assert_eq!( + parse_chrome_pwa_host("chrome-discord.com__channels_@me-default"), + Some("discord.com") + ); + } + + #[test] + fn parse_chrome_pwa_host_handles_pathless_form() { + // `--app=https://example.com/` (no path beyond root) → + // `chrome-example.com-default`. + assert_eq!( + parse_chrome_pwa_host("chrome-example.com-default"), + Some("example.com") + ); + } + + #[test] + fn parse_chrome_pwa_host_handles_alt_profiles() { + assert_eq!( + parse_chrome_pwa_host("chrome-example.com__app_-profile_2"), + Some("example.com") + ); + assert_eq!( + parse_chrome_pwa_host("chrome-example.com-profile_1"), + Some("example.com") + ); + } + + #[test] + fn parse_chrome_pwa_host_rejects_non_chrome_classes() { + assert!(parse_chrome_pwa_host("firefox").is_none()); + assert!(parse_chrome_pwa_host("chromium").is_none()); + // `chrome-` prefix but the trailing portion has no dot, so + // it's almost certainly an extension ID + // (`chrome--default`) — the direct .desktop index + // already covers those, so the webapp fallback should not + // claim them. + assert!(parse_chrome_pwa_host("chrome-mjoklplbddabcmpepnokjaffbmgbkkgg-default").is_none()); + } + + #[test] + fn parse_chrome_pwa_host_rejects_unknown_profile_suffix() { + // No `__` and no recognized `-default` / `-profile_N` + // suffix — could be anything. + assert!(parse_chrome_pwa_host("chrome-example.com-something").is_none()); + } + + #[test] + fn app_directory_lookup_falls_back_to_webapp_host() { + // End-to-end: a .desktop with `Exec=… https://discord.com/…` + // and no StartupWMClass should still resolve the + // omarchy `--app=` PWA class via the host index. + let mut by_id: HashMap = HashMap::new(); + let mut by_host: HashMap = HashMap::new(); + by_id.insert( + "discord".to_owned(), + DesktopAppMetadata { + display_name: "Discord".into(), + icon_name: Some("/path/to/discord.png".into()), + }, + ); + by_host.insert( + "discord.com".to_owned(), + DesktopAppMetadata { + display_name: "Discord".into(), + icon_name: Some("/path/to/discord.png".into()), + }, + ); + let dir = AppDirectory { + by_identifier: by_id, + by_webapp_host: by_host, + }; + // Direct hit by stem. + assert_eq!( + dir.lookup("discord").map(|m| m.display_name), + Some("Discord".into()) + ); + // Webapp fallback for the omarchy class. + assert_eq!( + dir.lookup("chrome-discord.com__channels_@me-Default") + .map(|m| m.display_name), + Some("Discord".into()) + ); + // Unknown class falls through to None. + assert!(dir.lookup("chrome-unknown.com-default").is_none()); + } + + /// Local-development convenience. Run with + /// `cargo test -p input-capture -- --ignored --nocapture + /// discover_apps_dump` to see what the .desktop scanner finds + /// on the current box. Pinned `#[ignore]` so CI / casual `cargo + /// test` doesn't print to stdout. + #[test] + #[ignore] + fn discover_apps_dump() { + let dir = discover_apps(); + println!( + "discovered {} direct entries, {} webapp hosts", + dir.by_identifier.len(), + dir.by_webapp_host.len() + ); + let mut keys: Vec<&String> = dir.by_identifier.keys().collect(); + keys.sort(); + for k in keys { + let m = &dir.by_identifier[k]; + println!( + " id {k:40} → name={:?} icon={:?}", + m.display_name, m.icon_name + ); + } + let mut hosts: Vec<&String> = dir.by_webapp_host.keys().collect(); + hosts.sort(); + for h in hosts { + let m = &dir.by_webapp_host[h]; + println!( + " web {h:40} → name={:?} icon={:?}", + m.display_name, m.icon_name + ); + } + } +} diff --git a/input-capture/src/frontmost_app.rs b/input-capture/src/frontmost_app.rs new file mode 100644 index 000000000..692fec8a3 --- /dev/null +++ b/input-capture/src/frontmost_app.rs @@ -0,0 +1,778 @@ +//! Cross-platform "what's the frontmost app right now?" lookup. +//! +//! Used by [`crate::clipboard::ClipboardMonitor`] to consult a +//! user-maintained suppression list before broadcasting a clipboard +//! change: when the active app at the moment of capture matches an +//! entry in the list (e.g. `1Password.app`), the change is dropped +//! locally rather than going on the wire. +//! +//! Each platform returns a `Some(AppIdent)` whose variant matches +//! the OS — see [`AppIdent`] in `lan-mouse-ipc`. None means we +//! couldn't determine the active app (no compositor support, no +//! permissions, transient race, …); the caller treats that as "not +//! suppressed." +//! +//! # macOS +//! +//! Implemented via `objc2-app-kit` against `NSWorkspace`: +//! +//! - `frontmost_app()` → +//! `NSWorkspace.sharedWorkspace.frontmostApplication.bundleIdentifier` +//! wrapped in `AppIdent::MacBundle`. +//! - `list_running_apps()` → +//! `NSWorkspace.runningApplications` map → bundle ID. Apps with +//! no bundle ID (anonymous helpers) are skipped. +//! +//! Concealed-type pasteboard detection lives in +//! [`crate::clipboard`] (`is_concealed_clipboard`) and uses the +//! same objc bridge to check `NSPasteboard.types` for +//! `org.nspasteboard.ConcealedType`. + +use lan_mouse_ipc::{AppIdent, RunningApp}; + +/// Helpers used by the Linux backend and exercised by Linux-only +/// unit tests. Module-scoped (rather than nested inside the +/// `#[cfg]`-gated Linux `backend` mod) so the test suite can +/// reach it without duplicating cfg gates. Compiled only on +/// non-macOS unixes — on macOS / Windows it would be dead code +/// and clippy's `-D dead-code` would fail the build. +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) mod backend_helpers { + /// Detect Wayland via `WAYLAND_DISPLAY` env var. Used by the + /// Linux backend and a unit test that pins the precedence + /// rule so a regression in env-var detection surfaces with a + /// clear failure rather than a silent compositor-mismatch. + pub fn is_wayland_for_test() -> bool { + std::env::var_os("WAYLAND_DISPLAY") + .map(|v| !v.is_empty()) + .unwrap_or(false) + } +} + +pub use lan_mouse_ipc::AppIdent as AppIdentRe; + +/// Best-effort lookup of the application whose window is currently +/// frontmost. Returns `None` when the platform doesn't support the +/// query (or when the lookup transiently fails — caller should +/// treat that as "not suppressed", not as "suppressed"). +pub fn frontmost_app() -> Option { + backend::frontmost_app() +} + +/// Best-effort enumeration of currently-running apps suitable for +/// the suppression-list picker. Each entry pairs a human-readable +/// display name with the host-OS identifier used by the runtime +/// suppression check. Empty when the platform can't enumerate +/// (no compositor support, missing permissions, transient race). +pub fn list_running_apps() -> Vec { + backend::list_running_apps() +} + +/// Resolve a host-OS identifier (e.g. macOS bundle ID) into a +/// `RunningApp` with display name + icon, even when the app isn't +/// currently running. Used by the GUI to render the suppressed- +/// apps list — the user added entries by bundle ID and we want to +/// show "1Password" with the 1Password icon, not the raw +/// `com.1password.1password` string. +/// +/// Returns `None` when the identifier doesn't resolve to an +/// installed app (e.g. uninstalled since being added) or on +/// platforms without a per-platform implementation. Callers +/// should fall back to displaying the identifier verbatim. +pub fn lookup_app_metadata(identifier: &str) -> Option { + backend::lookup_app_metadata(identifier) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Smoke test: the lookup must not panic even when no compositor + /// is reachable (CI sandboxes, headless `cargo test`, etc.). A + /// `None` return is a perfectly valid outcome — the caller + /// treats that as "not suppressed." + #[test] + fn frontmost_app_does_not_panic() { + let _ = frontmost_app(); + } + + #[test] + fn list_running_apps_does_not_panic() { + let _ = list_running_apps(); + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn wayland_detection_uses_wayland_display_env_var() { + // We can't actually mutate process env safely from a + // multi-threaded test runner, so just exercise the helper + // and verify it returns a deterministic bool. Pinning the + // mechanism here means a refactor to (e.g.) + // `XDG_SESSION_TYPE`-only detection would surface as a + // failed test instead of a silent compositor-mismatch. + let _ = backend_helpers::is_wayland_for_test(); + } +} + +#[cfg(target_os = "macos")] +mod backend { + use super::{AppIdent, RunningApp}; + use objc2_app_kit::{NSBitmapImageFileType, NSBitmapImageRep, NSImage, NSWorkspace}; + use objc2_foundation::{NSDictionary, NSString}; + use std::collections::HashMap; + use std::process::Command; + use std::sync::{Mutex, OnceLock}; + + /// Return the bundle ID of the frontmost app via osascript → + /// System Events. `NSWorkspace.frontmostApplication` from the + /// daemon process is silently scoped to the caller's + /// loginwindow / Aqua session and returns `nil` for plain + /// Cocoa apps the daemon doesn't share a Mach connection with + /// (Messages, Notes, most Apple system apps). System Events is + /// fully session-attached so it sees the real frontmost + /// regardless. ~50 ms per call but only fires when the + /// clipboard polling loop notices a change, so latency is + /// acceptable. + pub fn frontmost_app() -> Option { + const SCRIPT: &str = "tell application \"System Events\" to get bundle identifier of first application process whose frontmost is true"; + let output = Command::new("/usr/bin/osascript") + .args(["-e", SCRIPT]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let bundle_id = String::from_utf8(output.stdout).ok()?.trim().to_owned(); + if bundle_id.is_empty() { + return None; + } + Some(AppIdent::MacBundle(bundle_id)) + } + + /// Enumerate user-visible apps via `osascript` → System Events. + /// + /// Three direct AppKit APIs all silently scope to the caller's + /// loginwindow / Aqua session — a non-Cocoa GTK process running + /// as the .app's main process does NOT have a full session, so + /// `NSWorkspace.runningApplications`, `NSRunningApplication + /// .runningApplicationWithProcessIdentifier`, and + /// `CGWindowListCopyWindowInfo` only return apps with which + /// our process happens to share a Mach connection (XPC services + /// we use, accessibility agents, recently-activated panes, + /// pasteboard peers). Real Cocoa apps the user is using stay + /// invisible. + /// + /// System Events is itself fully session-attached and returns + /// the complete process list. We talk to it through the + /// already-permissioned Apple Events channel + /// (`NSAppleEventsUsageDescription` is declared, the user has + /// already granted automation control for input emulation). + /// The script returns one tab-separated row per visible app: + /// `bundle_id\tposix_path\tname`. Helpers / XPC services / + /// preference-pane extensions are excluded by System Events' + /// own definition of `background only is false`. + pub fn list_running_apps() -> Vec { + let raw = match query_visible_apps_via_system_events() { + Some(s) => s, + None => { + log::debug!("list_running_apps: System Events query failed"); + return Vec::new(); + } + }; + let mut out: Vec = Vec::with_capacity(32); + for line in raw.lines() { + let mut parts = line.splitn(3, '\t'); + let identifier = parts.next().unwrap_or("").trim(); + let path = parts.next().unwrap_or("").trim(); + let display_name = parts.next().unwrap_or("").trim(); + if identifier.is_empty() || path.is_empty() || display_name.is_empty() { + continue; + } + // Hide our own bundle — suppressing your own clipboard + // app makes no sense (we ARE the clipboard sender). + if identifier == "de.feschber.LanMouse" { + continue; + } + let icon_png = cached_or_encoded_icon(identifier, path); + out.push(RunningApp { + display_name: display_name.to_owned(), + identifier: identifier.to_owned(), + icon_png, + }); + } + out.sort_by_key(|a| a.display_name.to_lowercase()); + out.dedup_by(|a, b| a.identifier == b.identifier); + log::debug!( + "list_running_apps: {} visible apps via System Events", + out.len() + ); + out + } + + /// Spawn `osascript` with an inline AppleScript that asks + /// System Events for every non-background process and returns + /// `bundle_id\tposix_path\tname` per line. Inner try-catches + /// silently skip processes whose bundle ID or file we can't + /// resolve (rare system processes), so the result is always + /// well-formed. Returns `None` only if osascript itself fails + /// — typically because the user hasn't granted Apple Events + /// permission yet, in which case the picker stays empty until + /// they accept the system prompt. + fn query_visible_apps_via_system_events() -> Option { + const SCRIPT: &str = r#" +tell application "System Events" + set out to "" + try + set procs to (every process where background only is false) + repeat with p in procs + try + set bid to bundle identifier of p + set fp to POSIX path of ((file of p) as alias) + set nm to name of p + set out to out & bid & tab & fp & tab & nm & linefeed + end try + end repeat + end try + return out +end tell +"#; + let output = Command::new("/usr/bin/osascript") + .args(["-e", SCRIPT]) + .output() + .ok()?; + if !output.status.success() { + log::debug!( + "osascript failed (exit {:?}): {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + return None; + } + String::from_utf8(output.stdout).ok() + } + + /// Cache PNG icon bytes by bundle identifier. The 5-second + /// auto-refresh would otherwise re-encode every icon on every + /// tick, which adds up to tens of milliseconds of main-thread + /// work per refresh. Icons rarely change while an app is + /// running, so caching by bundle ID is a clean trade. + fn cached_or_encoded_icon(bundle_id: &str, app_path: &str) -> Option> { + static CACHE: OnceLock>>>> = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(guard) = cache.lock() { + if let Some(hit) = guard.get(bundle_id) { + return hit.clone(); + } + } + let png = encode_icon_for_app_path(app_path); + if let Ok(mut guard) = cache.lock() { + guard.insert(bundle_id.to_owned(), png.clone()); + } + png + } + + fn encode_icon_for_app_path(app_path: &str) -> Option> { + let workspace = NSWorkspace::sharedWorkspace(); + let path_str = NSString::from_str(app_path); + let icon = workspace.iconForFile(&path_str); + encode_nsimage_to_small_png(&icon) + } + + /// Look up display name + icon for an installed app by bundle + /// ID, even if it's not currently running. Uses Launch + /// Services (`URLForApplicationWithBundleIdentifier`) to find + /// the .app's path on disk, then derives the display name from + /// the bundle's file name (`/Applications/1Password.app` → + /// `1Password`) and loads its icon via `iconForFile:`. Both + /// APIs are path-based and session-independent. + pub(super) fn lookup_app_metadata(identifier: &str) -> Option { + let workspace = NSWorkspace::sharedWorkspace(); + let bid_str = NSString::from_str(identifier); + let url = workspace.URLForApplicationWithBundleIdentifier(&bid_str)?; + let path_ns = url.path()?; + let path_str = path_ns.to_string(); + let display_name = std::path::Path::new(&path_str) + .file_stem() + .and_then(|s| s.to_str()) + .map(String::from) + .unwrap_or_else(|| identifier.to_owned()); + let icon_png = cached_or_encoded_icon(identifier, &path_str); + Some(RunningApp { + display_name, + identifier: identifier.to_owned(), + icon_png, + }) + } + + fn encode_nsimage_to_small_png(icon: &NSImage) -> Option> { + // Pick the rep that's closest-but-no-smaller than 64 px. + // .icns files typically include 16/32/64/128/256/512/1024; + // anything bigger ships hundreds of KB of PNG over IPC for + // no display benefit. + let target_px: f64 = 64.0; + let reps = icon.representations(); + let mut best_idx: Option = None; + let mut best_w: f64 = f64::INFINITY; + for (i, rep) in reps.iter().enumerate() { + let w = rep.size().width; + if w >= target_px && w < best_w { + best_idx = Some(i); + best_w = w; + } + } + if best_idx.is_none() { + let mut max_w: f64 = 0.0; + for (i, rep) in reps.iter().enumerate() { + let w = rep.size().width; + if w > max_w { + best_idx = Some(i); + max_w = w; + } + } + } + let bitmap_rep = if let Some(i) = best_idx { + reps.objectAtIndex(i) + .downcast::() + .ok() + .or_else(|| { + let tiff = icon.TIFFRepresentation()?; + NSBitmapImageRep::imageRepWithData(&tiff) + }) + } else { + let tiff = icon.TIFFRepresentation()?; + NSBitmapImageRep::imageRepWithData(&tiff) + }?; + let empty = NSDictionary::::dictionary(); + let png = unsafe { + bitmap_rep.representationUsingType_properties(NSBitmapImageFileType::PNG, &empty) + }?; + let bytes = unsafe { png.as_bytes_unchecked() }; + Some(bytes.to_vec()) + } +} + +#[cfg(windows)] +mod backend { + use super::{AppIdent, RunningApp}; + use windows::Win32::Foundation::{CloseHandle, FALSE, HWND, LPARAM}; + use windows::Win32::System::Threading::{ + OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, + QueryFullProcessImageNameW, + }; + use windows::Win32::UI::WindowsAndMessaging::{ + EnumWindows, GetForegroundWindow, GetWindowThreadProcessId, IsWindowVisible, + }; + use windows::core::{BOOL, PWSTR}; + + fn process_basename(pid: u32) -> Option { + if pid == 0 { + return None; + } + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?; + let mut buf = [0u16; 1024]; + let mut len = buf.len() as u32; + let result = QueryFullProcessImageNameW( + handle, + PROCESS_NAME_WIN32, + PWSTR(buf.as_mut_ptr()), + &mut len, + ); + let _ = CloseHandle(handle); + result.ok()?; + let path = String::from_utf16_lossy(&buf[..len as usize]); + std::path::Path::new(&path) + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_lowercase()) + } + } + + pub fn frontmost_app() -> Option { + unsafe { + let hwnd = GetForegroundWindow(); + if hwnd == HWND::default() { + return None; + } + let mut pid: u32 = 0; + GetWindowThreadProcessId(hwnd, Some(&mut pid)); + process_basename(pid).map(AppIdent::WindowsExe) + } + } + + pub fn list_running_apps() -> Vec { + // Walk every visible top-level window, dedup by process + // basename. Closures captured via LPARAM pointer to a Vec. + let mut basenames: Vec = Vec::new(); + unsafe extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { + unsafe { + if IsWindowVisible(hwnd) == FALSE { + return BOOL(1); // continue + } + let mut pid: u32 = 0; + GetWindowThreadProcessId(hwnd, Some(&mut pid)); + let Some(name) = process_basename(pid) else { + return BOOL(1); + }; + let v: &mut Vec = &mut *(lparam.0 as *mut Vec); + if !v.iter().any(|n| n == &name) { + v.push(name); + } + BOOL(1) + } + } + unsafe { + let _ = EnumWindows(Some(enum_proc), LPARAM(&mut basenames as *mut _ as isize)); + } + let mut out: Vec = basenames + .into_iter() + .map(|name| RunningApp { + display_name: name.clone(), + identifier: name, + icon_png: None, + }) + .collect(); + out.sort_by(|a, b| a.display_name.cmp(&b.display_name)); + out + } + + pub(super) fn lookup_app_metadata(_identifier: &str) -> Option { + // No installed-app metadata source on Windows yet. + None + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +mod backend { + use super::{AppIdent, RunningApp}; + use crate::desktop_entries::{self, AppDirectory}; + use std::process::Command; + + /// Detect compositor flavor via env vars. Wayland sessions set + /// `WAYLAND_DISPLAY`; X11 sessions don't. `XDG_SESSION_TYPE` is + /// the modern signal but isn't always set on tiling WMs (Sway, + /// Hyprland) so we treat presence of `WAYLAND_DISPLAY` as + /// authoritative for Wayland. + fn is_wayland() -> bool { + super::backend_helpers::is_wayland_for_test() + } + + pub fn frontmost_app() -> Option { + if is_wayland() { + hyprland_active() + .or_else(sway_active) + .map(|s| AppIdent::LinuxWayland(s.to_lowercase())) + } else { + x11_active().map(|s| AppIdent::LinuxX11(s.to_lowercase())) + } + } + + pub fn list_running_apps() -> Vec { + let mut idents: Vec = if is_wayland() { + // Hyprland's `clients -j` returns every mapped client; + // sway's `get_tree` returns the whole tree. Either way + // we extract `class` / `app_id`, dedup, and sort for + // stable display in the GUI. + hyprland_clients() + .into_iter() + .chain(sway_clients()) + .collect() + } else { + x11_client_list() + }; + idents.sort(); + idents.dedup(); + // Enrich each runtime identifier with its installed-app + // metadata (display name + icon bytes) when a .desktop + // entry can be matched. Apps with no .desktop hit fall + // through to the raw-string display path so an unknown + // class still shows up in the picker. + let directory = desktop_entries::discover_apps(); + let mut out: Vec = idents + .into_iter() + .map(|raw| build_running_app(&directory, raw)) + .collect(); + // Re-sort by display name now that .desktop enrichment may + // have rewritten "firefox" → "Firefox", etc., so the picker + // shows entries in human-readable order. + out.sort_by(|a, b| { + a.display_name + .to_lowercase() + .cmp(&b.display_name.to_lowercase()) + }); + out + } + + /// Resolve a stored host-OS identifier (a lowercased class / + /// app_id) back to a [`RunningApp`] using the same .desktop + /// scan the picker uses. Lets the GUI render a previously- + /// added entry as `1Password` with its icon even when the app + /// isn't currently running. + pub(super) fn lookup_app_metadata(identifier: &str) -> Option { + let directory = desktop_entries::discover_apps(); + let app = build_running_app(&directory, identifier.to_owned()); + // build_running_app always returns Something; only treat + // it as "found" when the .desktop scan actually contributed + // metadata (display name differs from the identifier, or + // we got an icon). + if app.display_name.eq_ignore_ascii_case(identifier) && app.icon_png.is_none() { + None + } else { + Some(app) + } + } + + /// Assemble a [`RunningApp`] from a runtime identifier plus + /// the [`AppDirectory`]. The identifier is lowercased so the + /// direct + Chrome-PWA-fallback lookups in + /// [`AppDirectory::lookup`] hit the same case the indexer + /// inserted under. + fn build_running_app(directory: &AppDirectory, raw_identifier: String) -> RunningApp { + let lower = raw_identifier.to_lowercase(); + if let Some(meta) = directory.lookup(&lower) { + let icon_png = meta + .icon_name + .as_deref() + .and_then(desktop_entries::icon_bytes_for_name); + return RunningApp { + display_name: meta.display_name, + identifier: lower, + icon_png, + }; + } + RunningApp { + display_name: raw_identifier, + identifier: lower, + icon_png: None, + } + } + + fn run_capture(cmd: &str, args: &[&str]) -> Option { + let out = Command::new(cmd).args(args).output().ok()?; + if !out.status.success() { + return None; + } + String::from_utf8(out.stdout).ok() + } + + fn hyprland_active() -> Option { + let json = run_capture("hyprctl", &["activewindow", "-j"])?; + let parsed: serde_json::Value = serde_json::from_str(&json).ok()?; + // Hyprland reports the X11 WM_CLASS-equivalent as `class`. + // `initialClass` is the value the toplevel registered with; + // prefer it when present so a renamed window doesn't slip + // suppression by changing its title. + let class = parsed + .get("initialClass") + .and_then(|v| v.as_str()) + .or_else(|| parsed.get("class").and_then(|v| v.as_str()))?; + let class = class.trim(); + if class.is_empty() { + return None; + } + Some(class.to_owned()) + } + + fn hyprland_clients() -> Vec { + let Some(json) = run_capture("hyprctl", &["clients", "-j"]) else { + return Vec::new(); + }; + let Ok(parsed) = serde_json::from_str::(&json) else { + return Vec::new(); + }; + parsed + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|c| { + c.get("initialClass") + .and_then(|v| v.as_str()) + .or_else(|| c.get("class").and_then(|v| v.as_str())) + }) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default() + } + + fn sway_active() -> Option { + let json = run_capture("swaymsg", &["-t", "get_tree"])?; + let tree: serde_json::Value = serde_json::from_str(&json).ok()?; + find_focused_app_id(&tree) + } + + fn sway_clients() -> Vec { + let Some(json) = run_capture("swaymsg", &["-t", "get_tree"]) else { + return Vec::new(); + }; + let Ok(tree) = serde_json::from_str::(&json) else { + return Vec::new(); + }; + let mut acc = Vec::new(); + collect_app_ids(&tree, &mut acc); + acc + } + + /// Walk the sway/i3 tree depth-first looking for the node with + /// `focused == true` and a non-empty `app_id` (Wayland clients) + /// or `window_properties.class` (XWayland fallback). + fn find_focused_app_id(node: &serde_json::Value) -> Option { + if node.get("focused").and_then(|v| v.as_bool()) == Some(true) { + if let Some(s) = node.get("app_id").and_then(|v| v.as_str()) { + if !s.is_empty() { + return Some(s.to_owned()); + } + } + if let Some(s) = node + .get("window_properties") + .and_then(|wp| wp.get("class")) + .and_then(|v| v.as_str()) + { + if !s.is_empty() { + return Some(s.to_owned()); + } + } + } + for key in ["nodes", "floating_nodes"] { + if let Some(arr) = node.get(key).and_then(|v| v.as_array()) { + for child in arr { + if let Some(found) = find_focused_app_id(child) { + return Some(found); + } + } + } + } + None + } + + fn collect_app_ids(node: &serde_json::Value, acc: &mut Vec) { + if let Some(s) = node.get("app_id").and_then(|v| v.as_str()) { + if !s.is_empty() { + acc.push(s.to_owned()); + } + } + if let Some(s) = node + .get("window_properties") + .and_then(|wp| wp.get("class")) + .and_then(|v| v.as_str()) + { + if !s.is_empty() { + acc.push(s.to_owned()); + } + } + for key in ["nodes", "floating_nodes"] { + if let Some(arr) = node.get(key).and_then(|v| v.as_array()) { + for child in arr { + collect_app_ids(child, acc); + } + } + } + } + + fn x11_active() -> Option { + use x11rb::connection::Connection; + use x11rb::protocol::xproto::{AtomEnum, ConnectionExt}; + + let (conn, screen_num) = x11rb::connect(None).ok()?; + let root = conn.setup().roots[screen_num].root; + let net_active = conn + .intern_atom(false, b"_NET_ACTIVE_WINDOW") + .ok()? + .reply() + .ok()? + .atom; + let prop = conn + .get_property(false, root, net_active, AtomEnum::WINDOW, 0, 1) + .ok()? + .reply() + .ok()?; + let window_id = prop.value32()?.next()?; + if window_id == 0 { + return None; + } + let class_prop = conn + .get_property( + false, + window_id, + AtomEnum::WM_CLASS, + AtomEnum::STRING, + 0, + 1024, + ) + .ok()? + .reply() + .ok()?; + // WM_CLASS is two NUL-separated strings: instance, class. + // Prefer the second (class) since it tends to be the more + // stable identifier. + let raw = class_prop.value; + let mut parts = raw.split(|&b| b == 0).filter(|s| !s.is_empty()); + let _instance = parts.next(); + let class = parts.next(); + let bytes = class.or_else(|| { + // Single-string fallback (some toolkits put the same + // value in both fields without a separator). + raw.split(|&b| b == 0).find(|s| !s.is_empty()) + })?; + let s = String::from_utf8_lossy(bytes).into_owned(); + if s.is_empty() { + return None; + } + Some(s) + } + + fn x11_client_list() -> Vec { + use x11rb::connection::Connection; + use x11rb::protocol::xproto::{AtomEnum, ConnectionExt}; + + let Ok((conn, screen_num)) = x11rb::connect(None) else { + return Vec::new(); + }; + let root = conn.setup().roots[screen_num].root; + let Ok(reply) = conn.intern_atom(false, b"_NET_CLIENT_LIST") else { + return Vec::new(); + }; + let Ok(net_client_list) = reply.reply() else { + return Vec::new(); + }; + let net_client_list = net_client_list.atom; + let Ok(prop_req) = + conn.get_property(false, root, net_client_list, AtomEnum::WINDOW, 0, u32::MAX) + else { + return Vec::new(); + }; + let Ok(prop) = prop_req.reply() else { + return Vec::new(); + }; + let Some(values) = prop.value32() else { + return Vec::new(); + }; + let mut out = Vec::new(); + for window_id in values { + if window_id == 0 { + continue; + } + let Ok(req) = conn.get_property( + false, + window_id, + AtomEnum::WM_CLASS, + AtomEnum::STRING, + 0, + 1024, + ) else { + continue; + }; + let Ok(class_prop) = req.reply() else { + continue; + }; + let raw = class_prop.value; + let mut parts = raw.split(|&b| b == 0).filter(|s| !s.is_empty()); + let _instance = parts.next(); + let class = parts.next(); + if let Some(bytes) = class.or_else(|| raw.split(|&b| b == 0).find(|s| !s.is_empty())) { + out.push(String::from_utf8_lossy(bytes).into_owned()); + } + } + out + } +} diff --git a/input-capture/src/lib.rs b/input-capture/src/lib.rs index 6cc2efed9..ec47e4b0b 100644 --- a/input-capture/src/lib.rs +++ b/input-capture/src/lib.rs @@ -17,7 +17,11 @@ use input_event::{Event, KeyboardEvent, PointerEvent, scancode}; pub use error::{CaptureCreationError, CaptureError, InputCaptureError}; +pub mod clipboard; +#[cfg(all(unix, not(target_os = "macos")))] +pub mod desktop_entries; pub mod error; +pub mod frontmost_app; #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] mod libei; @@ -39,7 +43,7 @@ mod dummy; pub type CaptureHandle = u64; -#[derive(Copy, Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub enum CaptureEvent { /// Capture on this handle is now active. `cursor`, when present, /// is the host's screen-space cursor position (in pixels) at the @@ -822,8 +826,9 @@ impl Stream for InputCapture { }; // handle key presses - if let CaptureEvent::Input(Event::Keyboard(KeyboardEvent::Key { key, state, .. })) = event { - self.update_pressed_keys(key, state); + if let CaptureEvent::Input(Event::Keyboard(KeyboardEvent::Key { key, state, .. })) = &event + { + self.update_pressed_keys(*key, *state); } // wall-press auto-release tracking. Runs against every event @@ -850,7 +855,7 @@ impl Stream for InputCapture { swap(&mut self.position_map, &mut position_map); { for &id in position_map.get(&pos).expect("position") { - self.pending.push_back((id, event)); + self.pending.push_back((id, event.clone())); } } swap(&mut self.position_map, &mut position_map); diff --git a/input-capture/src/macos.rs b/input-capture/src/macos.rs index 73c8478c9..97df0da77 100644 --- a/input-capture/src/macos.rs +++ b/input-capture/src/macos.rs @@ -637,10 +637,10 @@ fn create_event_tap<'a>( } if let Some(pos) = capture_position { - res_events.iter().for_each(|e| { + res_events.into_iter().for_each(|e| { // error must be ignored, since the event channel // may already be closed when the InputCapture instance is dropped. - let _ = event_tx.blocking_send((pos, *e)); + let _ = event_tx.blocking_send((pos, e)); }); // Returning Drop should stop the event from being processed // but core fundation still returns the event diff --git a/input-emulation/Cargo.toml b/input-emulation/Cargo.toml index 38391223d..cfee168cc 100644 --- a/input-emulation/Cargo.toml +++ b/input-emulation/Cargo.toml @@ -24,6 +24,7 @@ tokio = { version = "1.32.0", features = [ "time" ] } once_cell = "1.19.0" +arboard = { version = "3.4", features = ["wayland-data-control"] } [target.'cfg(all(unix, not(target_os="macos")))'.dependencies] bitflags = "2.6.0" diff --git a/input-emulation/src/clipboard.rs b/input-emulation/src/clipboard.rs new file mode 100644 index 000000000..326b8c20f --- /dev/null +++ b/input-emulation/src/clipboard.rs @@ -0,0 +1,105 @@ +use arboard::Clipboard; +use input_event::ClipboardEvent; +use std::sync::{Arc, Mutex}; +use thiserror::Error; +use tokio::task::spawn_blocking; + +#[derive(Debug, Error)] +pub enum ClipboardError { + #[error("Failed to access clipboard: {0}")] + Access(String), + #[error("Failed to set clipboard: {0}")] + Set(String), +} + +/// Clipboard emulation that sets clipboard content +#[derive(Clone)] +pub struct ClipboardEmulation { + // Use Arc> to share clipboard across threads + clipboard: Arc>>, +} + +impl ClipboardEmulation { + pub fn new() -> Result { + // Try to create initial clipboard instance + let clipboard = match Clipboard::new() { + Ok(c) => Some(c), + Err(e) => { + log::warn!("Failed to create clipboard instance: {e}"); + None + } + }; + + Ok(Self { + clipboard: Arc::new(Mutex::new(clipboard)), + }) + } + + /// Set clipboard content from a clipboard event + pub async fn set(&self, event: ClipboardEvent) -> Result<(), ClipboardError> { + match event { + ClipboardEvent::Text(text) => { + let clipboard_arc = self.clipboard.clone(); + + spawn_blocking(move || { + let mut clipboard_guard = clipboard_arc.lock().unwrap(); + + // Try to get or create clipboard + let clipboard = match clipboard_guard.as_mut() { + Some(c) => c, + None => { + // Try to create a new clipboard instance + match Clipboard::new() { + Ok(c) => { + *clipboard_guard = Some(c); + clipboard_guard.as_mut().unwrap() + } + Err(e) => { + return Err(ClipboardError::Access(format!("{e}"))); + } + } + } + }; + + // Set clipboard text + clipboard + .set_text(text.clone()) + .map_err(|e| ClipboardError::Set(format!("{e}")))?; + + log::debug!("Clipboard set, length: {} bytes", text.len()); + Ok(()) + }) + .await + .map_err(|e| ClipboardError::Access(format!("Task join error: {e}")))? + } + } + } + + /// Get current clipboard content (for testing/verification) + pub async fn get(&self) -> Result { + let clipboard_arc = self.clipboard.clone(); + + spawn_blocking(move || { + let mut clipboard_guard = clipboard_arc.lock().unwrap(); + + let clipboard = match clipboard_guard.as_mut() { + Some(c) => c, + None => match Clipboard::new() { + Ok(c) => { + *clipboard_guard = Some(c); + clipboard_guard.as_mut().unwrap() + } + Err(e) => { + return Err(ClipboardError::Access(format!("{e}"))); + } + }, + }; + + clipboard + .get_text() + .map_err(|e| ClipboardError::Access(format!("{e}"))) + }) + .await + .map_err(|e| ClipboardError::Access(format!("Task join error: {e}")))? + } +} diff --git a/input-emulation/src/lib.rs b/input-emulation/src/lib.rs index 4e2dc6fe2..07ac93a0e 100644 --- a/input-emulation/src/lib.rs +++ b/input-emulation/src/lib.rs @@ -4,7 +4,9 @@ use std::{ fmt::Display, }; -use input_event::{Event, KeyboardEvent, PointerEvent}; +use input_event::{ClipboardEvent, Event, KeyboardEvent, PointerEvent}; + +use crate::clipboard::ClipboardEmulation; pub use self::error::{EmulationCreationError, EmulationError, InputEmulationError}; @@ -26,6 +28,7 @@ mod libei; #[cfg(target_os = "macos")] mod macos; +pub mod clipboard; /// fallback input emulation (logs events) mod dummy; mod error; @@ -101,6 +104,11 @@ pub struct InputEmulation { /// upper layer before each event is consumed; missing entries /// resolve to `ReceivePostProcessing::default()` (passthrough). post_processing: HashMap, + /// Cross-platform clipboard sink. Populated lazily on first + /// access; backends don't need to know clipboards exist. + /// `None` when the platform clipboard couldn't be opened (e.g. + /// headless CI, Wayland session without compositor support). + clipboard: Option, } impl InputEmulation { @@ -120,11 +128,19 @@ impl InputEmulation { Backend::MacOs => Box::new(macos::MacOSEmulation::new()?), Backend::Dummy => Box::new(dummy::DummyEmulation::new()), }; + let clipboard = match ClipboardEmulation::new() { + Ok(c) => Some(c), + Err(e) => { + log::warn!("clipboard emulation unavailable: {e}"); + None + } + }; Ok(Self { emulation, handles: HashSet::new(), pressed_keys: HashMap::new(), post_processing: HashMap::new(), + clipboard, }) } @@ -170,6 +186,19 @@ impl InputEmulation { event: Event, handle: EmulationHandle, ) -> Result<(), EmulationError> { + // Clipboard events route through the cross-platform + // `ClipboardEmulation` sink, not the per-backend pointer / + // keyboard pipeline. Per-backend `consume` impls treat + // `Event::Clipboard` as a no-op, so handling it here keeps + // the dispatch in one place. + if let Event::Clipboard(ClipboardEvent::Text(text)) = &event { + if let Some(clipboard) = self.clipboard.as_ref() { + if let Err(e) = clipboard.set(ClipboardEvent::Text(text.clone())).await { + log::warn!("failed to set clipboard: {e}"); + } + } + return Ok(()); + } // Apply per-handle receive-side post-processing in a single // place rather than per-backend. Backends stay // platform-mechanics-only and are spared duplicate sign-flip diff --git a/input-emulation/src/libei.rs b/input-emulation/src/libei.rs index d17f2eab0..751e3d48a 100644 --- a/input-emulation/src/libei.rs +++ b/input-emulation/src/libei.rs @@ -248,6 +248,10 @@ impl Emulation for LibeiEmulation { } KeyboardEvent::Modifiers { .. } => {} }, + Event::Clipboard(_) => { + // Clipboard injection is handled by the cross- + // platform `ClipboardEmulation` sink, not libei. + } } self.context .flush() diff --git a/input-emulation/src/macos.rs b/input-emulation/src/macos.rs index 873be7947..ca18052b4 100644 --- a/input-emulation/src/macos.rs +++ b/input-emulation/src/macos.rs @@ -545,6 +545,11 @@ impl Emulation for MacOSEmulation { modifier_event(self.event_source.clone(), self.modifier_state.get()); } }, + Event::Clipboard(_) => { + // Clipboard injection is handled by the cross- + // platform `ClipboardEmulation` sink, not the macOS + // emulation backend. + } } // FIXME Ok(()) diff --git a/input-emulation/src/windows.rs b/input-emulation/src/windows.rs index 18ca7782c..482399446 100644 --- a/input-emulation/src/windows.rs +++ b/input-emulation/src/windows.rs @@ -72,6 +72,10 @@ impl Emulation for WindowsEmulation { } KeyboardEvent::Modifiers { .. } => {} }, + Event::Clipboard(_) => { + // Clipboard injection is handled by the cross- + // platform `ClipboardEmulation` sink. + } } // FIXME Ok(()) diff --git a/input-emulation/src/wlroots.rs b/input-emulation/src/wlroots.rs index a0c039148..7cca09e2f 100644 --- a/input-emulation/src/wlroots.rs +++ b/input-emulation/src/wlroots.rs @@ -249,13 +249,14 @@ impl Emulation for WlrootsEmulation { _ => {} } } + let event_debug = format!("{event:?}"); virtual_input .consume_event(event) - .unwrap_or_else(|_| panic!("failed to convert event: {event:?}")); + .unwrap_or_else(|_| panic!("failed to convert event: {event_debug}")); match self.queue.flush() { Err(WaylandError::Io(e)) if e.kind() == io::ErrorKind::WouldBlock => { self.last_flush_failed = true; - log::warn!("can't keep up, discarding event: ({handle}) - {event:?}"); + log::warn!("can't keep up, discarding event: ({handle}) - {event_debug}"); } Err(WaylandError::Protocol(e)) => panic!("wayland protocol violation: {e}"), Ok(()) => self.last_flush_failed = false, @@ -390,6 +391,10 @@ impl VirtualInput { .modifiers(mods_depressed, mods_latched, mods_locked, group); } }, + Event::Clipboard(_) => { + // Clipboard injection is handled by the cross- + // platform `ClipboardEmulation` sink, not wlroots. + } } Ok(()) } diff --git a/input-emulation/src/xdg_desktop_portal.rs b/input-emulation/src/xdg_desktop_portal.rs index 7082c0a49..bb78d8593 100644 --- a/input-emulation/src/xdg_desktop_portal.rs +++ b/input-emulation/src/xdg_desktop_portal.rs @@ -12,7 +12,7 @@ use async_trait::async_trait; use futures::FutureExt; use input_event::{ - Event::{Keyboard, Pointer}, + Event::{self, Keyboard, Pointer}, KeyboardEvent, PointerEvent, }; @@ -147,6 +147,11 @@ impl Emulation for DesktopPortalEmulation { } } } + Event::Clipboard(_) => { + // Clipboard injection is handled by the cross- + // platform `ClipboardEmulation` sink, not the + // desktop portal backend. + } } Ok(()) } diff --git a/input-event/src/lib.rs b/input-event/src/lib.rs index 1d8c9ffb1..640d69182 100644 --- a/input-event/src/lib.rs +++ b/input-event/src/lib.rs @@ -38,12 +38,20 @@ pub enum KeyboardEvent { }, } -#[derive(PartialEq, Debug, Clone, Copy)] +#[derive(Debug, PartialEq, Clone)] +pub enum ClipboardEvent { + /// text content from clipboard + Text(String), +} + +#[derive(PartialEq, Debug, Clone)] pub enum Event { /// pointer event (motion / button / axis) Pointer(PointerEvent), /// keyboard events (key / modifiers) Keyboard(KeyboardEvent), + /// clipboard events (cross-peer clipboard sync) + Clipboard(ClipboardEvent), } impl Display for PointerEvent { @@ -109,11 +117,27 @@ impl Display for KeyboardEvent { } } +impl Display for ClipboardEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ClipboardEvent::Text(text) => { + let preview = if text.len() > 50 { + format!("{}...", &text[..50]) + } else { + text.clone() + }; + write!(f, "clipboard(text: {preview})") + } + } + } +} + impl Display for Event { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Event::Pointer(p) => write!(f, "{p}"), Event::Keyboard(k) => write!(f, "{k}"), + Event::Clipboard(c) => write!(f, "{c}"), } } } diff --git a/input-event/src/libei.rs b/input-event/src/libei.rs index f79048987..3c85a7fd1 100644 --- a/input-event/src/libei.rs +++ b/input-event/src/libei.rs @@ -46,7 +46,7 @@ impl Iterator for EventIterator { let res = if self.pos >= self.events.len() { None } else { - self.events[self.pos] + self.events[self.pos].clone() }; self.pos += 1; res diff --git a/lan-mouse-gtk/Cargo.toml b/lan-mouse-gtk/Cargo.toml index 4a1a20272..46dc53385 100644 --- a/lan-mouse-gtk/Cargo.toml +++ b/lan-mouse-gtk/Cargo.toml @@ -7,13 +7,17 @@ license = "GPL-3.0-or-later" repository = "https://github.com/feschber/lan-mouse" [dependencies] -gtk = { package = "gtk4", version = "0.9.0", features = ["v4_2"] } +gtk = { package = "gtk4", version = "0.9.0", features = ["v4_6"] } adw = { package = "libadwaita", version = "0.7.0", features = ["v1_1"] } async-channel = { version = "2.1.1" } hostname = "0.4.0" log = "0.4.20" lan-mouse-ipc = { path = "../lan-mouse-ipc", version = "0.2.0" } +input-capture = { path = "../input-capture", version = "0.3.0", default-features = false } thiserror = "2.0.0" +[target.'cfg(target_os = "linux")'.dependencies] +ksni = "0.2" + [build-dependencies] glib-build-tools = { version = "0.20.0" } diff --git a/lan-mouse-gtk/resources/client_row.ui b/lan-mouse-gtk/resources/client_row.ui index 82c2e1012..466365f12 100644 --- a/lan-mouse-gtk/resources/client_row.ui +++ b/lan-mouse-gtk/resources/client_row.ui @@ -72,6 +72,22 @@ + + + + Share Clipboard With This Peer + When you copy text on this device, send it to this peer. + + false + + + center + + + + diff --git a/lan-mouse-gtk/resources/clipboard_privacy_window.ui b/lan-mouse-gtk/resources/clipboard_privacy_window.ui new file mode 100644 index 000000000..2ed7f5152 --- /dev/null +++ b/lan-mouse-gtk/resources/clipboard_privacy_window.ui @@ -0,0 +1,94 @@ + + + + + + diff --git a/lan-mouse-gtk/resources/icons/lan-mouse-tray.svg b/lan-mouse-gtk/resources/icons/lan-mouse-tray.svg new file mode 100644 index 000000000..465fe81f6 --- /dev/null +++ b/lan-mouse-gtk/resources/icons/lan-mouse-tray.svg @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/lan-mouse-gtk/resources/key_row.ui b/lan-mouse-gtk/resources/key_row.ui index 9759fd695..864bdd9cd 100644 --- a/lan-mouse-gtk/resources/key_row.ui +++ b/lan-mouse-gtk/resources/key_row.ui @@ -80,6 +80,19 @@ + + + + Accept Clipboard From This Peer + Apply text this peer copies to your clipboard. + false + + + center + + + + diff --git a/lan-mouse-gtk/resources/resources.gresource.xml b/lan-mouse-gtk/resources/resources.gresource.xml index d38207413..61fb99a26 100644 --- a/lan-mouse-gtk/resources/resources.gresource.xml +++ b/lan-mouse-gtk/resources/resources.gresource.xml @@ -6,9 +6,11 @@ fingerprint_window.ui client_row.ui key_row.ui + clipboard_privacy_window.ui de.feschber.LanMouse.svg + icons/lan-mouse-tray.svg