diff --git a/Cargo.lock b/Cargo.lock index be48ef4..22b4a14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,7 +31,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" dependencies = [ "android-properties", - "bitflags 2.11.0", + "bitflags 2.13.2", "cc", "cesu8", "jni", @@ -94,29 +94,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "async-task" version = "4.7.1" @@ -131,7 +108,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -159,7 +136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "annotate-snippets", - "bitflags 2.11.0", + "bitflags 2.13.2", "cexpr", "clang-sys", "itertools", @@ -168,7 +145,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn", + "syn 2.0.117", ] [[package]] @@ -179,9 +156,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -233,7 +210,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -248,7 +225,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "log", "polling", "rustix 0.38.44", @@ -263,7 +240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ "async-task", - "bitflags 2.11.0", + "bitflags 2.13.2", "polling", "rustix 1.1.4", "slab", @@ -383,15 +360,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "convert_case" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "cookie-factory" version = "0.3.3" @@ -470,7 +438,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", - "nix 0.31.2", + "nix", "windows-sys 0.61.2", ] @@ -508,7 +476,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -523,7 +491,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -556,7 +524,22 @@ version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5b71449a23fe79542d6527ca572844b2016abf9573c49e43144d546b1735aec" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", + "bytemuck", + "bytemuck_derive", + "drm-ffi", + "drm-fourcc", + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "drm" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a41816e58f47f49acfd956651055ddcf137c4882c2098c30c448817af21183a" +dependencies = [ + "bitflags 2.13.2", "bytemuck", "bytemuck_derive", "drm-ffi", @@ -612,27 +595,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -646,28 +608,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", + "windows-sys 0.52.0", ] [[package]] @@ -706,7 +647,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -717,28 +658,15 @@ checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-task" @@ -764,8 +692,8 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a" dependencies = [ - "bitflags 2.11.0", - "drm", + "bitflags 2.13.2", + "drm 0.14.2", "drm-fourcc", "gbm-sys", "libc", @@ -927,7 +855,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbdc09524a91f9cacd26f16734ff63d7dc650daffadd2b6f84d17a285bd875a9" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "input-sys", "libc", "log", @@ -936,9 +864,9 @@ dependencies = [ [[package]] name = "input-sys" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd4f5b4d1c00331c5245163aacfe5f20be75b564c7112d45893d4ae038119eb0" +checksum = "36eee07d8e02bd95bf52b2e642cf13d33701b94c6e4b04fbf1d1fb07e9cb19e7" [[package]] name = "io-lifetimes" @@ -1048,7 +976,7 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "libc", "plain", "redox_syscall 0.7.3", @@ -1076,26 +1004,25 @@ dependencies = [ [[package]] name = "libspa" -version = "0.9.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b8cfa2a7656627b4c92c6b9ef929433acd673d5ab3708cda1b18478ac00df4" +checksum = "882f7427e7989dcc9d388b7f05c4630390a1d7696f9ffa469cd4a7a48f0b4c40" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "cc", - "convert_case", "cookie-factory", "libc", "libspa-sys", - "nix 0.30.1", "nom 8.0.0", + "rustix 1.1.4", "system-deps", ] [[package]] name = "libspa-sys" -version = "0.9.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901049455d2eb6decf9058235d745237952f4804bc584c5fcb41412e6adcc6e0" +checksum = "3b6e17bdaf63ed0d5e4144022624032b41fd9733112e8c74ac26fc9bf1291924" dependencies = [ "bindgen", "cc", @@ -1186,9 +1113,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.1.1" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", @@ -1201,7 +1128,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "jni-sys", "log", "ndk-sys", @@ -1225,25 +1152,13 @@ dependencies = [ "jni-sys", ] -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "nix" version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -1275,7 +1190,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1306,7 +1221,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1340,7 +1255,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -1356,7 +1271,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -1380,7 +1295,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation", @@ -1422,7 +1337,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "block2 0.5.1", "dispatch", "libc", @@ -1447,7 +1362,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation", @@ -1459,7 +1374,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation", @@ -1482,7 +1397,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", @@ -1514,7 +1429,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -1553,12 +1468,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - [[package]] name = "parking_lot" version = "0.12.5" @@ -1611,7 +1520,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1622,26 +1531,23 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pipewire" -version = "0.9.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9688b89abf11d756499f7c6190711d6dbe5a3acdb30c8fbf001d6596d06a8d44" +checksum = "bde71084c4e25959d68f1ea54daa75e5ecdb338e5caf0b5510143b79baa32d5c" dependencies = [ - "anyhow", - "bitflags 2.11.0", + "bitflags 2.13.2", "libc", "libspa", "libspa-sys", - "nix 0.30.1", - "once_cell", "pipewire-sys", - "thiserror 2.0.18", + "rustix 1.1.4", ] [[package]] name = "pipewire-sys" -version = "0.9.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb028afee0d6ca17020b090e3b8fa2d7de23305aef975c7e5192a5050246ea36" +checksum = "9ce653f53e63e5b93853218092ee9a8906a5d082c92f3f1db26316955dd63ce0" dependencies = [ "bindgen", "libspa-sys", @@ -1707,7 +1613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -1744,7 +1650,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1827,7 +1733,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", ] [[package]] @@ -1836,7 +1742,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", ] [[package]] @@ -1891,7 +1797,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.4.15", @@ -1904,11 +1810,11 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1971,7 +1877,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1995,14 +1901,14 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -2072,13 +1978,13 @@ checksum = "740cea6927892bc182d5bf70c8f79806c8bc9f68f2fb96e55a30be171b63af98" dependencies = [ "appendlist", "atomic_float", - "bitflags 2.11.0", + "bitflags 2.13.2", "calloop 0.14.4", "cc", "cgmath", "cursor-icon", "downcast-rs", - "drm", + "drm 0.14.2", "drm-ffi", "drm-fourcc", "encoding_rs", @@ -2121,7 +2027,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "calloop 0.13.0", "calloop-wayland-source 0.3.0", "cursor-icon", @@ -2151,9 +2057,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2170,6 +2076,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "system-deps" version = "7.0.7" @@ -2199,7 +2116,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2228,7 +2145,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2239,7 +2156,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2253,9 +2170,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.50.0" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -2265,19 +2182,18 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "tracing", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -2292,22 +2208,22 @@ dependencies = [ "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml" -version = "1.0.6+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", ] [[package]] @@ -2321,9 +2237,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] @@ -2335,25 +2251,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ - "winnow", + "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tracing" @@ -2375,7 +2291,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2443,7 +2359,7 @@ checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2579,7 +2495,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2620,7 +2536,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "hashbrown 0.15.5", "indexmap", "semver", @@ -2646,7 +2562,7 @@ version = "0.31.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "rustix 1.1.4", "wayland-backend", "wayland-scanner", @@ -2658,7 +2574,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "cursor-icon", "wayland-backend", ] @@ -2690,7 +2606,7 @@ version = "0.32.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-scanner", @@ -2703,7 +2619,7 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "429b99200febaf95d4f4e46deff6fe4382bcff3280ee16a41cf887b3c3364984" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "wayland-backend", "wayland-protocols", "wayland-scanner", @@ -2716,7 +2632,7 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d392fc283a87774afc9beefcd6f931582bb97fe0e6ced0b306a62cb1d026527c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -2729,7 +2645,7 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -2754,7 +2670,7 @@ version = "0.31.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63736a4a73e781cf6a736aa32c5d6773c3eb5389197562742a8c611b49b5e359" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "downcast-rs", "rustix 1.1.4", "wayland-backend", @@ -2801,7 +2717,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -3042,7 +2958,7 @@ dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.11.0", + "bitflags 2.13.2", "block2 0.5.1", "bytemuck", "calloop 0.13.0", @@ -3093,6 +3009,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3123,7 +3048,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3139,7 +3064,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3151,7 +3076,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags 2.13.2", "indexmap", "log", "serde", @@ -3189,21 +3114,22 @@ dependencies = [ "calloop 0.14.4", "calloop-wayland-source 0.4.1", "ctrlc", - "drm", + "drm 0.15.0", "drm-fourcc", "gbm", "gl", "input", "libc", - "nix 0.31.2", + "nix", "pipewire", "pixman", "serde", "serde_json", + "sha2", "shellexpand", "smithay", "tokio", - "toml 1.0.6+spec-1.1.0", + "toml 1.1.8+spec-1.1.0", "tracing", "tracing-subscriber", "udev", @@ -3282,7 +3208,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "dlib", "log", "once_cell", @@ -3303,58 +3229,64 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "zbus" -version = "5.14.0" +version = "6.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +checksum = "fcff5faa63a63d5b81051740f7382017f10b3440b3f401d97f0cf0b3b39c62d9" dependencies = [ - "async-broadcast", - "async-recursion", "async-trait", - "enumflags2", - "event-listener", + "bitflags 2.13.2", + "endi", "futures-core", - "futures-lite", "hex", "libc", "ordered-stream", "rustix 1.1.4", "serde", "serde_repr", + "socket2", "tokio", - "tracing", "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 1.0.4", "zbus_macros", - "zbus_names", - "zvariant", + "zbus_utils", + "zcheapstr", + "zruntime", ] [[package]] name = "zbus_macros" -version = "5.14.0" +version = "6.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +checksum = "5bba6e2bfe19571dc81b3e0be9f8daec6e5422db3e2eea2e766a859553e7bd37" dependencies = [ - "proc-macro-crate", "proc-macro2", "quote", - "syn", - "zbus_names", - "zvariant", - "zvariant_utils", + "syn 3.0.9", + "zbus_utils", ] [[package]] -name = "zbus_names" -version = "4.3.1" +name = "zbus_utils" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +checksum = "c27a055b114bd9fc3686e90f62bd1fe612d523fc5f620bac3b1d2f42d3f6cab8" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow 1.0.4", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" dependencies = [ "serde", - "winnow", - "zvariant", ] [[package]] @@ -3374,7 +3306,7 @@ checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3384,41 +3316,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] -name = "zvariant" -version = "5.10.0" +name = "zruntime" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +checksum = "dfc035df602243402949ddf3dcc5bcfad7edeb732dd49ec6e605431c4eca54ce" dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn", - "winnow", + "futures-core", + "futures-io", ] diff --git a/Cargo.toml b/Cargo.toml index 0bdfc86..5cda5e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,10 @@ path = "src/main.rs" name = "wo-portal" path = "src/bin/wo-portal.rs" +[[bin]] +name = "xdg-desktop-portal-wo" +path = "src/bin/wo-portal.rs" + [dependencies] # Smithay compositor framework smithay = { version = "0.7", default-features = false, features = ["backend_drm", "backend_gbm", "backend_egl", "backend_libinput", "backend_udev", "backend_session_libseat", "backend_winit", "renderer_gl", "wayland_frontend", "desktop", "xwayland", "use_system_lib", "wayland-client", "backend_session"] } @@ -21,7 +25,7 @@ calloop = { version = "0.14", features = ["executor"] } calloop-wayland-source = "0.4" # DRM/GBM -drm = "0.14" +drm = "0.15" gbm = "0.18" # EGL / GL @@ -38,13 +42,14 @@ tokio = { version = "1", features = ["full"] } nix = { version = "0.31", features = ["socket", "uio", "fs", "net", "time"] } # D-Bus and PipeWire for portal -zbus = { version = "5", default-features = false, features = ["tokio"] } -pipewire = { version = "0.9", default-features = false } +zbus = { version = "6", default-features = false, features = ["tokio", "service"] } +pipewire = { version = "0.10", default-features = false } # Config serde = { version = "1", features = ["derive"] } serde_json = "1" -toml = "1.0" +sha2 = "0.10" +toml = "1.1" # Platform syscalls (TIOCOUTQ ioctl for socket buffer checks) libc = "0.2" diff --git a/PKGBUILD b/PKGBUILD index 831dc16..cd148d3 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -10,7 +10,7 @@ license=('MIT') provides=('wo') conflicts=('wo') depends=( - 'electron' + 'electron>=45' 'libdrm' 'libinput' 'seatd' @@ -19,7 +19,8 @@ depends=( 'pipewire' 'wayland' 'xdg-desktop-portal' - 'xorg-xwayland' + 'xorg-xwayland', + 'python3' ) makedepends=( 'bun' @@ -56,6 +57,8 @@ build() { cd "$srcdir/comraw" bun install --no-audit --no-fund + rm -rf node_modules/@wl-wo/wo-types + cp -a "$srcdir/wo/electron/types" node_modules/@wl-wo/wo-types bun run build } @@ -64,6 +67,7 @@ package() { install -Dm755 target/release/wo "$pkgdir/usr/bin/wo" install -Dm755 target/release/wo-portal "$pkgdir/usr/bin/wo-portal" + install -Dm755 target/release/xdg-desktop-portal-wo "$pkgdir/usr/bin/xdg-desktop-portal-wo" install -Dm644 packaging/wayland-sessions/wo.desktop \ "$pkgdir/usr/share/wayland-sessions/wo.desktop" diff --git a/README.md b/README.md index 34443b2..f5aeb0e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,170 @@ # wo compositor -don't bother (woe) \ No newline at end of file +Wo is an Electron-centered Smithay Wayland compositor. The portal backend is +available as `xdg-desktop-portal-wo`; run `wo portals` to start it directly. +The packaged xdg-desktop-portal configuration also starts it on demand. + +## User-local install + +With Rust, Bun, and Python 3 installed, run `just install` from this checkout. +It builds Wo, Electron, and comraw, then installs them under `~/.local` and +`$XDG_DATA_HOME/wo`. On first install it copies `config.example.toml` to +`$XDG_CONFIG_HOME/wo/config.toml` and configures the copied comraw build to be +served locally. Existing user configuration is preserved. Add `~/.local/bin` +to `PATH` and select the Wo session from your display manager. + +## Portal requests in the Wo API + +Renderer code can subscribe to compositor-mediated requests and resolve them +through the context-isolated API. Requests are denied by default if the +application does not respond. + +```ts +const unsubscribe = window.woClient.portals.onRequest(async ({ requestId, payload }) => { + const allowed = await showConsentDialog(payload); + await window.woClient.portals.respond(requestId, { + allowed, + type: 'monitor', // or 'window' + windowName: null, + }); +}); +``` + +The API is also available as `window.compositor.portals` in compositor UI +contexts. Portal approval transport uses a per-user runtime socket with owner +only permissions. + +## Desktop and system APIs + +Wo exposes a typed convenience layer as `window.compositor` and +`window.woClient`: + +```ts +const directories = await window.compositor.xdg.directories(); +const apps = await window.compositor.desktop.applications.list(); +await window.compositor.desktop.applications.launch('org.gnome.Terminal.desktop'); +const icon = await window.compositor.icons.resolve('utilities-terminal', { size: 48 }); +const names = await window.compositor.dbus.listNames('user'); +``` + +Desktop entries are read from the XDG data directories, localized names and +user directories are honored, icons are resolved from XDG icon themes, and +desktop `Exec` fields are launched as argument vectors without a shell. The +D-Bus helpers use `busctl`; call arguments should include a D-Bus signature. +`window.compositor.audio` lists PipeWire audio sources and can forward PCM from +a desktop monitor source to the renderer. PipeWire and `pw-cat` must be +available. Audio forwarding is exposed separately from ScreenCast because the +standard XDG ScreenCast stream contains video, not a companion audio stream. + +## Website-first Wo API and component overlays + +Wo treats loaded pages as websites first: ordinary pages receive no compositor +bridge. A site can publish `/_wo.capabilities.json` and +`/_wo.components.css`. The manifest requests named APIs and binds the request to +the exact `index.html` SHA-256, a stylesheet SHA-256, and SHA-256 values for its +script assets. Wo prefetches and verifies those files before navigation, caches +verified assets by URL and hash, then asks the user before exposing the +requested API groups. Granting access applies only to that page/window; a +top-level navigation revokes it. Scripts for a manifest-enabled page are served +from Wo's verified cache, and unlisted script loads are blocked. + +The available permission names are `window-management`, `overlay-rendering`, +`desktop-applications`, `xdg`, `dbus`, `portals`, `filesystem`, +`process-launch`, `system-control`, `notifications`, and `audio-capture`. A +page can inspect `window.wo.permissions.requested`, `.granted`, and `.has(name)`. +The compositor bridge groups (`window.compositor` and `window.woClient`) are +present only after at least one API group is approved; unapproved groups are +omitted and their IPC operations remain denied by the main process. + +Pages granted `portals` can open a desktop context menu with +`await window.wo.portals.contextMenu({ x, y, items })`. It resolves with the +selected item ID after dismissal. Electron delegates the menu to the native +desktop popup implementation, so Wayland presents it as a popup surface and it +uses the desktop's native menu theme. Root portal UIs such as comraw's screen +share prompt can be themed with `_wo.components.css`. + +Comraw publishes `_wo.capabilities.json`, its schema, and `_wo.components.css` +from `comraw/public`. The schema is `_wo.capabilities.schema.json`. Its Vite +production build writes the current `index.html` hash, stylesheet hash, and +script hashes into the public manifest, so the request stays tied to the exact +build. The development server generates the matching index and stylesheet +hashes on request. Wo sends the verified +document and stylesheet bytes to the compositor, which computes SHA-256 itself +and stores both hashes against the owning window. `_wo.components.css` provides +component theme tokens and keyboard focus styling that Wo preloads and applies +to the page. Websites can use the same manifest format to request a window +surface or use the overlay-rendering bridge for HTML/CSS/JS surfaces. + +Sites with `window-management` can subscribe to the compositor's monitor +layout with `window.wo.windows?.monitors(callback)`. Sites with +`overlay-rendering` can pin an element to a monitor's global desktop rectangle: + +```ts +const unmount = window.wo.overlay?.mount(element, monitor.id); +// Call unmount() to restore the element's original inline positioning. +``` + +Monitor coordinates are global desktop coordinates, so pointer positions and +window placement remain consistent when an element is mounted on another +display. + +Comraw provides four persistent virtual desktops. Windows and their transient +dialogs are assigned together, hidden from other desktops, and can be moved +from the window titlebar; each monitor has an independent desktop selection, +and Ctrl+Alt+Left/Right switches desktops on the monitor under the pointer. +The DRM backend discovers connected outputs, assigns compatible CRTCs, places +them side by side, and composites the shared desktop scene into a per-output +swapchain. Native Wayland windows use the same global Smithay `Space` and can +span monitors. Newly connected DRM outputs are detected by a two-second +topology poll and attached when a compatible free CRTC is available. Unplug +cleanup and live mode replacement are not implemented yet; restart Wo after +removing a monitor or changing its mode. Workspace hiding remains a comraw +shell feature; it does not isolate native Wayland clients. Portal monitor +capture currently publishes the primary output rather than a stitched +multi-monitor image. + +## Native offscreen frames + +On Linux the Electron renderer requests `webPreferences.offscreen.useSharedTexture` +and forwards the native-pixmap DMA-BUF planes to Wo over SCM_RIGHTS. This avoids +the JS bitmap readback and Wo's RGBA-to-DMA-BUF import for normal offscreen +frames. Wo still imports the DMA-BUF into Smithay for composition. The Electron +and CEF source trees used to verify upstream support are separate checkouts; +their Linux shared-texture/accelerated-paint APIs already expose native pixmap +planes, so Wo's integration uses those APIs rather than carrying a fork of the +frame capture implementation. The CEF tree is not a current runtime backend. + +`packaging/engines` contains GN argument presets for an optional source build. +For Electron, copy `electron-wo.gn` into the Electron checkout's +`build/args/wo.gn` and import it from the Chromium `out//args.gn` as +`import("//electron/build/args/wo.gn")`. For CEF, copy `cef-wo.gn` into the CEF +checkout's `tools/wo.gn` and import it as `import("//cef/tools/wo.gn")`. Both +source builds need the matching Chromium checkout and platform toolchain. The +Arch package currently links to the system Electron package, so those presets +do not change its Electron binary. Ubuntu builds bundle Electron with +`packaging/ubuntu/build-deb.sh` and install a matching launcher/runtime pair. +See `packaging/ubuntu/README.md` for the build requirements and install command. + +## GPU rendering and native input + +Wo's compositor renderer uses Smithay GLES through EGL. Electron/Chromium +content leaves GPU backend selection to Chromium by default. Vulkan is disabled +for native Wayland Electron clients because Chromium's Wayland surface factory +does not support it; this leaves Chromium free to use its GL/EGL path. Set +`WO_ELECTRON_GPU_BACKEND=angle` or `gl` to select ANGLE OpenGL, `vulkan` for +ANGLE/Vulkan, or `desktop` for native X11 GL. To test XWayland/GLX for Electron +while Wo itself remains a Wayland compositor, launch with +`WO_ELECTRON_OZONE_PLATFORM=x11 WO_ELECTRON_GPU_BACKEND=desktop`. Wo's nested +backend prefers an inherited `WAYLAND_DISPLAY`, or discovers a `wayland-*` +socket under `XDG_RUNTIME_DIR` when launchers omit that variable. GPU support +still depends on the system driver and Chromium build. + +Wayland clients receive pointer motion, buttons, scroll axes, relative pointer +motion, and locked-pointer cursor hints through Smithay's native protocols. +Forwarded input accounts for client-surface geometry offsets so window borders +and client-side decorations do not skew pointer coordinates. + +## Headless tests + +Run the Electron renderer and buffer tests with `cd electron && bun run test`. +Run configuration tests without starting Wayland with `cargo test --lib config::tests`. diff --git a/comraw b/comraw new file mode 160000 index 0000000..807af01 --- /dev/null +++ b/comraw @@ -0,0 +1 @@ +Subproject commit 807af019a84995a047a4417d9decd1f64061384b diff --git a/config.example.toml b/config.example.toml index 3258ee1..59715eb 100644 --- a/config.example.toml +++ b/config.example.toml @@ -8,16 +8,10 @@ portal_socket = "$XDG_RUNTIME_DIR/wo-portal.sock" enable_syscalls = true focus_notify = true -# Environment variables passed to all spawned child processes. -# DISPLAY defaults to ":0" so X11 apps (e.g. Steam) work even before -# XWayland is ready. XWayland overrides DISPLAY with the actual value. -[compositor.environment] -DISPLAY = ":0" - [[root]] name = "main" -url = "http://localhost:5173" -fps = 60 +url = "http://127.0.0.1:5173" +fps = 0 # 0 follows the display refresh rate; set a positive value to cap this window focusable = false interactive = false @@ -27,5 +21,6 @@ command = "wo://portal/start" delay = 100 [[autostart]] -name = "vite" -command = "cd / \ No newline at end of file +name = "desktop-site" +command = "python3 -m http.server 5173 --bind 127.0.0.1 --directory \"$WO_DATA_DIR/comraw\" >/dev/null 2>&1" +delay = 0 diff --git a/electron/bun.lock b/electron/bun.lock index 326ec19..16a4d99 100644 --- a/electron/bun.lock +++ b/electron/bun.lock @@ -9,16 +9,17 @@ }, "devDependencies": { "@types/bun": "latest", - "@types/electron": "^latest", - "@types/node": "^latest", - "electron": "^29.0.0", - "node-gyp": "^10.0.0", - "typescript": "^latest", + "@types/node": "^25.3.5", + "electron": "^45.0.0", + "node-gyp": "^10.3.1", + "typescript": "^5.9.3", }, }, }, "packages": { - "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], + "@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.6", "", {}, "sha512-/aQLgFDdCSnwChfUwqekSmE1q2dCTzhUy5saIHBb5vyYn5LzWaKPaIz1wM0lDMCZYBoIG4svJNvAJb6vkI8BuA=="], + + "@electron/get": ["@electron/get@5.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA=="], "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], @@ -28,26 +29,10 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], - - "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], - "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], - "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], - - "@types/electron": ["@types/electron@1.6.12", "", { "dependencies": { "electron": "*" } }, "sha512-NIJokDkGv9h+MStCL1IuiL1FOHYVkszoWeNxJtSI5dcEKRGbX83JcVYNAgk019qOQgJkHtz9WdP0CDXvrArrGg=="], - - "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], - - "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], - "@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="], - "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], - - "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], - "abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], @@ -60,26 +45,16 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], - "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], - "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], - "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], "cacache": ["cacache@18.0.4", "", { "dependencies": { "@npmcli/fs": "^3.1.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^4.0.0", "ssri": "^10.0.0", "tar": "^6.1.11", "unique-filename": "^3.0.0" } }, "sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ=="], - "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], - - "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], - "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], "clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="], - "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="], - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -88,72 +63,32 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], - - "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], - - "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], - - "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], - - "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], - "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "electron": ["electron@29.4.6", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^20.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-fz8ndj8cmmf441t4Yh2FDP3Rn0JhLkVGvtUf2YVMbJ5SdJPlc0JWll9jYkhh60jDKVVCr/tBAmfxqRnXMWJpzg=="], + "electron": ["electron@45.0.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-yjz4uhMBcO6L5X7CWgRMobf2EMJPnb9j4zuf+nu/TU/6E4Sqfx7fn/a2eACo4qngjml/0JnsoZhxYroJLxLd2w=="], "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], - "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], - - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], - "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], - - "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], - "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], - "fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], - "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], - "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], - "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], - "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], - - "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], - - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - - "got": ["got@11.8.6", "", { "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" } }, "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g=="], - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], - "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], - "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -172,24 +107,10 @@ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - - "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], - - "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], - - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - - "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], - "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "make-fetch-happen": ["make-fetch-happen@13.0.1", "", { "dependencies": { "@npmcli/agent": "^2.0.0", "cacache": "^18.0.0", "http-cache-semantics": "^4.1.1", "is-lambda": "^1.0.1", "minipass": "^7.0.2", "minipass-fetch": "^3.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.3", "proc-log": "^4.2.0", "promise-retry": "^2.0.1", "ssri": "^10.0.0" } }, "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA=="], - "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], - - "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], @@ -218,14 +139,6 @@ "nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], - "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], - - "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - - "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], - "p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], @@ -234,34 +147,18 @@ "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], - "proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], - "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], - - "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], - - "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], - - "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], - "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], - "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], - - "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -274,8 +171,6 @@ "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], - "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], - "ssri": ["ssri@10.0.6", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ=="], "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -290,49 +185,33 @@ "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], - "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "undici": ["undici@7.30.1", "", {}, "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "unique-filename": ["unique-filename@3.0.0", "", { "dependencies": { "unique-slug": "^4.0.0" } }, "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g=="], "unique-slug": ["unique-slug@4.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ=="], - "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], - - "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@types/cacheable-request/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="], - - "@types/keyv/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="], - - "@types/responselike/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="], - - "@types/yauzl/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="], + "@electron/get/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], "bun-types/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="], "cacache/fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], - "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], - "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "electron/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="], + "electron/@types/node": ["@types/node@24.19.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ=="], "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -358,19 +237,11 @@ "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@types/cacheable-request/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "@types/keyv/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "@types/responselike/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "@types/yauzl/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "electron/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "electron/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/electron/dist/main.js b/electron/dist/main.js index e2acad5..fce94de 100644 --- a/electron/dist/main.js +++ b/electron/dist/main.js @@ -1,29 +1,482 @@ -import { createRequire } from "node:module"; -var __create = Object.create; -var __getProtoOf = Object.getPrototypeOf; -var __defProp = Object.defineProperty; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __toESM = (mod, isNodeMode, target) => { - target = mod != null ? __create(__getProtoOf(mod)) : {}; - const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target; - for (let key of __getOwnPropNames(mod)) - if (!__hasOwnProp.call(to, key)) - __defProp(to, key, { - get: () => mod[key], - enumerable: true - }); - return to; -}; -var __require = /* @__PURE__ */ createRequire(import.meta.url); - // src/main.ts -import { app, BrowserWindow, ipcMain } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, Menu, protocol } from "electron"; import { createConnection, createServer } from "net"; -import { fileURLToPath } from "url"; +import { fileURLToPath as fileURLToPath2 } from "url"; import { dirname, resolve as resolvePath, join as joinPath } from "path"; +import { createHash as createHash2, randomUUID } from "crypto"; import fs from "fs"; -import { createRequire as createRequire2 } from "module"; +import { createRequire } from "module"; +import { pathToFileURL } from "node:url"; + +// src/xdg.ts +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { basename, delimiter, extname, join, resolve } from "node:path"; +import { spawn, spawnSync } from "node:child_process"; +function getXdgDirectories(env = process.env) { + const home = env.HOME || "/"; + const configHome = env.XDG_CONFIG_HOME || join(home, ".config"); + const dataHome = env.XDG_DATA_HOME || join(home, ".local", "share"); + const dataDirs = splitDirs(env.XDG_DATA_DIRS || "/usr/local/share:/usr/share"); + const configDirs = splitDirs(env.XDG_CONFIG_DIRS || "/etc/xdg"); + const userDirsFile = join(configHome, "user-dirs.dirs"); + const userDirs = parseUserDirs(userDirsFile, home); + const userDir = (key, fallback) => userDirs[key] || join(home, fallback); + return { + home, + configHome, + configDirs, + dataHome, + dataDirs, + cacheHome: env.XDG_CACHE_HOME || join(home, ".cache"), + stateHome: env.XDG_STATE_HOME || join(home, ".local", "state"), + runtimeDir: env.XDG_RUNTIME_DIR || null, + desktop: userDir("XDG_DESKTOP_DIR", "Desktop"), + documents: userDir("XDG_DOCUMENTS_DIR", "Documents"), + downloads: userDir("XDG_DOWNLOAD_DIR", "Downloads"), + music: userDir("XDG_MUSIC_DIR", "Music"), + pictures: userDir("XDG_PICTURES_DIR", "Pictures"), + publicShare: userDir("XDG_PUBLICSHARE_DIR", "Public"), + templates: userDir("XDG_TEMPLATES_DIR", "Templates"), + videos: userDir("XDG_VIDEOS_DIR", "Videos") + }; +} +function splitDirs(value) { + return [...new Set(value.split(delimiter).filter(Boolean).map((dir) => resolve(dir)))]; +} +function parseUserDirs(file, home) { + if (!existsSync(file)) + return {}; + const result = {}; + for (const line of readFileSync(file, "utf8").split(/\r?\n/)) { + const match = line.match(/^\s*(XDG_[A-Z]+_DIR)="((?:\\.|[^"])*)"\s*$/); + if (!match) + continue; + const value = match[2].replace(/\\"/g, '"').replace(/\\\\/g, "\\").replace(/^\$HOME(?=\/|$)/, home); + if (value.startsWith("/")) + result[match[1]] = resolve(value); + } + return result; +} +function decodeDesktopValue(value) { + return value.replace(/\\([snrt\\])/g, (_match, code) => ({ + s: " ", + n: ` +`, + r: "\r", + t: "\t", + "\\": "\\" + })[code] ?? code); +} +function desktopKeyValues(content, locale) { + const values = new Map; + let inEntry = false; + const localized = new Map; + const language = locale.split(/[.@]/)[0]; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.startsWith("[")) { + inEntry = trimmed === "[Desktop Entry]"; + continue; + } + if (!inEntry || !trimmed || trimmed.startsWith("#")) + continue; + const splitAt = trimmed.indexOf("="); + if (splitAt < 1) + continue; + const key = trimmed.slice(0, splitAt); + const value = decodeDesktopValue(trimmed.slice(splitAt + 1)); + const localizedKey = key.match(/^Name\[(.+)\]$/); + if (localizedKey) + localized.set(localizedKey[1], value); + else + values.set(key, value); + } + if (language && localized.has(language)) + values.set("Name", localized.get(language)); + return values; +} +function availableCommand(command) { + if (!command) + return false; + if (command.includes("/")) + return existsSync(command); + const result = spawnSync("sh", ["-c", 'command -v "$1" >/dev/null 2>&1', "wo", command], { + stdio: "ignore", + env: process.env + }); + return result.status === 0; +} +function resolveDesktopIcon(name, options = {}) { + const xdg = getXdgDirectories(options.env); + const size = Math.max(16, options.size ?? 48); + const theme = options.theme || options.env?.XDG_ICON_THEME || process.env.XDG_ICON_THEME || "hicolor"; + const suffixes = extname(name) ? [""] : [".png", ".svg"]; + const candidates = []; + if (name.startsWith("/")) + candidates.push(name); + for (const suffix of suffixes) { + for (const root of [xdg.dataHome, ...xdg.dataDirs]) { + for (const iconTheme of [theme, "hicolor"]) { + const themeRoot = join(root, "icons", iconTheme); + for (const directory of iconSizeDirectories(themeRoot, size)) { + candidates.push(join(themeRoot, directory, "apps", `${name}${suffix}`)); + } + candidates.push(join(themeRoot, "apps", `${name}${suffix}`)); + } + candidates.push(join(root, "pixmaps", `${name}${suffix}`)); + } + } + const file = candidates.find((candidate) => existsSync(candidate)); + if (!file) + return; + const extension = extname(file).toLowerCase(); + const mimeType = extension === ".svg" ? "image/svg+xml" : "image/png"; + try { + return { type: "base64", data: readFileSync(file).toString("base64"), mimeType }; + } catch { + return; + } +} +function iconSizeDirectories(themeRoot, target) { + try { + return readdirSync(themeRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).filter((name) => /^(?:scalable|\d+x\d+(?:@\d+(?:\.\d+)?x)?)$/.test(name)).sort((a, b) => iconDirectoryDistance(a, target) - iconDirectoryDistance(b, target)); + } catch { + return ["scalable", `${target}x${target}`, "48x48", "32x32", "24x24", "16x16"]; + } +} +function iconDirectoryDistance(directory, target) { + if (directory === "scalable") + return 0; + const match = directory.match(/^(\d+)x\d+/); + return match ? Math.abs(Number(match[1]) - target) + 1 : Number.MAX_SAFE_INTEGER; +} +function listDesktopApplications(options = {}) { + const env = options.env || process.env; + const xdg = getXdgDirectories(env); + const locale = options.locale || env.LC_ALL || env.LC_MESSAGES || env.LANG || "C"; + const dirs = [join(xdg.dataHome, "applications"), ...xdg.dataDirs.map((dir) => join(dir, "applications"))]; + const seen = new Set; + const apps = []; + for (const dir of dirs) { + let entries; + try { + entries = readdirSync(dir).filter((entry) => entry.endsWith(".desktop")).sort(); + } catch { + continue; + } + for (const entry of entries) { + const file = join(dir, entry); + let keys; + try { + const content = readFileSync(file, "utf8"); + if (content.length > 512000) + continue; + keys = desktopKeyValues(content, locale); + } catch { + continue; + } + const id = entry; + if (seen.has(id) || keys.get("Type") !== "Application") + continue; + seen.add(id); + if (keys.get("Hidden") === "true" || keys.get("NoDisplay") === "true") + continue; + const onlyShowIn = (keys.get("OnlyShowIn") || "").split(";").filter(Boolean); + const notShowIn = (keys.get("NotShowIn") || "").split(";").filter(Boolean); + const desktops = (env.XDG_CURRENT_DESKTOP || "").split(":").filter(Boolean); + if (onlyShowIn.length && !onlyShowIn.some((desktop) => desktops.includes(desktop))) + continue; + if (notShowIn.some((desktop) => desktops.includes(desktop))) + continue; + const execLine = keys.get("Exec") || ""; + const command = parseDesktopExec(execLine).argv[0] || ""; + if (!command || keys.has("TryExec") && !availableCommand(keys.get("TryExec"))) + continue; + const name = keys.get("Name") || basename(entry, ".desktop"); + const iconName = keys.get("Icon"); + const icon = iconName ? resolveDesktopIcon(iconName, { env }) : undefined; + apps.push({ + name, + command, + desktopId: id, + desktopFile: file, + ...icon ? { icon } : {}, + ...keys.has("GenericName") ? { genericName: keys.get("GenericName") } : {}, + ...keys.has("Comment") ? { comment: keys.get("Comment") } : {}, + ...keys.has("Categories") ? { categories: keys.get("Categories").split(";").filter(Boolean) } : {}, + ...keys.has("Keywords") ? { keywords: keys.get("Keywords").split(";").filter(Boolean) } : {}, + ...keys.has("StartupWMClass") ? { startupWMClass: keys.get("StartupWMClass") } : {}, + ...keys.has("Terminal") ? { terminal: keys.get("Terminal") === "true" } : {}, + ...keys.has("StartupNotify") ? { startupNotify: keys.get("StartupNotify") === "true" } : {} + }); + } + } + return apps; +} +async function launchDesktopApplication(desktopId, files = []) { + const app = listDesktopApplications().find((entry) => entry.desktopId === desktopId || entry.name === desktopId); + if (!app) + return { ok: false, error: `Desktop application not found: ${desktopId}` }; + const keys = desktopKeyValues(readFileSync(app.desktopFile, "utf8"), process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || "C"); + const parsed = parseDesktopExec(keys.get("Exec") || "", { ...app, iconName: keys.get("Icon") }, files); + const [command, ...args] = parsed.argv; + if (!command) + return { ok: false, error: `No executable in desktop entry: ${desktopId}` }; + return new Promise((resolveResult) => { + try { + const child = spawn(command, args, { detached: true, stdio: "ignore", env: process.env }); + child.once("error", (error) => resolveResult({ ok: false, error: error.message })); + child.once("spawn", () => { + child.unref(); + resolveResult({ ok: true, pid: child.pid }); + }); + } catch (error) { + resolveResult({ ok: false, error: String(error) }); + } + }); +} +function parseDesktopExec(exec, app, files = []) { + const tokens = exec.match(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s]+/g) || []; + const argv = []; + for (const token of tokens) { + const unquoted = token.replace(/^['"]|['"]$/g, "").replace(/\\(["'\\`$])/g, "$1"); + if (unquoted === "%i") { + if (app?.iconName) + argv.push("--icon", app.iconName); + continue; + } + if (unquoted === "%F" || unquoted === "%U") { + argv.push(...files); + continue; + } + const value = unquoted.replace(/%%|%[fFuUdDnNickvm]/g, (code) => { + if (code === "%%") + return "%"; + if (code === "%c") + return app?.name || ""; + if (code === "%k") + return app?.desktopFile || ""; + if (code === "%f" || code === "%u") + return files[0] || ""; + return ""; + }); + if (value) + argv.push(value); + } + return { argv }; +} + +// src/wo-capabilities.ts +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +var WO_CAPABILITIES = [ + "window-management", + "overlay-rendering", + "desktop-applications", + "xdg", + "dbus", + "portals", + "filesystem", + "process-launch", + "system-control", + "notifications", + "audio-capture" +]; +var MAX_INDEX_BYTES = 4 * 1024 * 1024; +var MAX_ASSET_BYTES = 16 * 1024 * 1024; +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} +function isHash(value) { + return typeof value === "string" && /^[a-f0-9]{64}$/.test(value); +} +function validateWoManifest(value, indexHash, pageUrl) { + if (!value || typeof value !== "object") + return null; + const m = value; + let page; + try { + page = new URL(pageUrl); + } catch { + return null; + } + if (m.schemaVersion !== 1 || typeof m.appId !== "string" || !/^[a-zA-Z0-9._-]{1,128}$/.test(m.appId)) + return null; + if (m.indexSha256 !== indexHash || m.origin !== undefined && m.origin !== page.origin) + return null; + if (!Array.isArray(m.capabilities) || !m.capabilities.every((cap) => WO_CAPABILITIES.includes(cap))) + return null; + if (!m.componentsCss || typeof m.componentsCss.url !== "string" || !isHash(m.componentsCss.sha256)) + return null; + if (!Array.isArray(m.scripts) || m.scripts.length > 256) + return null; + if (m.styles !== undefined && (!Array.isArray(m.styles) || m.styles.length > 256)) + return null; + const trustedPath = (path) => { + try { + const resolved = new URL(path, page); + return resolved.origin === page.origin && (resolved.protocol === "https:" || resolved.protocol === "http:" || resolved.protocol === "file:"); + } catch { + return false; + } + }; + if (!trustedPath(m.componentsCss.url)) + return null; + const seen = new Set; + for (const script of m.scripts) { + if (!script || typeof script.url !== "string" || !isHash(script.sha256) || !trustedPath(script.url)) + return null; + const absolute = new URL(script.url, page).href; + if (seen.has(absolute)) + return null; + seen.add(absolute); + } + for (const style of m.styles ?? []) { + if (!style || typeof style.url !== "string" || !isHash(style.sha256) || !trustedPath(style.url)) + return null; + const absolute = new URL(style.url, page).href; + if (seen.has(absolute)) + return null; + seen.add(absolute); + } + return m; +} +async function readUrl(url, maxBytes, fetcher) { + try { + let bytes; + if (new URL(url).protocol === "file:") { + bytes = await readFile(fileURLToPath(url)); + } else { + const response = await fetcher(url); + if (!response.ok) + return null; + bytes = new Uint8Array(await response.arrayBuffer()); + } + return bytes.byteLength <= maxBytes ? bytes : null; + } catch { + return null; + } +} +var assetCache = new Map; +async function loadAsset(url, hash, maxBytes, fetcher) { + const cacheKey = `${url}#${hash}`; + const cached = assetCache.get(cacheKey); + if (cached) + return cached; + const bytes = await readUrl(url, maxBytes, fetcher); + if (!bytes || sha256(bytes) !== hash) + return null; + assetCache.set(cacheKey, bytes); + return bytes; +} +async function prefetchWoSiteAssets(pageUrl, fetcher = async (url) => { + try { + const response = await fetch(url); + if (response.ok) + return response; + const parsed = new URL(url); + if (parsed.hostname !== "localhost" || parsed.protocol !== "http:") + return response; + parsed.hostname = "127.0.0.1"; + return await fetch(parsed.href); + } catch (error) { + const parsed = new URL(url); + if (parsed.hostname !== "localhost" || parsed.protocol !== "http:") + throw error; + parsed.hostname = "127.0.0.1"; + return await fetch(parsed.href); + } +}) { + const empty = (trustError, indexSha256 = "") => ({ + pageUrl, + appId: "website", + capabilities: [], + styles: "", + stylesUrl: "", + stylesHash: "", + styleBodies: [], + scripts: [], + scriptBodies: [], + indexSha256, + indexBytes: new Uint8Array, + stylesBytes: new Uint8Array, + trusted: false, + trustError + }); + let page; + try { + page = new URL(pageUrl); + } catch { + return empty("invalid page URL"); + } + if (!["http:", "https:", "file:"].includes(page.protocol)) + return empty(`unsupported page protocol: ${page.protocol}`); + const index = await readUrl(page.href, MAX_INDEX_BYTES, fetcher); + if (!index) + return empty(`could not read page: ${page.href}`); + const indexSha256 = sha256(index); + const manifestUrl = new URL("_wo.capabilities.json", new URL(".", page)).href; + const manifestBytes = await readUrl(manifestUrl, 256 * 1024, fetcher); + if (!manifestBytes) + return empty(`could not read capability manifest: ${manifestUrl}`, indexSha256); + let parsed; + try { + parsed = JSON.parse(new TextDecoder().decode(manifestBytes)); + } catch { + return empty("capability manifest is not valid JSON", indexSha256); + } + const manifest = validateWoManifest(parsed, indexSha256, page.href); + if (!manifest) + return empty("capability manifest does not match this page or contains invalid fields", indexSha256); + const cssUrl = new URL(manifest.componentsCss.url, page).href; + const css = await loadAsset(cssUrl, manifest.componentsCss.sha256, 1024 * 1024, fetcher); + if (!css) + return empty(`component stylesheet failed SHA-256 verification: ${cssUrl}`, indexSha256); + const scripts = []; + const scriptBodies = []; + for (const script of manifest.scripts) { + const scriptUrl = new URL(script.url, page).href; + const bytes = await loadAsset(scriptUrl, script.sha256, MAX_ASSET_BYTES, fetcher); + if (!bytes) + return empty(`script failed SHA-256 verification: ${scriptUrl}`, indexSha256); + scripts.push(scriptUrl); + scriptBodies.push({ url: scriptUrl, hash: script.sha256, bytes }); + } + const styleBodies = []; + for (const style of manifest.styles ?? []) { + const styleUrl = new URL(style.url, page).href; + const bytes = await loadAsset(styleUrl, style.sha256, MAX_ASSET_BYTES, fetcher); + if (!bytes) + return empty(`stylesheet failed SHA-256 verification: ${styleUrl}`, indexSha256); + styleBodies.push({ url: styleUrl, hash: style.sha256, bytes }); + } + return { + pageUrl: page.href, + appId: manifest.appId, + capabilities: [...new Set(manifest.capabilities)], + styles: new TextDecoder().decode(css), + stylesUrl: cssUrl, + stylesHash: manifest.componentsCss.sha256, + styleBodies, + scripts, + scriptBodies, + indexSha256, + indexBytes: index, + stylesBytes: css, + trusted: true + }; +} + +// src/frame-pacing.ts +function chooseFrameRate(configuredFps, displayFps) { + const display = Number.isFinite(displayFps) && (displayFps ?? 0) > 0 ? Math.round(displayFps) : 60; + const configured = Number.isFinite(configuredFps) && (configuredFps ?? 0) > 0 ? Math.round(configuredFps) : display; + return Math.max(1, Math.min(configured, display)); +} +function frameIntervalMs(fps) { + return 1000 / Math.max(1, fps); +} // src/protocol.ts var MAGIC = { @@ -45,9 +498,14 @@ var MAGIC = { FORWARD_POINTER: 1464815685, FORWARD_KEYBOARD: 1464814405, FORWARD_RELATIVE_POINTER: 1464816197, + FORWARD_POINTER_BUTTON: 1464815682, + FORWARD_POINTER_SCROLL: 1464815699, + FORWARD_POINTER_LEAVE: 1464815694, POINTER_LOCK_REQUEST: 1464815692, ENV_UPDATE: 1464812885, - SCREENCOPY_EVENT: 1464816453 + SCREENCOPY_EVENT: 1464816453, + WINDOW_SIZE: 1464816218, + SITE_HASHES: 1464816456 }; function stringToBuffer(str) { return Buffer.from(str, "utf-8"); @@ -103,11 +561,13 @@ class DamageBuffer { if (rect.width <= 0 || rect.height <= 0) { return; } - const patchStride = patch.stride ?? rect.width * 4; + const patchStride = patch.stride ?? patch.rect.width * 4; + const sourceX = rect.x - patch.rect.x; + const sourceY = rect.y - patch.rect.y; const dstStartX = rect.x * 4; const rowBytes = rect.width * 4; for (let row = 0;row < rect.height; row += 1) { - const srcStart = row * patchStride; + const srcStart = (sourceY + row) * patchStride + sourceX * 4; const dstStart = (rect.y + row) * this.stride + dstStartX; this.frame.set(new Uint8Array(patch.rgba.buffer, patch.rgba.byteOffset + srcStart, rowBytes), dstStart); } @@ -121,22 +581,25 @@ class DamageBuffer { class WoDmabufSender { socket; - windowName; native; seq = 1n; wireBuf; + sharedWireBuf; + nameBuf; nameLen; seqOffset; widthOffset; heightOffset; + pendingReleases = new Map; constructor(socket, windowName, native) { this.socket = socket; - this.windowName = windowName; this.native = native; const nameBuf = Buffer.from(windowName, "utf8"); + this.nameBuf = nameBuf; this.nameLen = nameBuf.length; const totalLen = 32 + this.nameLen + 16; this.wireBuf = Buffer.alloc(totalLen); + this.sharedWireBuf = Buffer.alloc(32 + this.nameLen + 4 * 16); let off = 0; this.wireBuf.writeUInt32LE(MAGIC.FRAME, off); off += 4; @@ -155,7 +618,12 @@ class WoDmabufSender { this.wireBuf.writeUInt32LE(1, off); } send(buffer) { + if (buffer.getWidth() <= 0 || buffer.getHeight() <= 0 || buffer.getStride() < buffer.getWidth() * 4 || buffer.getFrameBuffer().byteLength < buffer.getStride() * buffer.getHeight()) { + throw new RangeError("Cannot send an incomplete or zero-sized DMA-BUF frame"); + } const imported = this.native.importRgba(buffer.getFrameBuffer(), buffer.getWidth(), buffer.getHeight(), buffer.getStride()); + let sent = false; + const sentSeq = this.seq; try { const socketFd = getSocketFd(this.socket); this.wireBuf.writeBigUInt64LE(this.seq, this.seqOffset); @@ -168,13 +636,94 @@ class WoDmabufSender { this.wireBuf.writeUInt32LE(imported.modifier_lo, planeOff + 12); writeSync(socketFd, this.wireBuf, 0, this.wireBuf.length); this.native.sendFd(socketFd, imported.fd); - const sentSeq = this.seq; + this.pendingReleases.set(sentSeq.toString(), () => this.native.releaseBuffer(imported.token)); this.seq += 1n; + sent = true; return sentSeq; } finally { - this.native.releaseBuffer(imported.token); + if (!sent) + this.native.releaseBuffer(imported.token); } } + sendSharedTexture(texture, release) { + const pixmap = texture.handle?.nativePixmap; + const planes = pixmap?.planes; + if (!planes || planes.length < 1 || planes.length > 4) { + throw new Error("shared texture has no supported DMA-BUF planes"); + } + const fourcc = texture.pixelFormat === "bgra" ? fourccCode("A", "R", "2", "4") : texture.pixelFormat === "rgba" ? fourccCode("A", "B", "2", "4") : texture.pixelFormat === "nv12" ? fourccCode("N", "V", "1", "2") : 0; + if (fourcc === 0) + throw new Error(`unsupported shared texture pixel format: ${texture.pixelFormat}`); + const width = texture.codedSize.width; + const height = texture.codedSize.height; + if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) { + throw new Error("shared texture has invalid coded dimensions"); + } + const headerLength = 32 + this.nameLen + planes.length * 16; + let off = 0; + this.sharedWireBuf.writeUInt32LE(MAGIC.FRAME, off); + off += 4; + this.sharedWireBuf.writeUInt32LE(this.nameLen, off); + off += 4; + this.nameBuf.copy(this.sharedWireBuf, off); + off += this.nameLen; + this.sharedWireBuf.writeBigUInt64LE(this.seq, off); + off += 8; + this.sharedWireBuf.writeUInt32LE(width, off); + off += 4; + this.sharedWireBuf.writeUInt32LE(height, off); + off += 4; + this.sharedWireBuf.writeUInt32LE(fourcc, off); + off += 4; + this.sharedWireBuf.writeUInt32LE(planes.length, off); + off += 4; + const modifier = BigInt(pixmap.modifier || "0"); + for (const plane of planes) { + this.sharedWireBuf.writeUInt32LE(plane.offset >>> 0, off); + off += 4; + this.sharedWireBuf.writeUInt32LE(plane.stride >>> 0, off); + off += 4; + this.sharedWireBuf.writeUInt32LE(Number(modifier >> 32n & 0xffffffffn), off); + off += 4; + this.sharedWireBuf.writeUInt32LE(Number(modifier & 0xffffffffn), off); + off += 4; + } + const socketFd = getSocketFd(this.socket); + for (const plane of planes) { + if (!Number.isInteger(plane.fd) || plane.fd < 0 || !Number.isInteger(plane.stride) || plane.stride <= 0 || !Number.isInteger(plane.offset) || plane.offset < 0) { + throw new Error("shared texture has invalid DMA-BUF plane metadata"); + } + } + writeAllSync(socketFd, this.sharedWireBuf, headerLength); + for (const plane of planes) + this.native.sendFd(socketFd, plane.fd); + const sentSeq = this.seq; + this.pendingReleases.set(sentSeq.toString(), release); + this.seq += 1n; + return sentSeq; + } + acknowledge(seq) { + const key = seq.toString(); + const release = this.pendingReleases.get(key); + if (!release) + return; + this.pendingReleases.delete(key); + release(); + } + releaseAll() { + for (const release of this.pendingReleases.values()) + release(); + this.pendingReleases.clear(); + } +} +function writeAllSync(fd, buffer, length) { + let offset = 0; + while (offset < length) { + const written = writeSync(fd, buffer, offset, length - offset); + if (written <= 0) + throw new Error("IPC socket closed while sending a DMA-BUF frame"); + offset += written; + } } function applyDamagePayload(buffer, payload) { if (payload.width !== buffer.getWidth() || payload.height !== buffer.getHeight()) { @@ -210,9 +759,430 @@ function fourccCode(a, b, c, d) { } // src/main.ts -var __filename2 = fileURLToPath(import.meta.url); +var __filename2 = fileURLToPath2(import.meta.url); var __dirname2 = dirname(__filename2); -var require2 = createRequire2(import.meta.url); +var require2 = createRequire(import.meta.url); +protocol.registerSchemesAsPrivileged([{ + scheme: "wo-cache", + privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true, codeCache: true } +}]); +var verifiedScriptCache = new Map; +var approvedResourceRequests = new Map; +var approvedAllResourcePages = new Set; +var resourceConsentQueue = Promise.resolve(); +var MAX_APPROVED_RESOURCE_BYTES = 16 * 1024 * 1024; +function setupWoCacheProtocol() { + protocol.handle("wo-cache", (request) => { + const url = new URL(request.url); + const [, hash = "", kind = "script.js", encodedSourceUrl = ""] = url.pathname.split("/"); + const bytes = verifiedScriptCache.get(hash); + if (!bytes) { + debugLog(`[Wo] Verified-script cache miss: ${url.pathname}`); + return new Response("Missing verified Wo script", { status: 404 }); + } + debugLog(`[Wo] Serving verified script ${hash.slice(0, 12)} (${bytes.byteLength} bytes)`); + const isStylesheet = kind === "style.css"; + let responseBytes = bytes; + if (encodedSourceUrl) { + try { + const sourceUrl = Buffer.from(encodedSourceUrl, "base64url").toString("utf8"); + responseBytes = isStylesheet ? rewriteStylesheetUrls(bytes, sourceUrl) : rewriteModuleImports(bytes, sourceUrl); + } catch (error) { + debugLog(`[Wo] Could not rewrite cached module imports for ${hash.slice(0, 12)}: ${String(error)}`); + } + } + return new Response(responseBytes, { + headers: { + "Content-Type": isStylesheet ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8", + "Access-Control-Allow-Origin": "*", + "Cross-Origin-Resource-Policy": "cross-origin", + "Cache-Control": "public, max-age=31536000, immutable" + } + }); + }); +} +function rewriteStylesheetUrls(bytes, sourceUrl) { + const css = bytes.toString("utf8"); + const rewritten = css.replace(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*?))\s*\)/gi, (whole, doubleQuoted, singleQuoted, bare) => { + const value = String(doubleQuoted ?? singleQuoted ?? bare ?? "").trim(); + if (!value || /^(?:data:|blob:|#|[a-z][a-z\d+.-]*:|\/\/)/i.test(value)) + return whole; + try { + return `url(${JSON.stringify(new URL(value, sourceUrl).href)})`; + } catch { + return whole; + } + }); + return Buffer.from(rewritten); +} +function rewriteModuleImports(bytes, sourceUrl) { + const source = bytes.toString("utf8"); + const absolute = (specifier) => { + try { + return new URL(specifier, sourceUrl).href; + } catch { + return specifier; + } + }; + const tokens = []; + const isIdentifierStart = (char) => /[A-Za-z_$]/.test(char); + const isIdentifierPart = (char) => /[\w$]/.test(char); + let i = 0; + let previousToken = ""; + while (i < source.length) { + const char = source[i]; + if (/\s/.test(char)) { + i++; + continue; + } + if (char === "/" && source[i + 1] === "/") { + i += 2; + while (i < source.length && source[i] !== ` +`) + i++; + continue; + } + if (char === "/" && source[i + 1] === "*") { + i += 2; + while (i < source.length && !(source[i] === "*" && source[i + 1] === "/")) + i++; + i = Math.min(source.length, i + 2); + continue; + } + if (char === "/" && !["", "(", "[", "{", "=", ":", ",", ";", "!", "?", "&&", "||", "=>", "return"].includes(previousToken)) { + tokens.push({ kind: "punctuation", value: "/", start: i, end: i + 1 }); + previousToken = "/"; + i++; + continue; + } + if (char === "/" && ["(", "[", "{", "=", ":", ",", ";", "!", "?", "&&", "||", "=>", "return"].includes(previousToken)) { + const start = i++; + let inClass = false; + while (i < source.length) { + if (source[i] === "\\") { + i += 2; + continue; + } + if (source[i] === "[") + inClass = true; + else if (source[i] === "]") + inClass = false; + else if (source[i] === "/" && !inClass) { + i++; + break; + } else if (source[i] === ` +`) + break; + i++; + } + while (/[A-Za-z]/.test(source[i] || "")) + i++; + tokens.push({ kind: "punctuation", value: "regex", start, end: i }); + previousToken = "regex"; + continue; + } + if (char === '"' || char === "'") { + const start = i++; + while (i < source.length) { + if (source[i] === "\\") { + i += 2; + continue; + } + if (source[i++] === char) + break; + } + const raw = source.slice(start + 1, Math.max(start + 1, i - 1)); + tokens.push({ kind: "string", value: raw, start, end: i }); + previousToken = "string"; + continue; + } + if (char === "`") { + const start = i++; + while (i < source.length) { + if (source[i] === "\\") { + i += 2; + continue; + } + if (source[i++] === "`") + break; + } + tokens.push({ kind: "punctuation", value: "template", start, end: i }); + previousToken = "template"; + continue; + } + if (isIdentifierStart(char)) { + const start = i++; + while (i < source.length && isIdentifierPart(source[i])) + i++; + const value = source.slice(start, i); + tokens.push({ kind: "identifier", value, start, end: i }); + previousToken = value; + continue; + } + const start = i++; + const value = source.slice(start, i); + tokens.push({ kind: "punctuation", value, start, end: i }); + previousToken = value; + } + const edits = []; + for (let t = 0;t < tokens.length; t++) { + const token = tokens[t]; + if (token.kind !== "identifier" || token.value !== "import" && token.value !== "export") + continue; + const next = tokens[t + 1]; + if (token.value === "import" && next?.value === "(") { + const specifier = tokens[t + 2]; + if (specifier?.kind === "string") { + edits.push({ start: specifier.start, end: specifier.end, replacement: JSON.stringify(absolute(specifier.value)) }); + } + continue; + } + if (token.value === "import" && next?.kind === "string") { + edits.push({ start: next.start, end: next.end, replacement: JSON.stringify(absolute(next.value)) }); + continue; + } + if (token.value === "import" && next?.value === ".") + continue; + for (let j = t + 1;j < tokens.length && j <= t + 256; j++) { + const candidate = tokens[j]; + if (candidate.value === ";" || j > t + 1 && candidate.kind === "identifier" && (candidate.value === "import" || candidate.value === "export")) + break; + if (candidate.kind === "identifier" && candidate.value === "from" && tokens[j + 1]?.kind === "string") { + const specifier = tokens[j + 1]; + edits.push({ start: specifier.start, end: specifier.end, replacement: JSON.stringify(absolute(specifier.value)) }); + break; + } + } + } + let rewritten = source; + for (const edit of edits.sort((a, b) => b.start - a.start)) { + rewritten = rewritten.slice(0, edit.start) + edit.replacement + rewritten.slice(edit.end); + } + return Buffer.from(rewritten); +} +function woCacheUrl(hash, resourceUrl, isStylesheet) { + const source = Buffer.from(resourceUrl).toString("base64url"); + return `wo-cache://cache/${hash}/${isStylesheet ? "style.css" : "script.js"}/${source}`; +} +function resourceApprovalKey(pageUrl, resourceUrl) { + return createHash2("sha256").update(`${pageUrl} +${resourceUrl}`).digest("hex"); +} +function resourceCacheDirectory() { + return joinPath(app.getPath("userData"), "wo-approved-resources"); +} +async function loadApprovedResource(pageUrl, resourceUrl) { + const directory = resourceCacheDirectory(); + const key = resourceApprovalKey(pageUrl, resourceUrl); + try { + const record = JSON.parse(await fs.promises.readFile(joinPath(directory, `${key}.json`), "utf8")); + if (record.pageUrl !== pageUrl || record.resourceUrl !== resourceUrl || typeof record.hash !== "string" || !/^[a-f0-9]{64}$/.test(record.hash)) + return null; + const bytes = await fs.promises.readFile(joinPath(directory, `${record.hash}.bin`)); + const actualHash = createHash2("sha256").update(bytes).digest("hex"); + if (actualHash !== record.hash || bytes.byteLength > MAX_APPROVED_RESOURCE_BYTES) + return null; + verifiedScriptCache.set(actualHash, bytes); + debugLog(`[Wo] Loaded user-approved resource cache ${actualHash.slice(0, 12)} for ${resourceUrl}`); + return actualHash; + } catch { + return null; + } +} +async function fetchApprovedResourceBytes(resourceUrl) { + const parsed = new URL(resourceUrl); + let bytes; + if (parsed.protocol === "file:") { + const path = fileURLToPath2(parsed); + const fileStat = await fs.promises.stat(path); + if (fileStat.size > MAX_APPROVED_RESOURCE_BYTES) + throw new Error("resource exceeds the 16 MiB approval cache limit"); + bytes = await fs.promises.readFile(path); + } else if (parsed.protocol === "http:" || parsed.protocol === "https:") { + let response; + try { + response = await fetch(resourceUrl, { redirect: "error" }); + } catch (error) { + if (parsed.protocol !== "http:" || parsed.hostname !== "localhost") + throw error; + parsed.hostname = "127.0.0.1"; + response = await fetch(parsed.href, { redirect: "error" }); + } + if (!response.ok) + throw new Error(`request returned HTTP ${response.status}`); + const contentLength = Number(response.headers.get("content-length") || 0); + if (contentLength > MAX_APPROVED_RESOURCE_BYTES) + throw new Error("resource exceeds the 16 MiB approval cache limit"); + if (!response.body) + throw new Error("resource response has no body"); + const reader = response.body.getReader(); + const chunks = []; + let totalBytes = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) + break; + totalBytes += value.byteLength; + if (totalBytes > MAX_APPROVED_RESOURCE_BYTES) { + await reader.cancel(); + throw new Error("resource exceeds the 16 MiB approval cache limit"); + } + chunks.push(Buffer.from(value)); + } + bytes = Buffer.concat(chunks, totalBytes); + } else { + throw new Error(`unsupported resource protocol: ${parsed.protocol}`); + } + if (bytes.byteLength === 0) + throw new Error("resource is empty"); + if (bytes.byteLength > MAX_APPROVED_RESOURCE_BYTES) + throw new Error("resource exceeds the 16 MiB approval cache limit"); + return bytes; +} +async function persistApprovedResource(pageUrl, resourceUrl, hash, bytes) { + const directory = resourceCacheDirectory(); + const key = resourceApprovalKey(pageUrl, resourceUrl); + await fs.promises.mkdir(directory, { recursive: true, mode: 448 }); + const bytesPath = joinPath(directory, `${hash}.bin`); + try { + await fs.promises.access(bytesPath); + } catch { + await fs.promises.writeFile(bytesPath, bytes, { mode: 384, flag: "wx" }).catch((error) => { + if (error.code !== "EEXIST") + throw error; + }); + } + const recordPath = joinPath(directory, `${key}.json`); + const temporaryPath = `${recordPath}.${randomUUID()}.tmp`; + await fs.promises.writeFile(temporaryPath, JSON.stringify({ pageUrl, resourceUrl, hash }), { mode: 384 }); + await fs.promises.rename(temporaryPath, recordPath); +} +function promptForResourceApproval(pageUrl, resourceUrl, resourceType, portalStyles) { + const prompt = resourceConsentQueue.then(async () => { + if (approvedAllResourcePages.has(pageUrl)) + return "allow"; + debugLog(`[Wo] Waiting for user approval to fetch and cache: ${resourceUrl}`); + const decision = await showResourceConsentOverlay(pageUrl, resourceUrl, resourceType, portalStyles); + if (decision === "allow-all") + approvedAllResourcePages.add(pageUrl); + debugLog(`[Wo] User ${decision} resource: ${resourceUrl}`); + return decision; + }); + resourceConsentQueue = prompt.then(() => { + return; + }, () => { + return; + }); + return prompt; +} +async function showResourceConsentOverlay(pageUrl, resourceUrl, resourceType, portalStyles) { + const targetWindow = mainWindow; + if (!targetWindow || targetWindow.isDestroyed()) + return "deny"; + if (!mainPageLoadFinished && !resourceConsentUiActive) { + await new Promise((resolve) => { + const ready = () => { + clearTimeout(timeout); + targetWindow.webContents.removeListener("did-finish-load", ready); + resolve(); + }; + const timeout = setTimeout(ready, 5000); + targetWindow.webContents.once("did-finish-load", ready); + }); + } + if (targetWindow.isDestroyed()) + return "deny"; + const requestId = randomUUID(); + const token = randomUUID(); + const payload = JSON.stringify({ requestId, token, pageUrl, resourceUrl, resourceType }).replace(/Wo security portal +
+

Allow this page to load a resource?

This resource is not covered by the page's verified Wo manifest.

+
Resource type
Requested URL
Page
+

Approve all allows this page's future script, worker, and stylesheet requests for this session.

+

+
+ + `; + return new Promise((resolve) => { + resourceApprovalPending = { requestId, token, resolve }; + resourceConsentUiActive = true; + targetWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}#wo-resource-consent`).then(() => { + debugLog(`[Wo] Displayed resource approval portal in the compositor root surface: ${resourceUrl}`); + }).catch((error) => { + if (resourceApprovalPending?.requestId === requestId) + resourceApprovalPending = null; + resourceConsentUiActive = false; + debugLog(`[Wo] Could not load resource approval portal: ${String(error)}`); + resolve("deny"); + if (!targetWindow.isDestroyed()) { + targetWindow.loadURL(pageUrl).catch((restoreError) => { + debugLog(`[Wo] Could not restore page after approval UI failure: ${String(restoreError)}`); + }); + } + }); + }); +} +function handleResourceConsentNavigation(event, targetUrl) { + if (!targetUrl.startsWith("wo-consent://")) + return; + event.preventDefault(); + try { + const decision = new URL(targetUrl); + if (decision.hostname !== "decision") + return; + const action = decision.searchParams.get("action"); + resolveResourceConsent(decision.pathname.slice(1), decision.searchParams.get("token") || "", action === "allow-all" ? "allow-all" : action === "allow" ? "allow" : "deny"); + } catch {} +} +function resolveResourceConsent(requestId, token, decision) { + const pending = resourceApprovalPending; + if (!resourceConsentUiActive || !pending || pending.requestId !== requestId || pending.token !== token) + return false; + resourceApprovalPending = null; + pending.resolve(decision); + return true; +} +async function approveAndCacheResource(pageUrl, resourceUrl, resourceType, portalStyles) { + const requestKey = resourceApprovalKey(pageUrl, resourceUrl); + const pending = approvedResourceRequests.get(requestKey); + if (pending) + return pending; + const operation = (async () => { + const cachedHash = await loadApprovedResource(pageUrl, resourceUrl); + if (cachedHash) + return cachedHash; + if (await promptForResourceApproval(pageUrl, resourceUrl, resourceType, portalStyles) === "deny") + return null; + const bytes = await fetchApprovedResourceBytes(resourceUrl); + const hash = createHash2("sha256").update(bytes).digest("hex"); + await persistApprovedResource(pageUrl, resourceUrl, hash, bytes); + verifiedScriptCache.set(hash, bytes); + debugLog(`[Wo] Cached user-approved resource ${hash.slice(0, 12)} (${bytes.byteLength} bytes): ${resourceUrl}`); + return hash; + })(); + approvedResourceRequests.set(requestKey, operation); + try { + return await operation; + } finally { + approvedResourceRequests.delete(requestKey); + } +} var DEBUG_LOG = "/tmp/wo-electron-debug.log"; var _logBuffer = []; var _logTimer = null; @@ -235,28 +1205,88 @@ function debugLog(...args) { console.log(...args); } debugLog("[Wo] Electron starting, NODE_VERSION=", process.version); -app.commandLine.appendSwitch("use-gl", "angle"); -app.commandLine.appendSwitch("use-angle", "vulkan"); -app.commandLine.appendSwitch("ozone-platform", "wayland"); -app.commandLine.appendSwitch("no-sandbox"); -app.commandLine.appendSwitch("disable-gpu-sandbox"); +var requestedOzonePlatform = (process.env.WO_ELECTRON_OZONE_PLATFORM || "auto").toLowerCase(); +var defaultGpuBackend = "angle"; +var requestedGpuBackend = (process.env.WO_ELECTRON_GPU_BACKEND || defaultGpuBackend).toLowerCase(); +var gpuBackend = ["vulkan", "gl", "angle", "desktop", "auto"].includes(requestedGpuBackend) ? requestedGpuBackend : defaultGpuBackend; +if (gpuBackend === "desktop") { + app.commandLine.appendSwitch("use-gl", "desktop"); +} else if (gpuBackend === "angle") { + app.commandLine.appendSwitch("use-gl", "angle"); + app.commandLine.appendSwitch("use-angle", "gl"); +} else if (gpuBackend !== "auto") { + app.commandLine.appendSwitch("use-gl", "angle"); + app.commandLine.appendSwitch("use-angle", gpuBackend); +} +if (requestedOzonePlatform === "wayland" || requestedOzonePlatform === "x11") { + app.commandLine.appendSwitch("ozone-platform", requestedOzonePlatform); +} app.commandLine.appendSwitch("ignore-gpu-blocklist"); -app.commandLine.appendSwitch("enable-gpu-rasterization"); -app.commandLine.appendSwitch("enable-zero-copy"); -app.commandLine.appendSwitch("enable-native-gpu-memory-buffers"); -app.commandLine.appendSwitch("disable-software-rasterizer"); -app.commandLine.appendSwitch("enable-features", "CanvasOopRasterization,Vulkan,VaapiVideoDecoder,VaapiVideoEncoder,SharedArrayBuffer,RawDraw,DefaultANGLEVulkan,VulkanFromANGLE"); +var chromiumFeatures = [ + "VaapiVideoDecoder", + "VaapiVideoEncoder", + "SharedArrayBuffer" +]; +if (gpuBackend === "vulkan") { + chromiumFeatures.push("Vulkan", "DefaultANGLEVulkan", "VulkanFromANGLE"); +} else if (requestedOzonePlatform === "wayland" || gpuBackend !== "auto") { + app.commandLine.appendSwitch("disable-vulkan"); + app.commandLine.appendSwitch("disable-features", "Vulkan"); +} +app.commandLine.appendSwitch("enable-features", chromiumFeatures.join(",")); app.commandLine.appendSwitch("enable-accelerated-video-decode"); -app.commandLine.appendSwitch("disable-frame-rate-limit"); -app.commandLine.appendSwitch("disable-gpu-vsync"); app.commandLine.appendSwitch("num-raster-threads", "4"); app.commandLine.appendSwitch("disable-renderer-backgrounding"); -debugLog("[Wo] GPU acceleration flags applied"); +debugLog(`[Wo] GPU backend preference: ${gpuBackend}; Ozone platform: ${requestedOzonePlatform}`); +function logGraphicsRuntime() { + const relevantEnv = [ + "WAYLAND_DISPLAY", + "DISPLAY", + "XDG_SESSION_TYPE", + "XDG_RUNTIME_DIR", + "EGL_PLATFORM", + "__EGL_VENDOR_LIBRARY_FILENAMES", + "__GLX_VENDOR_LIBRARY_NAME", + "GBM_BACKENDS_PATH", + "LIBGL_DRIVERS_PATH", + "MESA_LOADER_DRIVER_OVERRIDE", + "DRI_PRIME", + "NVIDIA_VISIBLE_DEVICES" + ]; + const env = Object.fromEntries(relevantEnv.filter((key) => process.env[key] !== undefined).map((key) => [key, process.env[key]])); + const renderNode = process.env.WO_DRM_RENDER_NODE || "/dev/dri/renderD128"; + let renderNodeAccess; + try { + const stat = fs.statSync(renderNode); + fs.accessSync(renderNode, fs.constants.R_OK | fs.constants.W_OK); + renderNodeAccess = { path: renderNode, mode: (stat.mode & 511).toString(8), readable: true, writable: true }; + } catch (error) { + renderNodeAccess = { path: renderNode, error: String(error) }; + } + const switches = ["ozone-platform", "use-gl", "use-angle", "disable-gpu", "disable-vulkan", "disable-features"].map((name) => ({ name, present: app.commandLine.hasSwitch(name), value: app.commandLine.getSwitchValue(name) })); + debugLog("[Wo] Graphics process context:", JSON.stringify({ + uid: typeof process.getuid === "function" ? process.getuid() : null, + groups: typeof process.getgroups === "function" ? process.getgroups() : [], + env, + renderNodeAccess, + switches + })); +} +logGraphicsRuntime(); var IPC_SOCKET = process.env.WO_IPC_SOCKET || "/run/user/1000/wo-ipc.sock"; debugLog("[Wo] WO_WINDOW_CONFIG env =", process.env.WO_WINDOW_CONFIG); var WINDOW_CONFIG = JSON.parse(process.env.WO_WINDOW_CONFIG || "{}"); debugLog("[Wo] WINDOW_CONFIG parsed =", JSON.stringify(WINDOW_CONFIG)); var CLIENT_MODE = process.env.WO_CLIENT_MODE === "1"; +var SYSTEM_SHELL = process.env.WO_SYSTEM_SHELL === "1"; +function isLoopbackHttp(url) { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]", "::1"].includes(parsed.hostname); + } catch { + return false; + } +} var { name = "default", url = null, @@ -264,17 +1294,36 @@ var { width = 1920, height = 1080 } = WINDOW_CONFIG; +var initialWindowWidth = Number.isInteger(width) && width > 0 ? Math.min(width, 16384) : 1920; +var initialWindowHeight = Number.isInteger(height) && height > 0 ? Math.min(height, 16384) : 1080; var mainWindow = null; +var resourceConsentUiActive = false; +var mainPageLoadFinished = false; +var resourceApprovalPending = null; +var displayScreen = null; +var apiGrants = new Set; +var apiGrantPageUrl = null; +var lockedScriptUrls = new Map; +var framePacer = null; +var framePacerRate = 0; +var gpuCompositingStatus = "pending"; +var webglStatus = "pending"; +var sharedDmabufStatus = null; +var offscreenSharedTextureEnabled = false; var ipcSocket = null; var portalUiServer = null; var portalPendingRequests = new Map; -var PORTAL_UI_SOCKET = "/tmp/wo-portal-ui.sock"; +var portalUiEvents = new Map; +var portalUiRendererReady = false; +var portalUiRendererGeneration = 0; +var PORTAL_UI_SOCKET = joinPath(process.env.XDG_RUNTIME_DIR || "/tmp", "wo-portal-ui.sock"); var ipcConnected = false; +var activeSiteHashes = null; var ipcReconnectTimer = null; -var windowPositionUpdateTimer = null; var dmabufSender = null; var damageBuffer = null; var nativeDmabuf = null; +var audioCapture = null; var compositorRxBuffer = Buffer.alloc(64 * 1024); var rxWriteOffset = 0; var rxReadOffset = 0; @@ -283,6 +1332,30 @@ var surfaceUpdatePending = new Map; var surfaceUpdateGeneration = 0; var surfaceUpdateFlushScheduled = false; var shmFdCache = new Map; +function isDisabledGpuFeature(status) { + return status.startsWith("disabled") || status.startsWith("unavailable") || status.startsWith("failed"); +} +function getPerformanceStatus() { + const reasons = []; + if (isDisabledGpuFeature(gpuCompositingStatus)) + reasons.push("GPU compositing is disabled"); + if (isDisabledGpuFeature(webglStatus)) + reasons.push("WebGL is unavailable"); + if (sharedDmabufStatus === false) + reasons.push("Electron is using bitmap frames instead of shared GPU DMA-BUFs"); + return { + limited: reasons.length > 0, + gpuCompositing: gpuCompositingStatus, + webgl: webglStatus, + sharedDmabuf: sharedDmabufStatus, + reasons + }; +} +function publishPerformanceStatus() { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("wo:performance-status", getPerformanceStatus()); + } +} function rxAppend(chunk) { const needed = rxWriteOffset + chunk.length; if (needed > compositorRxBuffer.length) { @@ -334,21 +1407,21 @@ function getOrOpenShmFd(windowName, pid, fd) { shmFdCache.set(windowName, { pid, fd, extFd }); return extFd; } -function getOrCreateSab(windowName, width2, height2, stride) { +function getOrCreateSab(windowName, width, height, stride) { const existing = surfaceSabCache.get(windowName); - const neededSize = stride * height2; - if (existing && existing.sab.byteLength >= neededSize && existing.width === width2 && existing.height === height2 && existing.stride === stride) { + const neededSize = stride * height; + if (existing && existing.sab.byteLength >= neededSize && existing.width === width && existing.height === height && existing.stride === stride) { return existing; } const sab = new SharedArrayBuffer(neededSize); - const entry = { sab, width: width2, height: height2, stride }; + const entry = { sab, width, height, stride }; surfaceSabCache.set(windowName, entry); if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.postMessage("wo:surface-sab", { + mainWindow.webContents.send("wo:surface-sab", { name: windowName, sab, - width: width2, - height: height2, + width, + height, stride }); } @@ -366,33 +1439,112 @@ function flushSurfaceUpdates() { surfaceUpdatePending.clear(); } var inFlightFrameSeqs = new Set; -var MAX_IN_FLIGHT_FRAMES = 3; +var MAX_IN_FLIGHT_FRAMES = 2; +var receivedSocketFds = []; +var fdReaderSocketFd = null; +function displayRefreshForWindow() { + if (!displayScreen) + return 60; + const bounds = mainWindow && !mainWindow.isDestroyed() ? mainWindow.getBounds() : null; + const display = bounds ? displayScreen.getDisplayMatching(bounds) : displayScreen.getPrimaryDisplay(); + return display.displayFrequency; +} +function configureFramePacing() { + if (CLIENT_MODE || !mainWindow || mainWindow.isDestroyed()) + return; + const rate = chooseFrameRate(Number(WINDOW_CONFIG.fps), displayRefreshForWindow()); + if (rate === framePacerRate && framePacer) + return; + framePacerRate = rate; + if (mainWindow.webContents.isOffscreen() && !mainWindow.webContents.isPainting()) { + mainWindow.webContents.startPainting(); + } + mainWindow.webContents.setFrameRate(rate); + if (framePacer) + clearInterval(framePacer); + framePacer = setInterval(() => { + if (mainWindow && !mainWindow.isDestroyed() && inFlightFrameSeqs.size < MAX_IN_FLIGHT_FRAMES) { + mainWindow.webContents.invalidate(); + } + }, frameIntervalMs(rate)); + framePacer.unref(); + debugLog(`[Wo] Offscreen frame pacing: ${rate} Hz (window cap ${WINDOW_CONFIG.fps || "auto"})`); +} +function senderHasCapability(sender, capability) { + if (!mainWindow || mainWindow.isDestroyed() || sender.id !== mainWindow.webContents.id || !apiGrants.has(capability) || !apiGrantPageUrl) + return false; + try { + const approved = new URL(apiGrantPageUrl); + const current = new URL(sender.getURL()); + approved.hash = ""; + current.hash = ""; + return approved.href === current.href; + } catch { + return false; + } +} +function syscallCapability(type) { + if (["list_applications", "launch_desktop"].includes(type)) + return "desktop-applications"; + if (["xdg_directories", "resolve_icon"].includes(type)) + return "xdg"; + if (type.startsWith("dbus_")) + return "dbus"; + if (type === "portal_respond") + return "portals"; + if (type.startsWith("audio_")) + return "audio-capture"; + if (["listdir", "read", "write"].includes(type)) + return "filesystem"; + if (["launch", "exec"].includes(type)) + return "process-launch"; + if (["shutdown", "restart", "logout", "lock", "sleep"].includes(type)) + return "system-control"; + if (type === "notify") + return "notifications"; + if (type === "load_local_file") + return "filesystem"; + return null; +} +function permissionDenied(capability) { + return { ok: false, error: `Wo capability not granted: ${capability}` }; +} var appUsedDamageHelper = false; var pointerX = 0; var pointerY = 0; +var encodedWindowNames = new Map; +function getEncodedWindowName(windowName) { + let encoded = encodedWindowNames.get(windowName); + if (!encoded) { + encoded = Buffer.from(windowName, "utf8"); + encodedWindowNames.set(windowName, encoded); + } + return encoded; +} function respondToPortalRequest(requestId, params) { const pending = portalPendingRequests.get(requestId); if (!pending) { return { ok: false, error: `No pending portal request: ${requestId}` }; } - let response; - if (pending.kind === "screen_share") { - response = { - allowed: Boolean(params.allowed), - sourceType: params.type === "window" ? "Window" : "Monitor", - windowName: typeof params.windowName === "string" ? params.windowName : null - }; - } else { - response = { allowed: false }; + const responsePayload = params.response !== undefined ? params.response : { + allowed: Boolean(params.allowed), + sourceType: params.type === "window" ? "Window" : "Monitor", + windowName: typeof params.windowName === "string" ? params.windowName : null + }; + if (responsePayload === undefined) { + portalPendingRequests.delete(requestId); + return { ok: false, error: "Missing portal response payload" }; } try { - pending.socket.write(`${JSON.stringify(response)} + pending.socket.write(`${JSON.stringify(responsePayload)} `); pending.socket.end(); portalPendingRequests.delete(requestId); + portalUiEvents.delete(requestId); return { ok: true }; } catch (error) { portalPendingRequests.delete(requestId); + portalUiEvents.delete(requestId); return { ok: false, error: `Failed to send portal response: ${String(error)}` }; } } @@ -406,64 +1558,59 @@ function startPortalUiBridge() { let received = ""; socket.on("data", (chunk) => { received += chunk.toString("utf8"); - const nl = received.indexOf(` + let nl = received.indexOf(` `); - if (nl < 0) - return; - const line = received.slice(0, nl).trim(); - received = received.slice(nl + 1); - if (!line) - return; - try { - const req = JSON.parse(line); - if (req.type !== "screen_share_request" || !req.sessionId) { - socket.end(`{"allowed":false,"reason":"invalid_request"} + while (nl >= 0) { + const line = received.slice(0, nl).trim(); + received = received.slice(nl + 1); + if (line.length > 0) { + try { + const req = JSON.parse(line); + const requestId = String(req?.requestId ?? req?.sessionId ?? req?.id ?? randomUUID()); + portalPendingRequests.set(requestId, { socket, requestId, payload: req }); + socket.setTimeout(90000, () => { + const pending = portalPendingRequests.get(requestId); + if (pending) { + portalPendingRequests.delete(requestId); + portalUiEvents.delete(requestId); + pending.socket.end(JSON.stringify({ error: "timeout", requestId }) + ` `); - return; - } - const requestId = req.sessionId; - portalPendingRequests.set(requestId, { - socket, - kind: "screen_share", - sessionId: req.sessionId - }); - socket.setTimeout(90000, () => { - const pending = portalPendingRequests.get(requestId); - if (pending) { - portalPendingRequests.delete(requestId); - pending.socket.end(`{"allowed":false,"reason":"timeout"} + } + }); + portalUiEvents.set(requestId, { + data: { + requestId, + payload: req, + kind: typeof req?.type === "string" ? req.type : typeof req?.kind === "string" ? req.kind : undefined + }, + sentGeneration: -1 + }); + flushPortalUiEvents(); + } catch { + socket.end(`{"error":"invalid_json"} `); } - }); - if (mainWindow && !mainWindow.isDestroyed()) { - const payload = { - requestId, - kind: "screen_share", - appName: req.appName || "Application", - sessionId: req.sessionId - }; - mainWindow.webContents.send("wo:portal-request", payload); - } else { - portalPendingRequests.delete(requestId); - socket.end(`{"allowed":false,"reason":"no_ui"} -`); } - } catch { - socket.end(`{"allowed":false,"reason":"invalid_json"} + nl = received.indexOf(` `); } }); socket.on("close", () => { - for (const [sessionId, pending] of portalPendingRequests.entries()) { + for (const [requestId, pending] of portalPendingRequests.entries()) { if (pending.socket === socket) { - portalPendingRequests.delete(sessionId); - break; + portalPendingRequests.delete(requestId); + portalUiEvents.delete(requestId); } } }); socket.on("error", () => {}); }); portalUiServer.listen(PORTAL_UI_SOCKET, () => { + try { + fs.chmodSync(PORTAL_UI_SOCKET, 384); + } catch (err) { + console.error("[Wo] Could not restrict portal socket permissions:", err); + } debugLog("[Wo] Portal UI bridge listening at", PORTAL_UI_SOCKET); }); portalUiServer.on("error", (err) => { @@ -473,11 +1620,13 @@ function startPortalUiBridge() { function stopPortalUiBridge() { for (const pending of portalPendingRequests.values()) { try { - pending.socket.end(`{"allowed":false,"reason":"shutdown"} + pending.socket.end(JSON.stringify({ error: "shutdown", requestId: pending.requestId }) + ` `); } catch {} } portalPendingRequests.clear(); + portalUiEvents.clear(); + portalUiRendererReady = false; if (portalUiServer) { portalUiServer.close(); portalUiServer = null; @@ -509,278 +1658,104 @@ function parseClientWindows() { } var clientWindows = parseClientWindows(); var compositorWindows = []; -function parseQuotedStrings(output) { - const matches = output.matchAll(/"((?:[^"\\]|\\.)*)"/g); - const values = []; - for (const match of matches) { - const raw = match[1]; - values.push(raw.replace(/\\"/g, '"').replace(/\\\\/g, "\\")); - } - return values; -} -function runBusctlUser(args) { +function runBusctl(bus, args) { try { - const { execFileSync } = require2("child_process"); - return execFileSync("busctl", ["--user", ...args], { + const { spawnSync } = require2("child_process"); + const res = spawnSync("busctl", [bus === "system" ? "--system" : "--user", ...args], { encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"] - }).trim(); - } catch { - return ""; - } -} -function parseServiceAndPath(itemRef) { - const slashIdx = itemRef.indexOf("/"); - if (slashIdx > 0) { - return { - service: itemRef.slice(0, slashIdx), - objectPath: itemRef.slice(slashIdx) - }; - } - if (!itemRef) { - return null; - } - return { - service: itemRef, - objectPath: "/StatusNotifierItem" - }; -} -function parseBusctlStringValue(output) { - const quoted = parseQuotedStrings(output); - if (quoted.length > 0) { - return quoted[0]; - } - const parts = output.split(/\s+/); - return parts.length >= 2 ? parts.slice(1).join(" ").trim() : ""; -} -function getBusProperty(service, objectPath, iface, prop) { - const out = runBusctlUser(["get-property", service, objectPath, iface, prop]); - return out ? parseBusctlStringValue(out) : ""; -} -function mapTrayIcon(iconName, title) { - const n = iconName.toLowerCase(); - const t = title.toLowerCase(); - if (n.includes("network") || t.includes("network") || t.includes("wifi")) - return "mdi:wifi"; - if (n.includes("audio") || n.includes("volume") || t.includes("volume")) - return "mdi:volume-high"; - if (n.includes("battery") || t.includes("battery")) - return "mdi:battery"; - if (n.includes("bluetooth") || t.includes("bluetooth")) - return "mdi:bluetooth"; - if (n.includes("telegram") || t.includes("telegram")) - return "mdi:telegram"; - if (n.includes("discord") || t.includes("discord")) - return "mdi:discord"; - if (n.includes("steam") || t.includes("steam")) - return "mdi:steam"; - if (n.includes("dropbox") || t.includes("dropbox")) - return "mdi:dropbox"; - if (n.includes("mail") || t.includes("mail")) - return "mdi:email-outline"; - if (n.includes("kde") || t.includes("kde")) - return "mdi:kde"; - return "mdi:circle-medium"; -} -function iconPathCandidates(iconName) { - const direct = iconName.startsWith("/") ? [iconName] : []; - if (iconName.startsWith("/")) { - return direct; - } - const exts = ["png", "svg"]; - const bases = [ - "/usr/share/pixmaps", - "/usr/share/icons/hicolor/16x16/apps", - "/usr/share/icons/hicolor/22x22/apps", - "/usr/share/icons/hicolor/24x24/apps", - "/usr/share/icons/hicolor/32x32/apps", - "/usr/share/icons/hicolor/48x48/apps", - "/usr/share/icons/hicolor/scalable/apps", - "/usr/share/icons/breeze/apps/22", - "/usr/share/icons/breeze/apps/24", - "/usr/share/icons/breeze/apps/32" - ]; - const results = []; - for (const base of bases) { - for (const ext of exts) { - results.push(`${base}/${iconName}.${ext}`); - } - } - return results; -} -function toDataUrlForIconFile(iconPath) { - try { - if (!fs.existsSync(iconPath)) { - return; - } - const lower = iconPath.toLowerCase(); - const mime = lower.endsWith(".svg") ? "image/svg+xml" : lower.endsWith(".png") ? "image/png" : ""; - if (!mime) { - return; - } - const base64 = fs.readFileSync(iconPath).toString("base64"); - return `data:${mime};base64,${base64}`; - } catch { - return; - } -} -function resolveTrayIconDataUrl(iconName) { - for (const candidate of iconPathCandidates(iconName)) { - const dataUrl = toDataUrlForIconFile(candidate); - if (dataUrl) { - return dataUrl; - } - } - return; -} -function getStatusNotifierTrayItems() { - const watcherCall = runBusctlUser([ - "call", - "org.kde.StatusNotifierWatcher", - "/StatusNotifierWatcher", - "org.kde.StatusNotifierWatcher", - "RegisteredStatusNotifierItems" - ]); - if (!watcherCall) { - return []; - } - const refs = parseQuotedStrings(watcherCall); - const results = []; - for (const itemRef of refs) { - const parsed = parseServiceAndPath(itemRef); - if (!parsed) { - continue; - } - const statusRaw = getBusProperty(parsed.service, parsed.objectPath, "org.kde.StatusNotifierItem", "Status"); - const title = getBusProperty(parsed.service, parsed.objectPath, "org.kde.StatusNotifierItem", "Title") || getBusProperty(parsed.service, parsed.objectPath, "org.kde.StatusNotifierItem", "Id") || parsed.service; - const iconName = getBusProperty(parsed.service, parsed.objectPath, "org.kde.StatusNotifierItem", "AttentionIconName") || getBusProperty(parsed.service, parsed.objectPath, "org.kde.StatusNotifierItem", "IconName"); - const menuPath = getBusProperty(parsed.service, parsed.objectPath, "org.kde.StatusNotifierItem", "Menu"); - const iconDataUrl = iconName ? resolveTrayIconDataUrl(iconName) : undefined; - let status = "active"; - const s = statusRaw.toLowerCase(); - if (s.includes("passive")) { - status = "passive"; - } else if (s.includes("attention") || s.includes("needsattention")) { - status = "attention"; - } - results.push({ - id: `${parsed.service}${parsed.objectPath}`, - title, - status, - icon: mapTrayIcon(iconName || title, title), - iconDataUrl, - service: parsed.service, - objectPath: parsed.objectPath, - menuPath: menuPath || undefined, - hasMenu: Boolean(menuPath && menuPath !== "/NO_DBUSMENU") + stdio: ["ignore", "pipe", "pipe"] }); + return { + ok: res.status === 0, + stdout: res.stdout?.trim?.() || "", + stderr: res.stderr?.trim?.() || "", + exitCode: res.status ?? -1 + }; + } catch (error) { + return { ok: false, stdout: "", stderr: String(error), exitCode: -1 }; } - return results; } -function activateStatusNotifierItem(service, objectPath) { - const out = runBusctlUser([ - "call", - service, - objectPath, - "org.kde.StatusNotifierItem", - "Activate", - "ii", - "0", - "0" - ]); - return out.length > 0; -} -function secondaryActivateStatusNotifierItem(service, objectPath) { - const out = runBusctlUser([ - "call", - service, - objectPath, - "org.kde.StatusNotifierItem", - "SecondaryActivate", - "ii", - "0", - "0" - ]); - return out.length > 0; -} -function openStatusNotifierContextMenu(service, objectPath, x, y) { - const out = runBusctlUser([ - "call", - service, - objectPath, - "org.kde.StatusNotifierItem", - "ContextMenu", - "ii", - String(Math.trunc(x)), - String(Math.trunc(y)) - ]); - return out.length > 0; -} -function getDBusMenuLayout(service, menuPath) { +function listPipeWireAudioSources() { + const { spawnSync } = require2("node:child_process"); + const result = spawnSync("pw-dump", [], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + if (result.status !== 0) { + return { ok: false, sources: [], error: String(result.stderr || "Could not read PipeWire sources").trim() }; + } try { - const out = runBusctlUser([ - "call", - service, - menuPath.startsWith("/") ? menuPath : "/com/canonical/dbusmenu", - "com.canonical.dbusmenu", - "GetLayout", - "u", - "0" - ]); - if (!out) { - return []; - } - const lines = out.split(` -`).filter((l) => l.trim().length > 0); - const items = []; - let depth = 0; - for (const line of lines) { - const trimmed = line.trim(); - const newDepth = (line.match(/^\s*/)?.[0]?.length ?? 0) / 2; - if (trimmed.startsWith("(") && trimmed.includes('"')) { - const quoted = parseQuotedStrings(trimmed); - if (quoted.length > 0) { - const label = quoted[0]; - const item = { - id: label, - label, - enabled: !line.toLowerCase().includes("disabled") - }; - if (newDepth === 0) { - items.push(item); - } else if (items.length > 0 && newDepth > depth) { - const parent = items[items.length - 1]; - if (!parent.children) { - parent.children = []; - } - parent.children.push(item); - } - } - depth = newDepth; - } - } - return items; - } catch { - return []; + const objects = JSON.parse(result.stdout); + const sources = objects.flatMap((object) => { + const props = object.info?.props; + if (!object.id || object.type !== "PipeWire:Interface:Node" || !props) + return []; + const mediaClass = String(props["media.class"] || ""); + if (!mediaClass.startsWith("Audio/Source")) + return []; + const name = String(props["node.name"] || `pipewire-${object.id}`); + const description = String(props["node.description"] || name); + return [{ + id: String(object.id), + name, + description, + monitor: String(props["device.class"] || "") === "monitor" || name.endsWith(".monitor") || description.toLowerCase().startsWith("monitor of ") + }]; + }); + return { ok: true, sources }; + } catch (error) { + return { ok: false, sources: [], error: `Could not parse PipeWire source list: ${String(error)}` }; } } -function triggerDBusMenuEvent(service, menuPath, menuId) { - try { - const out = runBusctlUser([ - "call", - service, - menuPath.startsWith("/") ? menuPath : "/com/canonical/dbusmenu", - "com.canonical.dbusmenu", - "Event", - "sis", - menuId, - "clicked", - "" - ]); - return out.length > 0; - } catch { - return false; - } +function broadcastAudioState(state) { + if (mainWindow && !mainWindow.isDestroyed()) + mainWindow.webContents.send("wo:audio-state", state); +} +function startPipeWireAudioCapture(source) { + if (audioCapture) + audioCapture.kill(); + const { spawn } = require2("node:child_process"); + const child = spawn("pw-cat", [ + "--record", + "--target", + source.id, + "--format", + "s16", + "--rate", + "48000", + "--channels", + "2", + "-" + ], { stdio: ["ignore", "pipe", "pipe"], env: process.env }); + audioCapture = child; + child.stdout?.on("data", (data) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("wo:audio-data", { + format: "s16le", + sampleRate: 48000, + channels: 2, + data, + timestamp: Date.now() + }); + } + }); + child.stderr?.on("data", (data) => debugLog("[Wo] pw-cat:", data.toString().trim())); + child.once("close", (code) => { + if (audioCapture === child) { + audioCapture = null; + broadcastAudioState({ active: false, ...code && code !== 0 ? { error: `pw-cat exited (${code})` } : {} }); + } + }); + return new Promise((resolve) => { + child.once("error", (error) => { + if (audioCapture === child) + audioCapture = null; + const message = error.message; + broadcastAudioState({ active: false, error: message }); + resolve({ ok: false, error: message }); + }); + child.once("spawn", () => { + broadcastAudioState({ active: true }); + resolve({ ok: true, source }); + }); + }); } function scheduleIpcReconnect(delayMs = 1000) { if (CLIENT_MODE || process.env.WO_STANDALONE === "1") { @@ -824,32 +1799,68 @@ function sendActionToCompositor(action, payload) { return false; } } -function sendWindowPositionUpdate(x, y, width2, height2) { - if (!ipcSocket || !ipcConnected) { - return false; - } - try { - const nameBuf = stringToBuffer(name); - const messageBuf = Buffer.alloc(8 + nameBuf.length + 16); - let offset = 0; - messageBuf.writeUInt32LE(MAGIC.WINDOW_POS, offset); - offset += 4; - messageBuf.writeUInt32LE(nameBuf.length, offset); - offset += 4; - nameBuf.copy(messageBuf, offset); - offset += nameBuf.length; - messageBuf.writeInt32LE(x, offset); - offset += 4; - messageBuf.writeInt32LE(y, offset); - offset += 4; - messageBuf.writeUInt32LE(width2, offset); - offset += 4; - messageBuf.writeUInt32LE(height2, offset); - ipcSocket.write(messageBuf); - return true; - } catch { - return false; - } +function sendForwardedPointer(windowName, x, y) { + if (!ipcSocket || !ipcConnected) + return; + const nameBuf = getEncodedWindowName(windowName); + const msg = Buffer.allocUnsafe(8 + nameBuf.length + 16); + let off = 0; + msg.writeUInt32LE(MAGIC.FORWARD_POINTER, off); + off += 4; + msg.writeUInt32LE(nameBuf.length, off); + off += 4; + nameBuf.copy(msg, off); + off += nameBuf.length; + msg.writeDoubleLE(x, off); + off += 8; + msg.writeDoubleLE(y, off); + ipcSocket.write(msg); +} +function sendForwardedPointerButton(windowName, x, y, button, pressed, time) { + if (!ipcSocket || !ipcConnected) + return; + const nameBuf = getEncodedWindowName(windowName); + const msg = Buffer.allocUnsafe(8 + nameBuf.length + 28); + let off = 0; + msg.writeUInt32LE(MAGIC.FORWARD_POINTER_BUTTON, off); + off += 4; + msg.writeUInt32LE(nameBuf.length, off); + off += 4; + nameBuf.copy(msg, off); + off += nameBuf.length; + msg.writeDoubleLE(x, off); + off += 8; + msg.writeDoubleLE(y, off); + off += 8; + msg.writeUInt32LE(button >>> 0, off); + off += 4; + msg.writeUInt32LE(pressed ? 1 : 0, off); + off += 4; + msg.writeUInt32LE(Math.trunc(time) >>> 0, off); + ipcSocket.write(msg); +} +function sendForwardedPointerScroll(windowName, x, y, dx, dy, time) { + if (!ipcSocket || !ipcConnected) + return; + const nameBuf = getEncodedWindowName(windowName); + const msg = Buffer.allocUnsafe(8 + nameBuf.length + 36); + let off = 0; + msg.writeUInt32LE(MAGIC.FORWARD_POINTER_SCROLL, off); + off += 4; + msg.writeUInt32LE(nameBuf.length, off); + off += 4; + nameBuf.copy(msg, off); + off += nameBuf.length; + msg.writeDoubleLE(x, off); + off += 8; + msg.writeDoubleLE(y, off); + off += 8; + msg.writeDoubleLE(dx, off); + off += 8; + msg.writeDoubleLE(dy, off); + off += 8; + msg.writeUInt32LE(Math.trunc(time) >>> 0, off); + ipcSocket.write(msg); } function linuxButtonToElectron(btn) { if (btn === 273) @@ -1027,6 +2038,7 @@ function connectToCompositor() { } debugLog("[Wo] Connecting to compositor IPC:", IPC_SOCKET); ipcSocket = createConnection(IPC_SOCKET, () => { + ipcSocket.setNoDelay(true); const windowName = name; const nameBuf = stringToBuffer(windowName); const messageBuf = Buffer.alloc(8 + nameBuf.length + 8); @@ -1037,21 +2049,39 @@ function connectToCompositor() { offset += 4; nameBuf.copy(messageBuf, offset); offset += nameBuf.length; - messageBuf.writeUInt32LE(width, offset); + messageBuf.writeUInt32LE(initialWindowWidth, offset); offset += 4; - messageBuf.writeUInt32LE(height, offset); + messageBuf.writeUInt32LE(initialWindowHeight, offset); ipcSocket.write(messageBuf); ipcConnected = true; debugLog("[Wo] IPC connected, sent HELLO message"); + sendSiteHashesToCompositor(); try { const nativePath = resolvePath(__dirname2, "../native/build/Release/wo_dmabuf.node"); nativeDmabuf = loadNativeDmabufModule(require2, nativePath); - nativeDmabuf.init(process.env.WO_DRM_RENDER_NODE || "/dev/dri/renderD128"); + const renderNode = process.env.WO_DRM_RENDER_NODE || "/dev/dri/renderD128"; + debugLog("[Wo] Initializing GBM on render node:", renderNode); + nativeDmabuf.init(renderNode); dmabufSender = new WoDmabufSender(ipcSocket, name, nativeDmabuf); + const socketFd = ipcSocket?._handle?.fd; + if (typeof socketFd === "number" && socketFd >= 0 && nativeDmabuf.startFdReader) { + ipcSocket.pause(); + fdReaderSocketFd = socketFd; + nativeDmabuf.startFdReader(socketFd, (chunk, fds, closed) => { + receivedSocketFds.push(...fds); + if (chunk.length > 0) + onIpcData(chunk); + if (closed) + ipcSocket?.destroy(); + }); + } else { + ipcSocket.on("data", onIpcData); + } debugLog("[Wo] DMABUF sender initialized"); } catch (error) { debugLog("[Wo] DMABUF sender unavailable:", error); dmabufSender = null; + ipcSocket.on("data", onIpcData); } resolve(); }); @@ -1070,7 +2100,7 @@ function connectToCompositor() { pendingMouseMove = null; } }; - ipcSocket.on("data", (chunk) => { + const onIpcData = (chunk) => { ipcDataCalls++; const now = Date.now(); if (now - ipcDataLastLog > 2000) { @@ -1084,9 +2114,14 @@ function connectToCompositor() { if (magic === MAGIC.FRAME_ACK) { if (rxAvailable() < 12) break; - const seq = compositorRxBuffer.readBigUInt64LE(rxReadOffset + 4).toString(); - inFlightFrameSeqs.delete(seq); + const seq = compositorRxBuffer.readBigUInt64LE(rxReadOffset + 4); + dmabufSender?.acknowledge(seq); + const wasBackpressured = inFlightFrameSeqs.size >= MAX_IN_FLIGHT_FRAMES; + inFlightFrameSeqs.delete(seq.toString()); rxConsume(12); + if (wasBackpressured && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.invalidate(); + } continue; } if (magic === MAGIC.MOUSE_MOVE) { @@ -1097,14 +2132,9 @@ function connectToCompositor() { rxConsume(20); pointerX = Math.round(x); pointerY = Math.round(y); - if (!inputFlushTimer) { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.sendInputEvent({ type: "mouseMove", x: pointerX, y: pointerY }); - } - } else { - pendingMouseMove = { x: pointerX, y: pointerY }; - inputFlushTimer = setTimeout(flushInputEvents, 0); - } + pendingMouseMove = { x: pointerX, y: pointerY }; + if (!inputFlushTimer) + inputFlushTimer = setImmediate(flushInputEvents); continue; } if (magic === MAGIC.MOUSE_BUTTON) { @@ -1122,7 +2152,7 @@ function connectToCompositor() { pendingMouseMove = null; } if (inputFlushTimer) { - clearTimeout(inputFlushTimer); + clearImmediate(inputFlushTimer); inputFlushTimer = null; } if (mainWindow && !mainWindow.isDestroyed()) { @@ -1175,6 +2205,14 @@ function connectToCompositor() { const vertical = compositorRxBuffer.readInt32LE(rxReadOffset + 4); const horizontal = compositorRxBuffer.readInt32LE(rxReadOffset + 8); rxConsume(16); + if (pendingMouseMove && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.sendInputEvent({ type: "mouseMove", x: pendingMouseMove.x, y: pendingMouseMove.y }); + pendingMouseMove = null; + } + if (inputFlushTimer) { + clearImmediate(inputFlushTimer); + inputFlushTimer = null; + } if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.sendInputEvent({ type: "mouseWheel", @@ -1233,6 +2271,9 @@ function connectToCompositor() { } compositorWindows = parsed.windows; mainWindow.webContents.send("wo:windows", compositorWindows); + if (Array.isArray(parsed.monitors)) { + mainWindow.webContents.send("wo:monitors", parsed.monitors); + } } } catch {} } @@ -1349,17 +2390,48 @@ function connectToCompositor() { off += 4; const numPlanes = compositorRxBuffer.readUInt32LE(off); off += 4; - const totalSize = off - rxReadOffset + numPlanes * 24; + const planeSectionSize = numPlanes * 24; + const totalSize = off - rxReadOffset + planeSectionSize; if (rxAvailable() < totalSize) break; + const planes = []; + let planeOff = off; + for (let i = 0;i < numPlanes; i++) { + planeOff += 4; + const planeOffset = compositorRxBuffer.readBigUInt64LE(planeOff); + planeOff += 8; + const planeStride = compositorRxBuffer.readUInt32LE(planeOff); + planeOff += 4; + const modifier = compositorRxBuffer.readBigUInt64LE(planeOff); + planeOff += 8; + planes.push({ offset: planeOffset, stride: planeStride, modifier }); + } try { if (nativeDmabuf && ipcSocket) { const socketFd = ipcSocket?._handle?.fd; if (typeof socketFd === "number" && socketFd >= 0) { - const dmabufFd = nativeDmabuf.recvFd(socketFd); - if (dmabufFd >= 0) { + const planeFds = receivedSocketFds.splice(0, numPlanes); + const primaryFd = planeFds.find((fd) => fd >= 0); + const primaryPlane = planes[0]; + const stride = primaryPlane?.stride || dmabufW * 4; + const offset = primaryPlane ? Number(primaryPlane.offset) : 0; + if (primaryFd !== undefined) { try { - const textureInfo = nativeDmabuf.importDmabufTexture(dmabufName, dmabufFd, dmabufW, dmabufH, dmabufFormat); + const sabEntry = getOrCreateSab(dmabufName, dmabufW, dmabufH, stride); + const copySize = offset + stride * dmabufH; + nativeDmabuf.copyMmapToSab(primaryFd, sabEntry.sab, copySize, offset); + surfaceUpdatePending.set(dmabufName, { + name: dmabufName, + width: dmabufW, + height: dmabufH, + stride, + generation: ++surfaceUpdateGeneration + }); + if (!surfaceUpdateFlushScheduled) { + surfaceUpdateFlushScheduled = true; + setImmediate(flushSurfaceUpdates); + } + const textureInfo = nativeDmabuf.importDmabufTexture(dmabufName, primaryFd, dmabufW, dmabufH, dmabufFormat); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send("wo:dmabuf-frame", { name: dmabufName, @@ -1369,7 +2441,11 @@ function connectToCompositor() { }); } } finally { - fs.closeSync(dmabufFd); + for (const fd of planeFds) { + try { + fs.closeSync(fd); + } catch {} + } } } } @@ -1427,6 +2503,17 @@ function connectToCompositor() { } continue; } + if (magic === MAGIC.WINDOW_SIZE) { + if (rxAvailable() < 12) + break; + const width = compositorRxBuffer.readUInt32LE(rxReadOffset + 4); + const height = compositorRxBuffer.readUInt32LE(rxReadOffset + 8); + rxConsume(12); + if (mainWindow && !mainWindow.isDestroyed() && width > 0 && height > 0) { + mainWindow.setSize(width, height, false); + } + continue; + } debugLog("[Wo] UNKNOWN magic 0x" + magic.toString(16).padStart(8, "0") + " bufLen=" + rxAvailable() + " — stream may be corrupted"); rxConsume(1); } @@ -1434,7 +2521,7 @@ function connectToCompositor() { if (dataElapsed > 50) { console.warn(`[IPC SLOW] data handler took ${dataElapsed}ms chunkLen=${chunk.length}`); } - }); + }; ipcSocket.on("error", (error) => { debugLog("[Wo] IPC connection error:", error); const hadConnected = ipcConnected; @@ -1449,7 +2536,17 @@ function connectToCompositor() { ipcSocket.on("close", () => { debugLog("[Wo] IPC connection closed"); ipcConnected = false; + if (fdReaderSocketFd !== null) { + nativeDmabuf?.stopFdReader?.(fdReaderSocketFd); + fdReaderSocketFd = null; + } + for (const fd of receivedSocketFds.splice(0)) { + try { + fs.closeSync(fd); + } catch {} + } ipcSocket = null; + dmabufSender?.releaseAll(); dmabufSender = null; inFlightFrameSeqs.clear(); compositorRxBuffer = Buffer.alloc(64 * 1024); @@ -1468,15 +2565,144 @@ function connectToCompositor() { }, 5000); }); } +function sendSiteHashesToCompositor() { + if (!ipcSocket || !ipcConnected || !activeSiteHashes) + return; + const windowName = stringToBuffer(name); + const payload = Buffer.from(JSON.stringify(activeSiteHashes)); + if (payload.length > 24 * 1024 * 1024) + return; + const message = Buffer.allocUnsafe(12 + windowName.length + payload.length); + let offset = 0; + message.writeUInt32LE(MAGIC.SITE_HASHES, offset); + offset += 4; + message.writeUInt32LE(windowName.length, offset); + offset += 4; + message.writeUInt32LE(payload.length, offset); + offset += 4; + windowName.copy(message, offset); + offset += windowName.length; + payload.copy(message, offset); + ipcSocket.write(message); +} function setupIpcHandlers() { - ipcMain.handle("action", (_event, action, payload) => { + ipcMain.handle("wo:performance-status", () => getPerformanceStatus()); + ipcMain.on("wo:portal-ui-ready", (event) => { + if (CLIENT_MODE || !mainWindow || event.sender !== mainWindow.webContents) + return; + portalUiRendererReady = true; + portalUiRendererGeneration += 1; + flushPortalUiEvents(); + }); + ipcMain.on("wo:portal-ui-delivered", (event, requestId) => { + if (CLIENT_MODE || !mainWindow || event.sender !== mainWindow.webContents || typeof requestId !== "string") + return; + portalUiEvents.delete(requestId); + }); + ipcMain.handle("wo:resource-consent-respond", (event, requestId, token, decision) => { + if (CLIENT_MODE || !mainWindow || event.sender !== mainWindow.webContents || typeof requestId !== "string" || typeof token !== "string" || !["deny", "allow", "allow-all"].includes(decision)) { + debugLog("[Wo] Rejected malformed resource-consent IPC response"); + return { ok: false }; + } + const ok = resolveResourceConsent(requestId, token, decision); + debugLog(`[Wo] Resource-consent IPC response received: accepted=${ok} decision=${decision}`); + return { ok }; + }); + ipcMain.handle("wo:portal-ui-respond", (event, requestId, response) => { + if (CLIENT_MODE || !mainWindow || event.sender !== mainWindow.webContents) { + return { ok: false, error: "Portal UI response is only available to the compositor shell" }; + } + if (typeof requestId !== "string" || !requestId) { + return { ok: false, error: "Missing portal requestId" }; + } + return respondToPortalRequest(requestId, { response }); + }); + ipcMain.handle("wo:portal-context-menu", async (event, options) => { + if (!senderHasCapability(event.sender, "portals")) + return permissionDenied("portals"); + if (!mainWindow || mainWindow.isDestroyed() || !Array.isArray(options?.items) || options.items.length === 0 || options.items.length > 100) { + return { ok: false, error: "A context menu requires between 1 and 100 items" }; + } + const selected = new Map; + const template = []; + for (const [index, raw] of options.items.entries()) { + const type = raw.type === "separator" ? "separator" : raw.type === "checkbox" || raw.type === "radio" ? raw.type : "normal"; + const id = typeof raw.id === "string" ? raw.id.slice(0, 128) : String(index); + if (type !== "separator" && (typeof raw.label !== "string" || raw.label.length > 256)) { + return { ok: false, error: `Invalid context-menu label at item ${index}` }; + } + const item = type === "separator" ? { type: "separator" } : { + id, + label: String(raw.label), + type, + enabled: raw.enabled !== false, + checked: raw.checked === true, + ...typeof raw.accelerator === "string" ? { accelerator: raw.accelerator.slice(0, 64) } : {}, + click: () => { + selected.set(0, id); + } + }; + template.push(item); + } + const menu = Menu.buildFromTemplate(template); + return await new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) + return; + settled = true; + const selectedId = selected.get(0); + resolve(selectedId === undefined ? { ok: true } : { ok: true, selectedId }); + }; + menu.popup({ + window: mainWindow, + x: Number.isFinite(options.x) ? Math.trunc(options.x) : undefined, + y: Number.isFinite(options.y) ? Math.trunc(options.y) : undefined, + callback: finish + }); + }); + }); + ipcMain.handle("action", (event, action, payload) => { + if (!senderHasCapability(event.sender, "window-management")) + return permissionDenied("window-management"); return sendActionToCompositor(action, payload); }); - ipcMain.on("action-sync", (_event, action, payload) => { + ipcMain.on("action-sync", (event, action, payload) => { + if (!senderHasCapability(event.sender, "window-management")) + return; sendActionToCompositor(action, payload); }); - ipcMain.handle("wo:get-windows", async () => compositorWindows.length > 0 ? compositorWindows : clientWindows); - ipcMain.on("wo:keybind-action", (_event, action) => { + ipcMain.on("wo:forward-pointer", (event, windowName, x, y) => { + if (!senderHasCapability(event.sender, "window-management")) + return; + sendForwardedPointer(windowName, x, y); + }); + ipcMain.on("wo:forward-pointer-button", (event, windowName, x, y, button, pressed, time) => { + if (!senderHasCapability(event.sender, "window-management")) + return; + sendForwardedPointerButton(windowName, x, y, button, pressed, time); + }); + ipcMain.on("wo:forward-pointer-scroll", (event, windowName, x, y, dx, dy, time) => { + if (!senderHasCapability(event.sender, "window-management")) + return; + sendForwardedPointerScroll(windowName, x, y, dx, dy, time); + }); + ipcMain.on("wo:forward-pointer-leave", (event, windowName) => { + if (!senderHasCapability(event.sender, "window-management")) + return; + if (!ipcSocket || !ipcConnected) + return; + const nameBuf = Buffer.from(windowName, "utf8"); + const msg = Buffer.allocUnsafe(8 + nameBuf.length); + msg.writeUInt32LE(MAGIC.FORWARD_POINTER_LEAVE, 0); + msg.writeUInt32LE(nameBuf.length, 4); + nameBuf.copy(msg, 8); + ipcSocket.write(msg); + }); + ipcMain.handle("wo:get-windows", async (event) => senderHasCapability(event.sender, "window-management") ? compositorWindows.length > 0 ? compositorWindows : clientWindows : permissionDenied("window-management")); + ipcMain.on("wo:keybind-action", (event, action) => { + if (!senderHasCapability(event.sender, "window-management")) + return; if (action === "shuffle") { clientWindows = [...clientWindows].sort(() => Math.random() - 0.5); } else if (action === "reverse") { @@ -1486,7 +2712,9 @@ function setupIpcHandlers() { mainWindow.webContents.send("wo:windows", clientWindows); } }); - ipcMain.handle("wo:submit-damage-frame", (_event, payload) => { + ipcMain.handle("wo:submit-damage-frame", (event, payload) => { + if (!senderHasCapability(event.sender, "overlay-rendering")) + return permissionDenied("overlay-rendering"); if (!dmabufSender) { return { ok: false, reason: "dmabuf-sender-not-ready" }; } @@ -1502,9 +2730,11 @@ function setupIpcHandlers() { inFlightFrameSeqs.add(seq.toString()); return { ok: true, seq: seq.toString() }; }); - ipcMain.on("wo:forward-keyboard", (_event, windowName, key, pressed, time) => { + ipcMain.on("wo:forward-keyboard", (event, windowName, key, pressed, time) => { + if (!senderHasCapability(event.sender, "window-management")) + return; if (ipcSocket && ipcConnected) { - const nameBuf = Buffer.from(windowName, "utf8"); + const nameBuf = getEncodedWindowName(windowName); const msg = Buffer.alloc(4 + 4 + nameBuf.length + 12); let off = 0; msg.writeUInt32LE(MAGIC.FORWARD_KEYBOARD, off); @@ -1517,13 +2747,15 @@ function setupIpcHandlers() { off += 4; msg.writeUInt32LE(pressed ? 1 : 0, off); off += 4; - msg.writeUInt32LE(time || 0, off); + msg.writeUInt32LE(Math.trunc(time) >>> 0, off); ipcSocket.write(msg); } }); - ipcMain.on("wo:forward-relative-pointer", (_event, windowName, dx, dy) => { + ipcMain.on("wo:forward-relative-pointer", (event, windowName, dx, dy) => { + if (!senderHasCapability(event.sender, "window-management")) + return; if (ipcSocket && ipcConnected) { - const nameBuf = Buffer.from(windowName, "utf8"); + const nameBuf = getEncodedWindowName(windowName); const msg = Buffer.alloc(4 + 4 + nameBuf.length + 16); let off = 0; msg.writeUInt32LE(MAGIC.FORWARD_RELATIVE_POINTER, off); @@ -1538,92 +2770,31 @@ function setupIpcHandlers() { ipcSocket.write(msg); } }); - const iconCache = new Map; - function resolveDesktopIconFast(iconName, fs2, path) { - if (iconCache.has(iconName)) - return iconCache.get(iconName) ?? null; - let result = null; - if (iconName.startsWith("/")) { - try { - if (fs2.existsSync(iconName)) { - const ext = path.extname(iconName).toLowerCase(); - const mime = ext === ".svg" ? "image/svg+xml" : "image/png"; - result = { type: "base64", data: fs2.readFileSync(iconName).toString("base64"), mimeType: mime }; - } - } catch {} - iconCache.set(iconName, result); - return result; - } - const quickPaths = [ - [`/usr/share/pixmaps/${iconName}.png`, "image/png"], - [`/usr/share/pixmaps/${iconName}.svg`, "image/svg+xml"], - [`/usr/share/icons/hicolor/48x48/apps/${iconName}.png`, "image/png"], - [`/usr/share/icons/hicolor/scalable/apps/${iconName}.svg`, "image/svg+xml"] - ]; - for (const [iconPath, mime] of quickPaths) { - try { - if (fs2.existsSync(iconPath)) { - result = { type: "base64", data: fs2.readFileSync(iconPath).toString("base64"), mimeType: mime }; - break; - } - } catch {} - } - iconCache.set(iconName, result); - return result; - } - ipcMain.handle("wo:syscall", async (_event, type, params) => { + ipcMain.handle("wo:syscall", async (event, type, params) => { + const capability = syscallCapability(type); + if (!capability || !senderHasCapability(event.sender, capability)) + return permissionDenied(capability || "window-management"); try { switch (type) { case "list_applications": { - const path = await import("path"); - const fs2 = await import("fs"); - const apps = []; - const dirs = ["/usr/share/applications", `${process.env.HOME}/.local/share/applications`]; - const seen = new Set; - for (const dir of dirs) { - try { - if (!fs2.default.existsSync(dir)) - continue; - const files = fs2.default.readdirSync(dir).filter((f) => f.endsWith(".desktop")); - for (const file of files) { - const filePath = path.default.join(dir, file); - try { - const content = fs2.default.readFileSync(filePath, "utf-8"); - if (content.includes("NoDisplay=true")) - continue; - const nameMatch = content.match(/^Name=(.+)$/m); - const execMatch = content.match(/^Exec=(.+)$/m); - const iconMatch = content.match(/^Icon=(.+)$/m); - if (nameMatch && execMatch) { - const name2 = nameMatch[1].trim(); - if (!seen.has(name2)) { - seen.add(name2); - const exec = execMatch[1].trim().split(" ")[0].split("/").pop() || execMatch[1].trim(); - const app2 = { - name: name2, - command: exec - }; - if (iconMatch) { - const iconName = iconMatch[1].trim(); - const resolved = resolveDesktopIconFast(iconName, fs2.default, path.default); - if (resolved) { - app2.icon = resolved; - } else { - app2.icon = { type: "iconify", data: iconName }; - } - } - apps.push(app2); - } - } - } catch {} - } - } catch {} - } - return apps.length > 0 ? apps : [ - { name: "Terminal", icon: { type: "iconify", data: "mdi:console" }, command: "xterm" }, - { name: "Firefox", icon: { type: "iconify", data: "mdi:firefox" }, command: "firefox" }, - { name: "Files", icon: { type: "iconify", data: "mdi:folder" }, command: "nautilus" } - ]; + return listDesktopApplications({ locale: typeof params.locale === "string" ? params.locale : undefined }); + } + case "launch_desktop": { + const desktopId = String(params.desktopId || ""); + const files = Array.isArray(params.files) ? params.files.map(String) : []; + return launchDesktopApplication(desktopId, files); + } + case "xdg_directories": { + return { ok: true, directories: getXdgDirectories() }; + } + case "resolve_icon": { + const iconName = String(params.name || ""); + if (!iconName) + return null; + return resolveDesktopIcon(iconName, { + size: typeof params.size === "number" ? params.size : undefined, + theme: typeof params.theme === "string" ? params.theme : undefined + }) ?? null; } case "launch": case "exec": { @@ -1732,67 +2903,104 @@ function setupIpcHandlers() { } return { ok: true }; } - case "tray_list": { - return getStatusNotifierTrayItems(); - } - case "tray_activate": { - const service = String(params.service || ""); - const objectPath = String(params.objectPath || ""); - if (!service || !objectPath) { - return { ok: false, error: "Missing service or objectPath" }; - } - const ok = activateStatusNotifierItem(service, objectPath); - return ok ? { ok: true } : { ok: false, error: "Activation failed" }; - } - case "tray_secondary_activate": { - const service = String(params.service || ""); - const objectPath = String(params.objectPath || ""); - if (!service || !objectPath) { - return { ok: false, error: "Missing service or objectPath" }; - } - const ok = secondaryActivateStatusNotifierItem(service, objectPath); - return ok ? { ok: true } : { ok: false, error: "Secondary activation failed" }; - } - case "tray_context_menu": { - const service = String(params.service || ""); - const objectPath = String(params.objectPath || ""); - const x = Number(params.x ?? 0); - const y = Number(params.y ?? 0); - if (!service || !objectPath) { - return { ok: false, error: "Missing service or objectPath" }; - } - const ok = openStatusNotifierContextMenu(service, objectPath, x, y); - return ok ? { ok: true } : { ok: false, error: "Context menu failed" }; - } - case "tray_menu_items": { - const service = String(params.service || ""); - const menuPath = String(params.menuPath || ""); - if (!service || !menuPath) { - return []; - } - return getDBusMenuLayout(service, menuPath); - } - case "tray_menu_event": { - const service = String(params.service || ""); - const menuPath = String(params.menuPath || ""); - const menuId = String(params.menuId || ""); - if (!service || !menuPath || !menuId) { - return { ok: false, error: "Missing parameters" }; - } - const ok = triggerDBusMenuEvent(service, menuPath, menuId); - return ok ? { ok: true } : { ok: false, error: "Menu event failed" }; - } case "portal_respond": { const requestId = String(params.requestId || ""); if (!requestId) { return { ok: false, error: "Missing portal requestId" }; } return respondToPortalRequest(requestId, { - allowed: Boolean(params.allowed), + response: params.response, + allowed: typeof params.allowed === "boolean" ? params.allowed : undefined, type: typeof params.type === "string" ? params.type : undefined, windowName: params.windowName }); } + case "dbus_call": { + const bus = params.bus === "system" ? "system" : "user"; + const service = String(params.service || ""); + const objectPath = String(params.objectPath || ""); + const iface = String(params.interface || params.iface || ""); + const method = String(params.method || ""); + const signature = typeof params.signature === "string" ? params.signature : ""; + const argList = Array.isArray(params.args) ? params.args : []; + if (!service || !objectPath || !iface || !method) { + return { ok: false, error: "Missing dbus parameters" }; + } + const args = ["call", service, objectPath, iface, method, signature || ""]; + if (signature) { + args.push(...argList.map((v) => String(v))); + } + const result = runBusctl(bus, args); + return result; + } + case "dbus_get_property": { + const bus = params.bus === "system" ? "system" : "user"; + const service = String(params.service || ""); + const objectPath = String(params.objectPath || ""); + const iface = String(params.interface || params.iface || ""); + const prop = String(params.property || params.prop || ""); + if (!service || !objectPath || !iface || !prop) { + return { ok: false, error: "Missing dbus parameters" }; + } + const result = runBusctl(bus, ["get-property", service, objectPath, iface, prop]); + return result; + } + case "dbus_get_all_properties": { + const bus = params.bus === "system" ? "system" : "user"; + const service = String(params.service || ""); + const objectPath = String(params.objectPath || ""); + const iface = String(params.interface || params.iface || ""); + if (!service || !objectPath || !iface) + return { ok: false, error: "Missing D-Bus parameters" }; + return runBusctl(bus, ["call", service, objectPath, "org.freedesktop.DBus.Properties", "GetAll", "s", iface]); + } + case "dbus_list_names": { + const bus = params.bus === "system" ? "system" : "user"; + const result = runBusctl(bus, ["--no-pager", "list"]); + const names = result.stdout.split(/\r?\n/).slice(1).map((line) => line.trim().split(/\s+/, 1)[0]).filter((name) => name && name !== ""); + return { ...result, names }; + } + case "audio_sources": { + const result = listPipeWireAudioSources(); + return result; + } + case "audio_capture_start": { + const result = listPipeWireAudioSources(); + if (!result.ok) + return { ok: false, error: result.error }; + const requestedId = typeof params.sourceId === "string" ? params.sourceId : ""; + const source = requestedId ? result.sources.find((entry) => entry.id === requestedId) : result.sources.find((entry) => entry.monitor); + if (!source) + return { ok: false, error: requestedId ? "PipeWire source not found" : "No desktop monitor source is available" }; + return startPipeWireAudioCapture(source); + } + case "audio_capture_stop": { + const child = audioCapture; + audioCapture = null; + if (child && !child.killed) + child.kill(); + broadcastAudioState({ active: false }); + return { ok: true }; + } + case "load_local_file": { + const filePath = String(params.path || ""); + if (!filePath) { + return { ok: false, error: "Missing path" }; + } + if (!fs.existsSync(filePath)) { + return { ok: false, error: `Path does not exist: ${filePath}` }; + } + if (!mainWindow || mainWindow.isDestroyed()) { + return { ok: false, error: "Main window unavailable" }; + } + try { + debugLog("[Wo] Loading local file:", filePath); + await mainWindow.loadFile(filePath); + return { ok: true }; + } catch (error) { + return { ok: false, error: String(error) }; + } + } default: return { ok: false, error: `Unknown syscall: ${type}` }; } @@ -1801,47 +3009,308 @@ function setupIpcHandlers() { } }); } +function flushPortalUiEvents() { + if (!portalUiRendererReady || !mainWindow || mainWindow.isDestroyed()) + return; + for (const [requestId, event] of portalUiEvents) { + if (!portalPendingRequests.has(requestId) || event.sentGeneration === portalUiRendererGeneration) + continue; + event.sentGeneration = portalUiRendererGeneration; + mainWindow.webContents.send("wo:portal-request", event.data); + } +} async function createWindow() { debugLog("[Wo] createWindow starting"); + const pageUrl = url || (html ? html.startsWith("file://") ? html : pathToFileURL(resolvePath(html)).href : null) || "data:text/html,"; + let siteAssets = { + pageUrl, + appId: "website", + capabilities: [], + styles: "", + stylesUrl: "", + stylesHash: "", + styleBodies: [], + scripts: [], + scriptBodies: [], + indexSha256: "", + indexBytes: new Uint8Array, + stylesBytes: new Uint8Array, + trusted: false + }; + if (/^(https?:|file:)/.test(pageUrl)) { + siteAssets = await prefetchWoSiteAssets(pageUrl); + } + debugLog(siteAssets.trusted ? `[Wo] Verified site ${siteAssets.appId}; requested capabilities: ${siteAssets.capabilities.join(", ") || "(none)"}` : `[Wo] Site capabilities not granted because verification failed: ${siteAssets.trustError || "no verifiable site manifest"}`); + apiGrants = new Set; + apiGrantPageUrl = null; + verifiedScriptCache.clear(); + lockedScriptUrls = new Map; + if (siteAssets.trusted && siteAssets.stylesUrl && siteAssets.stylesHash) { + verifiedScriptCache.set(siteAssets.stylesHash, Buffer.from(siteAssets.stylesBytes)); + lockedScriptUrls.set(siteAssets.stylesUrl, siteAssets.stylesHash); + } + for (const script of siteAssets.scriptBodies) { + verifiedScriptCache.set(script.hash, Buffer.from(script.bytes)); + lockedScriptUrls.set(script.url, script.hash); + } + for (const style of siteAssets.styleBodies) { + verifiedScriptCache.set(style.hash, Buffer.from(style.bytes)); + lockedScriptUrls.set(style.url, style.hash); + } + activeSiteHashes = siteAssets.trusted ? { + pageUrl: siteAssets.pageUrl, + indexBytes: Array.from(siteAssets.indexBytes), + stylesBytes: Array.from(siteAssets.stylesBytes) + } : null; + sendSiteHashesToCompositor(); + const trustedSystemShell = SYSTEM_SHELL && siteAssets.trusted && siteAssets.appId === "rip.crit.comraw" && isLoopbackHttp(pageUrl); + if (trustedSystemShell) { + apiGrants = new Set(siteAssets.capabilities); + apiGrantPageUrl = siteAssets.pageUrl; + debugLog("[Wo] Granted verified comraw system-shell capabilities"); + } else if (siteAssets.trusted && siteAssets.capabilities.length > 0) { + const permissionResult = await dialog.showMessageBox({ + type: "question", + title: "Wo API permission request", + message: `${siteAssets.appId} requests access to Wo APIs`, + detail: `Page: ${pageUrl} +Verified index.html SHA-256: ${siteAssets.indexSha256} + +Requested permissions: ${siteAssets.capabilities.join(", ")} + +The site APIs are exposed only for this window while it is open.`, + buttons: ["Allow requested APIs", "Open without Wo APIs"], + defaultId: 1, + cancelId: 1, + noLink: true + }); + if (permissionResult.response === 0) { + apiGrants = new Set(siteAssets.capabilities); + apiGrantPageUrl = siteAssets.pageUrl; + } + } + const requested = siteAssets.trusted ? siteAssets.capabilities : []; + const granted = [...apiGrants]; const preloadPath = joinPath(__dirname2, "preload.js"); + const opaqueDesktopRoot = !CLIENT_MODE && WINDOW_CONFIG.name === "main"; debugLog("[Wo] preloadPath=" + preloadPath); try { mainWindow = new BrowserWindow({ - width: CLIENT_MODE ? 1400 : width, - height: CLIENT_MODE ? 900 : height, - transparent: true, - backgroundColor: "#00000000", + width: CLIENT_MODE ? 1400 : initialWindowWidth, + height: CLIENT_MODE ? 900 : initialWindowHeight, + transparent: !opaqueDesktopRoot, + backgroundColor: opaqueDesktopRoot ? "#0b0d12" : "#00000000", frame: CLIENT_MODE, webPreferences: { sandbox: false, contextIsolation: true, preload: preloadPath, + additionalArguments: [ + `--wo-api-requested=${encodeURIComponent(JSON.stringify(requested))}`, + `--wo-api-granted=${encodeURIComponent(JSON.stringify(granted))}`, + `--wo-api-page=${encodeURIComponent(apiGrantPageUrl || "")}`, + ...!CLIENT_MODE ? ["--wo-system-portal-ui=1"] : [] + ], nodeIntegration: false, - offscreen: !CLIENT_MODE, + offscreen: CLIENT_MODE ? false : { + useSharedTexture: offscreenSharedTextureEnabled, + ...offscreenSharedTextureEnabled ? { sharedTexturePixelFormat: "argb" } : {} + }, webgl: true, backgroundThrottling: false }, show: CLIENT_MODE }); + mainPageLoadFinished = false; + let scriptLockActive = true; + let resourceApprovalPageUrl = siteAssets.pageUrl; + let firstNavigation = true; + let pendingResourceChecks = 0; + let navigationGeneration = 0; + let reloadAfterResourceApproval = false; + let resourceReloadTimer = null; + const deniedResourceKeys = new Set; + const finishResourceCheck = (approved) => { + if (approved) + reloadAfterResourceApproval = true; + pendingResourceChecks = Math.max(0, pendingResourceChecks - 1); + if (pendingResourceChecks !== 0 || !reloadAfterResourceApproval && !resourceConsentUiActive || resourceReloadTimer) + return; + resourceReloadTimer = setTimeout(() => { + resourceReloadTimer = null; + if ((reloadAfterResourceApproval || resourceConsentUiActive) && pendingResourceChecks === 0 && mainWindow && !mainWindow.isDestroyed()) { + reloadAfterResourceApproval = false; + resourceConsentUiActive = false; + debugLog("[Wo] Returning to page after resource approval decisions"); + mainWindow.webContents.loadURL(resourceApprovalPageUrl).catch((error) => { + debugLog("[Wo] Could not restore page after resource approval:", String(error)); + }); + } + }, 250); + }; + mainWindow.webContents.on("will-navigate", handleResourceConsentNavigation); + mainWindow.webContents.on("did-finish-load", () => { + if (!resourceConsentUiActive) + mainPageLoadFinished = true; + }); + mainWindow.webContents.on("did-start-navigation", (_event, navigationUrl, _isInPlace, isMainFrame) => { + if (!isMainFrame) + return; + portalUiRendererReady = false; + if (resourceConsentUiActive) + return; + mainPageLoadFinished = false; + if (resourceReloadTimer) + clearTimeout(resourceReloadTimer); + resourceReloadTimer = null; + reloadAfterResourceApproval = false; + pendingResourceChecks = 0; + navigationGeneration += 1; + deniedResourceKeys.clear(); + const wasFirstNavigation = firstNavigation; + firstNavigation = false; + try { + const requestedUrl = new URL(pageUrl); + const navigatingUrl = new URL(navigationUrl); + requestedUrl.hash = ""; + navigatingUrl.hash = ""; + if (requestedUrl.href === navigatingUrl.href) + return; + } catch { + if (wasFirstNavigation) + return; + } + apiGrants.clear(); + apiGrantPageUrl = null; + scriptLockActive = true; + try { + const currentPage = new URL(navigationUrl); + currentPage.hash = ""; + resourceApprovalPageUrl = currentPage.href; + } catch { + resourceApprovalPageUrl = navigationUrl; + } + lockedScriptUrls.clear(); + verifiedScriptCache.clear(); + }); + mainWindow.webContents.session.webRequest.onBeforeRequest((details, callback) => { + if (details.url.startsWith("wo-cache://")) { + callback({}); + return; + } + if (!scriptLockActive || !mainWindow || details.webContentsId !== mainWindow.webContents.id || !["script", "worker", "stylesheet"].includes(details.resourceType)) { + callback({}); + return; + } + const hash = lockedScriptUrls.get(details.url); + const isStylesheet = details.resourceType === "stylesheet"; + if (hash) { + debugLog(`[Wo] Locking verified script ${details.url} -> ${hash.slice(0, 12)}`); + callback({ redirectURL: woCacheUrl(hash, details.url, isStylesheet) }); + return; + } + const approvalPageUrl = resourceApprovalPageUrl; + const approvalKey = resourceApprovalKey(approvalPageUrl, details.url); + const requestGeneration = navigationGeneration; + pendingResourceChecks += 1; + let checkFinished = false; + const finishCheck = (approved) => { + if (checkFinished) + return; + checkFinished = true; + if (requestGeneration !== navigationGeneration) + return; + finishResourceCheck(approved); + }; + loadApprovedResource(approvalPageUrl, details.url).then((cachedHash) => { + if (requestGeneration !== navigationGeneration) { + callback({ cancel: true }); + finishCheck(false); + return; + } + if (cachedHash) { + callback({ redirectURL: woCacheUrl(cachedHash, details.url, isStylesheet) }); + finishCheck(false); + return; + } + callback({ cancel: true }); + if (deniedResourceKeys.has(approvalKey)) { + debugLog(`[Wo] Resource remains blocked after user denial: ${details.url}`); + finishCheck(false); + return; + } + approveAndCacheResource(approvalPageUrl, details.url, details.resourceType, siteAssets.styles).then((approvedHash) => { + if (!approvedHash) { + deniedResourceKeys.add(approvalKey); + debugLog(`[Wo] Blocked unapproved resource: ${details.url} (${details.resourceType})`); + finishCheck(false); + return; + } + finishCheck(true); + }).catch((error) => { + debugLog(`[Wo] Could not approve/cache resource ${details.url}: ${String(error)}`); + finishCheck(false); + }); + }).catch((error) => { + callback({ cancel: true }); + debugLog(`[Wo] Could not check approved resource cache for ${details.url}: ${String(error)}`); + finishCheck(false); + }); + }); + mainWindow.webContents.session.webRequest.onErrorOccurred((details) => { + if (mainWindow && details.webContentsId === mainWindow.webContents.id) { + debugLog(`[Wo] Resource request failed: ${details.error} ${details.resourceType} ${details.url}`); + } + }); mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => { callback({ responseHeaders: { ...details.responseHeaders, "Cross-Origin-Opener-Policy": ["same-origin"], - "Cross-Origin-Embedder-Policy": ["require-corp"] + "Cross-Origin-Embedder-Policy": ["require-corp"], + ...scriptLockActive && ["script", "worker", "stylesheet"].includes(details.resourceType) ? { "Access-Control-Allow-Origin": ["*"], "Cross-Origin-Resource-Policy": ["cross-origin"] } : {} } }); }); - debugLog("[Wo] BrowserWindow created, CLIENT_MODE=" + CLIENT_MODE + " offscreen=" + !CLIENT_MODE); + debugLog("[Wo] BrowserWindow created, CLIENT_MODE=" + CLIENT_MODE + " offscreen=" + mainWindow.webContents.isOffscreen() + " painting=" + mainWindow.webContents.isPainting()); + mainWindow.webContents.on("console-message", (details) => { + debugLog(`[Wo] page console level=${details.level} ${details.sourceId}:${details.lineNumber}: ${details.message}`); + }); + mainWindow.webContents.on("render-process-gone", (_event, details) => { + debugLog("[Wo] page render process exited:", JSON.stringify(details)); + }); setupIpcHandlers(); debugLog("[Wo] setupIpcHandlers completed"); mainWindow.webContents.send("wo:windows", clientWindows); + mainWindow.webContents.on("dom-ready", () => { + if (siteAssets.trusted && !resourceConsentUiActive) { + if (siteAssets.styles && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.insertCSS(siteAssets.styles, { cssOrigin: "user" }).catch((error) => { + debugLog("[Wo] Could not apply _wo.components.css:", String(error)); + }); + } + } + }); + let pageHasLoaded = false; + mainWindow.webContents.on("did-navigate", (_event, navigatedUrl) => { + if (/^https?:/.test(navigatedUrl)) + pageHasLoaded = true; + }); + mainWindow.webContents.on("did-fail-load", (_event, errorCode, errorDescription, failedUrl, isMainFrame) => { + if (!isMainFrame || resourceConsentUiActive || errorCode === -3 || pageHasLoaded || !/^https?:/.test(failedUrl)) + return; + debugLog(`[Wo] Page load failed (${errorCode} ${errorDescription}); retrying ${failedUrl}`); + setTimeout(() => { + if (mainWindow && !mainWindow.isDestroyed() && !resourceConsentUiActive && !pageHasLoaded) { + mainWindow.loadURL(pageUrl).catch((error) => debugLog("[Wo] Page retry failed:", String(error))); + } + }, 750); + }); if (CLIENT_MODE) { return; } - debugLog("[Wo] Loading content URL: " + (url || "minimal fallback")); + debugLog("[Wo] Loading content URL: " + pageUrl); try { - const pageUrl = url || "data:text/html,"; const loadPromise = mainWindow.loadURL(pageUrl); await Promise.race([ loadPromise, @@ -1854,23 +3323,73 @@ async function createWindow() { } catch (e) { debugLog("[Wo] Load failed:", String(e)); } + try { + const pageState = await mainWindow.webContents.executeJavaScript(`(() => { + const root = document.getElementById('root'); + const wallpaper = document.querySelector('.wo-wallpaper'); + const portalCard = document.querySelector('main[role="dialog"]'); + return { + readyState: document.readyState, + title: document.title, + bodyText: (document.body?.innerText || '').slice(0, 240), + bodyBackground: document.body ? getComputedStyle(document.body).backgroundColor : '', + bodyBackgroundImage: document.body ? getComputedStyle(document.body).backgroundImage : '', + bodyDisplay: document.body ? getComputedStyle(document.body).display : '', + portalCardBackground: portalCard ? getComputedStyle(portalCard).backgroundColor : '', + portalCardBounds: portalCard ? (() => { const r = portalCard.getBoundingClientRect(); return { x:r.x, y:r.y, width:r.width, height:r.height }; })() : null, + buttons: document.querySelectorAll('button').length, + resourceConsentBridge: !!window.woResourceConsent, + rootChildren: root?.childElementCount ?? -1, + shellPresent: !!document.querySelector('.wo-shell'), + wallpaperImage: wallpaper ? getComputedStyle(wallpaper).backgroundImage : '', + compositorApi: !!window.compositor, + woApi: !!window.wo, + }; + })()`); + debugLog("[Wo] Page render state:", JSON.stringify(pageState)); + } catch (error) { + debugLog("[Wo] Could not inspect loaded page:", String(error)); + } debugLog("[Wo] Page initialized, setting up rendering"); mainWindow.webContents.focus(); if (!CLIENT_MODE) { - debugLog("[Wo] Setting up OSR rendering, FPS=" + (WINDOW_CONFIG.fps ?? 60)); - mainWindow.webContents.setFrameRate(WINDOW_CONFIG.fps ?? 60); - mainWindow.webContents.on("did-finish-load", () => { - mainWindow.webContents.setFrameRate(WINDOW_CONFIG.fps ?? 60); - }); - const frameIntervalMs = Math.round(1000 / (WINDOW_CONFIG.fps ?? 60)); - setInterval(() => { - if (mainWindow && !mainWindow.isDestroyed() && inFlightFrameSeqs.size < MAX_IN_FLIGHT_FRAMES) { - mainWindow.webContents.invalidate(); - } - }, frameIntervalMs); + configureFramePacing(); + mainWindow.webContents.on("did-finish-load", configureFramePacing); let patchBuf = null; + let emptyPaintRecoveryAt = 0; + let emptyPaintLogged = false; let skippedCount = 0; - mainWindow.webContents.on("paint", (_event, dirty, image) => { + let loggedFirstBitmapFrame = false; + let loggedSecondBitmapFrame = false; + mainWindow.webContents.on("paint", (paintEvent, dirty, image) => { + const sharedTexture = paintEvent.texture; + if (sharedTexture) { + if (sharedDmabufStatus !== true) { + sharedDmabufStatus = true; + publishPerformanceStatus(); + } + let releasedByAck = false; + try { + if (!dmabufSender || !ipcConnected || appUsedDamageHelper || inFlightFrameSeqs.size >= MAX_IN_FLIGHT_FRAMES) { + return; + } + const seq = dmabufSender.sendSharedTexture(sharedTexture.textureInfo, () => sharedTexture.release()); + releasedByAck = true; + inFlightFrameSeqs.add(seq.toString()); + } catch (error) { + debugLog("[Wo] Native shared-texture frame failed:", String(error)); + sharedDmabufStatus = false; + publishPerformanceStatus(); + } finally { + if (!releasedByAck) + sharedTexture.release(); + } + return; + } + if (sharedDmabufStatus !== false) { + sharedDmabufStatus = false; + publishPerformanceStatus(); + } if (!dmabufSender || !ipcConnected) { skippedCount++; debugLog("[Wo] SKIPPED paint (dmabufSender=" + !!dmabufSender + " ipcConnected=" + ipcConnected + ")"); @@ -1887,12 +3406,59 @@ async function createWindow() { return; } const size = image.getSize(); + if (size.width <= 0 || size.height <= 0) { + const now = Date.now(); + if (!emptyPaintLogged) { + const contentSize = mainWindow?.getContentSize() ?? [0, 0]; + debugLog(`[Wo] Ignoring empty offscreen paint ${size.width}x${size.height}; content=${contentSize[0]}x${contentSize[1]} dirty=${dirty.x},${dirty.y},${dirty.width},${dirty.height}`); + emptyPaintLogged = true; + } + if (now - emptyPaintRecoveryAt >= 250) { + emptyPaintRecoveryAt = now; + if (mainWindow && !mainWindow.isDestroyed()) { + const [contentWidth, contentHeight] = mainWindow.getContentSize(); + if (contentWidth <= 0 || contentHeight <= 0) { + mainWindow.setSize(initialWindowWidth, initialWindowHeight); + } + mainWindow.webContents.invalidate(); + } + } + return; + } + let resized = !damageBuffer; if (!damageBuffer) { damageBuffer = new DamageBuffer(size.width, size.height); + } else if (damageBuffer.getWidth() !== size.width || damageBuffer.getHeight() !== size.height) { + damageBuffer.reset(size.width, size.height); + resized = true; } const pixels = image.toBitmap(); const pixelData = new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength); - const isFullFrame = dirty.x === 0 && dirty.y === 0 && dirty.width === size.width && dirty.height === size.height; + if (pixelData.byteLength < size.width * size.height * 4) { + if (!emptyPaintLogged) { + debugLog(`[Wo] Ignoring incomplete offscreen bitmap: ${pixelData.byteLength} bytes for ${size.width}x${size.height}`); + emptyPaintLogged = true; + } + if (Date.now() - emptyPaintRecoveryAt >= 250 && mainWindow && !mainWindow.isDestroyed()) { + emptyPaintRecoveryAt = Date.now(); + mainWindow.webContents.invalidate(); + } + return; + } + emptyPaintLogged = false; + if (!loggedFirstBitmapFrame) { + const centerOffset = (Math.floor(size.height / 2) * size.width + Math.floor(size.width / 2)) * 4; + const sampleOffsets = [0, Math.max(0, Math.floor(pixelData.byteLength / 2 / 4) * 4), centerOffset, Math.max(0, pixelData.byteLength - 4)]; + const samples = sampleOffsets.map((offset) => Array.from(pixelData.subarray(offset, offset + 4))); + debugLog(`[Wo] First bitmap pixels (BGRA top-left/mid-left/center/bottom-right): ${JSON.stringify(samples)}`); + } else if (!loggedSecondBitmapFrame) { + const centerOffset = (Math.floor(size.height / 2) * size.width + Math.floor(size.width / 2)) * 4; + const sampleOffsets = [0, Math.max(0, Math.floor(pixelData.byteLength / 2 / 4) * 4), centerOffset, Math.max(0, pixelData.byteLength - 4)]; + const samples = sampleOffsets.map((offset) => Array.from(pixelData.subarray(offset, offset + 4))); + debugLog(`[Wo] Second bitmap pixels (BGRA top-left/mid-left/center/bottom-right): ${JSON.stringify(samples)}`); + loggedSecondBitmapFrame = true; + } + const isFullFrame = resized || dirty.x === 0 && dirty.y === 0 && dirty.width === size.width && dirty.height === size.height; if (isFullFrame) { applyDamagePayload(damageBuffer, { width: size.width, @@ -1925,6 +3491,10 @@ async function createWindow() { try { const seq = dmabufSender.send(damageBuffer); inFlightFrameSeqs.add(seq.toString()); + if (!loggedFirstBitmapFrame) { + debugLog(`[Wo] First bitmap frame sent as DMA-BUF: seq=${seq} size=${size.width}x${size.height} stride=${damageBuffer.getStride()}`); + loggedFirstBitmapFrame = true; + } } catch (e) { debugLog("[Wo] ERROR sending frame:", String(e)); } @@ -1940,29 +3510,29 @@ async function createWindow() { } mainWindow.on("closed", () => { mainWindow = null; - if (windowPositionUpdateTimer) { - clearInterval(windowPositionUpdateTimer); - windowPositionUpdateTimer = null; - } + if (framePacer) + clearInterval(framePacer); + framePacer = null; + framePacerRate = 0; }); - if (!CLIENT_MODE && ipcConnected) { - const sendPosition = () => { - if (mainWindow && !mainWindow.isDestroyed()) { - const bounds = mainWindow.getBounds(); - sendWindowPositionUpdate(bounds.x, bounds.y, bounds.width, bounds.height); - } - }; - sendPosition(); - windowPositionUpdateTimer = setInterval(sendPosition, 100); - mainWindow.on("move", sendPosition); - mainWindow.on("resize", sendPosition); - } } app.on("ready", async () => { debugLog("[Wo] App ready, CLIENT_MODE=", CLIENT_MODE, "WO_STANDALONE=", process.env.WO_STANDALONE); - const gpuInfo = app.getGPUFeatureStatus(); - debugLog("[Wo] GPU feature status:", JSON.stringify(gpuInfo)); - debugLog("[Wo] GPU info:", JSON.stringify(app.getGPUInfo("basic").catch(() => ({})))); + displayScreen = (await import("electron")).screen; + displayScreen.on("display-added", configureFramePacing); + displayScreen.on("display-removed", configureFramePacing); + displayScreen.on("display-metrics-changed", configureFramePacing); + setupWoCacheProtocol(); + const gpuInfo = await app.getGPUInfo("complete").catch((error) => ({ error: String(error) })); + const gpuFeatures = app.getGPUFeatureStatus(); + gpuCompositingStatus = gpuFeatures.gpu_compositing || "unknown"; + webglStatus = gpuFeatures.webgl || "unknown"; + const threadedTextureMailbox = gpuInfo.auxAttributes?.canSupportThreadedTextureMailbox; + offscreenSharedTextureEnabled = gpuCompositingStatus === "enabled" && threadedTextureMailbox !== false; + debugLog("[Wo] GPU feature status:", JSON.stringify(gpuFeatures)); + debugLog("[Wo] GPU info:", JSON.stringify(gpuInfo)); + debugLog(`[Wo] Offscreen shared texture: ${offscreenSharedTextureEnabled ? "enabled" : "disabled"} (GPU compositor=${gpuCompositingStatus}, threaded texture mailbox=${threadedTextureMailbox ?? "unknown"}, WebGL=${webglStatus})`); + publishPerformanceStatus(); try { startPortalUiBridge(); if (!CLIENT_MODE && process.env.WO_STANDALONE !== "1") { @@ -1989,10 +3559,13 @@ app.on("activate", () => { } }); app.on("before-quit", () => { + if (audioCapture && !audioCapture.killed) + audioCapture.kill(); + audioCapture = null; stopPortalUiBridge(); closeShmFdCache(); }); export { - sendActionToCompositor, - connectToCompositor + connectToCompositor, + sendActionToCompositor }; diff --git a/electron/native/wo_dmabuf.cc b/electron/native/wo_dmabuf.cc index 81daffb..1fb779d 100644 --- a/electron/native/wo_dmabuf.cc +++ b/electron/native/wo_dmabuf.cc @@ -2,10 +2,16 @@ #include #include +#include +#include #include +#include #include +#include #include #include +#include +#include // Linux / GBM / DRM #include @@ -211,6 +217,40 @@ static void release_buf(uint32_t tok) { pool_return(bo, w, h, stride); } +static gbm_bo *create_cpu_writable_texture(uint32_t w, uint32_t h) { + // importRgba writes through gbm_bo_map(), so these buffers need a CPU + // readable layout and compositor texture use. Requesting RENDERING here is + // both unnecessary and rejected by some GBM drivers when combined with + // LINEAR (notably proprietary NVIDIA GBM). + constexpr uint32_t candidates[] = { + GBM_BO_USE_LINEAR | GBM_BO_USE_WRITE, + GBM_BO_USE_LINEAR, + GBM_BO_USE_WRITE, + 0, + }; + + for (uint32_t flags : candidates) { + if (!gbm_device_is_format_supported(g_gbm, GBM_FORMAT_ARGB8888, flags)) + continue; + gbm_bo *bo = gbm_bo_create(g_gbm, w, h, GBM_FORMAT_ARGB8888, flags); + if (bo) return bo; + } + + // Some GBM backends implement modifiers more reliably than the legacy + // create call. Keep the requested layout linear because the CPU maps it. + const uint64_t linear = DRM_FORMAT_MOD_LINEAR; + constexpr uint32_t modifier_flags[] = { + GBM_BO_USE_LINEAR | GBM_BO_USE_WRITE, + GBM_BO_USE_LINEAR, + }; + for (uint32_t flags : modifier_flags) { + gbm_bo *bo = gbm_bo_create_with_modifiers2( + g_gbm, w, h, GBM_FORMAT_ARGB8888, &linear, 1, flags); + if (bo) return bo; + } + return nullptr; +} + Napi::Value Init(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); std::string dev = "/dev/dri/renderD128"; @@ -238,6 +278,13 @@ Napi::Value ImportRgba(const Napi::CallbackInfo &info) { uint32_t h = info[2].As().Uint32Value(); uint32_t src_stride = info[3].As().Uint32Value(); + if (w == 0 || h == 0 || static_cast(src_stride) < static_cast(w) * 4U || + pixel_buf.Length() < static_cast(src_stride) * h) { + Napi::RangeError::New(env, "importRgba requires non-zero dimensions and a complete pixel buffer") + .ThrowAsJavaScriptException(); + return env.Null(); + } + ensure_gbm(); if (!g_gbm) { Napi::Error::New(env, "GBM device not initialised") @@ -250,20 +297,20 @@ Napi::Value ImportRgba(const Napi::CallbackInfo &info) { gbm_bo *bo = pool_acquire(w, h, &dst_stride); if (!bo) { - bo = gbm_bo_create_with_modifiers2(g_gbm, w, h, GBM_FORMAT_ARGB8888, - (const uint64_t[]){DRM_FORMAT_MOD_LINEAR}, - 1, - GBM_BO_USE_RENDERING | GBM_BO_USE_LINEAR); - if (!bo) { - bo = gbm_bo_create(g_gbm, w, h, GBM_FORMAT_ARGB8888, - GBM_BO_USE_RENDERING | GBM_BO_USE_LINEAR); - } - if (!bo) { - bo = gbm_bo_create(g_gbm, w, h, GBM_FORMAT_ARGB8888, GBM_BO_USE_WRITE); - } + bo = create_cpu_writable_texture(w, h); if (!bo) { + const char *backend = gbm_device_get_backend_name(g_gbm); + const bool format_write = gbm_device_is_format_supported( + g_gbm, GBM_FORMAT_ARGB8888, GBM_BO_USE_LINEAR | GBM_BO_USE_WRITE); + const bool format_linear = gbm_device_is_format_supported( + g_gbm, GBM_FORMAT_ARGB8888, GBM_BO_USE_LINEAR); Napi::Error::New( - env, "gbm_bo_create failed: all buffer creation methods exhausted") + env, "gbm_bo_create failed: all buffer creation methods exhausted for " + + std::to_string(w) + "x" + std::to_string(h) + + " ARGB8888; backend=" + (backend ? backend : "unknown") + + " linear-write-supported=" + (format_write ? "yes" : "no") + + " linear-supported=" + (format_linear ? "yes" : "no") + + "; check Wo and Electron are using the same DRM render node") .ThrowAsJavaScriptException(); return env.Null(); } @@ -401,6 +448,122 @@ Napi::Value RecvFd(const Napi::CallbackInfo &info) { return Napi::Number::New(env, -1); } +struct FdReadPacket { + std::vector bytes; + std::vector fds; + bool closed = false; +}; + +struct FdReader { + int fd; + std::atomic stopping{false}; + Napi::ThreadSafeFunction callback; + std::thread worker; +}; + +static std::mutex g_fd_readers_mutex; +static std::map> g_fd_readers; + +static void stop_fd_reader(int fd) { + std::shared_ptr reader; + { + std::lock_guard lock(g_fd_readers_mutex); + auto it = g_fd_readers.find(fd); + if (it == g_fd_readers.end()) return; + reader = it->second; + g_fd_readers.erase(it); + } + reader->stopping.store(true); + if (reader->worker.joinable()) reader->worker.join(); + reader->callback.Release(); +} + +Napi::Value StartFdReader(const Napi::CallbackInfo &info) { + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsFunction()) { + Napi::TypeError::New(env, "startFdReader(socketFd, callback)") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + + const int fd = info[0].As().Int32Value(); + stop_fd_reader(fd); + + auto reader = std::make_shared(); + reader->fd = fd; + reader->callback = Napi::ThreadSafeFunction::New( + env, info[1].As(), "wo-fd-reader", 0, 1); + { + std::lock_guard lock(g_fd_readers_mutex); + g_fd_readers.emplace(fd, reader); + } + + reader->worker = std::thread([reader]() { + while (!reader->stopping.load()) { + pollfd pfd{reader->fd, POLLIN | POLLERR | POLLHUP, 0}; + const int ready = ::poll(&pfd, 1, 50); + if (ready < 0) { + if (errno == EINTR) continue; + break; + } + if (ready == 0) continue; + + uint8_t data[64 * 1024]; + char control[CMSG_SPACE(sizeof(int) * 16)] = {}; + iovec iov{data, sizeof(data)}; + msghdr msg{}; + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + msg.msg_control = control; + msg.msg_controllen = sizeof(control); + const ssize_t count = ::recvmsg(reader->fd, &msg, MSG_DONTWAIT | MSG_CMSG_CLOEXEC); + if (count < 0) { + if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) continue; + break; + } + + auto *packet = new FdReadPacket(); + packet->closed = count == 0; + if (count > 0) packet->bytes.assign(data, data + count); + for (cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); cmsg; cmsg = CMSG_NXTHDR(&msg, cmsg)) { + if (cmsg->cmsg_level != SOL_SOCKET || cmsg->cmsg_type != SCM_RIGHTS) continue; + const size_t payloadBytes = cmsg->cmsg_len - CMSG_LEN(0); + const size_t fdCount = payloadBytes / sizeof(int); + const int *received = reinterpret_cast(CMSG_DATA(cmsg)); + packet->fds.insert(packet->fds.end(), received, received + fdCount); + } + + const napi_status status = reader->callback.NonBlockingCall( + packet, [](Napi::Env callbackEnv, Napi::Function callback, FdReadPacket *data) { + Napi::Buffer bytes = data->bytes.empty() + ? Napi::Buffer::New(callbackEnv, 0) + : Napi::Buffer::Copy( + callbackEnv, data->bytes.data(), data->bytes.size()); + Napi::Array fds = Napi::Array::New(callbackEnv, data->fds.size()); + for (size_t i = 0; i < data->fds.size(); ++i) { + fds.Set(i, Napi::Number::New(callbackEnv, data->fds[i])); + } + callback.Call({bytes, fds, Napi::Boolean::New(callbackEnv, data->closed)}); + delete data; + }); + if (status != napi_ok) { + for (int receivedFd : packet->fds) ::close(receivedFd); + delete packet; + } + if (count == 0) break; + } + }); + + return env.Undefined(); +} + +Napi::Value StopFdReader(const Napi::CallbackInfo &info) { + if (info.Length() >= 1 && info[0].IsNumber()) { + stop_fd_reader(info[0].As().Int32Value()); + } + return info.Env().Undefined(); +} + Napi::Value ImportDmabufTexture(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); @@ -565,10 +728,6 @@ Napi::Value MmapFd(const Napi::CallbackInfo &info) { hint); } -// ── Persistent mmap cache ───────────────────────────────────────────── -// CopyMmapToSab and CopyMmapDamageToSab are called on every frame for -// the same fd+size. Avoid the mmap/munmap syscall pair per frame by -// caching the mapping. Evict when fd or size changes. struct MmapCacheEntry { int fd; void *ptr; @@ -683,6 +842,8 @@ Napi::Object ModuleInit(Napi::Env env, Napi::Object exports) { exports.Set("releaseBuffer", Napi::Function::New(env, ReleaseBuffer)); exports.Set("sendFd", Napi::Function::New(env, SendFd)); exports.Set("recvFd", Napi::Function::New(env, RecvFd)); + exports.Set("startFdReader", Napi::Function::New(env, StartFdReader)); + exports.Set("stopFdReader", Napi::Function::New(env, StopFdReader)); exports.Set("mmapFd", Napi::Function::New(env, MmapFd)); exports.Set("copyMmapToSab", Napi::Function::New(env, CopyMmapToSab)); exports.Set("copyMmapDamageToSab", Napi::Function::New(env, CopyMmapDamageToSab)); diff --git a/electron/package.json b/electron/package.json index 5b16a90..8fdafc7 100644 --- a/electron/package.json +++ b/electron/package.json @@ -8,8 +8,9 @@ "scripts": { "start": "electron dist/main.js", "dev": "bun src/main.ts", - "build": "bun build src/main.ts src/preload.ts --outdir dist --target node --format esm --external electron", - "build-native": "node-gyp rebuild --directory native", + "test": "bun test", + "build": "bun build src/main.ts --outdir dist --target node --format esm --external electron && bun build src/preload.ts --outfile dist/preload.js --target node --format cjs --external electron", + "build-native": "node-gyp rebuild --directory native --jobs=1", "postinstall": "bun run build && bun run build-native" }, "dependencies": { @@ -17,9 +18,8 @@ }, "devDependencies": { "@types/bun": "latest", - "@types/electron": "^1.6.12", "@types/node": "^25.3.5", - "electron": "^29.4.6", + "electron": "^45.0.0", "node-gyp": "^10.3.1", "typescript": "^5.9.3" }, diff --git a/electron/src/damage-buffer.test.ts b/electron/src/damage-buffer.test.ts new file mode 100644 index 0000000..2749f9f --- /dev/null +++ b/electron/src/damage-buffer.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from 'bun:test'; +import { closeSync, mkdtempSync, openSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + applyDamagePayload, + DamageBuffer, + DamageRegionTracker, + WoDmabufSender, +} from './damage-buffer.js'; +import { MAGIC } from './protocol.js'; +import { createRendererDamageHelper } from './renderer-damage-helper.js'; + +describe('damage buffer', () => { + test('copies padded full-frame rows into the destination stride', () => { + const buffer = new DamageBuffer(2, 2); + const source = Uint8Array.from([ + 1, 2, 3, 4, 5, 6, 7, 8, 90, 91, 92, 93, + 9, 10, 11, 12, 13, 14, 15, 16, 94, 95, 96, 97, + ]); + + buffer.applyFullFrame(source, 12); + + expect([...buffer.getFrameBuffer()]).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, + 9, 10, 11, 12, 13, 14, 15, 16, + ]); + }); + + test('clips patches without shifting source pixels', () => { + const buffer = new DamageBuffer(2, 2); + buffer.applyFullFrame(new Uint8Array(16)); + + buffer.applyPatch({ + rect: { x: -1, y: -1, width: 3, height: 3 }, + stride: 12, + rgba: Uint8Array.from([ + 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, + 4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6, + 7, 7, 7, 7, 8, 8, 8, 8, 9, 9, 9, 9, + ]), + }); + + expect([...buffer.getFrameBuffer()]).toEqual([ + 5, 5, 5, 5, 6, 6, 6, 6, + 8, 8, 8, 8, 9, 9, 9, 9, + ]); + }); + + test('tracks touching damage and ignores empty rectangles', () => { + const tracker = new DamageRegionTracker(); + tracker.addAll([ + { x: 0, y: 0, width: 2, height: 2 }, + { x: 2, y: 0, width: 1, height: 2 }, + { x: 8, y: 8, width: 0, height: 1 }, + ]); + + expect(tracker.take()).toEqual([{ x: 0, y: 0, width: 3, height: 2 }]); + expect(tracker.take()).toEqual([]); + }); + + test('renderer damage helper emits patches that reconstruct the updated frame', async () => { + const payloads: Parameters[1][] = []; + const helper = createRendererDamageHelper(async (payload) => { + payloads.push(payload); + return { ok: true }; + }, { width: 3, height: 2, fullFrameThreshold: 1 }); + + helper.updatePatch( + { x: -1, y: 0, width: 3, height: 1 }, + Uint8Array.from([1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3]), + ); + await helper.flush(); + + const rendered = new DamageBuffer(3, 2); + const payload = payloads[0]; + expect(payload?.patches).toEqual([{ + rect: { x: 0, y: 0, width: 2, height: 1 }, + rgba: Uint8Array.from([2, 2, 2, 2, 3, 3, 3, 3]), + stride: 8, + }]); + if (payload) applyDamagePayload(rendered, payload); + expect([...rendered.getFrameBuffer()]).toEqual([ + 2, 2, 2, 2, 3, 3, 3, 3, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + ]); + }); +}); + +describe('shared DMA-BUF frame forwarding', () => { + test('holds a GBM buffer until the compositor acknowledges its frame', () => { + const directory = mkdtempSync(join(tmpdir(), 'wo-buffer-')); + const file = join(directory, 'frame'); + const socketFd = openSync(file, 'w+'); + const released: number[] = []; + try { + const socket = { _handle: { fd: socketFd } } as never; + const native = { + importRgba: () => ({ fd: 31, offset: 0, stride: 8, modifier_hi: 0, modifier_lo: 0, token: 7 }), + sendFd: () => {}, + releaseBuffer: (token: number) => released.push(token), + } as never; + const sender = new WoDmabufSender(socket, 'web', native); + const seq = sender.send(new DamageBuffer(2, 2)); + expect(released).toEqual([]); + sender.acknowledge(seq); + expect(released).toEqual([7]); + } finally { + closeSync(socketFd); + rmSync(directory, { recursive: true, force: true }); + } + }); + + test('writes plane metadata and forwards original file descriptors', async () => { + const directory = mkdtempSync(join(tmpdir(), 'wo-frame-')); + const file = join(directory, 'frame'); + const socketFd = openSync(file, 'w+'); + const sentFds: number[] = []; + try { + const socket = { _handle: { fd: socketFd } } as never; + const sender = new WoDmabufSender(socket, 'web', { sendFd: (_fd: number, fd: number) => sentFds.push(fd) } as never); + let released = 0; + const seq = sender.sendSharedTexture({ + pixelFormat: 'bgra', + codedSize: { width: 2, height: 3 }, + handle: { nativePixmap: { modifier: '0', planes: [ + { fd: 21, stride: 8, offset: 0 }, + { fd: 22, stride: 8, offset: 24 }, + ] } }, + }, () => { released += 1; }); + + const bytes = readFileSync(file); + const planeCountOffset = 8 + Buffer.byteLength('web') + 8 + 4 + 4 + 4; + expect(bytes.readUInt32LE(0)).toBe(MAGIC.FRAME); + expect(bytes.readUInt32LE(planeCountOffset)).toBe(2); + expect(bytes.readUInt32LE(planeCountOffset + 4)).toBe(0); + expect(bytes.readUInt32LE(planeCountOffset + 8)).toBe(8); + expect(bytes.readUInt32LE(planeCountOffset + 20)).toBe(24); + expect(sentFds).toEqual([21, 22]); + expect(released).toBe(0); + sender.acknowledge(seq); + expect(released).toBe(1); + } finally { + closeSync(socketFd); + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/electron/src/damage-buffer.ts b/electron/src/damage-buffer.ts index 68685ee..5438e64 100644 --- a/electron/src/damage-buffer.ts +++ b/electron/src/damage-buffer.ts @@ -59,6 +59,8 @@ type NativeDmabufModule = { releaseBuffer: (token: number) => void; sendFd: (socketFd: number, dmabufFd: number) => void; recvFd: (socketFd: number) => number; + startFdReader: (socketFd: number, callback: (chunk: Buffer, fds: number[], closed: boolean) => void) => void; + stopFdReader: (socketFd: number) => void; mmapFd: (fd: number, size: number) => Buffer; copyMmapToSab: (fd: number, sab: SharedArrayBuffer, size: number, srcOff?: number, dstOff?: number) => void; copyMmapDamageToSab: (fd: number, sab: SharedArrayBuffer, stride: number, rects: Array<{y: number; h: number}>) => void; @@ -67,6 +69,24 @@ type NativeDmabufModule = { releaseTexture: (windowName: string) => void; }; +export interface OffscreenNativePixmapPlane { + fd: number; + stride: number; + offset: number; + size?: number; +} + +export interface OffscreenSharedTextureInfo { + pixelFormat: 'bgra' | 'rgba' | 'rgbaf16' | 'nv12'; + codedSize: { width: number; height: number }; + handle: { + nativePixmap: { + planes: OffscreenNativePixmapPlane[]; + modifier: string; + }; + }; +} + const ARGB8888 = fourccCode('A', 'R', '2', '4'); export class DamageBuffer { @@ -107,7 +127,6 @@ export class DamageBuffer { applyFullFrame(frame: Uint8Array, frameStride: number = this.width * 4): void { if (frameStride === this.stride) { - // Fast path: direct typed-array copy, no intermediate Buffer wrapper this.frame.set(new Uint8Array(frame.buffer, frame.byteOffset, this.frame.length)); return; } @@ -129,12 +148,14 @@ export class DamageBuffer { return; } - const patchStride = patch.stride ?? rect.width * 4; + const patchStride = patch.stride ?? patch.rect.width * 4; + const sourceX = rect.x - patch.rect.x; + const sourceY = rect.y - patch.rect.y; const dstStartX = rect.x * 4; const rowBytes = rect.width * 4; for (let row = 0; row < rect.height; row += 1) { - const srcStart = row * patchStride; + const srcStart = (sourceY + row) * patchStride + sourceX * 4; const dstStart = (rect.y + row) * this.stride + dstStartX; this.frame.set( new Uint8Array(patch.rgba.buffer, patch.rgba.byteOffset + srcStart, rowBytes), @@ -152,29 +173,28 @@ export class DamageBuffer { export class WoDmabufSender { private readonly socket: Socket; - private readonly windowName: string; private readonly native: NativeDmabufModule; private seq = 1n; - // Pre-allocated wire buffer: header (variable) + plane (16 bytes). - // Layout: MAGIC(4) + nameLen(4) + name(N) + seq(8) + w(4) + h(4) + fmt(4) + planes(4) + plane(16) private readonly wireBuf: Buffer; + private readonly sharedWireBuf: Buffer; + private readonly nameBuf: Buffer; private readonly nameLen: number; private readonly seqOffset: number; private readonly widthOffset: number; private readonly heightOffset: number; + private readonly pendingReleases = new Map void>(); constructor(socket: Socket, windowName: string, native: NativeDmabufModule) { this.socket = socket; - this.windowName = windowName; this.native = native; const nameBuf = Buffer.from(windowName, 'utf8'); + this.nameBuf = nameBuf; this.nameLen = nameBuf.length; - // header = 4+4+nameLen+8+4+4+4+4 = 32+nameLen, plus 16 bytes for plane const totalLen = 32 + this.nameLen + 16; this.wireBuf = Buffer.alloc(totalLen); + this.sharedWireBuf = Buffer.alloc(32 + this.nameLen + 4 * 16); - // Static fields that never change let off = 0; this.wireBuf.writeUInt32LE(MAGIC.FRAME, off); off += 4; this.wireBuf.writeUInt32LE(this.nameLen, off); off += 4; @@ -183,10 +203,16 @@ export class WoDmabufSender { this.widthOffset = off; off += 4; this.heightOffset = off; off += 4; this.wireBuf.writeUInt32LE(ARGB8888, off); off += 4; - this.wireBuf.writeUInt32LE(1, off); // planes count = 1 + this.wireBuf.writeUInt32LE(1, off); } send(buffer: DamageBuffer): bigint { + if (buffer.getWidth() <= 0 || buffer.getHeight() <= 0 + || buffer.getStride() < buffer.getWidth() * 4 + || buffer.getFrameBuffer().byteLength < buffer.getStride() * buffer.getHeight()) { + throw new RangeError('Cannot send an incomplete or zero-sized DMA-BUF frame'); + } + const imported = this.native.importRgba( buffer.getFrameBuffer(), buffer.getWidth(), @@ -194,15 +220,15 @@ export class WoDmabufSender { buffer.getStride(), ); + let sent = false; + const sentSeq = this.seq; try { const socketFd = getSocketFd(this.socket); - // Write per-frame fields into the pre-allocated buffer this.wireBuf.writeBigUInt64LE(this.seq, this.seqOffset); this.wireBuf.writeUInt32LE(buffer.getWidth(), this.widthOffset); this.wireBuf.writeUInt32LE(buffer.getHeight(), this.heightOffset); - // Plane data (last 16 bytes of wireBuf) const planeOff = this.wireBuf.length - 16; this.wireBuf.writeUInt32LE(imported.offset, planeOff); this.wireBuf.writeUInt32LE(imported.stride, planeOff + 4); @@ -212,13 +238,91 @@ export class WoDmabufSender { writeSync(socketFd, this.wireBuf, 0, this.wireBuf.length); this.native.sendFd(socketFd, imported.fd); - const sentSeq = this.seq; + this.pendingReleases.set(sentSeq.toString(), () => this.native.releaseBuffer(imported.token)); this.seq += 1n; + sent = true; return sentSeq; } finally { - this.native.releaseBuffer(imported.token); + if (!sent) this.native.releaseBuffer(imported.token); } } + + sendSharedTexture(texture: OffscreenSharedTextureInfo, release: () => void): bigint { + const pixmap = texture.handle?.nativePixmap; + const planes = pixmap?.planes; + if (!planes || planes.length < 1 || planes.length > 4) { + throw new Error('shared texture has no supported DMA-BUF planes'); + } + const fourcc = texture.pixelFormat === 'bgra' + ? fourccCode('A', 'R', '2', '4') + : texture.pixelFormat === 'rgba' + ? fourccCode('A', 'B', '2', '4') + : texture.pixelFormat === 'nv12' + ? fourccCode('N', 'V', '1', '2') + : 0; + if (fourcc === 0) throw new Error(`unsupported shared texture pixel format: ${texture.pixelFormat}`); + + const width = texture.codedSize.width; + const height = texture.codedSize.height; + if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) { + throw new Error('shared texture has invalid coded dimensions'); + } + + const headerLength = 32 + this.nameLen + planes.length * 16; + let off = 0; + this.sharedWireBuf.writeUInt32LE(MAGIC.FRAME, off); off += 4; + this.sharedWireBuf.writeUInt32LE(this.nameLen, off); off += 4; + this.nameBuf.copy(this.sharedWireBuf, off); off += this.nameLen; + this.sharedWireBuf.writeBigUInt64LE(this.seq, off); off += 8; + this.sharedWireBuf.writeUInt32LE(width, off); off += 4; + this.sharedWireBuf.writeUInt32LE(height, off); off += 4; + this.sharedWireBuf.writeUInt32LE(fourcc, off); off += 4; + this.sharedWireBuf.writeUInt32LE(planes.length, off); off += 4; + const modifier = BigInt(pixmap.modifier || '0'); + for (const plane of planes) { + this.sharedWireBuf.writeUInt32LE(plane.offset >>> 0, off); off += 4; + this.sharedWireBuf.writeUInt32LE(plane.stride >>> 0, off); off += 4; + this.sharedWireBuf.writeUInt32LE(Number((modifier >> 32n) & 0xffff_ffffn), off); off += 4; + this.sharedWireBuf.writeUInt32LE(Number(modifier & 0xffff_ffffn), off); off += 4; + } + + const socketFd = getSocketFd(this.socket); + for (const plane of planes) { + if (!Number.isInteger(plane.fd) || plane.fd < 0 || !Number.isInteger(plane.stride) || plane.stride <= 0 || !Number.isInteger(plane.offset) || plane.offset < 0) { + throw new Error('shared texture has invalid DMA-BUF plane metadata'); + } + } + + writeAllSync(socketFd, this.sharedWireBuf, headerLength); + for (const plane of planes) this.native.sendFd(socketFd, plane.fd); + + const sentSeq = this.seq; + this.pendingReleases.set(sentSeq.toString(), release); + this.seq += 1n; + return sentSeq; + } + + acknowledge(seq: string | bigint): void { + const key = seq.toString(); + const release = this.pendingReleases.get(key); + if (!release) return; + this.pendingReleases.delete(key); + release(); + } + + releaseAll(): void { + for (const release of this.pendingReleases.values()) release(); + this.pendingReleases.clear(); + } +} + +function writeAllSync(fd: number, buffer: Buffer, length: number): void { + let offset = 0; + while (offset < length) { + const written = writeSync(fd, buffer, offset, length - offset); + if (written <= 0) throw new Error('IPC socket closed while sending a DMA-BUF frame'); + offset += written; + } } export function applyDamagePayload(buffer: DamageBuffer, payload: DamageFramePayload): void { @@ -257,13 +361,12 @@ function clampRect(rect: DamageRect, width: number, height: number): DamageRect } function coalesceDamageRects(input: DamageRect[], maxRects: number): DamageRect[] { - if (input.length <= 1) { - return input.slice(); - } - const rects = input .map((r) => ({ ...r })) .filter((r) => r.width > 0 && r.height > 0); + if (rects.length <= 1) { + return rects; + } const out: DamageRect[] = []; while (rects.length > 0) { diff --git a/electron/src/frame-pacing.test.ts b/electron/src/frame-pacing.test.ts new file mode 100644 index 0000000..01ff84f --- /dev/null +++ b/electron/src/frame-pacing.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test'; +import { chooseFrameRate, frameIntervalMs } from './frame-pacing'; + +describe('per-display frame pacing', () => { + test('follows the display refresh when no FPS override is configured', () => { + expect(chooseFrameRate(0, 144)).toBe(144); + expect(chooseFrameRate(undefined, 360)).toBe(360); + }); + + test('caps a per-window override at its display refresh', () => { + expect(chooseFrameRate(240, 144)).toBe(144); + expect(chooseFrameRate(120, 360)).toBe(120); + }); + + test('does not impose a global FPS ceiling above high-refresh displays', () => { + expect(chooseFrameRate(1000, 360)).toBe(360); + }); + + test('falls back to 60 Hz when display information is unavailable', () => { + expect(chooseFrameRate(0, 0)).toBe(60); + expect(frameIntervalMs(144)).toBeCloseTo(1000 / 144); + }); +}); diff --git a/electron/src/frame-pacing.ts b/electron/src/frame-pacing.ts new file mode 100644 index 0000000..bb88789 --- /dev/null +++ b/electron/src/frame-pacing.ts @@ -0,0 +1,13 @@ +export function chooseFrameRate(configuredFps: number | undefined, displayFps: number | undefined): number { + const display = Number.isFinite(displayFps) && (displayFps ?? 0) > 0 + ? Math.round(displayFps!) + : 60; + const configured = Number.isFinite(configuredFps) && (configuredFps ?? 0) > 0 + ? Math.round(configuredFps!) + : display; + return Math.max(1, Math.min(configured, display)); +} + +export function frameIntervalMs(fps: number): number { + return 1000 / Math.max(1, fps); +} diff --git a/electron/src/main.ts b/electron/src/main.ts index e693ac4..1563011 100644 --- a/electron/src/main.ts +++ b/electron/src/main.ts @@ -1,25 +1,447 @@ -import { app, BrowserWindow, ipcMain } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, Menu, protocol } from 'electron'; import { createConnection, createServer, Socket, Server } from 'net'; import { fileURLToPath } from 'url'; import { dirname, resolve as resolvePath, join as joinPath } from 'path'; -import { randomUUID } from 'crypto'; +import { createHash, randomUUID } from 'crypto'; import fs from 'fs'; import { createRequire } from 'module'; +import { pathToFileURL } from 'node:url'; +import { getXdgDirectories, listDesktopApplications, launchDesktopApplication, resolveDesktopIcon } from './xdg.js'; +import { prefetchWoSiteAssets, type WoCapability, type WoSiteAssets } from './wo-capabilities.js'; +import { chooseFrameRate, frameIntervalMs } from './frame-pacing.js'; import { MAGIC, stringToBuffer } from './protocol.js'; import { applyDamagePayload, DamageBuffer, type DamageFramePayload, + type OffscreenSharedTextureInfo, loadNativeDmabufModule, WoDmabufSender, } from './damage-buffer.js'; -// ESM __dirname equivalent const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const require = createRequire(import.meta.url); -// Debug logging to file — buffered to avoid synchronous I/O in hot paths +protocol.registerSchemesAsPrivileged([{ + scheme: 'wo-cache', + privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true, codeCache: true }, +}]); +const verifiedScriptCache = new Map(); +const approvedResourceRequests = new Map>(); +// Page-level approval applies only for this Electron session. Each resource is +// still fetched and stored in the content-addressed per-URL cache separately. +const approvedAllResourcePages = new Set(); +let resourceConsentQueue: Promise = Promise.resolve(); +const MAX_APPROVED_RESOURCE_BYTES = 16 * 1024 * 1024; + +function setupWoCacheProtocol() { + protocol.handle('wo-cache', (request) => { + const url = new URL(request.url); + const [, hash = '', kind = 'script.js', encodedSourceUrl = ''] = url.pathname.split('/'); + const bytes = verifiedScriptCache.get(hash); + if (!bytes) { + debugLog(`[Wo] Verified-script cache miss: ${url.pathname}`); + return new Response('Missing verified Wo script', { status: 404 }); + } + debugLog(`[Wo] Serving verified script ${hash.slice(0, 12)} (${bytes.byteLength} bytes)`); + const isStylesheet = kind === 'style.css'; + let responseBytes = bytes; + if (encodedSourceUrl) { + try { + const sourceUrl = Buffer.from(encodedSourceUrl, 'base64url').toString('utf8'); + responseBytes = isStylesheet + ? rewriteStylesheetUrls(bytes, sourceUrl) + : rewriteModuleImports(bytes, sourceUrl); + } catch (error) { + debugLog(`[Wo] Could not rewrite cached module imports for ${hash.slice(0, 12)}: ${String(error)}`); + } + } + return new Response(responseBytes, { + headers: { + 'Content-Type': isStylesheet ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8', + 'Access-Control-Allow-Origin': '*', + 'Cross-Origin-Resource-Policy': 'cross-origin', + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); + }); +} + +function rewriteStylesheetUrls(bytes: Buffer, sourceUrl: string): Buffer { + const css = bytes.toString('utf8'); + const rewritten = css.replace(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*?))\s*\)/gi, (whole, doubleQuoted, singleQuoted, bare) => { + const value = String(doubleQuoted ?? singleQuoted ?? bare ?? '').trim(); + if (!value || /^(?:data:|blob:|#|[a-z][a-z\d+.-]*:|\/\/)/i.test(value)) return whole; + try { + return `url(${JSON.stringify(new URL(value, sourceUrl).href)})`; + } catch { + return whole; + } + }); + return Buffer.from(rewritten); +} + +function rewriteModuleImports(bytes: Buffer, sourceUrl: string): Buffer { + const source = bytes.toString('utf8'); + const absolute = (specifier: string): string => { + try { return new URL(specifier, sourceUrl).href; } catch { return specifier; } + }; + // Redirected modules execute from wo-cache://, so resolve actual import + // specifiers against their original URL. Tokenizing first avoids rewriting + // examples in comments, strings, template literals, or regular expressions; + // the old broad regex could corrupt Vite's @react-refresh runtime. + type Token = { kind: 'identifier' | 'string' | 'punctuation'; value: string; start: number; end: number }; + const tokens: Token[] = []; + const isIdentifierStart = (char: string) => /[A-Za-z_$]/.test(char); + const isIdentifierPart = (char: string) => /[\w$]/.test(char); + let i = 0; + let previousToken = ''; + while (i < source.length) { + const char = source[i]; + if (/\s/.test(char)) { i++; continue; } + if (char === '/' && source[i + 1] === '/') { + i += 2; + while (i < source.length && source[i] !== '\n') i++; + continue; + } + if (char === '/' && source[i + 1] === '*') { + i += 2; + while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) i++; + i = Math.min(source.length, i + 2); + continue; + } + if (char === '/' && !['', '(', '[', '{', '=', ':', ',', ';', '!', '?', '&&', '||', '=>', 'return'].includes(previousToken)) { + tokens.push({ kind: 'punctuation', value: '/', start: i, end: i + 1 }); + previousToken = '/'; + i++; + continue; + } + if (char === '/' && ['(', '[', '{', '=', ':', ',', ';', '!', '?', '&&', '||', '=>', 'return'].includes(previousToken)) { + // Skip a regex literal so words such as "import" inside its pattern + // cannot be mistaken for module declarations. + const start = i++; + let inClass = false; + while (i < source.length) { + if (source[i] === '\\') { i += 2; continue; } + if (source[i] === '[') inClass = true; + else if (source[i] === ']') inClass = false; + else if (source[i] === '/' && !inClass) { i++; break; } + else if (source[i] === '\n') break; + i++; + } + while (/[A-Za-z]/.test(source[i] || '')) i++; + tokens.push({ kind: 'punctuation', value: 'regex', start, end: i }); + previousToken = 'regex'; + continue; + } + if (char === '"' || char === "'") { + const start = i++; + while (i < source.length) { + if (source[i] === '\\') { i += 2; continue; } + if (source[i++] === char) break; + } + const raw = source.slice(start + 1, Math.max(start + 1, i - 1)); + tokens.push({ kind: 'string', value: raw, start, end: i }); + previousToken = 'string'; + continue; + } + if (char === '`') { + const start = i++; + while (i < source.length) { + if (source[i] === '\\') { i += 2; continue; } + if (source[i++] === '`') break; + } + tokens.push({ kind: 'punctuation', value: 'template', start, end: i }); + previousToken = 'template'; + continue; + } + if (isIdentifierStart(char)) { + const start = i++; + while (i < source.length && isIdentifierPart(source[i])) i++; + const value = source.slice(start, i); + tokens.push({ kind: 'identifier', value, start, end: i }); + previousToken = value; + continue; + } + const start = i++; + const value = source.slice(start, i); + tokens.push({ kind: 'punctuation', value, start, end: i }); + previousToken = value; + } + + const edits: Array<{ start: number; end: number; replacement: string }> = []; + for (let t = 0; t < tokens.length; t++) { + const token = tokens[t]; + if (token.kind !== 'identifier' || (token.value !== 'import' && token.value !== 'export')) continue; + const next = tokens[t + 1]; + if (token.value === 'import' && next?.value === '(') { + const specifier = tokens[t + 2]; + if (specifier?.kind === 'string') { + edits.push({ start: specifier.start, end: specifier.end, replacement: JSON.stringify(absolute(specifier.value)) }); + } + continue; + } + if (token.value === 'import' && next?.kind === 'string') { + edits.push({ start: next.start, end: next.end, replacement: JSON.stringify(absolute(next.value)) }); + continue; + } + if (token.value === 'import' && next?.value === '.') continue; + // Static import/export declarations can span lines. Scan only as far as + // their `from` clause, stopping at a statement terminator or next module + // declaration so unrelated strings are left untouched. + for (let j = t + 1; j < tokens.length && j <= t + 256; j++) { + const candidate = tokens[j]; + if (candidate.value === ';' || (j > t + 1 && candidate.kind === 'identifier' + && (candidate.value === 'import' || candidate.value === 'export'))) break; + if (candidate.kind === 'identifier' && candidate.value === 'from' && tokens[j + 1]?.kind === 'string') { + const specifier = tokens[j + 1]; + edits.push({ start: specifier.start, end: specifier.end, replacement: JSON.stringify(absolute(specifier.value)) }); + break; + } + } + } + + let rewritten = source; + for (const edit of edits.sort((a, b) => b.start - a.start)) { + rewritten = rewritten.slice(0, edit.start) + edit.replacement + rewritten.slice(edit.end); + } + return Buffer.from(rewritten); +} + +function woCacheUrl(hash: string, resourceUrl: string, isStylesheet: boolean): string { + const source = Buffer.from(resourceUrl).toString('base64url'); + return `wo-cache://cache/${hash}/${isStylesheet ? 'style.css' : 'script.js'}/${source}`; +} + +function resourceApprovalKey(pageUrl: string, resourceUrl: string): string { + return createHash('sha256').update(`${pageUrl}\n${resourceUrl}`).digest('hex'); +} + +function resourceCacheDirectory(): string { + return joinPath(app.getPath('userData'), 'wo-approved-resources'); +} + +async function loadApprovedResource(pageUrl: string, resourceUrl: string): Promise { + const directory = resourceCacheDirectory(); + const key = resourceApprovalKey(pageUrl, resourceUrl); + try { + const record = JSON.parse(await fs.promises.readFile(joinPath(directory, `${key}.json`), 'utf8')) as { + pageUrl?: string; resourceUrl?: string; hash?: string; + }; + if (record.pageUrl !== pageUrl || record.resourceUrl !== resourceUrl + || typeof record.hash !== 'string' || !/^[a-f0-9]{64}$/.test(record.hash)) return null; + const bytes = await fs.promises.readFile(joinPath(directory, `${record.hash}.bin`)); + const actualHash = createHash('sha256').update(bytes).digest('hex'); + if (actualHash !== record.hash || bytes.byteLength > MAX_APPROVED_RESOURCE_BYTES) return null; + verifiedScriptCache.set(actualHash, bytes); + debugLog(`[Wo] Loaded user-approved resource cache ${actualHash.slice(0, 12)} for ${resourceUrl}`); + return actualHash; + } catch { + return null; + } +} + +async function fetchApprovedResourceBytes(resourceUrl: string): Promise { + const parsed = new URL(resourceUrl); + let bytes: Buffer; + if (parsed.protocol === 'file:') { + const path = fileURLToPath(parsed); + const fileStat = await fs.promises.stat(path); + if (fileStat.size > MAX_APPROVED_RESOURCE_BYTES) throw new Error('resource exceeds the 16 MiB approval cache limit'); + bytes = await fs.promises.readFile(path); + } else if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + let response: Response; + try { + response = await fetch(resourceUrl, { redirect: 'error' }); + } catch (error) { + if (parsed.protocol !== 'http:' || parsed.hostname !== 'localhost') throw error; + parsed.hostname = '127.0.0.1'; + response = await fetch(parsed.href, { redirect: 'error' }); + } + if (!response.ok) throw new Error(`request returned HTTP ${response.status}`); + const contentLength = Number(response.headers.get('content-length') || 0); + if (contentLength > MAX_APPROVED_RESOURCE_BYTES) throw new Error('resource exceeds the 16 MiB approval cache limit'); + if (!response.body) throw new Error('resource response has no body'); + const reader = response.body.getReader(); + const chunks: Buffer[] = []; + let totalBytes = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_APPROVED_RESOURCE_BYTES) { + await reader.cancel(); + throw new Error('resource exceeds the 16 MiB approval cache limit'); + } + chunks.push(Buffer.from(value)); + } + bytes = Buffer.concat(chunks, totalBytes); + } else { + throw new Error(`unsupported resource protocol: ${parsed.protocol}`); + } + if (bytes.byteLength === 0) throw new Error('resource is empty'); + if (bytes.byteLength > MAX_APPROVED_RESOURCE_BYTES) throw new Error('resource exceeds the 16 MiB approval cache limit'); + return bytes; +} + +async function persistApprovedResource(pageUrl: string, resourceUrl: string, hash: string, bytes: Buffer): Promise { + const directory = resourceCacheDirectory(); + const key = resourceApprovalKey(pageUrl, resourceUrl); + await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 }); + const bytesPath = joinPath(directory, `${hash}.bin`); + try { + await fs.promises.access(bytesPath); + } catch { + await fs.promises.writeFile(bytesPath, bytes, { mode: 0o600, flag: 'wx' }).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'EEXIST') throw error; + }); + } + const recordPath = joinPath(directory, `${key}.json`); + const temporaryPath = `${recordPath}.${randomUUID()}.tmp`; + await fs.promises.writeFile(temporaryPath, JSON.stringify({ pageUrl, resourceUrl, hash }), { mode: 0o600 }); + await fs.promises.rename(temporaryPath, recordPath); +} + +type ResourceApprovalDecision = 'deny' | 'allow' | 'allow-all'; + +function promptForResourceApproval( + pageUrl: string, + resourceUrl: string, + resourceType: string, + portalStyles: string, +): Promise { + const prompt = resourceConsentQueue.then(async () => { + if (approvedAllResourcePages.has(pageUrl)) return 'allow' as const; + debugLog(`[Wo] Waiting for user approval to fetch and cache: ${resourceUrl}`); + const decision = await showResourceConsentOverlay(pageUrl, resourceUrl, resourceType, portalStyles); + if (decision === 'allow-all') approvedAllResourcePages.add(pageUrl); + debugLog(`[Wo] User ${decision} resource: ${resourceUrl}`); + return decision; + }); + resourceConsentQueue = prompt.then(() => undefined, () => undefined); + return prompt; +} + +async function showResourceConsentOverlay( + pageUrl: string, + resourceUrl: string, + resourceType: string, + portalStyles: string, +): Promise { + const targetWindow = mainWindow; + if (!targetWindow || targetWindow.isDestroyed()) return 'deny'; + if (!mainPageLoadFinished && !resourceConsentUiActive) { + await new Promise((resolve) => { + const ready = () => { + clearTimeout(timeout); + targetWindow.webContents.removeListener('did-finish-load', ready); + resolve(); + }; + const timeout = setTimeout(ready, 5000); + targetWindow.webContents.once('did-finish-load', ready); + }); + } + if (targetWindow.isDestroyed()) return 'deny'; + const requestId = randomUUID(); + const token = randomUUID(); + const payload = JSON.stringify({ requestId, token, pageUrl, resourceUrl, resourceType }).replace(/Wo security portal +
+

Allow this page to load a resource?

This resource is not covered by the page's verified Wo manifest.

+
Resource type
Requested URL
Page
+

Approve all allows this page's future script, worker, and stylesheet requests for this session.

+

+
+ + `; + return new Promise((resolve) => { + resourceApprovalPending = { requestId, token, resolve }; + resourceConsentUiActive = true; + void targetWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}#wo-resource-consent`).then(() => { + debugLog(`[Wo] Displayed resource approval portal in the compositor root surface: ${resourceUrl}`); + }).catch((error) => { + if (resourceApprovalPending?.requestId === requestId) resourceApprovalPending = null; + resourceConsentUiActive = false; + debugLog(`[Wo] Could not load resource approval portal: ${String(error)}`); + resolve('deny'); + if (!targetWindow.isDestroyed()) { + void targetWindow.loadURL(pageUrl).catch((restoreError) => { + debugLog(`[Wo] Could not restore page after approval UI failure: ${String(restoreError)}`); + }); + } + }); + }); +} + +function handleResourceConsentNavigation(event: Electron.Event, targetUrl: string): void { + if (!targetUrl.startsWith('wo-consent://')) return; + event.preventDefault(); + try { + const decision = new URL(targetUrl); + if (decision.hostname !== 'decision') return; + const action = decision.searchParams.get('action'); + resolveResourceConsent( + decision.pathname.slice(1), + decision.searchParams.get('token') || '', + action === 'allow-all' ? 'allow-all' : action === 'allow' ? 'allow' : 'deny', + ); + } catch { /* Ignore malformed decisions. */ } +} + +function resolveResourceConsent(requestId: string, token: string, decision: ResourceApprovalDecision): boolean { + const pending = resourceApprovalPending; + if (!resourceConsentUiActive || !pending || pending.requestId !== requestId || pending.token !== token) return false; + resourceApprovalPending = null; + pending.resolve(decision); + return true; +} + +async function approveAndCacheResource( + pageUrl: string, + resourceUrl: string, + resourceType: string, + portalStyles: string, +): Promise { + const requestKey = resourceApprovalKey(pageUrl, resourceUrl); + const pending = approvedResourceRequests.get(requestKey); + if (pending) return pending; + const operation = (async () => { + const cachedHash = await loadApprovedResource(pageUrl, resourceUrl); + if (cachedHash) return cachedHash; + if (await promptForResourceApproval(pageUrl, resourceUrl, resourceType, portalStyles) === 'deny') return null; + const bytes = await fetchApprovedResourceBytes(resourceUrl); + const hash = createHash('sha256').update(bytes).digest('hex'); + await persistApprovedResource(pageUrl, resourceUrl, hash, bytes); + verifiedScriptCache.set(hash, bytes); + debugLog(`[Wo] Cached user-approved resource ${hash.slice(0, 12)} (${bytes.byteLength} bytes): ${resourceUrl}`); + return hash; + })(); + approvedResourceRequests.set(requestKey, operation); + try { + return await operation; + } finally { + approvedResourceRequests.delete(requestKey); + } +} + const DEBUG_LOG = '/tmp/wo-electron-debug.log'; let _logBuffer: string[] = []; let _logTimer: ReturnType | null = null; @@ -28,7 +450,6 @@ function _flushLog() { try { fs.appendFileSync(DEBUG_LOG, _logBuffer.join('')); } catch (e) { - // ignore } _logBuffer = []; _logTimer = null; @@ -44,27 +465,82 @@ function debugLog(...args: any[]) { debugLog('[Wo] Electron starting, NODE_VERSION=', process.version); -// GPU acceleration & rendering performance flags (must be set before app 'ready') -// Chromium flags MUST go through appendSwitch — process argv flags are not -// reliably forwarded by Electron to Chromium's command-line parser. -app.commandLine.appendSwitch('use-gl', 'angle'); -app.commandLine.appendSwitch('use-angle', 'vulkan'); -app.commandLine.appendSwitch('ozone-platform', 'wayland'); -app.commandLine.appendSwitch('no-sandbox'); -app.commandLine.appendSwitch('disable-gpu-sandbox'); +// Wo runs Chromium on native Wayland. ANGLE/OpenGL gives it an EGL path on +// Wayland without depending on a separate X server; explicit backend choices +// remain available for systems that need desktop GL or Vulkan. +const requestedOzonePlatform = (process.env.WO_ELECTRON_OZONE_PLATFORM || 'auto').toLowerCase(); +// The nested Wayland surface cannot present through Chromium's Vulkan path. +// Pin ANGLE/OpenGL by default; set WO_ELECTRON_GPU_BACKEND=auto to ask Chromium +// to choose its own implementation when diagnosing a driver-specific issue. +const defaultGpuBackend = 'angle'; +const requestedGpuBackend = (process.env.WO_ELECTRON_GPU_BACKEND || defaultGpuBackend).toLowerCase(); +const gpuBackend = ['vulkan', 'gl', 'angle', 'desktop', 'auto'].includes(requestedGpuBackend) + ? requestedGpuBackend + : defaultGpuBackend; +if (gpuBackend === 'desktop') { + // On X11, ask Chromium for the system GLX/EGL implementation directly. + // ANGLE's GL backend can fail to initialize on nested XWayland and then + // leaves offscreen paints as an opaque white software surface. + app.commandLine.appendSwitch('use-gl', 'desktop'); +} else if (gpuBackend === 'angle') { + app.commandLine.appendSwitch('use-gl', 'angle'); + app.commandLine.appendSwitch('use-angle', 'gl'); +} else if (gpuBackend !== 'auto') { + app.commandLine.appendSwitch('use-gl', 'angle'); + app.commandLine.appendSwitch('use-angle', gpuBackend); +} +if (requestedOzonePlatform === 'wayland' || requestedOzonePlatform === 'x11') { + app.commandLine.appendSwitch('ozone-platform', requestedOzonePlatform); +} app.commandLine.appendSwitch('ignore-gpu-blocklist'); -app.commandLine.appendSwitch('enable-gpu-rasterization'); -app.commandLine.appendSwitch('enable-zero-copy'); -app.commandLine.appendSwitch('enable-native-gpu-memory-buffers'); -app.commandLine.appendSwitch('disable-software-rasterizer'); -app.commandLine.appendSwitch('enable-features', - 'CanvasOopRasterization,Vulkan,VaapiVideoDecoder,VaapiVideoEncoder,SharedArrayBuffer,RawDraw,DefaultANGLEVulkan,VulkanFromANGLE'); +const chromiumFeatures = [ + 'VaapiVideoDecoder', + 'VaapiVideoEncoder', + 'SharedArrayBuffer', +]; +if (gpuBackend === 'vulkan') { + chromiumFeatures.push('Vulkan', 'DefaultANGLEVulkan', 'VulkanFromANGLE'); +} else if (requestedOzonePlatform === 'wayland' || gpuBackend !== 'auto') { + // Chromium's Linux Wayland surface factory rejects Vulkan. Keep it disabled + // while using the explicitly selected ANGLE/OpenGL implementation. + app.commandLine.appendSwitch('disable-vulkan'); + app.commandLine.appendSwitch('disable-features', 'Vulkan'); +} +app.commandLine.appendSwitch('enable-features', chromiumFeatures.join(',')); app.commandLine.appendSwitch('enable-accelerated-video-decode'); -app.commandLine.appendSwitch('disable-frame-rate-limit'); -app.commandLine.appendSwitch('disable-gpu-vsync'); app.commandLine.appendSwitch('num-raster-threads', '4'); app.commandLine.appendSwitch('disable-renderer-backgrounding'); -debugLog('[Wo] GPU acceleration flags applied'); +debugLog(`[Wo] GPU backend preference: ${gpuBackend}; Ozone platform: ${requestedOzonePlatform}`); +function logGraphicsRuntime(): void { + const relevantEnv = [ + 'WAYLAND_DISPLAY', 'DISPLAY', 'XDG_SESSION_TYPE', 'XDG_RUNTIME_DIR', + 'EGL_PLATFORM', '__EGL_VENDOR_LIBRARY_FILENAMES', '__GLX_VENDOR_LIBRARY_NAME', + 'GBM_BACKENDS_PATH', 'LIBGL_DRIVERS_PATH', 'MESA_LOADER_DRIVER_OVERRIDE', + 'DRI_PRIME', 'NVIDIA_VISIBLE_DEVICES', + ]; + const env = Object.fromEntries(relevantEnv + .filter((key) => process.env[key] !== undefined) + .map((key) => [key, process.env[key]])); + const renderNode = process.env.WO_DRM_RENDER_NODE || '/dev/dri/renderD128'; + let renderNodeAccess: Record; + try { + const stat = fs.statSync(renderNode); + fs.accessSync(renderNode, fs.constants.R_OK | fs.constants.W_OK); + renderNodeAccess = { path: renderNode, mode: (stat.mode & 0o777).toString(8), readable: true, writable: true }; + } catch (error) { + renderNodeAccess = { path: renderNode, error: String(error) }; + } + const switches = ['ozone-platform', 'use-gl', 'use-angle', 'disable-gpu', 'disable-vulkan', 'disable-features'] + .map((name) => ({ name, present: app.commandLine.hasSwitch(name), value: app.commandLine.getSwitchValue(name) })); + debugLog('[Wo] Graphics process context:', JSON.stringify({ + uid: typeof process.getuid === 'function' ? process.getuid() : null, + groups: typeof process.getgroups === 'function' ? process.getgroups() : [], + env, + renderNodeAccess, + switches, + })); +} +logGraphicsRuntime(); type ClientWindowInfo = { id: string; @@ -78,6 +554,16 @@ debugLog('[Wo] WO_WINDOW_CONFIG env =', process.env.WO_WINDOW_CONFIG); const WINDOW_CONFIG = JSON.parse(process.env.WO_WINDOW_CONFIG || '{}'); debugLog('[Wo] WINDOW_CONFIG parsed =', JSON.stringify(WINDOW_CONFIG)); const CLIENT_MODE = process.env.WO_CLIENT_MODE === '1'; +const SYSTEM_SHELL = process.env.WO_SYSTEM_SHELL === '1'; + +function isLoopbackHttp(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]', '::1'].includes(parsed.hostname); + } catch { + return false; + } +} const { name = 'default', @@ -86,8 +572,27 @@ const { width = 1920, height = 1080, } = WINDOW_CONFIG; +const initialWindowWidth = Number.isInteger(width) && width > 0 ? Math.min(width, 16384) : 1920; +const initialWindowHeight = Number.isInteger(height) && height > 0 ? Math.min(height, 16384) : 1080; let mainWindow: BrowserWindow | null = null; +let resourceConsentUiActive = false; +let mainPageLoadFinished = false; +let resourceApprovalPending: { + requestId: string; + token: string; + resolve: (decision: ResourceApprovalDecision) => void; +} | null = null; +let displayScreen: typeof import('electron').screen | null = null; +let apiGrants = new Set(); +let apiGrantPageUrl: string | null = null; +let lockedScriptUrls = new Map(); +let framePacer: NodeJS.Timeout | null = null; +let framePacerRate = 0; +let gpuCompositingStatus = 'pending'; +let webglStatus = 'pending'; +let sharedDmabufStatus: boolean | null = null; +let offscreenSharedTextureEnabled = false; let ipcSocket: Socket | null = null; let portalUiServer: Server | null = null; type PendingPortalRequest = { @@ -96,21 +601,21 @@ type PendingPortalRequest = { payload: any; }; const portalPendingRequests = new Map(); -const PORTAL_UI_SOCKET = '/tmp/wo-portal-ui.sock'; +type PortalUiEvent = { requestId: string; payload: Record; kind?: string }; +const portalUiEvents = new Map(); +let portalUiRendererReady = false; +let portalUiRendererGeneration = 0; +const PORTAL_UI_SOCKET = joinPath(process.env.XDG_RUNTIME_DIR || '/tmp', 'wo-portal-ui.sock'); let ipcConnected = false; +let activeSiteHashes: { pageUrl: string; indexBytes: number[]; stylesBytes: number[] } | null = null; let ipcReconnectTimer: NodeJS.Timeout | null = null; -let windowPositionUpdateTimer: NodeJS.Timeout | null = null; let dmabufSender: WoDmabufSender | null = null; let damageBuffer: DamageBuffer | null = null; let nativeDmabuf: any = null; -// IPC receive buffer — grows as needed, avoids Buffer.concat on every chunk. +let audioCapture: import('node:child_process').ChildProcess | null = null; let compositorRxBuffer = Buffer.alloc(64 * 1024); -let rxWriteOffset = 0; // next write position -let rxReadOffset = 0; // next read position -// ── Zero-copy surface buffer transfer via SharedArrayBuffer ── -// One SAB per window. The main process mmap's compositor memfd data into -// the SAB; the renderer reads directly from the same memory — no -// structured-clone serialization across the Electron IPC boundary. +let rxWriteOffset = 0; +let rxReadOffset = 0; type SabEntry = { sab: SharedArrayBuffer; width: number; @@ -118,7 +623,6 @@ type SabEntry = { stride: number; }; const surfaceSabCache = new Map(); -// Lightweight generation counter + damage rects sent via IPC instead of pixels const surfaceUpdatePending = new Map(); -/** Append incoming chunk to the rx ring buffer, growing if needed. */ +function isDisabledGpuFeature(status: string): boolean { + return status.startsWith('disabled') || status.startsWith('unavailable') || status.startsWith('failed'); +} + +function getPerformanceStatus() { + const reasons: string[] = []; + if (isDisabledGpuFeature(gpuCompositingStatus)) reasons.push('GPU compositing is disabled'); + if (isDisabledGpuFeature(webglStatus)) reasons.push('WebGL is unavailable'); + if (sharedDmabufStatus === false) reasons.push('Electron is using bitmap frames instead of shared GPU DMA-BUFs'); + return { + limited: reasons.length > 0, + gpuCompositing: gpuCompositingStatus, + webgl: webglStatus, + sharedDmabuf: sharedDmabufStatus, + reasons, + }; +} + +function publishPerformanceStatus(): void { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('wo:performance-status', getPerformanceStatus()); + } +} + function rxAppend(chunk: Buffer) { const needed = rxWriteOffset + chunk.length; if (needed > compositorRxBuffer.length) { - // Compact first: move unread data to front if (rxReadOffset > 0) { compositorRxBuffer.copy(compositorRxBuffer, 0, rxReadOffset, rxWriteOffset); rxWriteOffset -= rxReadOffset; rxReadOffset = 0; } - // Grow if still needed if (rxWriteOffset + chunk.length > compositorRxBuffer.length) { const next = Buffer.alloc(Math.max(compositorRxBuffer.length * 2, rxWriteOffset + chunk.length)); compositorRxBuffer.copy(next, 0, 0, rxWriteOffset); @@ -152,15 +677,12 @@ function rxAppend(chunk: Buffer) { rxWriteOffset += chunk.length; } -/** Number of unread bytes in the rx buffer. */ function rxAvailable(): number { return rxWriteOffset - rxReadOffset; } -/** Consume `n` bytes from the front of the rx buffer. */ function rxConsume(n: number) { rxReadOffset += n; - // Compact when the read head passes the halfway point if (rxReadOffset > compositorRxBuffer.length / 2) { compositorRxBuffer.copy(compositorRxBuffer, 0, rxReadOffset, rxWriteOffset); rxWriteOffset -= rxReadOffset; @@ -173,7 +695,6 @@ function closeShmFdCache() { try { fs.closeSync(entry.extFd); } catch { - // ignore close errors } } shmFdCache.clear(); @@ -189,7 +710,6 @@ function getOrOpenShmFd(windowName: string, pid: number, fd: number): number { try { fs.closeSync(cached.extFd); } catch { - // ignore } } @@ -208,7 +728,6 @@ function getOrCreateSab(windowName: string, width: number, height: number, strid const sab = new SharedArrayBuffer(neededSize); const entry: SabEntry = { sab, width, height, stride }; surfaceSabCache.set(windowName, entry); - // Tell renderer about the new SAB so it can reference it if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('wo:surface-sab', { name: windowName, @@ -225,18 +744,85 @@ function flushSurfaceUpdates() { surfaceUpdateFlushScheduled = false; if (!mainWindow || mainWindow.isDestroyed()) { surfaceUpdatePending.clear(); return; } for (const entry of surfaceUpdatePending.values()) { - // Send only metadata (no pixels) — the renderer reads from SAB directly mainWindow.webContents.send('wo:surface-update', entry); } surfaceUpdatePending.clear(); } const inFlightFrameSeqs = new Set(); -const MAX_IN_FLIGHT_FRAMES = 3; -// When the app uses compositor.submitDamageFrame() directly, skip OSR paint path to avoid double submission +const MAX_IN_FLIGHT_FRAMES = 2; +const receivedSocketFds: number[] = []; +let fdReaderSocketFd: number | null = null; + +function displayRefreshForWindow(): number { + if (!displayScreen) return 60; + const bounds = mainWindow && !mainWindow.isDestroyed() ? mainWindow.getBounds() : null; + const display = bounds + ? displayScreen.getDisplayMatching(bounds) + : displayScreen.getPrimaryDisplay(); + return display.displayFrequency; +} + +function configureFramePacing(): void { + if (CLIENT_MODE || !mainWindow || mainWindow.isDestroyed()) return; + const rate = chooseFrameRate(Number(WINDOW_CONFIG.fps), displayRefreshForWindow()); + if (rate === framePacerRate && framePacer) return; + framePacerRate = rate; + if (mainWindow.webContents.isOffscreen() && !mainWindow.webContents.isPainting()) { + mainWindow.webContents.startPainting(); + } + mainWindow.webContents.setFrameRate(rate); + if (framePacer) clearInterval(framePacer); + framePacer = setInterval(() => { + if (mainWindow && !mainWindow.isDestroyed() && inFlightFrameSeqs.size < MAX_IN_FLIGHT_FRAMES) { + mainWindow.webContents.invalidate(); + } + }, frameIntervalMs(rate)); + framePacer.unref(); + debugLog(`[Wo] Offscreen frame pacing: ${rate} Hz (window cap ${WINDOW_CONFIG.fps || 'auto'})`); +} + +function senderHasCapability(sender: Electron.WebContents, capability: WoCapability): boolean { + if (!mainWindow || mainWindow.isDestroyed() || sender.id !== mainWindow.webContents.id || !apiGrants.has(capability) || !apiGrantPageUrl) return false; + try { + const approved = new URL(apiGrantPageUrl); + const current = new URL(sender.getURL()); + approved.hash = ''; + current.hash = ''; + return approved.href === current.href; + } catch { return false; } +} + +function syscallCapability(type: string): WoCapability | null { + if (['list_applications', 'launch_desktop'].includes(type)) return 'desktop-applications'; + if (['xdg_directories', 'resolve_icon'].includes(type)) return 'xdg'; + if (type.startsWith('dbus_')) return 'dbus'; + if (type === 'portal_respond') return 'portals'; + if (type.startsWith('audio_')) return 'audio-capture'; + if (['listdir', 'read', 'write'].includes(type)) return 'filesystem'; + if (['launch', 'exec'].includes(type)) return 'process-launch'; + if (['shutdown', 'restart', 'logout', 'lock', 'sleep'].includes(type)) return 'system-control'; + if (type === 'notify') return 'notifications'; + if (type === 'load_local_file') return 'filesystem'; + return null; +} + +function permissionDenied(capability: WoCapability) { + return { ok: false, error: `Wo capability not granted: ${capability}` }; +} + let appUsedDamageHelper = false; -// Track pointer position for mouse button/scroll events (window-local coords) let pointerX = 0; let pointerY = 0; +const encodedWindowNames = new Map(); + +function getEncodedWindowName(windowName: string): Buffer { + let encoded = encodedWindowNames.get(windowName); + if (!encoded) { + encoded = Buffer.from(windowName, 'utf8'); + encodedWindowNames.set(windowName, encoded); + } + return encoded; +} function respondToPortalRequest(requestId: string, params: { response?: unknown; @@ -267,9 +853,11 @@ function respondToPortalRequest(requestId: string, params: { pending.socket.write(`${JSON.stringify(responsePayload)}\n`); pending.socket.end(); portalPendingRequests.delete(requestId); + portalUiEvents.delete(requestId); return { ok: true }; } catch (error) { portalPendingRequests.delete(requestId); + portalUiEvents.delete(requestId); return { ok: false, error: `Failed to send portal response: ${String(error)}` }; } } @@ -280,7 +868,6 @@ function startPortalUiBridge() { fs.unlinkSync(PORTAL_UI_SOCKET); } } catch { - // ignore stale socket cleanup errors } portalUiServer = createServer((socket) => { @@ -305,17 +892,17 @@ function startPortalUiBridge() { portalPendingRequests.set(requestId, { socket, requestId, payload: req }); - // Auto-timeout if UI does not respond within 90s socket.setTimeout(90_000, () => { const pending = portalPendingRequests.get(requestId); if (pending) { portalPendingRequests.delete(requestId); + portalUiEvents.delete(requestId); pending.socket.end(JSON.stringify({ error: 'timeout', requestId }) + '\n'); } }); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('wo:portal-request', { + portalUiEvents.set(requestId, { + data: { requestId, payload: req, kind: typeof req?.type === 'string' @@ -323,11 +910,10 @@ function startPortalUiBridge() { : typeof req?.kind === 'string' ? (req.kind as string) : undefined, - }); - } else { - portalPendingRequests.delete(requestId); - socket.end(JSON.stringify({ error: 'no_ui', requestId }) + '\n'); - } + }, + sentGeneration: -1, + }); + flushPortalUiEvents(); } catch { socket.end('{"error":"invalid_json"}\n'); } @@ -341,17 +927,21 @@ function startPortalUiBridge() { for (const [requestId, pending] of portalPendingRequests.entries()) { if (pending.socket === socket) { portalPendingRequests.delete(requestId); - break; + portalUiEvents.delete(requestId); } } }); socket.on('error', () => { - // socket errors are expected if requester exits early }); }); portalUiServer.listen(PORTAL_UI_SOCKET, () => { + try { + fs.chmodSync(PORTAL_UI_SOCKET, 0o600); + } catch (err) { + console.error('[Wo] Could not restrict portal socket permissions:', err); + } debugLog('[Wo] Portal UI bridge listening at', PORTAL_UI_SOCKET); }); @@ -365,10 +955,11 @@ function stopPortalUiBridge() { try { pending.socket.end(JSON.stringify({ error: 'shutdown', requestId: pending.requestId }) + '\n'); } catch { - // ignore } } portalPendingRequests.clear(); + portalUiEvents.clear(); + portalUiRendererReady = false; if (portalUiServer) { portalUiServer.close(); @@ -380,7 +971,6 @@ function stopPortalUiBridge() { fs.unlinkSync(PORTAL_UI_SOCKET); } } catch { - // ignore socket cleanup errors } } @@ -406,7 +996,6 @@ function parseClientWindows(): ClientWindowInfo[] { } let clientWindows: ClientWindowInfo[] = parseClientWindows(); -// Live window list received from the compositor via WOWM metadata messages. let compositorWindows: Record[] = []; type DbBus = 'user' | 'system'; @@ -428,6 +1017,82 @@ function runBusctl(bus: DbBus, args: string[]): { ok: boolean; stdout: string; s } } +type PipeWireAudioSource = { id: string; name: string; description: string; monitor: boolean }; + +function listPipeWireAudioSources(): { ok: boolean; sources: PipeWireAudioSource[]; error?: string } { + const { spawnSync } = require('node:child_process') as typeof import('node:child_process'); + const result = spawnSync('pw-dump', [], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + if (result.status !== 0) { + return { ok: false, sources: [], error: String(result.stderr || 'Could not read PipeWire sources').trim() }; + } + try { + const objects = JSON.parse(result.stdout as string) as Array<{ + id?: number; + type?: string; + info?: { props?: Record }; + }>; + const sources = objects.flatMap((object) => { + const props = object.info?.props; + if (!object.id || object.type !== 'PipeWire:Interface:Node' || !props) return []; + const mediaClass = String(props['media.class'] || ''); + if (!mediaClass.startsWith('Audio/Source')) return []; + const name = String(props['node.name'] || `pipewire-${object.id}`); + const description = String(props['node.description'] || name); + return [{ + id: String(object.id), + name, + description, + monitor: String(props['device.class'] || '') === 'monitor' + || name.endsWith('.monitor') + || description.toLowerCase().startsWith('monitor of '), + }]; + }); + return { ok: true, sources }; + } catch (error) { + return { ok: false, sources: [], error: `Could not parse PipeWire source list: ${String(error)}` }; + } +} + +function broadcastAudioState(state: { active: boolean; error?: string }): void { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('wo:audio-state', state); +} + +function startPipeWireAudioCapture(source: PipeWireAudioSource): Promise<{ ok: boolean; source?: PipeWireAudioSource; error?: string }> { + if (audioCapture) audioCapture.kill(); + const { spawn } = require('node:child_process') as typeof import('node:child_process'); + const child = spawn('pw-cat', [ + '--record', '--target', source.id, '--format', 's16', '--rate', '48000', '--channels', '2', '-', + ], { stdio: ['ignore', 'pipe', 'pipe'], env: process.env }); + audioCapture = child; + child.stdout?.on('data', (data: Buffer) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('wo:audio-data', { + format: 's16le', sampleRate: 48000, channels: 2, data, timestamp: Date.now(), + }); + } + }); + child.stderr?.on('data', (data: Buffer) => debugLog('[Wo] pw-cat:', data.toString().trim())); + child.once('close', (code) => { + if (audioCapture === child) { + audioCapture = null; + broadcastAudioState({ active: false, ...(code && code !== 0 ? { error: `pw-cat exited (${code})` } : {}) }); + } + }); + + return new Promise((resolve) => { + child.once('error', (error) => { + if (audioCapture === child) audioCapture = null; + const message = error.message; + broadcastAudioState({ active: false, error: message }); + resolve({ ok: false, error: message }); + }); + child.once('spawn', () => { + broadcastAudioState({ active: true }); + resolve({ ok: true, source }); + }); + }); +} + function getSocketFd(socket: Socket): number { const fd = (socket as any)?._handle?.fd; if (typeof fd !== 'number' || !Number.isFinite(fd) || fd < 0) { @@ -482,39 +1147,13 @@ function sendActionToCompositor(action: string, payload?: Record>> 0, offset); ipcSocket.write(messageBuf); return true; @@ -531,26 +1170,68 @@ function sendForwardedKeyboard(windowName: string, evdevKey: number, pressed: bo } } -// Map Linux evdev button code to Electron mouse button name +function sendForwardedPointer(windowName: string, x: number, y: number): void { + if (!ipcSocket || !ipcConnected) return; + const nameBuf = getEncodedWindowName(windowName); + const msg = Buffer.allocUnsafe(8 + nameBuf.length + 16); + let off = 0; + msg.writeUInt32LE(MAGIC.FORWARD_POINTER, off); off += 4; + msg.writeUInt32LE(nameBuf.length, off); off += 4; + nameBuf.copy(msg, off); off += nameBuf.length; + msg.writeDoubleLE(x, off); off += 8; + msg.writeDoubleLE(y, off); + ipcSocket.write(msg); +} + +function sendForwardedPointerButton(windowName: string, x: number, y: number, button: number, pressed: boolean, time: number): void { + if (!ipcSocket || !ipcConnected) return; + const nameBuf = getEncodedWindowName(windowName); + const msg = Buffer.allocUnsafe(8 + nameBuf.length + 28); + let off = 0; + msg.writeUInt32LE(MAGIC.FORWARD_POINTER_BUTTON, off); off += 4; + msg.writeUInt32LE(nameBuf.length, off); off += 4; + nameBuf.copy(msg, off); off += nameBuf.length; + msg.writeDoubleLE(x, off); off += 8; + msg.writeDoubleLE(y, off); off += 8; + msg.writeUInt32LE(button >>> 0, off); off += 4; + msg.writeUInt32LE(pressed ? 1 : 0, off); off += 4; + msg.writeUInt32LE(Math.trunc(time) >>> 0, off); + ipcSocket.write(msg); +} + +function sendForwardedPointerScroll(windowName: string, x: number, y: number, dx: number, dy: number, time: number): void { + if (!ipcSocket || !ipcConnected) return; + const nameBuf = getEncodedWindowName(windowName); + const msg = Buffer.allocUnsafe(8 + nameBuf.length + 36); + let off = 0; + msg.writeUInt32LE(MAGIC.FORWARD_POINTER_SCROLL, off); off += 4; + msg.writeUInt32LE(nameBuf.length, off); off += 4; + nameBuf.copy(msg, off); off += nameBuf.length; + msg.writeDoubleLE(x, off); off += 8; + msg.writeDoubleLE(y, off); off += 8; + msg.writeDoubleLE(dx, off); off += 8; + msg.writeDoubleLE(dy, off); off += 8; + msg.writeUInt32LE(Math.trunc(time) >>> 0, off); + ipcSocket.write(msg); +} + function linuxButtonToElectron(btn: number): 'left' | 'middle' | 'right' { if (btn === 273) return 'right'; if (btn === 274) return 'middle'; - return 'left'; // 272 = BTN_LEFT and default + return 'left'; } -// Modifier state tracked from key events const modifierState = { shift: false, control: false, alt: false, meta: false }; -// Evdev keycodes that correspond to modifier keys const MODIFIER_KEYCODES: Record = { - 29: 'control', // KEY_LEFTCTRL - 42: 'shift', // KEY_LEFTSHIFT - 54: 'shift', // KEY_RIGHTSHIFT - 56: 'alt', // KEY_LEFTALT - 97: 'control', // KEY_RIGHTCTRL - 100: 'alt', // KEY_RIGHTALT - 125: 'meta', // KEY_LEFTMETA - 126: 'meta', // KEY_RIGHTMETA + 29: 'control', + 42: 'shift', + 54: 'shift', + 56: 'alt', + 97: 'control', + 100: 'alt', + 125: 'meta', + 126: 'meta', }; type InputModifier = 'shift' | 'control' | 'alt' | 'meta'; @@ -564,57 +1245,45 @@ function getModifierArray(): InputModifier[] { return mods; } -// Shifted character mapping for generating correct char events const SHIFTED_CHARS: Record = { '1': '!', '2': '@', '3': '#', '4': '$', '5': '%', '6': '^', '7': '&', '8': '*', '9': '(', '0': ')', '-': '_', '=': '+', '[': '{', ']': '}', '\\': '|', ';': ':', "'": '"', '`': '~', ',': '<', '.': '>', '/': '?', }; -// Map Linux evdev keycode to Electron sendInputEvent keyCode string const LINUX_KEYCODE_MAP: Record = { - // Row 1: Escape and function keys 1: 'Escape', 59: 'F1', 60: 'F2', 61: 'F3', 62: 'F4', 63: 'F5', 64: 'F6', 65: 'F7', 66: 'F8', 67: 'F9', 68: 'F10', 87: 'F11', 88: 'F12', - // Row 2: Number row 41: '`', 2: '1', 3: '2', 4: '3', 5: '4', 6: '5', 7: '6', 8: '7', 9: '8', 10: '9', 11: '0', 12: '-', 13: '=', 14: 'Backspace', - // Row 3: QWERTY 15: 'Tab', 16: 'q', 17: 'w', 18: 'e', 19: 'r', 20: 't', 21: 'y', 22: 'u', 23: 'i', 24: 'o', 25: 'p', 26: '[', 27: ']', 43: '\\', - // Row 4: Home row 58: 'CapsLock', 30: 'a', 31: 's', 32: 'd', 33: 'f', 34: 'g', 35: 'h', 36: 'j', 37: 'k', 38: 'l', 39: ';', 40: "'", 28: 'Return', - // Row 5: Bottom row 42: 'Shift', 44: 'z', 45: 'x', 46: 'c', 47: 'v', 48: 'b', 49: 'n', 50: 'm', 51: ',', 52: '.', 53: '/', 54: 'Shift', - // Row 6: Modifiers and space 29: 'Control', 125: 'Meta', 56: 'Alt', 57: 'Space', 100: 'Alt', 126: 'Meta', 97: 'Control', - // Navigation cluster 110: 'Insert', 102: 'Home', 104: 'PageUp', 111: 'Delete', 107: 'End', 109: 'PageDown', - // Arrow keys 103: 'Up', 105: 'Left', 108: 'Down', 106: 'Right', - // Numpad 69: 'NumLock', 98: 'numdiv', 55: 'nummult', 74: 'numsub', 71: 'num7', 72: 'num8', 73: 'num9', 78: 'numadd', 75: 'num4', 76: 'num5', 77: 'num6', 79: 'num1', 80: 'num2', 81: 'num3', 96: 'Enter', 82: 'num0', 83: 'numdec', - // Media / misc 99: 'PrintScreen', 70: 'ScrollLock', 119: 'Pause', 113: 'VolumeMute', 114: 'VolumeDown', 115: 'VolumeUp', 163: 'MediaNextTrack', 165: 'MediaPreviousTrack', 164: 'MediaPlayPause', 166: 'MediaStop', @@ -632,6 +1301,7 @@ function connectToCompositor(): Promise { debugLog('[Wo] Connecting to compositor IPC:', IPC_SOCKET); ipcSocket = createConnection(IPC_SOCKET, () => { + ipcSocket!.setNoDelay(true); const windowName = name; const nameBuf = stringToBuffer(windowName); const messageBuf = Buffer.alloc(8 + nameBuf.length + 8); @@ -640,22 +1310,40 @@ function connectToCompositor(): Promise { messageBuf.writeUInt32LE(MAGIC.HELLO, offset); offset += 4; messageBuf.writeUInt32LE(nameBuf.length, offset); offset += 4; nameBuf.copy(messageBuf, offset); offset += nameBuf.length; - messageBuf.writeUInt32LE(width, offset); offset += 4; - messageBuf.writeUInt32LE(height, offset); + messageBuf.writeUInt32LE(initialWindowWidth, offset); offset += 4; + messageBuf.writeUInt32LE(initialWindowHeight, offset); ipcSocket!.write(messageBuf); ipcConnected = true; debugLog('[Wo] IPC connected, sent HELLO message'); + sendSiteHashesToCompositor(); try { const nativePath = resolvePath(__dirname, '../native/build/Release/wo_dmabuf.node'); nativeDmabuf = loadNativeDmabufModule(require, nativePath); - nativeDmabuf.init(process.env.WO_DRM_RENDER_NODE || '/dev/dri/renderD128'); + const renderNode = process.env.WO_DRM_RENDER_NODE || '/dev/dri/renderD128'; + debugLog('[Wo] Initializing GBM on render node:', renderNode); + nativeDmabuf.init(renderNode); dmabufSender = new WoDmabufSender(ipcSocket!, name, nativeDmabuf); + const socketFd = (ipcSocket as any)?._handle?.fd; + if (typeof socketFd === 'number' && socketFd >= 0 && nativeDmabuf.startFdReader) { + // Node's stream reader uses readv(2), which discards SCM_RIGHTS + // control messages. Keep reads in the native recvmsg pump instead. + ipcSocket!.pause(); + fdReaderSocketFd = socketFd; + nativeDmabuf.startFdReader(socketFd, (chunk: Buffer, fds: number[], closed: boolean) => { + receivedSocketFds.push(...fds); + if (chunk.length > 0) onIpcData(chunk); + if (closed) ipcSocket?.destroy(); + }); + } else { + ipcSocket!.on('data', onIpcData); + } debugLog('[Wo] DMABUF sender initialized'); } catch (error) { debugLog('[Wo] DMABUF sender unavailable:', error); dmabufSender = null; + ipcSocket!.on('data', onIpcData); } resolve(); @@ -663,9 +1351,8 @@ function connectToCompositor(): Promise { let ipcDataCalls = 0; let ipcDataLastLog = Date.now(); - // Input event batching for improved responsiveness let pendingMouseMove: { x: number; y: number } | null = null; - let inputFlushTimer: NodeJS.Timeout | null = null; + let inputFlushTimer: NodeJS.Immediate | null = null; const flushInputEvents = () => { inputFlushTimer = null; if (pendingMouseMove && mainWindow && !mainWindow.isDestroyed()) { @@ -677,8 +1364,8 @@ function connectToCompositor(): Promise { pendingMouseMove = null; } }; - - ipcSocket.on('data', (chunk: Buffer) => { + + const onIpcData = (chunk: Buffer) => { ipcDataCalls++; const now = Date.now(); if (now - ipcDataLastLog > 2000) { @@ -692,43 +1379,39 @@ function connectToCompositor(): Promise { if (magic === MAGIC.FRAME_ACK) { if (rxAvailable() < 12) break; - const seq = compositorRxBuffer.readBigUInt64LE(rxReadOffset + 4).toString(); - inFlightFrameSeqs.delete(seq); + const seq = compositorRxBuffer.readBigUInt64LE(rxReadOffset + 4); + dmabufSender?.acknowledge(seq); + const wasBackpressured = inFlightFrameSeqs.size >= MAX_IN_FLIGHT_FRAMES; + inFlightFrameSeqs.delete(seq.toString()); rxConsume(12); + if (wasBackpressured && mainWindow && !mainWindow.isDestroyed()) { + // Paint events dropped at the in-flight limit are not replayed by + // Chromium. Request a fresh frame as soon as the compositor frees + // a slot so the most recent page state reaches the display. + mainWindow.webContents.invalidate(); + } continue; } if (magic === MAGIC.MOUSE_MOVE) { - // magic(4) + x(8, f64) + y(8, f64) = 20 bytes if (rxAvailable() < 20) break; const x = compositorRxBuffer.readDoubleLE(rxReadOffset + 4); const y = compositorRxBuffer.readDoubleLE(rxReadOffset + 12); rxConsume(20); pointerX = Math.round(x); pointerY = Math.round(y); - - // OPTIMIZATION: Batch mouse moves for responsiveness - // Flush immediately if no timer pending, otherwise coalesce - if (!inputFlushTimer) { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.sendInputEvent({ type: 'mouseMove', x: pointerX, y: pointerY }); - } - } else { - // Coalesce subsequent moves - pendingMouseMove = { x: pointerX, y: pointerY }; - inputFlushTimer = setTimeout(flushInputEvents, 0); - } + + pendingMouseMove = { x: pointerX, y: pointerY }; + if (!inputFlushTimer) inputFlushTimer = setImmediate(flushInputEvents); continue; } if (magic === MAGIC.MOUSE_BUTTON) { - // magic(4) + button(4) + pressed(4) + time(4) = 16 bytes if (rxAvailable() < 16) break; const button = compositorRxBuffer.readUInt32LE(rxReadOffset + 4); const pressed = compositorRxBuffer.readUInt32LE(rxReadOffset + 8) !== 0; rxConsume(16); - - // OPTIMIZATION: Flush pending mouse moves before button events + if (pendingMouseMove && mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.sendInputEvent({ type: 'mouseMove', @@ -738,10 +1421,10 @@ function connectToCompositor(): Promise { pendingMouseMove = null; } if (inputFlushTimer) { - clearTimeout(inputFlushTimer); + clearImmediate(inputFlushTimer); inputFlushTimer = null; } - + if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.sendInputEvent({ type: pressed ? 'mouseDown' : 'mouseUp', @@ -756,13 +1439,11 @@ function connectToCompositor(): Promise { } if (magic === MAGIC.KEYBOARD) { - // magic(4) + key(4) + pressed(4) + time(4) = 16 bytes if (rxAvailable() < 16) break; const key = compositorRxBuffer.readUInt32LE(rxReadOffset + 4); const pressed = compositorRxBuffer.readUInt32LE(rxReadOffset + 8) !== 0; rxConsume(16); - // Update modifier state before generating events const modKey = MODIFIER_KEYCODES[key]; if (modKey) { modifierState[modKey] = pressed; @@ -776,7 +1457,6 @@ function connectToCompositor(): Promise { keyCode, modifiers, }); - // Send char event for printable keys on press if (pressed && keyCode.length === 1) { let charKey = keyCode; if (modifierState.shift) { @@ -793,11 +1473,15 @@ function connectToCompositor(): Promise { } if (magic === MAGIC.SCROLL) { - // magic(4) + vertical(4, i32) + horizontal(4, i32) + time(4) = 16 bytes if (rxAvailable() < 16) break; const vertical = compositorRxBuffer.readInt32LE(rxReadOffset + 4); const horizontal = compositorRxBuffer.readInt32LE(rxReadOffset + 8); rxConsume(16); + if (pendingMouseMove && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.sendInputEvent({ type: 'mouseMove', x: pendingMouseMove.x, y: pendingMouseMove.y }); + pendingMouseMove = null; + } + if (inputFlushTimer) { clearImmediate(inputFlushTimer); inputFlushTimer = null; } if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.sendInputEvent({ type: 'mouseWheel', @@ -812,7 +1496,6 @@ function connectToCompositor(): Promise { } if (magic === MAGIC.FOCUS_CHANGE) { - // magic(4) + name_len(4) + focused(4) + name(N) = 12 + N bytes if (rxAvailable() < 12) break; const nameLen = compositorRxBuffer.readUInt32LE(rxReadOffset + 4); if (rxAvailable() < 12 + nameLen) break; @@ -826,7 +1509,6 @@ function connectToCompositor(): Promise { } if (magic === MAGIC.WINDOW_META) { - // magic(4) + payload_len(4) + payload(N) = 8 + N bytes if (rxAvailable() < 8) break; const payloadLen = compositorRxBuffer.readUInt32LE(rxReadOffset + 4); if (rxAvailable() < 8 + payloadLen) break; @@ -834,11 +1516,9 @@ function connectToCompositor(): Promise { rxConsume(8 + payloadLen); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('wo:window-metadata', { type: 'windowMetadata', metadata }); - // Parse and forward as structured window list for woClient.onWindows try { const parsed = JSON.parse(metadata); if (parsed && Array.isArray(parsed.windows)) { - // Clean up caches for windows that disappeared const currentNames = new Set((parsed.windows as Array<{name?: string}>).map(w => w.name).filter(Boolean)); for (const cachedName of surfaceSabCache.keys()) { if (!currentNames.has(cachedName)) { @@ -857,6 +1537,9 @@ function connectToCompositor(): Promise { compositorWindows = parsed.windows; mainWindow.webContents.send('wo:windows', compositorWindows); + if (Array.isArray(parsed.monitors)) { + mainWindow.webContents.send('wo:monitors', parsed.monitors); + } } } catch { /* ignore parse errors */ } } @@ -866,8 +1549,7 @@ function connectToCompositor(): Promise { if (magic === MAGIC.SHM_BUFFER) { if (rxAvailable() < 8) break; const nameLen = compositorRxBuffer.readUInt32LE(rxReadOffset + 4); - // Minimum header before we know num_rects - const baseHeader = 8 + nameLen + 20 + 4; // +4 for num_rects field + const baseHeader = 8 + nameLen + 20 + 4; if (rxAvailable() < baseHeader) break; let off = rxReadOffset + 8; const windowName = compositorRxBuffer.slice(off, off + nameLen).toString('utf8'); @@ -897,12 +1579,10 @@ function connectToCompositor(): Promise { const extFd = getOrOpenShmFd(windowName, pid, fd); const fullSize = sbStride * sbHeight; - // Ensure a SAB exists for this window at the right dimensions const sabEntry = getOrCreateSab(windowName, sbWidth, sbHeight, sbStride); const hasUsableRects = damageRects.length > 0 && damageRects.length < 64; if (hasUsableRects && nativeDmabuf?.copyMmapDamageToSab) { - // Partial damage: mmap and copy only damaged row bands into SAB let minY = sbHeight, maxY = 0; for (const r of damageRects) { const ry = Math.max(0, r.y); @@ -916,10 +1596,8 @@ function connectToCompositor(): Promise { ]); } } else if (nativeDmabuf?.copyMmapToSab) { - // Full frame: mmap entire buffer into SAB nativeDmabuf.copyMmapToSab(extFd, sabEntry.sab, fullSize); } else { - // Fallback: fs.readSync into a temporary buffer, then copy to SAB const tmpBuf = Buffer.alloc(fullSize); let totalRead = 0; while (totalRead < fullSize) { @@ -948,7 +1626,6 @@ function connectToCompositor(): Promise { try { fs.closeSync(cached.extFd); } catch { - // ignore } shmFdCache.delete(windowName); } @@ -978,7 +1655,7 @@ function connectToCompositor(): Promise { const planes: Array<{ offset: bigint; stride: number; modifier: bigint }> = []; let planeOff = off; for (let i = 0; i < numPlanes; i++) { - planeOff += 4; // fd placeholder + planeOff += 4; const planeOffset = compositorRxBuffer.readBigUInt64LE(planeOff); planeOff += 8; const planeStride = compositorRxBuffer.readUInt32LE(planeOff); planeOff += 4; const modifier = compositorRxBuffer.readBigUInt64LE(planeOff); planeOff += 8; @@ -989,11 +1666,7 @@ function connectToCompositor(): Promise { if (nativeDmabuf && ipcSocket) { const socketFd = (ipcSocket as any)?._handle?.fd; if (typeof socketFd === 'number' && socketFd >= 0) { - const planeFds: number[] = []; - for (let i = 0; i < numPlanes; i++) { - const fd = nativeDmabuf.recvFd(socketFd); - if (fd >= 0) planeFds.push(fd); - } + const planeFds = receivedSocketFds.splice(0, numPlanes); const primaryFd = planeFds.find((fd) => fd >= 0); const primaryPlane = planes[0]; @@ -1002,7 +1675,6 @@ function connectToCompositor(): Promise { if (primaryFd !== undefined) { try { - // Map DMABUF into SAB so renderer can reuse existing surface path const sabEntry = getOrCreateSab(dmabufName, dmabufW, dmabufH, stride); const copySize = offset + stride * dmabufH; nativeDmabuf.copyMmapToSab(primaryFd, sabEntry.sab, copySize, offset); @@ -1019,7 +1691,6 @@ function connectToCompositor(): Promise { setImmediate(flushSurfaceUpdates); } - // Preserve existing DMABUF texture notification for any consumers const textureInfo = nativeDmabuf.importDmabufTexture(dmabufName, primaryFd, dmabufW, dmabufH, dmabufFormat); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('wo:dmabuf-frame', { @@ -1046,7 +1717,6 @@ function connectToCompositor(): Promise { } if (magic === MAGIC.POINTER_LOCK_REQUEST) { - // magic(4) + name_len(4) + lock(4) + name(N) = 12 + N bytes if (rxAvailable() < 12) break; const nameLen = compositorRxBuffer.readUInt32LE(rxReadOffset + 4); if (rxAvailable() < 12 + nameLen) break; @@ -1061,7 +1731,6 @@ function connectToCompositor(): Promise { } if (magic === MAGIC.ENV_UPDATE) { - // magic(4) + json_len(4) + json(N) = 8 + N bytes if (rxAvailable() < 8) break; const jsonLen = compositorRxBuffer.readUInt32LE(rxReadOffset + 4); if (rxAvailable() < 8 + jsonLen) break; @@ -1084,7 +1753,6 @@ function connectToCompositor(): Promise { } if (magic === MAGIC.SCREENCOPY_EVENT) { - // magic(4) + active(4) + client_count(4) = 12 bytes if (rxAvailable() < 12) break; const active = compositorRxBuffer.readUInt32LE(rxReadOffset + 4) !== 0; const clientCount = compositorRxBuffer.readUInt32LE(rxReadOffset + 8); @@ -1096,7 +1764,17 @@ function connectToCompositor(): Promise { continue; } - // Unknown message magic; resync by advancing one byte. + if (magic === MAGIC.WINDOW_SIZE) { + if (rxAvailable() < 12) break; + const width = compositorRxBuffer.readUInt32LE(rxReadOffset + 4); + const height = compositorRxBuffer.readUInt32LE(rxReadOffset + 8); + rxConsume(12); + if (mainWindow && !mainWindow.isDestroyed() && width > 0 && height > 0) { + mainWindow.setSize(width, height, false); + } + continue; + } + debugLog('[Wo] UNKNOWN magic 0x' + magic.toString(16).padStart(8, '0') + ' bufLen=' + rxAvailable() + ' — stream may be corrupted'); rxConsume(1); } @@ -1104,7 +1782,7 @@ function connectToCompositor(): Promise { if (dataElapsed > 50) { console.warn(`[IPC SLOW] data handler took ${dataElapsed}ms chunkLen=${chunk.length}`); } - }); + }; ipcSocket.on('error', (error: Error) => { debugLog('[Wo] IPC connection error:', error); @@ -1120,13 +1798,20 @@ function connectToCompositor(): Promise { ipcSocket.on('close', () => { debugLog('[Wo] IPC connection closed'); ipcConnected = false; + if (fdReaderSocketFd !== null) { + nativeDmabuf?.stopFdReader?.(fdReaderSocketFd); + fdReaderSocketFd = null; + } + for (const fd of receivedSocketFds.splice(0)) { + try { fs.closeSync(fd); } catch { /* already closed */ } + } ipcSocket = null; + dmabufSender?.releaseAll(); dmabufSender = null; inFlightFrameSeqs.clear(); compositorRxBuffer = Buffer.alloc(64 * 1024); rxWriteOffset = 0; rxReadOffset = 0; - // Clean up surface caches surfaceSabCache.clear(); surfaceUpdatePending.clear(); closeShmFdCache(); @@ -1142,18 +1827,154 @@ function connectToCompositor(): Promise { }); } +function sendSiteHashesToCompositor(): void { + if (!ipcSocket || !ipcConnected || !activeSiteHashes) return; + const windowName = stringToBuffer(name); + const payload = Buffer.from(JSON.stringify(activeSiteHashes)); + if (payload.length > 24 * 1024 * 1024) return; + const message = Buffer.allocUnsafe(12 + windowName.length + payload.length); + let offset = 0; + message.writeUInt32LE(MAGIC.SITE_HASHES, offset); offset += 4; + message.writeUInt32LE(windowName.length, offset); offset += 4; + message.writeUInt32LE(payload.length, offset); offset += 4; + windowName.copy(message, offset); offset += windowName.length; + payload.copy(message, offset); + ipcSocket.write(message); +} + function setupIpcHandlers() { - ipcMain.handle('action', (_event, action: string, payload?: Record) => { + ipcMain.handle('wo:performance-status', () => getPerformanceStatus()); + + ipcMain.on('wo:portal-ui-ready', (event) => { + if (CLIENT_MODE || !mainWindow || event.sender !== mainWindow.webContents) return; + portalUiRendererReady = true; + portalUiRendererGeneration += 1; + flushPortalUiEvents(); + }); + + ipcMain.on('wo:portal-ui-delivered', (event, requestId: unknown) => { + if (CLIENT_MODE || !mainWindow || event.sender !== mainWindow.webContents + || typeof requestId !== 'string') return; + portalUiEvents.delete(requestId); + }); + + ipcMain.handle('wo:resource-consent-respond', ( + event, + requestId: string, + token: string, + decision: ResourceApprovalDecision, + ) => { + if (CLIENT_MODE || !mainWindow || event.sender !== mainWindow.webContents + || typeof requestId !== 'string' || typeof token !== 'string' + || !['deny', 'allow', 'allow-all'].includes(decision)) { + debugLog('[Wo] Rejected malformed resource-consent IPC response'); + return { ok: false }; + } + const ok = resolveResourceConsent(requestId, token, decision); + debugLog(`[Wo] Resource-consent IPC response received: accepted=${ok} decision=${decision}`); + return { ok }; + }); + + ipcMain.handle('wo:portal-ui-respond', (event, requestId: string, response: unknown) => { + if (CLIENT_MODE || !mainWindow || event.sender !== mainWindow.webContents) { + return { ok: false, error: 'Portal UI response is only available to the compositor shell' }; + } + if (typeof requestId !== 'string' || !requestId) { + return { ok: false, error: 'Missing portal requestId' }; + } + return respondToPortalRequest(requestId, { response }); + }); + + ipcMain.handle('wo:portal-context-menu', async (event, options: { + x?: number; y?: number; items?: Array>; + }) => { + if (!senderHasCapability(event.sender, 'portals')) return permissionDenied('portals'); + if (!mainWindow || mainWindow.isDestroyed() || !Array.isArray(options?.items) + || options.items.length === 0 || options.items.length > 100) { + return { ok: false, error: 'A context menu requires between 1 and 100 items' }; + } + const selected = new Map(); + const template: Electron.MenuItemConstructorOptions[] = []; + for (const [index, raw] of options.items.entries()) { + const type = raw.type === 'separator' ? 'separator' + : raw.type === 'checkbox' || raw.type === 'radio' ? raw.type : 'normal'; + const id = typeof raw.id === 'string' ? raw.id.slice(0, 128) : String(index); + if (type !== 'separator' && (typeof raw.label !== 'string' || raw.label.length > 256)) { + return { ok: false, error: `Invalid context-menu label at item ${index}` }; + } + const item: Electron.MenuItemConstructorOptions = type === 'separator' + ? { type: 'separator' } + : { + id, + label: String(raw.label), + type, + enabled: raw.enabled !== false, + checked: raw.checked === true, + ...(typeof raw.accelerator === 'string' ? { accelerator: raw.accelerator.slice(0, 64) } : {}), + click: () => { selected.set(0, id); }, + }; + template.push(item); + } + const menu = Menu.buildFromTemplate(template); + return await new Promise<{ ok: boolean; selectedId?: string }>((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + const selectedId = selected.get(0); + resolve(selectedId === undefined ? { ok: true } : { ok: true, selectedId }); + }; + menu.popup({ + window: mainWindow!, + x: Number.isFinite(options.x) ? Math.trunc(options.x!) : undefined, + y: Number.isFinite(options.y) ? Math.trunc(options.y!) : undefined, + callback: finish, + }); + }); + }); + + ipcMain.handle('action', (event, action: string, payload?: Record) => { + if (!senderHasCapability(event.sender, 'window-management')) return permissionDenied('window-management'); return sendActionToCompositor(action, payload); }); - ipcMain.on('action-sync', (_event, action: string, payload?: Record) => { + ipcMain.on('action-sync', (event, action: string, payload?: Record) => { + if (!senderHasCapability(event.sender, 'window-management')) return; sendActionToCompositor(action, payload); }); - ipcMain.handle('wo:get-windows', async () => compositorWindows.length > 0 ? compositorWindows : clientWindows); + ipcMain.on('wo:forward-pointer', (event, windowName: string, x: number, y: number) => { + if (!senderHasCapability(event.sender, 'window-management')) return; + sendForwardedPointer(windowName, x, y); + }); - ipcMain.on('wo:keybind-action', (_event, action: string) => { + ipcMain.on('wo:forward-pointer-button', (event, windowName: string, x: number, y: number, button: number, pressed: boolean, time: number) => { + if (!senderHasCapability(event.sender, 'window-management')) return; + sendForwardedPointerButton(windowName, x, y, button, pressed, time); + }); + + ipcMain.on('wo:forward-pointer-scroll', (event, windowName: string, x: number, y: number, dx: number, dy: number, time: number) => { + if (!senderHasCapability(event.sender, 'window-management')) return; + sendForwardedPointerScroll(windowName, x, y, dx, dy, time); + }); + + ipcMain.on('wo:forward-pointer-leave', (event, windowName: string) => { + if (!senderHasCapability(event.sender, 'window-management')) return; + if (!ipcSocket || !ipcConnected) return; + const nameBuf = Buffer.from(windowName, 'utf8'); + const msg = Buffer.allocUnsafe(8 + nameBuf.length); + msg.writeUInt32LE(MAGIC.FORWARD_POINTER_LEAVE, 0); + msg.writeUInt32LE(nameBuf.length, 4); + nameBuf.copy(msg, 8); + ipcSocket.write(msg); + }); + + ipcMain.handle('wo:get-windows', async (event) => senderHasCapability(event.sender, 'window-management') + ? (compositorWindows.length > 0 ? compositorWindows : clientWindows) + : permissionDenied('window-management')); + + ipcMain.on('wo:keybind-action', (event, action: string) => { + if (!senderHasCapability(event.sender, 'window-management')) return; if (action === 'shuffle') { clientWindows = [...clientWindows].sort(() => Math.random() - 0.5); } else if (action === 'reverse') { @@ -1165,7 +1986,8 @@ function setupIpcHandlers() { } }); - ipcMain.handle('wo:submit-damage-frame', (_event, payload: DamageFramePayload) => { + ipcMain.handle('wo:submit-damage-frame', (event, payload: DamageFramePayload) => { + if (!senderHasCapability(event.sender, 'overlay-rendering')) return permissionDenied('overlay-rendering'); if (!dmabufSender) { return { ok: false, reason: 'dmabuf-sender-not-ready' }; } @@ -1178,7 +2000,6 @@ function setupIpcHandlers() { damageBuffer = new DamageBuffer(payload.width, payload.height); } - // Mark that the app is using the damage helper path; OSR paint will be suppressed appUsedDamageHelper = true; applyDamagePayload(damageBuffer, payload); const seq = dmabufSender.send(damageBuffer); @@ -1186,10 +2007,10 @@ function setupIpcHandlers() { return { ok: true, seq: seq.toString() }; }); - // Keyboard forwarding: renderer sends evdev key events for focused Wayland canvas windows - ipcMain.on('wo:forward-keyboard', (_event, windowName: string, key: number, pressed: boolean, time: number) => { + ipcMain.on('wo:forward-keyboard', (event, windowName: string, key: number, pressed: boolean, time: number) => { + if (!senderHasCapability(event.sender, 'window-management')) return; if (ipcSocket && ipcConnected) { - const nameBuf = Buffer.from(windowName, 'utf8'); + const nameBuf = getEncodedWindowName(windowName); const msg = Buffer.alloc(4 + 4 + nameBuf.length + 12); let off = 0; msg.writeUInt32LE(MAGIC.FORWARD_KEYBOARD, off); off += 4; @@ -1197,14 +2018,15 @@ function setupIpcHandlers() { nameBuf.copy(msg, off); off += nameBuf.length; msg.writeUInt32LE(key, off); off += 4; msg.writeUInt32LE(pressed ? 1 : 0, off); off += 4; - msg.writeUInt32LE(time || 0, off); + msg.writeUInt32LE(Math.trunc(time) >>> 0, off); ipcSocket.write(msg); } }); - ipcMain.on('wo:forward-relative-pointer', (_event, windowName: string, dx: number, dy: number) => { + ipcMain.on('wo:forward-relative-pointer', (event, windowName: string, dx: number, dy: number) => { + if (!senderHasCapability(event.sender, 'window-management')) return; if (ipcSocket && ipcConnected) { - const nameBuf = Buffer.from(windowName, 'utf8'); + const nameBuf = getEncodedWindowName(windowName); const msg = Buffer.alloc(4 + 4 + nameBuf.length + 16); let off = 0; msg.writeUInt32LE(MAGIC.FORWARD_RELATIVE_POINTER, off); off += 4; @@ -1216,115 +2038,32 @@ function setupIpcHandlers() { } }); - // Syscall handler for system operations - const iconCache = new Map(); - - function resolveDesktopIconFast(iconName: string, fs: any, path: any): { type: string; data: string; mimeType?: string } | null { - if (iconCache.has(iconName)) return iconCache.get(iconName) ?? null; - - let result: { type: string; data: string; mimeType?: string } | null = null; - - // Absolute path: read directly - if (iconName.startsWith('/')) { - try { - if (fs.existsSync(iconName)) { - const ext = path.extname(iconName).toLowerCase(); - const mime = ext === '.svg' ? 'image/svg+xml' : 'image/png'; - result = { type: 'base64', data: fs.readFileSync(iconName).toString('base64'), mimeType: mime }; - } - } catch { /* ignore */ } - iconCache.set(iconName, result); - return result; - } - - // Only check 4 fast paths to avoid blocking the event loop: - // 1. /usr/share/pixmaps/{name}.png - // 2. /usr/share/pixmaps/{name}.svg - // 3. hicolor 48x48/apps/{name}.png - // 4. hicolor scalable/apps/{name}.svg - const quickPaths = [ - [`/usr/share/pixmaps/${iconName}.png`, 'image/png'], - [`/usr/share/pixmaps/${iconName}.svg`, 'image/svg+xml'], - [`/usr/share/icons/hicolor/48x48/apps/${iconName}.png`, 'image/png'], - [`/usr/share/icons/hicolor/scalable/apps/${iconName}.svg`, 'image/svg+xml'], - ]; - - for (const [iconPath, mime] of quickPaths) { - try { - if (fs.existsSync(iconPath)) { - result = { type: 'base64', data: fs.readFileSync(iconPath).toString('base64'), mimeType: mime }; - break; - } - } catch { /* ignore */ } - } - - iconCache.set(iconName, result); - return result; - } - - ipcMain.handle('wo:syscall', async (_event, type: string, params: Record) => { + ipcMain.handle('wo:syscall', async (event, type: string, params: Record) => { + const capability = syscallCapability(type); + if (!capability || !senderHasCapability(event.sender, capability)) return permissionDenied(capability || 'window-management'); try { switch (type) { case 'list_applications': { - // Scan desktop files from standard locations - const path = await import('path'); - const fs = await import('fs'); + return listDesktopApplications({ locale: typeof params.locale === 'string' ? params.locale : undefined }); + } - const apps: Array<{ name: string; icon?: { type: string; data: string }; command: string }> = []; - const dirs = ['/usr/share/applications', `${process.env.HOME}/.local/share/applications`]; - const seen = new Set(); + case 'launch_desktop': { + const desktopId = String(params.desktopId || ''); + const files = Array.isArray(params.files) ? params.files.map(String) : []; + return launchDesktopApplication(desktopId, files); + } - for (const dir of dirs) { - try { - if (!fs.default.existsSync(dir)) continue; - const files = fs.default.readdirSync(dir).filter((f: string) => f.endsWith('.desktop')); + case 'xdg_directories': { + return { ok: true, directories: getXdgDirectories() }; + } - for (const file of files) { - const filePath = path.default.join(dir, file); - try { - const content = fs.default.readFileSync(filePath, 'utf-8'); - if (content.includes('NoDisplay=true')) continue; - - const nameMatch = content.match(/^Name=(.+)$/m); - const execMatch = content.match(/^Exec=(.+)$/m); - const iconMatch = content.match(/^Icon=(.+)$/m); - - if (nameMatch && execMatch) { - const name = nameMatch[1].trim(); - if (!seen.has(name)) { - seen.add(name); - const exec = execMatch[1].trim().split(' ')[0].split('/').pop() || execMatch[1].trim(); - const app: any = { - name, - command: exec, - }; - // Resolve icon: if absolute path use it directly, otherwise search icon theme dirs - if (iconMatch) { - const iconName = iconMatch[1].trim(); - const resolved = resolveDesktopIconFast(iconName, fs.default, path.default); - if (resolved) { - app.icon = resolved; - } else { - app.icon = { type: 'iconify', data: iconName }; - } - } - apps.push(app); - } - } - } catch { - // Skip files that can't be read - } - } - } catch { - // Skip directories that don't exist or can't be read - } - } - - return apps.length > 0 ? apps : [ - { name: 'Terminal', icon: { type: 'iconify', data: 'mdi:console' }, command: 'xterm' }, - { name: 'Firefox', icon: { type: 'iconify', data: 'mdi:firefox' }, command: 'firefox' }, - { name: 'Files', icon: { type: 'iconify', data: 'mdi:folder' }, command: 'nautilus' }, - ]; + case 'resolve_icon': { + const iconName = String(params.name || ''); + if (!iconName) return null; + return resolveDesktopIcon(iconName, { + size: typeof params.size === 'number' ? params.size : undefined, + theme: typeof params.theme === 'string' ? params.theme : undefined, + }) ?? null; } case 'launch': @@ -1420,7 +2159,6 @@ function setupIpcHandlers() { if (sessionId) { spawn('loginctl', ['terminate-session', sessionId], { detached: true, stdio: 'ignore' }).unref(); } else { - // Fallback: quit the compositor sendActionToCompositor('quit', { code: 0 }); } return { ok: true }; @@ -1439,7 +2177,6 @@ function setupIpcHandlers() { } case 'notify': { - // Forward notification to renderer if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('wo:notification', { id: params.id || `notify-${Date.now()}`, @@ -1460,7 +2197,7 @@ function setupIpcHandlers() { } return respondToPortalRequest(requestId, { response: params.response, - allowed: params.allowed, + allowed: typeof params.allowed === 'boolean' ? params.allowed : undefined, type: typeof params.type === 'string' ? params.type : undefined, windowName: params.windowName, }); @@ -1498,6 +2235,50 @@ function setupIpcHandlers() { return result; } + case 'dbus_get_all_properties': { + const bus: DbBus = params.bus === 'system' ? 'system' : 'user'; + const service = String(params.service || ''); + const objectPath = String(params.objectPath || ''); + const iface = String(params.interface || params.iface || ''); + if (!service || !objectPath || !iface) return { ok: false, error: 'Missing D-Bus parameters' }; + return runBusctl(bus, ['call', service, objectPath, 'org.freedesktop.DBus.Properties', 'GetAll', 's', iface]); + } + + case 'dbus_list_names': { + const bus: DbBus = params.bus === 'system' ? 'system' : 'user'; + const result = runBusctl(bus, ['--no-pager', 'list']); + const names = result.stdout + .split(/\r?\n/) + .slice(1) + .map((line) => line.trim().split(/\s+/, 1)[0]) + .filter((name) => name && name !== ''); + return { ...result, names }; + } + + case 'audio_sources': { + const result = listPipeWireAudioSources(); + return result; + } + + case 'audio_capture_start': { + const result = listPipeWireAudioSources(); + if (!result.ok) return { ok: false, error: result.error }; + const requestedId = typeof params.sourceId === 'string' ? params.sourceId : ''; + const source = requestedId + ? result.sources.find((entry) => entry.id === requestedId) + : result.sources.find((entry) => entry.monitor); + if (!source) return { ok: false, error: requestedId ? 'PipeWire source not found' : 'No desktop monitor source is available' }; + return startPipeWireAudioCapture(source); + } + + case 'audio_capture_stop': { + const child = audioCapture; + audioCapture = null; + if (child && !child.killed) child.kill(); + broadcastAudioState({ active: false }); + return { ok: true }; + } + case 'load_local_file': { const filePath = String(params.path || ''); if (!filePath) { @@ -1527,65 +2308,322 @@ function setupIpcHandlers() { }); } +function flushPortalUiEvents(): void { + if (!portalUiRendererReady || !mainWindow || mainWindow.isDestroyed()) return; + for (const [requestId, event] of portalUiEvents) { + if (!portalPendingRequests.has(requestId) || event.sentGeneration === portalUiRendererGeneration) continue; + event.sentGeneration = portalUiRendererGeneration; + mainWindow.webContents.send('wo:portal-request', event.data); + } +} + async function createWindow() { debugLog('[Wo] createWindow starting'); + const pageUrl = url + || (html ? (html.startsWith('file://') ? html : pathToFileURL(resolvePath(html)).href) : null) + || 'data:text/html,'; + let siteAssets: WoSiteAssets = { + pageUrl, appId: 'website', capabilities: [], styles: '', stylesUrl: '', stylesHash: '', styleBodies: [], scripts: [], scriptBodies: [], indexSha256: '', + indexBytes: new Uint8Array(), stylesBytes: new Uint8Array(), trusted: false, + }; + if (/^(https?:|file:)/.test(pageUrl)) { + siteAssets = await prefetchWoSiteAssets(pageUrl); + } + debugLog(siteAssets.trusted + ? `[Wo] Verified site ${siteAssets.appId}; requested capabilities: ${siteAssets.capabilities.join(', ') || '(none)'}` + : `[Wo] Site capabilities not granted because verification failed: ${siteAssets.trustError || 'no verifiable site manifest'}`); + apiGrants = new Set(); + apiGrantPageUrl = null; + verifiedScriptCache.clear(); + lockedScriptUrls = new Map(); + if (siteAssets.trusted && siteAssets.stylesUrl && siteAssets.stylesHash) { + verifiedScriptCache.set(siteAssets.stylesHash, Buffer.from(siteAssets.stylesBytes)); + lockedScriptUrls.set(siteAssets.stylesUrl, siteAssets.stylesHash); + } + for (const script of siteAssets.scriptBodies) { + verifiedScriptCache.set(script.hash, Buffer.from(script.bytes)); + lockedScriptUrls.set(script.url, script.hash); + } + for (const style of siteAssets.styleBodies) { + verifiedScriptCache.set(style.hash, Buffer.from(style.bytes)); + lockedScriptUrls.set(style.url, style.hash); + } + activeSiteHashes = siteAssets.trusted + ? { + pageUrl: siteAssets.pageUrl, + indexBytes: Array.from(siteAssets.indexBytes), + stylesBytes: Array.from(siteAssets.stylesBytes), + } + : null; + sendSiteHashesToCompositor(); + const trustedSystemShell = SYSTEM_SHELL + && siteAssets.trusted + && siteAssets.appId === 'rip.crit.comraw' + && isLoopbackHttp(pageUrl); + if (trustedSystemShell) { + // The compositor-selected local comraw shell is the trusted UI that would + // otherwise have to display its own native consent dialog before its + // BrowserWindow exists. Other root pages and ordinary sites still use the + // explicit consent prompt below. + apiGrants = new Set(siteAssets.capabilities); + apiGrantPageUrl = siteAssets.pageUrl; + debugLog('[Wo] Granted verified comraw system-shell capabilities'); + } else if (siteAssets.trusted && siteAssets.capabilities.length > 0) { + const permissionResult = await dialog.showMessageBox({ + type: 'question', + title: 'Wo API permission request', + message: `${siteAssets.appId} requests access to Wo APIs`, + detail: `Page: ${pageUrl}\nVerified index.html SHA-256: ${siteAssets.indexSha256}\n\nRequested permissions: ${siteAssets.capabilities.join(', ')}\n\nThe site APIs are exposed only for this window while it is open.`, + buttons: ['Allow requested APIs', 'Open without Wo APIs'], + defaultId: 1, + cancelId: 1, + noLink: true, + }); + if (permissionResult.response === 0) { + apiGrants = new Set(siteAssets.capabilities); + apiGrantPageUrl = siteAssets.pageUrl; + } + } + const requested = siteAssets.trusted ? siteAssets.capabilities : []; + const granted = [...apiGrants]; const preloadPath = joinPath(__dirname, 'preload.js'); + // The system-shell root covers the whole desktop. Keeping this OSR surface + // opaque avoids Chromium's broken white transparent backing bitmap on + // software compositing paths; floating portal/window surfaces keep alpha. + const opaqueDesktopRoot = !CLIENT_MODE && WINDOW_CONFIG.name === 'main'; debugLog('[Wo] preloadPath=' + preloadPath); try { mainWindow = new BrowserWindow({ - width: CLIENT_MODE ? 1400 : width, - height: CLIENT_MODE ? 900 : height, - transparent: true, - backgroundColor: '#00000000', + width: CLIENT_MODE ? 1400 : initialWindowWidth, + height: CLIENT_MODE ? 900 : initialWindowHeight, + transparent: !opaqueDesktopRoot, + backgroundColor: opaqueDesktopRoot ? '#0b0d12' : '#00000000', frame: CLIENT_MODE, webPreferences: { sandbox: false, contextIsolation: true, preload: preloadPath, + additionalArguments: [ + `--wo-api-requested=${encodeURIComponent(JSON.stringify(requested))}`, + `--wo-api-granted=${encodeURIComponent(JSON.stringify(granted))}`, + `--wo-api-page=${encodeURIComponent(apiGrantPageUrl || '')}`, + ...(!CLIENT_MODE ? ['--wo-system-portal-ui=1'] : []), + ], nodeIntegration: false, - offscreen: !CLIENT_MODE, + // Chromium's experimental shared-texture OSR can emit only empty + // paints when its GPU compositor is unavailable. Fall back to the + // ordinary bitmap paint path in that case. + offscreen: CLIENT_MODE ? false : { + useSharedTexture: offscreenSharedTextureEnabled, + ...(offscreenSharedTextureEnabled ? { sharedTexturePixelFormat: 'argb' as const } : {}), + }, webgl: true, backgroundThrottling: false, }, show: CLIENT_MODE, }); - // Enable SharedArrayBuffer in the renderer by injecting - // cross-origin isolation headers on all responses. + // Even sites without a valid Wo manifest must explicitly approve every + // external script, worker, and stylesheet before it is fetched. + mainPageLoadFinished = false; + let scriptLockActive = true; + let resourceApprovalPageUrl = siteAssets.pageUrl; + let firstNavigation = true; + let pendingResourceChecks = 0; + let navigationGeneration = 0; + let reloadAfterResourceApproval = false; + let resourceReloadTimer: ReturnType | null = null; + const deniedResourceKeys = new Set(); + const finishResourceCheck = (approved: boolean) => { + if (approved) reloadAfterResourceApproval = true; + pendingResourceChecks = Math.max(0, pendingResourceChecks - 1); + if (pendingResourceChecks !== 0 || (!reloadAfterResourceApproval && !resourceConsentUiActive) || resourceReloadTimer) return; + resourceReloadTimer = setTimeout(() => { + resourceReloadTimer = null; + if ((reloadAfterResourceApproval || resourceConsentUiActive) && pendingResourceChecks === 0 && mainWindow && !mainWindow.isDestroyed()) { + reloadAfterResourceApproval = false; + resourceConsentUiActive = false; + debugLog('[Wo] Returning to page after resource approval decisions'); + void mainWindow.webContents.loadURL(resourceApprovalPageUrl).catch((error) => { + debugLog('[Wo] Could not restore page after resource approval:', String(error)); + }); + } + }, 250); + }; + mainWindow.webContents.on('will-navigate', handleResourceConsentNavigation); + mainWindow.webContents.on('did-finish-load', () => { + if (!resourceConsentUiActive) mainPageLoadFinished = true; + }); + mainWindow.webContents.on('did-start-navigation', (_event, navigationUrl, _isInPlace, isMainFrame) => { + if (!isMainFrame) return; + portalUiRendererReady = false; + if (resourceConsentUiActive) return; + mainPageLoadFinished = false; + if (resourceReloadTimer) clearTimeout(resourceReloadTimer); + resourceReloadTimer = null; + reloadAfterResourceApproval = false; + pendingResourceChecks = 0; + navigationGeneration += 1; + deniedResourceKeys.clear(); + const wasFirstNavigation = firstNavigation; + firstNavigation = false; + try { + const requestedUrl = new URL(pageUrl); + const navigatingUrl = new URL(navigationUrl); + requestedUrl.hash = ''; + navigatingUrl.hash = ''; + if (requestedUrl.href === navigatingUrl.href) return; + } catch { + if (wasFirstNavigation) return; + } + apiGrants.clear(); + apiGrantPageUrl = null; + scriptLockActive = true; + try { + const currentPage = new URL(navigationUrl); + currentPage.hash = ''; + resourceApprovalPageUrl = currentPage.href; + } catch { + resourceApprovalPageUrl = navigationUrl; + } + lockedScriptUrls.clear(); + verifiedScriptCache.clear(); + }); + + mainWindow.webContents.session.webRequest.onBeforeRequest((details, callback) => { + if (details.url.startsWith('wo-cache://')) { + callback({}); + return; + } + if (!scriptLockActive || !mainWindow || details.webContentsId !== mainWindow.webContents.id + || !['script', 'worker', 'stylesheet'].includes(details.resourceType)) { + callback({}); + return; + } + const hash = lockedScriptUrls.get(details.url); + const isStylesheet = details.resourceType === 'stylesheet'; + if (hash) { + debugLog(`[Wo] Locking verified script ${details.url} -> ${hash.slice(0, 12)}`); + callback({ redirectURL: woCacheUrl(hash, details.url, isStylesheet) }); + return; + } + + const approvalPageUrl = resourceApprovalPageUrl; + const approvalKey = resourceApprovalKey(approvalPageUrl, details.url); + const requestGeneration = navigationGeneration; + pendingResourceChecks += 1; + let checkFinished = false; + const finishCheck = (approved: boolean) => { + if (checkFinished) return; + checkFinished = true; + if (requestGeneration !== navigationGeneration) return; + finishResourceCheck(approved); + }; + // Cancel the request first so the page commits. Consent then replaces the + // root document with an internal portal page on the same compositor surface. + void loadApprovedResource(approvalPageUrl, details.url).then((cachedHash) => { + if (requestGeneration !== navigationGeneration) { + callback({ cancel: true }); + finishCheck(false); + return; + } + if (cachedHash) { + callback({ redirectURL: woCacheUrl(cachedHash, details.url, isStylesheet) }); + finishCheck(false); + return; + } + callback({ cancel: true }); + if (deniedResourceKeys.has(approvalKey)) { + debugLog(`[Wo] Resource remains blocked after user denial: ${details.url}`); + finishCheck(false); + return; + } + void approveAndCacheResource(approvalPageUrl, details.url, details.resourceType, siteAssets.styles).then((approvedHash) => { + if (!approvedHash) { + deniedResourceKeys.add(approvalKey); + debugLog(`[Wo] Blocked unapproved resource: ${details.url} (${details.resourceType})`); + finishCheck(false); + return; + } + finishCheck(true); + }).catch((error) => { + debugLog(`[Wo] Could not approve/cache resource ${details.url}: ${String(error)}`); + finishCheck(false); + }); + }).catch((error) => { + callback({ cancel: true }); + debugLog(`[Wo] Could not check approved resource cache for ${details.url}: ${String(error)}`); + finishCheck(false); + }); + }); + + mainWindow.webContents.session.webRequest.onErrorOccurred((details) => { + if (mainWindow && details.webContentsId === mainWindow.webContents.id) { + debugLog(`[Wo] Resource request failed: ${details.error} ${details.resourceType} ${details.url}`); + } + }); + mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => { callback({ responseHeaders: { ...details.responseHeaders, 'Cross-Origin-Opener-Policy': ['same-origin'], 'Cross-Origin-Embedder-Policy': ['require-corp'], + ...(scriptLockActive && ['script', 'worker', 'stylesheet'].includes(details.resourceType) + ? { 'Access-Control-Allow-Origin': ['*'], 'Cross-Origin-Resource-Policy': ['cross-origin'] } + : {}), }, }); }); - debugLog('[Wo] BrowserWindow created, CLIENT_MODE=' + CLIENT_MODE + ' offscreen=' + !CLIENT_MODE); + debugLog('[Wo] BrowserWindow created, CLIENT_MODE=' + CLIENT_MODE + + ' offscreen=' + mainWindow.webContents.isOffscreen() + + ' painting=' + mainWindow.webContents.isPainting()); + + mainWindow.webContents.on('console-message', (details) => { + debugLog(`[Wo] page console level=${details.level} ${details.sourceId}:${details.lineNumber}: ${details.message}`); + }); + mainWindow.webContents.on('render-process-gone', (_event, details) => { + debugLog('[Wo] page render process exited:', JSON.stringify(details)); + }); setupIpcHandlers(); debugLog('[Wo] setupIpcHandlers completed'); - // Send initial window list regardless of mode mainWindow.webContents.send('wo:windows', clientWindows); + mainWindow.webContents.on('dom-ready', () => { + if (siteAssets.trusted && !resourceConsentUiActive) { + if (siteAssets.styles && mainWindow && !mainWindow.isDestroyed()) { + void mainWindow.webContents.insertCSS(siteAssets.styles, { cssOrigin: 'user' }).catch((error) => { + debugLog('[Wo] Could not apply _wo.components.css:', String(error)); + }); + } + } + }); + + let pageHasLoaded = false; + mainWindow.webContents.on('did-navigate', (_event, navigatedUrl) => { + if (/^https?:/.test(navigatedUrl)) pageHasLoaded = true; + }); + mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, failedUrl, isMainFrame) => { + if (!isMainFrame || resourceConsentUiActive || errorCode === -3 || pageHasLoaded || !/^https?:/.test(failedUrl)) return; + debugLog(`[Wo] Page load failed (${errorCode} ${errorDescription}); retrying ${failedUrl}`); + setTimeout(() => { + if (mainWindow && !mainWindow.isDestroyed() && !resourceConsentUiActive && !pageHasLoaded) { + void mainWindow.loadURL(pageUrl).catch((error) => debugLog('[Wo] Page retry failed:', String(error))); + } + }, 750); + }); + if (CLIENT_MODE) { return; } - // In offscreen mode, we need actual content to trigger paint events - // Load the configured URL/file or fallback to minimal page - debugLog('[Wo] Loading content URL: ' + (url || html || 'minimal fallback')); + debugLog('[Wo] Loading content URL: ' + pageUrl); try { - const pageUrl = url - || (html ? `file://${html}` : null) - || 'data:text/html,'; - const loadPromise = pageUrl.startsWith('file://') - ? mainWindow!.loadURL(pageUrl) - : mainWindow!.loadURL(pageUrl); - // Give it time to load but don't wait forever + const loadPromise = mainWindow!.loadURL(pageUrl); await Promise.race([ loadPromise, new Promise(r => setTimeout(() => { debugLog('[Wo] Load timeout, continuing'); r(undefined); }, 3000)) @@ -1595,35 +2633,82 @@ async function createWindow() { debugLog('[Wo] Load failed:', String(e)); } + try { + const pageState = await mainWindow!.webContents.executeJavaScript(`(() => { + const root = document.getElementById('root'); + const wallpaper = document.querySelector('.wo-wallpaper'); + const portalCard = document.querySelector('main[role="dialog"]'); + return { + readyState: document.readyState, + title: document.title, + bodyText: (document.body?.innerText || '').slice(0, 240), + bodyBackground: document.body ? getComputedStyle(document.body).backgroundColor : '', + bodyBackgroundImage: document.body ? getComputedStyle(document.body).backgroundImage : '', + bodyDisplay: document.body ? getComputedStyle(document.body).display : '', + portalCardBackground: portalCard ? getComputedStyle(portalCard).backgroundColor : '', + portalCardBounds: portalCard ? (() => { const r = portalCard.getBoundingClientRect(); return { x:r.x, y:r.y, width:r.width, height:r.height }; })() : null, + buttons: document.querySelectorAll('button').length, + resourceConsentBridge: !!window.woResourceConsent, + rootChildren: root?.childElementCount ?? -1, + shellPresent: !!document.querySelector('.wo-shell'), + wallpaperImage: wallpaper ? getComputedStyle(wallpaper).backgroundImage : '', + compositorApi: !!window.compositor, + woApi: !!window.wo, + }; + })()`); + debugLog('[Wo] Page render state:', JSON.stringify(pageState)); + } catch (error) { + debugLog('[Wo] Could not inspect loaded page:', String(error)); + } + debugLog('[Wo] Page initialized, setting up rendering'); - // Ensure the webcontents has focus so sendInputEvent works for keyboard events mainWindow.webContents.focus(); if (!CLIENT_MODE) { - debugLog('[Wo] Setting up OSR rendering, FPS=' + (WINDOW_CONFIG.fps ?? 60)); - mainWindow.webContents.setFrameRate(WINDOW_CONFIG.fps ?? 60); - mainWindow.webContents.on('did-finish-load', () => { - mainWindow!.webContents.setFrameRate(WINDOW_CONFIG.fps ?? 60); - }); + configureFramePacing(); + mainWindow.webContents.on('did-finish-load', configureFramePacing); - // Invalidate periodically to ensure continuous repaints for dynamic - // content. We only invalidate when below the in-flight limit so we - // don't pile up redundant paints while the compositor is still - // processing previous frames. - const frameIntervalMs = Math.round(1000 / (WINDOW_CONFIG.fps ?? 60)); - setInterval(() => { - if (mainWindow && !mainWindow.isDestroyed() && inFlightFrameSeqs.size < MAX_IN_FLIGHT_FRAMES) { - mainWindow.webContents.invalidate(); - } - }, frameIntervalMs); - - // Reusable patch buffer to avoid allocating a new typed array on - // every partial-update paint event. let patchBuf: Uint8Array | null = null; + let emptyPaintRecoveryAt = 0; + let emptyPaintLogged = false; let skippedCount = 0; - mainWindow.webContents.on('paint', (_event, dirty, image) => { + let loggedFirstBitmapFrame = false; + let loggedSecondBitmapFrame = false; + mainWindow.webContents.on('paint', (paintEvent, dirty, image) => { + const sharedTexture = (paintEvent as unknown as { + texture?: { textureInfo: OffscreenSharedTextureInfo; release: () => void }; + }).texture; + if (sharedTexture) { + if (sharedDmabufStatus !== true) { + sharedDmabufStatus = true; + publishPerformanceStatus(); + } + let releasedByAck = false; + try { + if (!dmabufSender || !ipcConnected || appUsedDamageHelper + || inFlightFrameSeqs.size >= MAX_IN_FLIGHT_FRAMES) { + return; + } + const seq = dmabufSender.sendSharedTexture(sharedTexture.textureInfo, () => sharedTexture.release()); + releasedByAck = true; + inFlightFrameSeqs.add(seq.toString()); + } catch (error) { + debugLog('[Wo] Native shared-texture frame failed:', String(error)); + sharedDmabufStatus = false; + publishPerformanceStatus(); + } finally { + if (!releasedByAck) sharedTexture.release(); + } + return; + } + + if (sharedDmabufStatus !== false) { + sharedDmabufStatus = false; + publishPerformanceStatus(); + } + if (!dmabufSender || !ipcConnected) { skippedCount++; debugLog('[Wo] SKIPPED paint (dmabufSender=' + !!dmabufSender + ' ipcConnected=' + ipcConnected + ')'); @@ -1640,15 +2725,65 @@ async function createWindow() { return; } const size = image.getSize(); + if (size.width <= 0 || size.height <= 0) { + const now = Date.now(); + if (!emptyPaintLogged) { + const contentSize = mainWindow?.getContentSize() ?? [0, 0]; + debugLog(`[Wo] Ignoring empty offscreen paint ${size.width}x${size.height}; content=${contentSize[0]}x${contentSize[1]} dirty=${dirty.x},${dirty.y},${dirty.width},${dirty.height}`); + emptyPaintLogged = true; + } + // Chromium can emit an empty paint during initial surface setup or + // resize. Ask it to repaint periodically instead of allocating an + // impossible 0x0 GBM BO (and flooding stderr on every paint event). + if (now - emptyPaintRecoveryAt >= 250) { + emptyPaintRecoveryAt = now; + if (mainWindow && !mainWindow.isDestroyed()) { + const [contentWidth, contentHeight] = mainWindow.getContentSize(); + if (contentWidth <= 0 || contentHeight <= 0) { + mainWindow.setSize(initialWindowWidth, initialWindowHeight); + } + mainWindow.webContents.invalidate(); + } + } + return; + } + let resized = !damageBuffer; if (!damageBuffer) { damageBuffer = new DamageBuffer(size.width, size.height); + } else if (damageBuffer.getWidth() !== size.width || damageBuffer.getHeight() !== size.height) { + damageBuffer.reset(size.width, size.height); + resized = true; } const pixels = image.toBitmap(); const pixelData = new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength); + if (pixelData.byteLength < size.width * size.height * 4) { + if (!emptyPaintLogged) { + debugLog(`[Wo] Ignoring incomplete offscreen bitmap: ${pixelData.byteLength} bytes for ${size.width}x${size.height}`); + emptyPaintLogged = true; + } + if (Date.now() - emptyPaintRecoveryAt >= 250 && mainWindow && !mainWindow.isDestroyed()) { + emptyPaintRecoveryAt = Date.now(); + mainWindow.webContents.invalidate(); + } + return; + } + emptyPaintLogged = false; - // Use dirty rect to avoid full-frame copy when only a region changed - const isFullFrame = dirty.x === 0 && dirty.y === 0 - && dirty.width === size.width && dirty.height === size.height; + if (!loggedFirstBitmapFrame) { + const centerOffset = (Math.floor(size.height / 2) * size.width + Math.floor(size.width / 2)) * 4; + const sampleOffsets = [0, Math.max(0, Math.floor(pixelData.byteLength / 2 / 4) * 4), centerOffset, Math.max(0, pixelData.byteLength - 4)]; + const samples = sampleOffsets.map((offset) => Array.from(pixelData.subarray(offset, offset + 4))); + debugLog(`[Wo] First bitmap pixels (BGRA top-left/mid-left/center/bottom-right): ${JSON.stringify(samples)}`); + } else if (!loggedSecondBitmapFrame) { + const centerOffset = (Math.floor(size.height / 2) * size.width + Math.floor(size.width / 2)) * 4; + const sampleOffsets = [0, Math.max(0, Math.floor(pixelData.byteLength / 2 / 4) * 4), centerOffset, Math.max(0, pixelData.byteLength - 4)]; + const samples = sampleOffsets.map((offset) => Array.from(pixelData.subarray(offset, offset + 4))); + debugLog(`[Wo] Second bitmap pixels (BGRA top-left/mid-left/center/bottom-right): ${JSON.stringify(samples)}`); + loggedSecondBitmapFrame = true; + } + + const isFullFrame = resized || (dirty.x === 0 && dirty.y === 0 + && dirty.width === size.width && dirty.height === size.height); if (isFullFrame) { applyDamagePayload(damageBuffer, { @@ -1657,9 +2792,6 @@ async function createWindow() { fullFrame: pixelData, }); } else { - // Partial update — copy only the dirty region into the damage - // buffer. Reuse a single scratch buffer to avoid per-frame GC - // pressure from allocating a new Uint8Array every paint. const stride = size.width * 4; const patchStride = dirty.width * 4; const patchBytes = patchStride * dirty.height; @@ -1688,6 +2820,10 @@ async function createWindow() { try { const seq = dmabufSender.send(damageBuffer); inFlightFrameSeqs.add(seq.toString()); + if (!loggedFirstBitmapFrame) { + debugLog(`[Wo] First bitmap frame sent as DMA-BUF: seq=${seq} size=${size.width}x${size.height} stride=${damageBuffer.getStride()}`); + loggedFirstBitmapFrame = true; + } } catch (e) { debugLog('[Wo] ERROR sending frame:', String(e)); } @@ -1706,40 +2842,38 @@ async function createWindow() { mainWindow.on('closed', () => { mainWindow = null; - if (windowPositionUpdateTimer) { - clearInterval(windowPositionUpdateTimer); - windowPositionUpdateTimer = null; - } + if (framePacer) clearInterval(framePacer); + framePacer = null; + framePacerRate = 0; }); - - // Send window position updates to compositor for window capture and positioning - if (!CLIENT_MODE && ipcConnected) { - const sendPosition = () => { - if (mainWindow && !mainWindow.isDestroyed()) { - const bounds = mainWindow.getBounds(); - sendWindowPositionUpdate(bounds.x, bounds.y, bounds.width, bounds.height); - } - }; - - // Send initial position - sendPosition(); - - // Update position periodically (every 100ms) - windowPositionUpdateTimer = setInterval(sendPosition, 100); - - // Also send on move/resize events - mainWindow.on('move', sendPosition); - mainWindow.on('resize', sendPosition); - } } app.on('ready', async () => { debugLog('[Wo] App ready, CLIENT_MODE=', CLIENT_MODE, 'WO_STANDALONE=', process.env.WO_STANDALONE); + // Electron exposes `screen` only after app readiness. Resolve it dynamically + // here so no imported module or early helper can touch its guarded getter. + displayScreen = (await import('electron')).screen; + displayScreen.on('display-added', configureFramePacing); + displayScreen.on('display-removed', configureFramePacing); + displayScreen.on('display-metrics-changed', configureFramePacing); + setupWoCacheProtocol(); - // Log GPU status so we can verify hardware acceleration is active. - const gpuInfo = app.getGPUFeatureStatus(); - debugLog('[Wo] GPU feature status:', JSON.stringify(gpuInfo)); - debugLog('[Wo] GPU info:', JSON.stringify(app.getGPUInfo('basic').catch(() => ({})))); + const gpuInfo = await app.getGPUInfo('complete').catch((error) => ({ error: String(error) })); + const gpuFeatures = app.getGPUFeatureStatus(); + gpuCompositingStatus = gpuFeatures.gpu_compositing || 'unknown'; + webglStatus = gpuFeatures.webgl || 'unknown'; + const threadedTextureMailbox = (gpuInfo as { auxAttributes?: { canSupportThreadedTextureMailbox?: boolean } }) + .auxAttributes?.canSupportThreadedTextureMailbox; + // Offscreen shared textures are a GPU-compositing capability. WebGL is an + // independent page API and must not disable zero-copy for ordinary DOM/CSS. + // When Chromium explicitly says mailbox sharing is unsupported, keep the + // bitmap path; an unknown value can still be probed by Electron at runtime. + offscreenSharedTextureEnabled = gpuCompositingStatus === 'enabled' + && threadedTextureMailbox !== false; + debugLog('[Wo] GPU feature status:', JSON.stringify(gpuFeatures)); + debugLog('[Wo] GPU info:', JSON.stringify(gpuInfo)); + debugLog(`[Wo] Offscreen shared texture: ${offscreenSharedTextureEnabled ? 'enabled' : 'disabled'} (GPU compositor=${gpuCompositingStatus}, threaded texture mailbox=${threadedTextureMailbox ?? 'unknown'}, WebGL=${webglStatus})`); + publishPerformanceStatus(); try { startPortalUiBridge(); @@ -1772,6 +2906,8 @@ app.on('activate', () => { }); app.on('before-quit', () => { + if (audioCapture && !audioCapture.killed) audioCapture.kill(); + audioCapture = null; stopPortalUiBridge(); closeShmFdCache(); }); diff --git a/electron/src/native-fd-reader.test.ts b/electron/src/native-fd-reader.test.ts new file mode 100644 index 0000000..a797961 --- /dev/null +++ b/electron/src/native-fd-reader.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'bun:test'; +import { closeSync, existsSync, fstatSync, mkdtempSync, openSync, rmSync, writeSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { createConnection, createServer, type Socket } from 'net'; +import { fileURLToPath } from 'url'; + +const nativePath = fileURLToPath(new URL('../native/build/Release/wo_dmabuf.node', import.meta.url)); + +describe('native SCM_RIGHTS receiver', () => { + if (!existsSync(nativePath) || process.env.WO_RUN_NATIVE_SOCKET_TESTS !== '1') { + test.skip('reads socket bytes and ancillary file descriptors with recvmsg (set WO_RUN_NATIVE_SOCKET_TESTS=1)', () => {}); + return; + } + + test('preserves SCM_RIGHTS descriptors alongside the Unix stream bytes', async () => { + const native = require(nativePath) as { + startFdReader: (fd: number, callback: (bytes: Buffer, fds: number[], closed: boolean) => void) => void; + stopFdReader: (fd: number) => void; + sendFd: (socketFd: number, fd: number) => void; + }; + const directory = mkdtempSync(join(tmpdir(), 'wo-fd-reader-')); + const socketPath = join(directory, 'ipc.sock'); + const filePath = join(directory, 'source'); + const sourceFd = openSync(filePath, 'w+'); + writeSync(sourceFd, Buffer.from('dma-buf-fd')); + + const server = createServer(); + const acceptedPromise = new Promise((resolve) => server.once('connection', resolve)); + let client: Socket | null = null; + let accepted: Socket | null = null; + let readerFd: number | null = null; + try { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, resolve); + }); + client = createConnection(socketPath); + await new Promise((resolve, reject) => { + client!.once('connect', resolve); + client!.once('error', reject); + }); + accepted = await acceptedPromise; + accepted.pause(); + readerFd = (accepted as any)._handle.fd as number; + const clientFd = (client as any)._handle.fd as number; + + const packet = new Promise<{ bytes: Buffer; fd: number }>((resolve, reject) => { + const chunks: Buffer[] = []; + native.startFdReader(readerFd!, (bytes, fds, closed) => { + chunks.push(bytes); + if (fds.length > 0) resolve({ bytes: Buffer.concat(chunks), fd: fds[0]! }); + else if (closed) reject(new Error('socket closed before the descriptor arrived')); + }); + client!.write(Buffer.from([0x57, 0x4f, 0x46, 0x44])); + native.sendFd(clientFd, sourceFd); + }); + const received = await packet; + expect(received.bytes.includes(Buffer.from([0x57, 0x4f, 0x46, 0x44]))).toBe(true); + expect(fstatSync(received.fd).size).toBe(Buffer.byteLength('dma-buf-fd')); + closeSync(received.fd); + } finally { + if (readerFd !== null) native.stopFdReader(readerFd); + accepted?.destroy(); + client?.destroy(); + server.close(); + closeSync(sourceFd); + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/electron/src/preload.ts b/electron/src/preload.ts index b8d3b33..85f7741 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -6,15 +6,54 @@ import { contextBridge, ipcRenderer } from 'electron'; import type { CompositorAPI, WoClientAPI, + WoPortals, WindowMetadataEvent, FocusChangeEvent, WoWindow, DamageFramePayload, RendererDamageHelper, RendererDamageHelperOptions, + PortalRequestEvent, + DBusBus, + DBusCallOptions, + DBusPropertyOptions, + DBusInterfaceOptions, + ApplicationIcon, + ApplicationInfo, + SyscallDbusGetAllPropertiesResult, + SyscallDbusListNamesResult, + SyscallLaunchResult, + SyscallXdgDirectoriesResult, + WoAudio, + WoAudioSource, + WoAudioChunk, + WoCapability, + WoPermissionState, + WoPerformanceStatus, + WoMonitor, } from 'wo-types'; import { createRendererDamageHelper } from './renderer-damage-helper.js'; +// The resource approval document is an internal data URL. Expose only the +// one-shot response bridge there; the main process validates its request ID +// and token before it accepts a decision. +try { + const consentUrl = new URL(globalThis.location.href); + if (consentUrl.protocol === 'data:' && consentUrl.hash === '#wo-resource-consent') { + contextBridge.exposeInMainWorld('woResourceConsent', { + respond: (requestId: string, token: string, decision: 'deny' | 'allow' | 'allow-all') => + ipcRenderer.invoke('wo:resource-consent-respond', requestId, token, decision), + }); + } +} catch { + // The bridge is absent from ordinary pages and unavailable if the URL is malformed. +} + +let monitorCache: WoMonitor[] = []; +ipcRenderer.on('wo:monitors', (_event, monitors: WoMonitor[]) => { + monitorCache = Array.isArray(monitors) ? monitors : []; +}); + const compositorAPI: CompositorAPI = { action: async (name: string, payload?: Record) => { return await ipcRenderer.invoke('action', name, payload ?? {}); @@ -26,7 +65,6 @@ const compositorAPI: CompositorAPI = { reload: () => ipcRenderer.invoke('action', 'reload', {}), toggleDevTools: () => ipcRenderer.invoke('action', 'devtools', {}), - // New: Focus change notifications onFocusChange: (callback: (window: string, focused: boolean) => void) => { const handler = (_event: Electron.IpcRendererEvent, data: FocusChangeEvent) => callback(data.window, data.focused); @@ -34,24 +72,75 @@ const compositorAPI: CompositorAPI = { return () => ipcRenderer.removeListener('wo:focus-change', handler); }, - // New: Window metadata onWindowMetadata: (callback: (metadata: WindowMetadataEvent) => void) => { const handler = (_event: Electron.IpcRendererEvent, metadata: WindowMetadataEvent) => callback(metadata); ipcRenderer.on('wo:window-metadata', handler); return () => ipcRenderer.removeListener('wo:window-metadata', handler); }, + + onMonitors: (callback: (monitors: WoMonitor[]) => void) => { + const handler = (_event: Electron.IpcRendererEvent, monitors: WoMonitor[]) => callback(monitors); + ipcRenderer.on('wo:monitors', handler); + if (monitorCache.length) callback(monitorCache); + return () => ipcRenderer.removeListener('wo:monitors', handler); + }, - // New: Syscall API - syscall: async (type: string, params: Record) => { + syscall: async (type: string, params?: any) => { return await ipcRenderer.invoke('wo:syscall', type, params); }, + dbus: { + call: (options: DBusCallOptions) => ipcRenderer.invoke('wo:syscall', 'dbus_call', options), + getProperty: (options: DBusPropertyOptions) => ipcRenderer.invoke('wo:syscall', 'dbus_get_property', options), + getAllProperties: (options: DBusInterfaceOptions) => ipcRenderer.invoke('wo:syscall', 'dbus_get_all_properties', options) as Promise, + listNames: (bus: DBusBus = 'user') => ipcRenderer.invoke('wo:syscall', 'dbus_list_names', { bus }) as Promise, + }, + + desktop: { + applications: { + list: (options?: { locale?: string }) => ipcRenderer.invoke('wo:syscall', 'list_applications', options ?? {}) as Promise, + launch: (desktopId: string, files?: string[]) => ipcRenderer.invoke('wo:syscall', 'launch_desktop', { desktopId, files }) as Promise, + }, + }, + + icons: { + resolve: (name: string, options?: { size?: number; theme?: string }) => ipcRenderer.invoke('wo:syscall', 'resolve_icon', { name, ...options }) as Promise, + }, + + xdg: { + directories: () => ipcRenderer.invoke('wo:syscall', 'xdg_directories', {}) as Promise, + }, + + audio: { + sources: () => ipcRenderer.invoke('wo:syscall', 'audio_sources') as Promise<{ ok: boolean; sources: WoAudioSource[]; error?: string }>, + startDesktopCapture: () => ipcRenderer.invoke('wo:syscall', 'audio_capture_start', {}), + startCapture: (sourceId: string) => ipcRenderer.invoke('wo:syscall', 'audio_capture_start', { sourceId }), + stopCapture: () => ipcRenderer.invoke('wo:syscall', 'audio_capture_stop', {}), + onData: (callback) => { + const handler = (_event: Electron.IpcRendererEvent, chunk: WoAudioChunk) => callback(chunk); + ipcRenderer.on('wo:audio-data', handler); + return () => ipcRenderer.removeListener('wo:audio-data', handler); + }, + onState: (callback) => { + const handler = (_event: Electron.IpcRendererEvent, state: { active: boolean; error?: string }) => callback(state); + ipcRenderer.on('wo:audio-state', handler); + return () => ipcRenderer.removeListener('wo:audio-state', handler); + }, + } satisfies WoAudio, + + portals: { + onRequest: (callback) => subscribePortalRequests(callback), + contextMenu: (options) => ipcRenderer.invoke('wo:portal-context-menu', options), + respond: async (requestId, response) => ipcRenderer.invoke('wo:syscall', 'portal_respond', { + requestId, + ...response, + }), + }, + submitDamageFrame: async (payload: DamageFramePayload) => { return await ipcRenderer.invoke('wo:submit-damage-frame', payload); }, - // Surface buffer: Wayland window pixel content from compositor - // Legacy path for backward compatibility (full pixel copy via IPC) onSurfaceBuffer: (callback: (data: { name: string; width: number; height: number; stride: number; pixels: Buffer; damageRects?: Array<{x: number; y: number; width: number; height: number}> }) => void) => { const handler = (_event: Electron.IpcRendererEvent, data: { name: string; width: number; height: number; stride: number; pixels: Buffer; damageRects?: Array<{x: number; y: number; width: number; height: number}> }) => callback(data); @@ -59,9 +148,6 @@ const compositorAPI: CompositorAPI = { return () => ipcRenderer.removeListener('wo:surface-buffer', handler); }, - // Zero-copy surface buffer via SharedArrayBuffer. - // 'wo:surface-sab' delivers the SAB once per window (or on resize). - // 'wo:surface-update' is a lightweight signal (no pixels) when new data is ready. onSurfaceSab: (callback: (data: { name: string; sab: SharedArrayBuffer; width: number; height: number; stride: number }) => void) => { const handler = (_event: Electron.IpcRendererEvent, data: { name: string; sab: SharedArrayBuffer; width: number; height: number; stride: number }) => callback(data); @@ -83,7 +169,6 @@ const compositorAPI: CompositorAPI = { ); }, - // Notification events from compositor onNotification: (callback: (data: { id?: string; title: string; body: string; icon?: string; timeout?: number; timestamp?: number }) => void) => { const handler = (_event: Electron.IpcRendererEvent, data: { id?: string; title: string; body: string; icon?: string; timeout?: number; timestamp?: number }) => callback(data); @@ -91,17 +176,30 @@ const compositorAPI: CompositorAPI = { return () => ipcRenderer.removeListener('wo:notification', handler); }, - // Forward keyboard event from canvas to compositor (evdev keycode) forwardKeyboard: (windowName: string, evdevKey: number, pressed: boolean, time: number) => { ipcRenderer.send('wo:forward-keyboard', windowName, evdevKey, pressed, time); }, - // Forward relative mouse motion + forwardPointerMotion: (windowName: string, x: number, y: number) => { + ipcRenderer.send('wo:forward-pointer', windowName, x, y); + }, + + forwardPointerButton: (windowName: string, x: number, y: number, button: number, pressed: boolean, time: number) => { + ipcRenderer.send('wo:forward-pointer-button', windowName, x, y, button, pressed, time); + }, + + forwardPointerScroll: (windowName: string, x: number, y: number, dx: number, dy: number, time: number) => { + ipcRenderer.send('wo:forward-pointer-scroll', windowName, x, y, dx, dy, time); + }, + + forwardPointerLeave: (windowName: string) => { + ipcRenderer.send('wo:forward-pointer-leave', windowName); + }, + forwardRelativePointer: (windowName: string, dx: number, dy: number) => { ipcRenderer.send('wo:forward-relative-pointer', windowName, dx, dy); }, - // Pointer lock request from compositor onPointerLockRequest: (callback: (data: { window: string; lock: boolean }) => void) => { const handler = (_event: Electron.IpcRendererEvent, data: { window: string; lock: boolean }) => callback(data); @@ -109,7 +207,6 @@ const compositorAPI: CompositorAPI = { return () => ipcRenderer.removeListener('wo:pointer-lock-request', handler); }, - // Environment variable updates from compositor (e.g. DISPLAY after XWayland ready) onEnvUpdate: (callback: (vars: Record) => void) => { const handler = (_event: Electron.IpcRendererEvent, vars: Record) => callback(vars); @@ -117,22 +214,12 @@ const compositorAPI: CompositorAPI = { return () => ipcRenderer.removeListener('wo:env-update', handler); }, - // Generic portal request event for client-provided popup/approval handlers onPortalRequest: (callback: (data: { requestId: string; payload: any; kind?: string; - }) => void) => { - const handler = (_event: Electron.IpcRendererEvent, data: { - requestId: string; - payload: any; - kind?: string; - }) => callback(data); - ipcRenderer.on('wo:portal-request', handler); - return () => ipcRenderer.removeListener('wo:portal-request', handler); - }, + }) => void) => subscribePortalRequests(callback), - // Screencopy capture event notifications onScreencopyEvent: (callback: (data: { active: boolean; clientCount: number }) => void) => { const handler = (_event: Electron.IpcRendererEvent, data: { active: boolean; clientCount: number }) => callback(data); @@ -141,6 +228,41 @@ const compositorAPI: CompositorAPI = { }, }; +function subscribePortalRequests(callback: Parameters[0]) { + portalRequestListeners.add(callback); + for (const data of pendingPortalRequests.splice(0)) { + callback(data); + ipcRenderer.send('wo:portal-ui-delivered', data.requestId); + } + return () => portalRequestListeners.delete(callback); +} + +const portalRequestListeners = new Set<(data: PortalRequestEvent) => void>(); +const pendingPortalRequests: PortalRequestEvent[] = []; +ipcRenderer.on('wo:portal-request', (_event, data: PortalRequestEvent) => { + if (portalRequestListeners.size === 0) { + pendingPortalRequests.push(data); + return; + } + for (const callback of portalRequestListeners) callback(data); + ipcRenderer.send('wo:portal-ui-delivered', data.requestId); +}); + +// The desktop shell owns the system portal UI. Keep its consent response path +// separate from website capability grants so portal prompts still work when a +// site's Wo APIs have not been approved. +if (process.argv.includes('--wo-system-portal-ui=1')) { + // The main process holds portal requests until this preload has installed + // its queue listener. React can subscribe after that without losing an + // early xdg-desktop-portal request during shell startup. + ipcRenderer.send('wo:portal-ui-ready'); + contextBridge.exposeInMainWorld('woPortalUI', { + onRequest: (callback: Parameters[0]) => subscribePortalRequests(callback), + respond: (requestId: string, response: unknown) => + ipcRenderer.invoke('wo:portal-ui-respond', requestId, response), + }); +} + const woClientAPI: WoClientAPI = { getWindows: async () => ipcRenderer.invoke('wo:get-windows') as Promise, onWindows: (callback: (windows: WoWindow[]) => void) => { @@ -151,9 +273,157 @@ const woClientAPI: WoClientAPI = { sendAction: (action: string) => { ipcRenderer.send('wo:keybind-action', action); }, + portals: compositorAPI.portals, + dbus: compositorAPI.dbus, + desktop: compositorAPI.desktop, + icons: compositorAPI.icons, + xdg: compositorAPI.xdg, + audio: compositorAPI.audio, }; -contextBridge.exposeInMainWorld('compositor', compositorAPI); -contextBridge.exposeInMainWorld('woClient', woClientAPI); +function readCapabilityArgument(name: string): WoCapability[] { + const prefix = `--wo-api-${name}=`; + const argument = process.argv.find((value) => value.startsWith(prefix)); + if (!argument) return []; + try { + const value = JSON.parse(decodeURIComponent(argument.slice(prefix.length))); + return Array.isArray(value) ? value.filter((item): item is WoCapability => typeof item === 'string') : []; + } catch { return []; } +} + +function capabilityPageMatches(): boolean { + const argument = process.argv.find((value) => value.startsWith('--wo-api-page=')); + if (!argument) return false; + try { + const approved = new URL(decodeURIComponent(argument.slice('--wo-api-page='.length))); + const current = new URL((globalThis as any).location.href); + approved.hash = ''; + current.hash = ''; + return approved.href === current.href; + } catch { return false; } +} + +const requestedCapabilities = readCapabilityArgument('requested'); +const grantedCapabilities = new Set(capabilityPageMatches() ? readCapabilityArgument('granted') : []); +const permissions: WoPermissionState = Object.freeze({ + requested: Object.freeze([...requestedCapabilities]), + granted: Object.freeze([...grantedCapabilities]), + has: (capability: WoCapability) => grantedCapabilities.has(capability), +}); + +const compositorExposure: Record = { ...compositorAPI, permissions }; +const hide = (...keys: string[]) => keys.forEach((key) => { delete compositorExposure[key]; }); +if (!grantedCapabilities.has('window-management')) hide( + 'action', 'actionSync', 'onFocusChange', 'onWindowMetadata', 'onMonitors', 'forwardKeyboard', + 'forwardPointerMotion', 'forwardPointerButton', 'forwardPointerScroll', 'forwardPointerLeave', + 'forwardRelativePointer', 'onPointerLockRequest', +); +if (!grantedCapabilities.has('overlay-rendering')) hide( + 'createDamageHelper', 'submitDamageFrame', 'onSurfaceBuffer', 'onSurfaceSab', 'onSurfaceUpdate', +); +if (!grantedCapabilities.has('desktop-applications')) hide('desktop'); +if (!grantedCapabilities.has('dbus')) hide('dbus'); +if (!grantedCapabilities.has('xdg')) hide('icons', 'xdg', 'onEnvUpdate'); +if (!grantedCapabilities.has('portals')) hide('portals', 'onPortalRequest', 'onScreencopyEvent'); +if (!grantedCapabilities.has('audio-capture')) hide('audio'); +if (!grantedCapabilities.has('notifications')) hide('onNotification'); +if (grantedCapabilities.size > 0) { + contextBridge.exposeInMainWorld('compositor', compositorExposure); + const clientExposure: Record = { ...woClientAPI, permissions }; + if (!grantedCapabilities.has('window-management')) { + delete clientExposure.getWindows; + delete clientExposure.onWindows; + delete clientExposure.sendAction; + } + if (!grantedCapabilities.has('portals')) delete clientExposure.portals; + if (!grantedCapabilities.has('dbus')) delete clientExposure.dbus; + if (!grantedCapabilities.has('desktop-applications')) delete clientExposure.desktop; + if (!grantedCapabilities.has('xdg')) delete clientExposure.icons; + if (!grantedCapabilities.has('xdg')) delete clientExposure.xdg; + if (!grantedCapabilities.has('audio-capture')) delete clientExposure.audio; + contextBridge.exposeInMainWorld('woClient', clientExposure); + + const woExposure: Record = { + permissions, + getPerformanceStatus: () => ipcRenderer.invoke('wo:performance-status') as Promise, + onPerformanceStatus: (callback: (status: WoPerformanceStatus) => void) => { + const handler = (_event: Electron.IpcRendererEvent, status: WoPerformanceStatus) => callback(status); + ipcRenderer.on('wo:performance-status', handler); + return () => ipcRenderer.removeListener('wo:performance-status', handler); + }, + }; + if (grantedCapabilities.has('window-management')) { + woExposure.windows = { + get: woClientAPI.getWindows, + on: woClientAPI.onWindows, + action: compositorAPI.action, + onFocusChange: compositorAPI.onFocusChange, + onMetadata: compositorAPI.onWindowMetadata, + monitors: compositorAPI.onMonitors, + }; + } + if (grantedCapabilities.has('overlay-rendering')) { + woExposure.overlay = { + createDamageHelper: compositorAPI.createDamageHelper, + submitDamageFrame: compositorAPI.submitDamageFrame, + onSurfaceBuffer: compositorAPI.onSurfaceBuffer, + onSurfaceSab: compositorAPI.onSurfaceSab, + onSurfaceUpdate: compositorAPI.onSurfaceUpdate, + mount: (element: HTMLElement, monitorId: string) => { + const monitor = monitorCache.find((entry) => entry.id === monitorId); + if (!monitor) throw new Error(`Unknown Wo monitor: ${monitorId}`); + const style = element.style; + const previous = { + position: style.position, + left: style.left, + top: style.top, + width: style.width, + height: style.height, + transform: style.transform, + }; + const previousMonitor = element.dataset.woMonitor; + style.position = 'absolute'; + style.left = `${monitor.x}px`; + style.top = `${monitor.y}px`; + style.width = `${monitor.width}px`; + style.height = `${monitor.height}px`; + style.transform = 'translateZ(0)'; + element.dataset.woMonitor = monitor.id; + return () => { + style.position = previous.position; + style.left = previous.left; + style.top = previous.top; + style.width = previous.width; + style.height = previous.height; + style.transform = previous.transform; + if (previousMonitor === undefined) delete element.dataset.woMonitor; + else element.dataset.woMonitor = previousMonitor; + }; + }, + }; + } + if (grantedCapabilities.has('desktop-applications')) woExposure.desktop = compositorAPI.desktop; + if (grantedCapabilities.has('xdg')) { + woExposure.xdg = compositorAPI.xdg; + woExposure.icons = compositorAPI.icons; + } + if (grantedCapabilities.has('dbus')) woExposure.dbus = compositorAPI.dbus; + if (grantedCapabilities.has('portals')) woExposure.portals = compositorAPI.portals; + if (grantedCapabilities.has('audio-capture')) woExposure.audio = compositorAPI.audio; + if (grantedCapabilities.has('notifications')) woExposure.onNotification = compositorAPI.onNotification; + woExposure.syscall = compositorAPI.syscall; + contextBridge.exposeInMainWorld('wo', woExposure); +} +if (grantedCapabilities.size === 0) { + contextBridge.exposeInMainWorld('wo', { + permissions, + getPerformanceStatus: () => ipcRenderer.invoke('wo:performance-status') as Promise, + onPerformanceStatus: (callback: (status: WoPerformanceStatus) => void) => { + const handler = (_event: Electron.IpcRendererEvent, status: WoPerformanceStatus) => callback(status); + ipcRenderer.on('wo:performance-status', handler); + return () => ipcRenderer.removeListener('wo:performance-status', handler); + }, + }); +} export type { CompositorAPI, WoClientAPI }; diff --git a/electron/src/protocol.ts b/electron/src/protocol.ts index a7b6be9..40b2fe6 100644 --- a/electron/src/protocol.ts +++ b/electron/src/protocol.ts @@ -1,9 +1,9 @@ /** * Wo Compositor - Shared Types and Protocol - * + * * This file defines the protocol messages and types used for communication * between the Wo compositor and Electron/Wayland clients. - * + * * These types are shared across the Rust compositor and TypeScript servers/clients. */ @@ -11,27 +11,32 @@ * IPC Protocol Magic Numbers (little-endian u32) */ export const MAGIC = { - HELLO: 0x574F484C, // "WOHL" - FRAME: 0x574F4652, // "WOFR" - MOUSE_MOVE: 0x574F4D4D, // "WOMM" - MOUSE_BUTTON: 0x574F4D42, // "WOMB" - KEYBOARD: 0x574F4B42, // "WOKB" - SCROLL: 0x574F5343, // "WOSC" - ACTION: 0x574F4341, // "WOAC" - FOCUS_CHANGE: 0x574F4643, // "WOFC" - WINDOW_META: 0x574F574D, // "WOWM" - WINDOW_POS: 0x574F5750, // "WOWP" - SYSCALL: 0x574F5359, // "WOSY" - FRAME_ACK: 0x574F4641, // "WOFA" - SURFACE_BUFFER: 0x574F5342, // "WOSB" — Wayland surface pixels from compositor - SHM_BUFFER: 0x574F534D, // "WOSM" — Wayland SHM surface sent to Electron via process FD - DMABUF_FRAME: 0x574F4446, // "WODF" — Wayland surface DMABUF FDs from compositor - FORWARD_POINTER: 0x574F5045, // "WOPE" — forwarded pointer from web UI canvas - FORWARD_KEYBOARD: 0x574F4B45, // "WOKE" — forwarded keyboard from web UI canvas - FORWARD_RELATIVE_POINTER: 0x574F5245, // "WORE" — forwarded relative pointer (dx/dy) - POINTER_LOCK_REQUEST: 0x574F504C, // "WOPL" — server-to-client pointer lock request - ENV_UPDATE: 0x574F4555, // "WOEU" — environment variable update broadcast - SCREENCOPY_EVENT: 0x574F5345, // "WOSE" — screencopy capture event notification + HELLO: 0x574F484C, + FRAME: 0x574F4652, + MOUSE_MOVE: 0x574F4D4D, + MOUSE_BUTTON: 0x574F4D42, + KEYBOARD: 0x574F4B42, + SCROLL: 0x574F5343, + ACTION: 0x574F4341, + FOCUS_CHANGE: 0x574F4643, + WINDOW_META: 0x574F574D, + WINDOW_POS: 0x574F5750, + SYSCALL: 0x574F5359, + FRAME_ACK: 0x574F4641, + SURFACE_BUFFER: 0x574F5342, + SHM_BUFFER: 0x574F534D, + DMABUF_FRAME: 0x574F4446, + FORWARD_POINTER: 0x574F5045, + FORWARD_KEYBOARD: 0x574F4B45, + FORWARD_RELATIVE_POINTER: 0x574F5245, + FORWARD_POINTER_BUTTON: 0x574F5042, + FORWARD_POINTER_SCROLL: 0x574F5053, + FORWARD_POINTER_LEAVE: 0x574F504E, + POINTER_LOCK_REQUEST: 0x574F504C, + ENV_UPDATE: 0x574F4555, + SCREENCOPY_EVENT: 0x574F5345, + WINDOW_SIZE: 0x574F525A, + SITE_HASHES: 0x574F5348, } as const; /** @@ -83,7 +88,7 @@ export interface FocusChangeEvent { export interface WindowMetadataEvent { type: 'windowMetadata'; - metadata: string; // JSON string + metadata: string; } export interface PointerLockRequestEvent { @@ -137,7 +142,7 @@ export interface FrameInfo { seq: number; width: number; height: number; - format: string; // DRM fourcc as string + format: string; planes: PlaneInfo[]; } @@ -154,10 +159,10 @@ export interface DmabufFrameInfo { name: string; width: number; height: number; - format: number; // DRM fourcc as u32 + format: number; numPlanes: number; planes: DmabufPlaneInfo[]; - fds: number[]; // File descriptors passed with this message + fds: number[]; } export interface DmabufPlaneInfo { @@ -212,7 +217,6 @@ export function bufferToF64LE(buf: Buffer, offset: number = 0): number { export function bufferToI32LE(buf: Buffer, offset: number = 0): number { const val = bufferToU32LE(buf, offset); - // Convert unsigned to signed return val > 0x7fffffff ? val - 0x100000000 : val; } diff --git a/electron/src/renderer-damage-helper.ts b/electron/src/renderer-damage-helper.ts index 5053212..26034f0 100644 --- a/electron/src/renderer-damage-helper.ts +++ b/electron/src/renderer-damage-helper.ts @@ -74,9 +74,11 @@ export function createRendererDamageHelper( return; } const rowBytes = clamped.width * 4; + const sourceX = clamped.x - rect.x; + const sourceY = clamped.y - rect.y; const startX = clamped.x * 4; for (let row = 0; row < clamped.height; row += 1) { - const srcStart = row * srcStride; + const srcStart = (sourceY + row) * srcStride + sourceX * 4; const dstStart = (clamped.y + row) * stride + startX; frame.set(rgba.subarray(srcStart, srcStart + rowBytes), dstStart); } diff --git a/electron/src/wo-capabilities.test.ts b/electron/src/wo-capabilities.test.ts new file mode 100644 index 0000000..0f04fbc --- /dev/null +++ b/electron/src/wo-capabilities.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { prefetchWoSiteAssets, sha256, validateWoManifest } from './wo-capabilities.js'; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const path of tempDirs.splice(0)) rmSync(path, { recursive: true, force: true }); +}); + +describe('Wo site metadata', () => { + test('requires the manifest to be tied to the exact index and origin', () => { + const page = 'https://example.test/app/index.html'; + const indexSha256 = 'a'.repeat(64); + const valid = { + schemaVersion: 1, + appId: 'org.example.app', + origin: 'https://example.test', + indexSha256, + capabilities: ['window-management'], + componentsCss: { url: './_wo.components.css', sha256: 'b'.repeat(64) }, + styles: [], + scripts: [], + }; + expect(validateWoManifest(valid, indexSha256, page)).not.toBeNull(); + expect(validateWoManifest(valid, 'c'.repeat(64), page)).toBeNull(); + expect(validateWoManifest({ ...valid, origin: 'https://attacker.test' }, indexSha256, page)).toBeNull(); + expect(validateWoManifest({ ...valid, capabilities: ['system-root'] }, indexSha256, page)).toBeNull(); + }); + + test('prefetches and hashes page assets before accepting capability requests', async () => { + const page = 'https://example.test/app/index.html'; + const indexBytes = new TextEncoder().encode(''); + const cssBytes = new TextEncoder().encode(':root { --wo-accent: #abc; }'); + const appCssBytes = new TextEncoder().encode('body { color: white; }'); + const jsBytes = new TextEncoder().encode('window.appReady = true;'); + const indexSha256 = sha256(indexBytes); + const manifest = { + schemaVersion: 1, + appId: 'org.example.app', + origin: 'https://example.test', + indexSha256, + capabilities: ['window-management'], + componentsCss: { url: './_wo.components.css', sha256: sha256(cssBytes) }, + styles: [{ url: './assets/app.css', sha256: sha256(appCssBytes) }], + scripts: [{ url: './app.js', sha256: sha256(jsBytes) }], + }; + const assets = new Map([ + [page, indexBytes], + ['https://example.test/app/_wo.capabilities.json', new TextEncoder().encode(JSON.stringify(manifest))], + ['https://example.test/app/_wo.components.css', cssBytes], + ['https://example.test/app/assets/app.css', appCssBytes], + ['https://example.test/app/app.js', jsBytes], + ]); + const fetcher = async (url: string) => { + const bytes = assets.get(url); + return { ok: !!bytes, arrayBuffer: async () => bytes!.buffer.slice(bytes!.byteOffset, bytes!.byteOffset + bytes!.byteLength) }; + }; + + const site = await prefetchWoSiteAssets(page, fetcher); + expect(site.trusted).toBe(true); + expect(site.capabilities).toEqual(['window-management']); + expect(site.styles).toContain('--wo-accent'); + expect(site.styleBodies.map((style) => style.url)).toEqual(['https://example.test/app/assets/app.css']); + expect(site.scripts).toEqual(['https://example.test/app/app.js']); + + assets.set('https://example.test/app/bad.js', new TextEncoder().encode('changed script')); + assets.set('https://example.test/app/_wo.capabilities.json', new TextEncoder().encode(JSON.stringify({ + ...manifest, + scripts: [{ url: './bad.js', sha256: sha256(jsBytes) }], + }))); + const tampered = await prefetchWoSiteAssets(page, fetcher); + expect(tampered.trusted).toBe(false); + expect(tampered.capabilities).toEqual([]); + }); + + test('loads an exact local build and its relative assets from file URLs', async () => { + const root = mkdtempSync(join(tmpdir(), 'wo-site-')); + tempDirs.push(root); + const index = new TextEncoder().encode(''); + const css = new TextEncoder().encode(':root { --wo-accent: red; }'); + const js = new TextEncoder().encode('export const app = true;'); + const assetsDir = join(root, 'assets'); + mkdirSync(assetsDir); + writeFileSync(join(root, 'index.html'), index); + writeFileSync(join(root, '_wo.components.css'), css); + writeFileSync(join(assetsDir, 'app.js'), js); + writeFileSync(join(root, '_wo.capabilities.json'), JSON.stringify({ + schemaVersion: 1, + appId: 'org.example.local', + indexSha256: sha256(index), + capabilities: ['overlay-rendering'], + componentsCss: { url: './_wo.components.css', sha256: sha256(css) }, + scripts: [{ url: './assets/app.js', sha256: sha256(js) }], + })); + + const result = await prefetchWoSiteAssets(pathToFileURL(join(root, 'index.html')).href); + expect(result.trusted).toBe(true); + expect(result.appId).toBe('org.example.local'); + expect(result.scripts).toHaveLength(1); + expect(result.styles).toContain('--wo-accent'); + }); +}); diff --git a/electron/src/wo-capabilities.ts b/electron/src/wo-capabilities.ts new file mode 100644 index 0000000..8e2655c --- /dev/null +++ b/electron/src/wo-capabilities.ts @@ -0,0 +1,182 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +export const WO_CAPABILITIES = [ + 'window-management', 'overlay-rendering', 'desktop-applications', 'xdg', 'dbus', 'portals', + 'filesystem', 'process-launch', 'system-control', 'notifications', 'audio-capture', +] as const; + +export type WoCapability = typeof WO_CAPABILITIES[number]; + +export interface WoSiteManifest { + schemaVersion: 1; + appId: string; + origin?: string; + indexSha256: string; + capabilities: WoCapability[]; + componentsCss: { url: string; sha256: string }; + styles?: Array<{ url: string; sha256: string }>; + scripts: Array<{ url: string; sha256: string }>; +} + +export interface WoSiteAssets { + pageUrl: string; + appId: string; + capabilities: WoCapability[]; + styles: string; + stylesUrl: string; + stylesHash: string; + styleBodies: Array<{ url: string; hash: string; bytes: Uint8Array }>; + scripts: string[]; + scriptBodies: Array<{ url: string; hash: string; bytes: Uint8Array }>; + indexSha256: string; + indexBytes: Uint8Array; + stylesBytes: Uint8Array; + trusted: boolean; + trustError?: string; +} + +type FetchLike = (url: string) => Promise<{ ok: boolean; arrayBuffer(): Promise }>; +const MAX_INDEX_BYTES = 4 * 1024 * 1024; +const MAX_ASSET_BYTES = 16 * 1024 * 1024; + +export function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function isHash(value: unknown): value is string { + return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); +} + +export function validateWoManifest(value: unknown, indexHash: string, pageUrl: string): WoSiteManifest | null { + if (!value || typeof value !== 'object') return null; + const m = value as Partial; + let page: URL; + try { page = new URL(pageUrl); } catch { return null; } + if (m.schemaVersion !== 1 || typeof m.appId !== 'string' || !/^[a-zA-Z0-9._-]{1,128}$/.test(m.appId)) return null; + if (m.indexSha256 !== indexHash || (m.origin !== undefined && m.origin !== page.origin)) return null; + if (!Array.isArray(m.capabilities) || !m.capabilities.every((cap) => WO_CAPABILITIES.includes(cap))) return null; + if (!m.componentsCss || typeof m.componentsCss.url !== 'string' || !isHash(m.componentsCss.sha256)) return null; + if (!Array.isArray(m.scripts) || m.scripts.length > 256) return null; + if (m.styles !== undefined && (!Array.isArray(m.styles) || m.styles.length > 256)) return null; + const trustedPath = (path: string): boolean => { + try { + const resolved = new URL(path, page); + return resolved.origin === page.origin && (resolved.protocol === 'https:' || resolved.protocol === 'http:' || resolved.protocol === 'file:'); + } catch { return false; } + }; + if (!trustedPath(m.componentsCss.url)) return null; + const seen = new Set(); + for (const script of m.scripts) { + if (!script || typeof script.url !== 'string' || !isHash(script.sha256) || !trustedPath(script.url)) return null; + const absolute = new URL(script.url, page).href; + if (seen.has(absolute)) return null; + seen.add(absolute); + } + for (const style of m.styles ?? []) { + if (!style || typeof style.url !== 'string' || !isHash(style.sha256) || !trustedPath(style.url)) return null; + const absolute = new URL(style.url, page).href; + if (seen.has(absolute)) return null; + seen.add(absolute); + } + return m as WoSiteManifest; +} + +async function readUrl(url: string, maxBytes: number, fetcher: FetchLike): Promise { + try { + let bytes: Uint8Array; + if (new URL(url).protocol === 'file:') { + bytes = await readFile(fileURLToPath(url)); + } else { + const response = await fetcher(url); + if (!response.ok) return null; + bytes = new Uint8Array(await response.arrayBuffer()); + } + return bytes.byteLength <= maxBytes ? bytes : null; + } catch { return null; } +} + +const assetCache = new Map(); + +async function loadAsset(url: string, hash: string, maxBytes: number, fetcher: FetchLike): Promise { + const cacheKey = `${url}#${hash}`; + const cached = assetCache.get(cacheKey); + if (cached) return cached; + const bytes = await readUrl(url, maxBytes, fetcher); + if (!bytes || sha256(bytes) !== hash) return null; + assetCache.set(cacheKey, bytes); + return bytes; +} + +export async function prefetchWoSiteAssets( + pageUrl: string, + fetcher: FetchLike = async (url) => { + try { + const response = await fetch(url); + if (response.ok) return response; + const parsed = new URL(url); + if (parsed.hostname !== 'localhost' || parsed.protocol !== 'http:') return response; + parsed.hostname = '127.0.0.1'; + return await fetch(parsed.href); + } catch (error) { + const parsed = new URL(url); + if (parsed.hostname !== 'localhost' || parsed.protocol !== 'http:') throw error; + parsed.hostname = '127.0.0.1'; + return await fetch(parsed.href); + } + }, +): Promise { + const empty = (trustError: string, indexSha256 = ''): WoSiteAssets => ({ + pageUrl, appId: 'website', capabilities: [], styles: '', stylesUrl: '', stylesHash: '', styleBodies: [], scripts: [], scriptBodies: [], indexSha256, indexBytes: new Uint8Array(), stylesBytes: new Uint8Array(), trusted: false, trustError, + }); + let page: URL; + try { page = new URL(pageUrl); } catch { return empty('invalid page URL'); } + if (!['http:', 'https:', 'file:'].includes(page.protocol)) return empty(`unsupported page protocol: ${page.protocol}`); + const index = await readUrl(page.href, MAX_INDEX_BYTES, fetcher); + if (!index) return empty(`could not read page: ${page.href}`); + const indexSha256 = sha256(index); + const manifestUrl = new URL('_wo.capabilities.json', new URL('.', page)).href; + const manifestBytes = await readUrl(manifestUrl, 256 * 1024, fetcher); + if (!manifestBytes) return empty(`could not read capability manifest: ${manifestUrl}`, indexSha256); + let parsed: unknown; + try { parsed = JSON.parse(new TextDecoder().decode(manifestBytes)); } catch { return empty('capability manifest is not valid JSON', indexSha256); } + const manifest = validateWoManifest(parsed, indexSha256, page.href); + if (!manifest) return empty('capability manifest does not match this page or contains invalid fields', indexSha256); + + const cssUrl = new URL(manifest.componentsCss.url, page).href; + const css = await loadAsset(cssUrl, manifest.componentsCss.sha256, 1024 * 1024, fetcher); + if (!css) return empty(`component stylesheet failed SHA-256 verification: ${cssUrl}`, indexSha256); + const scripts: string[] = []; + const scriptBodies: Array<{ url: string; hash: string; bytes: Uint8Array }> = []; + for (const script of manifest.scripts) { + const scriptUrl = new URL(script.url, page).href; + const bytes = await loadAsset(scriptUrl, script.sha256, MAX_ASSET_BYTES, fetcher); + if (!bytes) return empty(`script failed SHA-256 verification: ${scriptUrl}`, indexSha256); + scripts.push(scriptUrl); + scriptBodies.push({ url: scriptUrl, hash: script.sha256, bytes }); + } + const styleBodies: Array<{ url: string; hash: string; bytes: Uint8Array }> = []; + for (const style of manifest.styles ?? []) { + const styleUrl = new URL(style.url, page).href; + const bytes = await loadAsset(styleUrl, style.sha256, MAX_ASSET_BYTES, fetcher); + if (!bytes) return empty(`stylesheet failed SHA-256 verification: ${styleUrl}`, indexSha256); + styleBodies.push({ url: styleUrl, hash: style.sha256, bytes }); + } + return { + pageUrl: page.href, + appId: manifest.appId, + capabilities: [...new Set(manifest.capabilities)], + styles: new TextDecoder().decode(css), + stylesUrl: cssUrl, + stylesHash: manifest.componentsCss.sha256, + styleBodies, + scripts, + scriptBodies, + indexSha256, + indexBytes: index, + stylesBytes: css, + trusted: true, + }; +} + diff --git a/electron/src/wo-input-receiver.ts b/electron/src/wo-input-receiver.ts index 35bc8ed..da9bacb 100644 --- a/electron/src/wo-input-receiver.ts +++ b/electron/src/wo-input-receiver.ts @@ -1,51 +1,14 @@ -/** - * Wo Composer Input Event Receiver for Electron - * - * This module provides utilities for Electron apps to receive input events - * from the Wo compositor via the IPC protocol. - * - * Usage in your Electron renderer process: - * - * const { WoInputReceiver } = require('./wo-input-receiver'); - * const receiver = new WoInputReceiver(); - * - * receiver.on('mouseMove', (x, y) => { - * console.log(`Mouse moved to: ${x}, ${y}`); - * }); - * - * receiver.on('mouseButton', (button, pressed, time) => { - * console.log(`Button ${button} ${pressed ? 'pressed' : 'released'}`); - * }); - * - * receiver.on('keyboard', (key, pressed, time) => { - * console.log(`Key ${key} ${pressed ? 'pressed' : 'released'}`); - * }); - * - * receiver.on('scroll', (vertical, horizontal, time) => { - * console.log(`Scrolled V:${vertical} H:${horizontal}`); - * }); - * - * receiver.startListening(); - * - * Or in the client app: - * - * // Access via window.__WO_INPUT_RECEIVER__ - * window.__WO_INPUT_RECEIVER__.on('mouseMove', (x, y) => { - * // Handle mouse move - * }); - */ - import { EventEmitter } from 'events'; import net from 'net'; -const MAGIC_MOUSE_MOVE = 0x574F4D4D; // "WOMM" -const MAGIC_MOUSE_BUTTON = 0x574F4D42; // "WOMB" -const MAGIC_KEYBOARD = 0x574F4B42; // "WOKB" -const MAGIC_SCROLL = 0x574F5343; // "WOSC" +const MAGIC_MOUSE_MOVE = 0x574F4D4D; +const MAGIC_MOUSE_BUTTON = 0x574F4D42; +const MAGIC_KEYBOARD = 0x574F4B42; +const MAGIC_SCROLL = 0x574F5343; /** * Receives input events from the Wo compositor via IPC socket. - * + * * Events: * - mouseMove(x, y) - Pointer moved * - mouseButton(button, pressed, time) - Mouse button event @@ -81,8 +44,6 @@ class WoInputReceiver extends EventEmitter { this.socket.destroy(); } - // Create a new socket connection just for receiving input events - // This reuses the DMABUF connection through parameter passing this.socket = net.createConnection(this.ipcSocket); this.socket.on('connect', () => { @@ -119,8 +80,7 @@ class WoInputReceiver extends EventEmitter { let eventSize = 0; let parsed = false; - // Mouse move: 4 (magic) + 8 (x) + 8 (y) = 20 bytes - if (magic === MAGIC_MOUSE_MOVE) { // "WOMM" + if (magic === MAGIC_MOUSE_MOVE) { if (this.buffer.length >= 20) { const x = this.buffer.readDoubleLE(4); const y = this.buffer.readDoubleLE(12); @@ -129,8 +89,7 @@ class WoInputReceiver extends EventEmitter { parsed = true; } } - // Mouse button: 4 (magic) + 4 (button) + 4 (pressed) + 4 (time) = 16 bytes - else if (magic === MAGIC_MOUSE_BUTTON) { // "WOMB" + else if (magic === MAGIC_MOUSE_BUTTON) { if (this.buffer.length >= 16) { const button = this.buffer.readUInt32LE(4); const pressed = this.buffer.readUInt32LE(8) !== 0; @@ -140,8 +99,7 @@ class WoInputReceiver extends EventEmitter { parsed = true; } } - // Keyboard: 4 (magic) + 4 (key) + 4 (pressed) + 4 (time) = 16 bytes - else if (magic === MAGIC_KEYBOARD) { // "WOKB" + else if (magic === MAGIC_KEYBOARD) { if (this.buffer.length >= 16) { const key = this.buffer.readUInt32LE(4); const pressed = this.buffer.readUInt32LE(8) !== 0; @@ -151,8 +109,7 @@ class WoInputReceiver extends EventEmitter { parsed = true; } } - // Scroll: 4 (magic) + 4 (vertical) + 4 (horizontal) + 4 (time) = 16 bytes - else if (magic === MAGIC_SCROLL) { // "WOSC" + else if (magic === MAGIC_SCROLL) { if (this.buffer.length >= 16) { const vertical = this.buffer.readInt32LE(4); const horizontal = this.buffer.readInt32LE(8); @@ -163,13 +120,11 @@ class WoInputReceiver extends EventEmitter { } } else { - // Unknown magic, skip this byte and try again console.warn('[Wo] Unknown message magic: 0x' + magic.toString(16)); this.buffer = this.buffer.slice(1); parsed = true; } - // If we couldn't parse a complete event, wait for more data if (!parsed) { break; } diff --git a/electron/src/xdg.test.ts b/electron/src/xdg.test.ts new file mode 100644 index 0000000..3ddf962 --- /dev/null +++ b/electron/src/xdg.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { getXdgDirectories, listDesktopApplications, resolveDesktopIcon } from './xdg.js'; + +const tempDirs: string[] = []; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'wo-xdg-')); + tempDirs.push(root); + const home = join(root, 'home'); + const dataHome = join(home, '.local', 'share'); + const applications = join(dataHome, 'applications'); + const systemData = join(root, 'system-data'); + mkdirSync(applications, { recursive: true }); + mkdirSync(join(systemData, 'applications'), { recursive: true }); + return { + root, + home, + applications, + systemData, + env: { + HOME: home, + XDG_DATA_HOME: dataHome, + XDG_DATA_DIRS: systemData, + XDG_CONFIG_HOME: join(home, '.config'), + LANG: 'en_US.UTF-8', + PATH: process.env.PATH, + } as NodeJS.ProcessEnv, + }; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe('XDG desktop helpers', () => { + test('reads XDG paths and user directories from the config file', () => { + const f = fixture(); + mkdirSync(join(f.home, '.config'), { recursive: true }); + writeFileSync(join(f.home, '.config', 'user-dirs.dirs'), 'XDG_DOWNLOAD_DIR="$HOME/Inbox"\n'); + + const dirs = getXdgDirectories(f.env); + expect(dirs.dataHome).toBe(join(f.home, '.local', 'share')); + expect(dirs.dataDirs).toEqual([f.systemData]); + expect(dirs.downloads).toBe(join(f.home, 'Inbox')); + expect(dirs.runtimeDir).toBeNull(); + }); + + test('lists localized desktop entries and resolves themed icons', () => { + const f = fixture(); + const iconFile = join(f.home, '.local', 'share', 'icons', 'hicolor', '64x64', 'apps', 'wo-test.png'); + mkdirSync(join(iconFile, '..'), { recursive: true }); + writeFileSync(iconFile, Buffer.from([1, 2, 3, 4])); + writeFileSync(join(f.applications, 'wo-test.desktop'), [ + '[Desktop Entry]', + 'Type=Application', + 'Name=Test App', + 'Name[en_US]=Localized Test App', + 'Exec=/usr/bin/env -- %F', + 'Icon=wo-test', + 'Categories=Utility;Development;', + 'Terminal=false', + ].join('\n')); + writeFileSync(join(f.applications, 'hidden.desktop'), [ + '[Desktop Entry]', + 'Type=Application', + 'Name=Hidden App', + 'Exec=/usr/bin/env', + 'Hidden=true', + ].join('\n')); + + const apps = listDesktopApplications({ env: f.env }); + expect(apps).toHaveLength(1); + expect(apps[0]).toMatchObject({ + name: 'Localized Test App', + command: '/usr/bin/env', + desktopId: 'wo-test.desktop', + categories: ['Utility', 'Development'], + terminal: false, + }); + expect(apps[0].icon?.mimeType).toBe('image/png'); + expect(Buffer.from(apps[0].icon?.data || '', 'base64')).toEqual(Buffer.from([1, 2, 3, 4])); + }); + + test('returns null for icons that are not present in active XDG icon trees', () => { + const f = fixture(); + expect(resolveDesktopIcon('no-such-wo-icon', { env: f.env })).toBeUndefined(); + }); +}); diff --git a/electron/src/xdg.ts b/electron/src/xdg.ts new file mode 100644 index 0000000..944e7a1 --- /dev/null +++ b/electron/src/xdg.ts @@ -0,0 +1,292 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { basename, delimiter, extname, join, resolve } from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; + +export interface XdgDirectories { + home: string; + configHome: string; + configDirs: string[]; + dataHome: string; + dataDirs: string[]; + cacheHome: string; + stateHome: string; + runtimeDir: string | null; + desktop: string; + documents: string; + downloads: string; + music: string; + pictures: string; + publicShare: string; + templates: string; + videos: string; +} + +export interface DesktopApplication { + name: string; + command: string; + desktopId: string; + desktopFile: string; + icon?: { type: 'base64'; data: string; mimeType: string }; + genericName?: string; + comment?: string; + categories?: string[]; + keywords?: string[]; + startupWMClass?: string; + terminal?: boolean; + startupNotify?: boolean; +} + +export function getXdgDirectories(env: NodeJS.ProcessEnv = process.env): XdgDirectories { + const home = env.HOME || '/'; + const configHome = env.XDG_CONFIG_HOME || join(home, '.config'); + const dataHome = env.XDG_DATA_HOME || join(home, '.local', 'share'); + const dataDirs = splitDirs(env.XDG_DATA_DIRS || '/usr/local/share:/usr/share'); + const configDirs = splitDirs(env.XDG_CONFIG_DIRS || '/etc/xdg'); + const userDirsFile = join(configHome, 'user-dirs.dirs'); + const userDirs = parseUserDirs(userDirsFile, home); + const userDir = (key: string, fallback: string) => userDirs[key] || join(home, fallback); + + return { + home, + configHome, + configDirs, + dataHome, + dataDirs, + cacheHome: env.XDG_CACHE_HOME || join(home, '.cache'), + stateHome: env.XDG_STATE_HOME || join(home, '.local', 'state'), + runtimeDir: env.XDG_RUNTIME_DIR || null, + desktop: userDir('XDG_DESKTOP_DIR', 'Desktop'), + documents: userDir('XDG_DOCUMENTS_DIR', 'Documents'), + downloads: userDir('XDG_DOWNLOAD_DIR', 'Downloads'), + music: userDir('XDG_MUSIC_DIR', 'Music'), + pictures: userDir('XDG_PICTURES_DIR', 'Pictures'), + publicShare: userDir('XDG_PUBLICSHARE_DIR', 'Public'), + templates: userDir('XDG_TEMPLATES_DIR', 'Templates'), + videos: userDir('XDG_VIDEOS_DIR', 'Videos'), + }; +} + +function splitDirs(value: string): string[] { + return [...new Set(value.split(delimiter).filter(Boolean).map((dir) => resolve(dir)))]; +} + +function parseUserDirs(file: string, home: string): Record { + if (!existsSync(file)) return {}; + const result: Record = {}; + for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) { + const match = line.match(/^\s*(XDG_[A-Z]+_DIR)="((?:\\.|[^"])*)"\s*$/); + if (!match) continue; + const value = match[2].replace(/\\"/g, '"').replace(/\\\\/g, '\\').replace(/^\$HOME(?=\/|$)/, home); + if (value.startsWith('/')) result[match[1]] = resolve(value); + } + return result; +} + +function decodeDesktopValue(value: string): string { + return value.replace(/\\([snrt\\])/g, (_match, code: string) => ({ + s: ' ', n: '\n', r: '\r', t: '\t', '\\': '\\', + }[code] ?? code)); +} + +function desktopKeyValues(content: string, locale: string): Map { + const values = new Map(); + let inEntry = false; + const localized = new Map(); + const language = locale.split(/[.@]/)[0]; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.startsWith('[')) { + inEntry = trimmed === '[Desktop Entry]'; + continue; + } + if (!inEntry || !trimmed || trimmed.startsWith('#')) continue; + const splitAt = trimmed.indexOf('='); + if (splitAt < 1) continue; + const key = trimmed.slice(0, splitAt); + const value = decodeDesktopValue(trimmed.slice(splitAt + 1)); + const localizedKey = key.match(/^Name\[(.+)\]$/); + if (localizedKey) localized.set(localizedKey[1], value); + else values.set(key, value); + } + if (language && localized.has(language)) values.set('Name', localized.get(language)!); + return values; +} + +function availableCommand(command: string): boolean { + if (!command) return false; + if (command.includes('/')) return existsSync(command); + const result = spawnSync('sh', ['-c', 'command -v "$1" >/dev/null 2>&1', 'wo', command], { + stdio: 'ignore', + env: process.env, + }); + return result.status === 0; +} + +export function resolveDesktopIcon( + name: string, + options: { size?: number; theme?: string; env?: NodeJS.ProcessEnv } = {}, +): DesktopApplication['icon'] | undefined { + const xdg = getXdgDirectories(options.env); + const size = Math.max(16, options.size ?? 48); + const theme = options.theme || options.env?.XDG_ICON_THEME || process.env.XDG_ICON_THEME || 'hicolor'; + const suffixes = extname(name) ? [''] : ['.png', '.svg']; + const candidates: string[] = []; + + if (name.startsWith('/')) candidates.push(name); + for (const suffix of suffixes) { + for (const root of [xdg.dataHome, ...xdg.dataDirs]) { + for (const iconTheme of [theme, 'hicolor']) { + const themeRoot = join(root, 'icons', iconTheme); + for (const directory of iconSizeDirectories(themeRoot, size)) { + candidates.push(join(themeRoot, directory, 'apps', `${name}${suffix}`)); + } + candidates.push(join(themeRoot, 'apps', `${name}${suffix}`)); + } + candidates.push(join(root, 'pixmaps', `${name}${suffix}`)); + } + } + + const file = candidates.find((candidate) => existsSync(candidate)); + if (!file) return undefined; + const extension = extname(file).toLowerCase(); + const mimeType = extension === '.svg' ? 'image/svg+xml' : 'image/png'; + try { + return { type: 'base64', data: readFileSync(file).toString('base64'), mimeType }; + } catch { + return undefined; + } +} + +function iconSizeDirectories(themeRoot: string, target: number): string[] { + try { + return readdirSync(themeRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => /^(?:scalable|\d+x\d+(?:@\d+(?:\.\d+)?x)?)$/.test(name)) + .sort((a, b) => iconDirectoryDistance(a, target) - iconDirectoryDistance(b, target)); + } catch { + return ['scalable', `${target}x${target}`, '48x48', '32x32', '24x24', '16x16']; + } +} + +function iconDirectoryDistance(directory: string, target: number): number { + if (directory === 'scalable') return 0; + const match = directory.match(/^(\d+)x\d+/); + return match ? Math.abs(Number(match[1]) - target) + 1 : Number.MAX_SAFE_INTEGER; +} + +export function listDesktopApplications(options: { env?: NodeJS.ProcessEnv; locale?: string } = {}): DesktopApplication[] { + const env = options.env || process.env; + const xdg = getXdgDirectories(env); + const locale = options.locale || env.LC_ALL || env.LC_MESSAGES || env.LANG || 'C'; + const dirs = [join(xdg.dataHome, 'applications'), ...xdg.dataDirs.map((dir) => join(dir, 'applications'))]; + const seen = new Set(); + const apps: DesktopApplication[] = []; + + for (const dir of dirs) { + let entries: string[]; + try { + entries = readdirSync(dir).filter((entry) => entry.endsWith('.desktop')).sort(); + } catch { + continue; + } + for (const entry of entries) { + const file = join(dir, entry); + let keys: Map; + try { + const content = readFileSync(file, 'utf8'); + if (content.length > 512_000) continue; + keys = desktopKeyValues(content, locale); + } catch { + continue; + } + const id = entry; + if (seen.has(id) || keys.get('Type') !== 'Application') continue; + seen.add(id); + if (keys.get('Hidden') === 'true' || keys.get('NoDisplay') === 'true') continue; + + const onlyShowIn = (keys.get('OnlyShowIn') || '').split(';').filter(Boolean); + const notShowIn = (keys.get('NotShowIn') || '').split(';').filter(Boolean); + const desktops = (env.XDG_CURRENT_DESKTOP || '').split(':').filter(Boolean); + if (onlyShowIn.length && !onlyShowIn.some((desktop) => desktops.includes(desktop))) continue; + if (notShowIn.some((desktop) => desktops.includes(desktop))) continue; + + const execLine = keys.get('Exec') || ''; + const command = parseDesktopExec(execLine).argv[0] || ''; + if (!command || (keys.has('TryExec') && !availableCommand(keys.get('TryExec')!))) continue; + const name = keys.get('Name') || basename(entry, '.desktop'); + const iconName = keys.get('Icon'); + const icon = iconName ? resolveDesktopIcon(iconName, { env }) : undefined; + apps.push({ + name, + command, + desktopId: id, + desktopFile: file, + ...(icon ? { icon } : {}), + ...(keys.has('GenericName') ? { genericName: keys.get('GenericName') } : {}), + ...(keys.has('Comment') ? { comment: keys.get('Comment') } : {}), + ...(keys.has('Categories') ? { categories: keys.get('Categories')!.split(';').filter(Boolean) } : {}), + ...(keys.has('Keywords') ? { keywords: keys.get('Keywords')!.split(';').filter(Boolean) } : {}), + ...(keys.has('StartupWMClass') ? { startupWMClass: keys.get('StartupWMClass') } : {}), + ...(keys.has('Terminal') ? { terminal: keys.get('Terminal') === 'true' } : {}), + ...(keys.has('StartupNotify') ? { startupNotify: keys.get('StartupNotify') === 'true' } : {}), + }); + } + } + + return apps; +} + +export async function launchDesktopApplication( + desktopId: string, + files: string[] = [], +): Promise<{ ok: boolean; pid?: number; error?: string }> { + const app = listDesktopApplications().find((entry) => entry.desktopId === desktopId || entry.name === desktopId); + if (!app) return { ok: false, error: `Desktop application not found: ${desktopId}` }; + const keys = desktopKeyValues(readFileSync(app.desktopFile, 'utf8'), process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || 'C'); + const parsed = parseDesktopExec(keys.get('Exec') || '', { ...app, iconName: keys.get('Icon') }, files); + const [command, ...args] = parsed.argv; + if (!command) return { ok: false, error: `No executable in desktop entry: ${desktopId}` }; + + return new Promise((resolveResult) => { + try { + const child = spawn(command, args, { detached: true, stdio: 'ignore', env: process.env }); + child.once('error', (error) => resolveResult({ ok: false, error: error.message })); + child.once('spawn', () => { + child.unref(); + resolveResult({ ok: true, pid: child.pid }); + }); + } catch (error) { + resolveResult({ ok: false, error: String(error) }); + } + }); +} + +function parseDesktopExec( + exec: string, + app?: Pick & { iconName?: string }, + files: string[] = [], +): { argv: string[] } { + const tokens = exec.match(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s]+/g) || []; + const argv: string[] = []; + for (const token of tokens) { + const unquoted = token.replace(/^['"]|['"]$/g, '').replace(/\\(["'\\`$])/g, '$1'); + if (unquoted === '%i') { + if (app?.iconName) argv.push('--icon', app.iconName); + continue; + } + if (unquoted === '%F' || unquoted === '%U') { + argv.push(...files); + continue; + } + const value = unquoted.replace(/%%|%[fFuUdDnNickvm]/g, (code) => { + if (code === '%%') return '%'; + if (code === '%c') return app?.name || ''; + if (code === '%k') return app?.desktopFile || ''; + if (code === '%f' || code === '%u') return files[0] || ''; + return ''; + }); + if (value) argv.push(value); + } + return { argv }; +} diff --git a/electron/types/index.ts b/electron/types/index.ts index 5d62cec..52a8afe 100644 --- a/electron/types/index.ts +++ b/electron/types/index.ts @@ -2,7 +2,26 @@ export type { WindowsCallback, WoClient, WoClientAPI, + WoCapability, + WoPermissionState, + WoGlobalAPI, + WoPerformanceStatus, + WoPortals, + WoDBus, + WoDesktop, + WoIcons, + WoXdg, + WoAudio, + WoAudioSource, + WoAudioChunk, + XdgDirectories, + DBusBus, + DBusCallOptions, + DBusPropertyOptions, + DBusInterfaceOptions, + DBusResult, WoWindow, + WoMonitor, ApplicationIcon, ApplicationInfo, CompositorActionConfig, @@ -23,6 +42,21 @@ export type { RendererDamageHelperOptions, PortalRequestEvent, ScreencopyEvent, + SyscallXdgDirectoriesResult, + SyscallListApplicationsParams, + SyscallLaunchDesktopParams, + SyscallLaunchResult, + SyscallDbusGetAllPropertiesParams, + SyscallDbusCallParams, + SyscallDbusCallResult, + SyscallDbusGetPropertyParams, + SyscallDbusGetPropertyResult, + SyscallDbusListNamesParams, + SyscallDbusGetAllPropertiesResult, + SyscallDbusListNamesResult, + SyscallAudioCaptureParams, + SyscallAudioCaptureResult, + SyscallResolveIconParams, } from './types'; export { InteractionDispatcher } from './dispatcher'; diff --git a/electron/types/types.d.ts b/electron/types/types.d.ts index 590e11d..098a0b7 100644 --- a/electron/types/types.d.ts +++ b/electron/types/types.d.ts @@ -6,61 +6,60 @@ * Represents a single window managed by the compositor */ export interface WoWindow { - /** Unique identifier for this window */ name: string; - /** Window title/label */ title?: string; - /** X coordinate of window in pixels */ x: number; - /** Y coordinate of window in pixels */ y: number; - /** Width of window in pixels */ width: number; - /** Height of window in pixels */ height: number; - /** Z-order (stacking) of the window (higher = on top) */ + content_width?: number; + content_height?: number; + z_order?: number; - /** Whether this window is currently mapped/visible */ mapped?: boolean; - /** Whether this window is currently focused */ focused?: boolean; - /** Frames per second being rendered */ fps?: number; - /** Process ID of the application (if available) */ pid?: number; - /** Window source: 'electron', 'wayland', or 'x11' (XWayland clients). */ source?: 'electron' | 'wayland' | 'x11'; - /** True when server-side decorations/chrome should be drawn by comraw. */ ssd?: boolean; - /** Application ID (for Wayland windows) */ + fullscreen?: boolean; + app_id?: string; - /** True if this is a dialog window (xdg_toplevel with set_parent) */ dialog?: boolean; - /** Name of the parent window for dialog windows (e.g. "wayland-0") */ parent_name?: string; } +export interface WoMonitor { + id: string; + x: number; + y: number; + width: number; + height: number; + scale: number; + primary: boolean; +} + /** * Action types for compositor window control */ export type CompositorActionType = | 'focus' | 'minimize' | 'close' | 'resize' | 'move' | 'maximize' - | 'pointer_motion' | 'pointer_button' | 'pointer_leave' | 'pointer_scroll' | 'keyboard_key'; + | 'fullscreen' | 'pointer_motion' | 'pointer_button' | 'pointer_leave' | 'pointer_scroll' | 'keyboard_key'; /** * Configuration for compositor actions @@ -74,9 +73,9 @@ export interface CompositorActionConfig { [key: string]: unknown; } -// Stronger syscall typing -export interface SyscallListApplicationsParams { [key: string]: any; } +export interface SyscallListApplicationsParams { locale?: string; } +export interface SyscallLaunchDesktopParams { desktopId: string; files?: string[]; } export interface SyscallLaunchParams { command: string; [key: string]: any; } export interface SyscallExecParams { command: string; [key: string]: any; } export interface SyscallListDirParams { path?: string; [key: string]: any; } @@ -91,11 +90,15 @@ export interface SyscallNotifyParams { id?: string; title: string; body?: string export interface SyscallPortalRespondParams { requestId: string; response?: any; allowed?: boolean; type?: string; windowName?: string | null; [key: string]: any; } export interface SyscallDbusCallParams { service: string; objectPath: string; interface: string; method: string; signature?: string; args?: any[]; bus?: 'user' | 'system'; [key: string]: any; } export interface SyscallDbusGetPropertyParams { service: string; objectPath: string; interface: string; property: string; bus?: 'user' | 'system'; [key: string]: any; } +export interface SyscallDbusGetAllPropertiesParams { service: string; objectPath: string; interface: string; bus?: 'user' | 'system'; } +export interface SyscallDbusListNamesParams { bus?: 'user' | 'system'; } export interface SyscallLoadLocalFileParams { path: string; [key: string]: any; } +export interface SyscallResolveIconParams { name: string; size?: number; theme?: string; } export interface SyscallLaunchResult { ok: boolean; pid?: number; error?: string; } export interface SyscallExecResult { ok: boolean; pid?: number; error?: string; } export interface SyscallListDirResult { ok: boolean; path?: string; entries?: { name: string; type: 'file'|'dir' }[]; error?: string; } +export interface SyscallXdgDirectoriesResult { ok: boolean; directories?: XdgDirectories; error?: string; } export interface SyscallReadResult { ok: boolean; content?: string; size?: number; error?: string; } export interface SyscallWriteResult { ok: boolean; bytesWritten?: number; error?: string; } export interface SyscallShutdownResult { ok: boolean; error?: string; } @@ -107,6 +110,10 @@ export interface SyscallNotifyResult { ok: boolean; error?: string; } export interface SyscallPortalRespondResult { ok: boolean; error?: string; } export interface SyscallDbusCallResult { ok: boolean; stdout?: string; stderr?: string; exitCode?: number; error?: string; } export interface SyscallDbusGetPropertyResult { ok: boolean; stdout?: string; stderr?: string; exitCode?: number; error?: string; } +export interface SyscallDbusListNamesResult { ok: boolean; names?: string[]; stdout?: string; stderr?: string; exitCode?: number; error?: string; } +export interface SyscallDbusGetAllPropertiesResult { ok: boolean; properties?: Record; stdout?: string; stderr?: string; exitCode?: number; error?: string; } +export interface SyscallAudioCaptureParams { sourceId?: string; } +export interface SyscallAudioCaptureResult { ok: boolean; source?: WoAudioSource; error?: string; } export interface SyscallLoadLocalFileResult { ok: boolean; error?: string; } export interface PortalRequestEvent { @@ -115,6 +122,49 @@ export interface PortalRequestEvent { kind?: string; } +export type WoCapability = + | 'window-management' | 'overlay-rendering' | 'desktop-applications' | 'xdg' | 'dbus' + | 'portals' | 'filesystem' | 'process-launch' | 'system-control' | 'notifications' | 'audio-capture'; + +export interface WoPermissionState { + requested: readonly WoCapability[]; + granted: readonly WoCapability[]; + has(capability: WoCapability): boolean; +} + +export interface WoPerformanceStatus { + limited: boolean; + gpuCompositing: string; + webgl: string; + sharedDmabuf: boolean | null; + reasons: string[]; +} + +export interface WoGlobalAPI { + permissions: WoPermissionState; + getPerformanceStatus(): Promise; + onPerformanceStatus(callback: (status: WoPerformanceStatus) => void): Unsubscribe; + windows?: { + get(): Promise; + on(callback: WindowsCallback): Unsubscribe; + action: Compositor['action']; + onFocusChange: Compositor['onFocusChange']; + onMetadata: Compositor['onWindowMetadata']; + monitors: Compositor['onMonitors']; + }; + overlay?: Pick & { + mount(element: HTMLElement, monitorId: string): Unsubscribe; + }; + desktop?: WoDesktop; + xdg?: WoXdg; + icons?: WoIcons; + dbus?: WoDBus; + portals?: WoPortals; + audio?: WoAudio; + syscall?: Compositor['syscall']; + onNotification?: Compositor['onNotification']; +} + export interface ScreencopyEvent { active: boolean; clientCount: number; @@ -128,19 +178,113 @@ export interface ApplicationInfo { command: string; multi_instance?: boolean; icon?: ApplicationIcon; + desktopId?: string; + desktopFile?: string; + genericName?: string; + comment?: string; + categories?: string[]; + keywords?: string[]; + startupWMClass?: string; + terminal?: boolean; + startupNotify?: boolean; +} + +export interface XdgDirectories { + home: string; + configHome: string; + configDirs: string[]; + dataHome: string; + dataDirs: string[]; + cacheHome: string; + stateHome: string; + runtimeDir: string | null; + desktop: string; + documents: string; + downloads: string; + music: string; + pictures: string; + publicShare: string; + templates: string; + videos: string; +} + +export type DBusBus = 'user' | 'system'; +export interface DBusCallOptions { + service: string; + objectPath: string; + interface: string; + method: string; + signature?: string; + args?: Array; + bus?: DBusBus; +} +export interface DBusPropertyOptions { + service: string; + objectPath: string; + interface: string; + property: string; + bus?: DBusBus; +} +export interface DBusInterfaceOptions { + service: string; + objectPath: string; + interface: string; + bus?: DBusBus; +} +export interface DBusResult { + ok: boolean; + stdout?: string; + stderr?: string; + exitCode?: number; + error?: string; +} +export interface WoDBus { + call(options: DBusCallOptions): Promise; + getProperty(options: DBusPropertyOptions): Promise; + getAllProperties(options: DBusInterfaceOptions): Promise; + listNames(bus?: DBusBus): Promise; +} +export interface WoDesktop { + applications: { + list(options?: SyscallListApplicationsParams): Promise; + launch(desktopId: string, files?: string[]): Promise; + }; +} +export interface WoIcons { + resolve(name: string, options?: { size?: number; theme?: string }): Promise; +} +export interface WoXdg { + directories(): Promise; +} +export interface WoAudioSource { + id: string; + name: string; + description: string; + monitor: boolean; +} +export interface WoAudioChunk { + format: 's16le'; + sampleRate: 48000; + channels: 2; + data: Uint8Array; + timestamp: number; +} +export interface WoAudio { + sources(): Promise<{ ok: boolean; sources: WoAudioSource[]; error?: string }>; + startDesktopCapture(): Promise<{ ok: boolean; source?: WoAudioSource; error?: string }>; + startCapture(sourceId: string): Promise<{ ok: boolean; source?: WoAudioSource; error?: string }>; + stopCapture(): Promise<{ ok: boolean; error?: string }>; + onData(callback: (chunk: WoAudioChunk) => void): Unsubscribe; + onState(callback: (state: { active: boolean; error?: string }) => void): Unsubscribe; } /** * Icon data for applications - supports multiple formats */ export interface ApplicationIcon { - /** Type of icon: 'iconify', 'base64', 'url', or 'path' */ type: 'iconify' | 'base64' | 'url' | 'path'; - /** Icon data or reference */ data: string; - /** Optional MIME type (e.g., 'image/png', 'image/svg+xml') */ mimeType?: string; - /** Optional fallback icon if primary fails */ fallback?: ApplicationIcon; } @@ -168,6 +312,7 @@ export type Unsubscribe = () => void; * Wayland client interface for receiving window updates */ export interface WoClient { + permissions?: WoPermissionState; /** * Get the current window list once */ @@ -187,15 +332,47 @@ export interface WoClient { * Send a keybind/management action to the compositor */ sendAction(action: string): void; + + portals: WoPortals; + dbus: WoDBus; + desktop: WoDesktop; + icons: WoIcons; + xdg: WoXdg; + audio: WoAudio; +} + +export interface WoPortals { + onRequest(callback: (data: PortalRequestEvent) => void): Unsubscribe; + /** Open a native desktop context menu through the platform popup path. */ + contextMenu(options: WoContextMenuOptions): Promise<{ ok: boolean; selectedId?: string; error?: string }>; + respond(requestId: string, response: { + allowed: boolean; + type?: 'window' | 'monitor' | 'virtual'; + windowName?: string | null; + [key: string]: unknown; + }): Promise; +} + +export interface WoContextMenuOptions { + x?: number; + y?: number; + items: Array<{ + id?: string; + label?: string; + type?: 'normal' | 'separator' | 'checkbox' | 'radio'; + enabled?: boolean; + checked?: boolean; + accelerator?: string; + }>; } -/** Alias for WoClient to match preload.ts imports */ export type WoClientAPI = WoClient; /** * Compositor interface for controlling windows and the compositor */ export interface Compositor { + permissions?: WoPermissionState; /** * Send an action to modify a window */ @@ -210,6 +387,9 @@ export interface Compositor { * Execute a syscall (filesystem, process, etc.) with strong typings */ syscall(type: 'list_applications', params?: SyscallListApplicationsParams): Promise; + syscall(type: 'launch_desktop', params: SyscallLaunchDesktopParams): Promise; + syscall(type: 'xdg_directories', params?: Record): Promise; + syscall(type: 'resolve_icon', params: SyscallResolveIconParams): Promise; syscall(type: 'launch', params: SyscallLaunchParams): Promise; syscall(type: 'exec', params: SyscallExecParams): Promise; syscall(type: 'listdir', params?: SyscallListDirParams): Promise; @@ -224,9 +404,22 @@ export interface Compositor { syscall(type: 'portal_respond', params: SyscallPortalRespondParams): Promise; syscall(type: 'dbus_call', params: SyscallDbusCallParams): Promise; syscall(type: 'dbus_get_property', params: SyscallDbusGetPropertyParams): Promise; + syscall(type: 'dbus_get_all_properties', params: SyscallDbusGetAllPropertiesParams): Promise; + syscall(type: 'dbus_list_names', params?: SyscallDbusListNamesParams): Promise; + syscall(type: 'audio_sources', params?: Record): Promise<{ ok: boolean; sources: WoAudioSource[]; error?: string }>; + syscall(type: 'audio_capture_start', params?: SyscallAudioCaptureParams): Promise; + syscall(type: 'audio_capture_stop', params?: Record): Promise<{ ok: boolean; error?: string }>; syscall(type: 'load_local_file', params: SyscallLoadLocalFileParams): Promise; syscall(type: string, params?: any): Promise; + /** XDG desktop entries, D-Bus and icon helpers. */ + dbus: WoDBus; + portals: WoPortals; + desktop: WoDesktop; + icons: WoIcons; + xdg: WoXdg; + audio: WoAudio; + /** * Subscribe to focus changes */ @@ -236,6 +429,7 @@ export interface Compositor { * Subscribe to raw window metadata broadcasts from the compositor */ onWindowMetadata(callback: (metadata: WindowMetadataEvent) => void): Unsubscribe; + onMonitors(callback: (monitors: WoMonitor[]) => void): Unsubscribe; /** * Create a damage helper for optimized rendering @@ -248,6 +442,11 @@ export interface Compositor { */ forwardKeyboard(windowName: string, evdevKey: number, pressed: boolean, time: number): void; + forwardPointerMotion(windowName: string, x: number, y: number): void; + forwardPointerButton(windowName: string, x: number, y: number, button: number, pressed: boolean, time: number): void; + forwardPointerScroll(windowName: string, x: number, y: number, dx: number, dy: number, time: number): void; + forwardPointerLeave(windowName: string): void; + /** * Quit the compositor */ @@ -321,7 +520,6 @@ export interface Compositor { onSurfaceUpdate(callback: (data: SurfaceUpdateData) => void): Unsubscribe; } -/** Alias for Compositor to match preload.ts imports */ export type CompositorAPI = Compositor; /** @@ -375,6 +573,7 @@ export interface NotificationData { declare global { window.woClient = {} as WoClient; window.compositor = {} as Compositor; + window.wo = {} as WoGlobalAPI; } /** diff --git a/justfile b/justfile new file mode 100644 index 0000000..3306d3c --- /dev/null +++ b/justfile @@ -0,0 +1,127 @@ +set shell := ["bash", "-eu", "-o", "pipefail", "-c"] + +# List available build and development recipes. +default: + @just --list + +# Build the Rust executables in release mode. +build-rust: + cargo build --release --locked --bins + +# Build the Electron application and its native addon. +build-electron: + cd electron && bun install --frozen-lockfile --ignore-scripts && bun run build && bun run build-native + cd electron && if [ ! -x node_modules/electron/dist/electron ] && ! command -v electron >/dev/null 2>&1; then node node_modules/electron/install.js; fi + cd electron && if [ ! -x node_modules/electron/dist/electron ] && ! command -v electron >/dev/null 2>&1; then echo "Electron runtime download failed; install Electron or retry with network access" >&2; exit 1; fi + +# Install the local Wo types package before building comraw. +build-types: + cd electron/types && bun install --frozen-lockfile + +# Build the compositor UI using the types shipped with this checkout. +build-comraw: + cd comraw && bun install --frozen-lockfile + mkdir -p comraw/node_modules/@wl-wo + rm -rf comraw/node_modules/@wl-wo/wo-types + cp -a electron/types comraw/node_modules/@wl-wo/wo-types + cd comraw && bun run build + +# Build every runtime component in dependency order. +build: build-rust build-electron build-types build-comraw + +# Build and install Wo for the current user under ~/.local and XDG directories. +# An existing user config is kept; a first install gets a config.toml derived +# from config.example.toml and serves the packaged comraw build on localhost. +install: build + #!/usr/bin/env bash + set -eu + repo_root="$(pwd)" + : "${HOME:?HOME must be set for a per-user install}" + xdg_data_home="${XDG_DATA_HOME:-$HOME/.local/share}" + data_home="$xdg_data_home/wo" + config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/wo" + user_bin="$HOME/.local/bin" + electron_runtime="$data_home/electron-runtime" + electron_app="$data_home/electron" + comraw_dir="$data_home/comraw" + electron_source="$repo_root/electron/node_modules/electron/dist/electron" + install_tag="$(date +%Y%m%d%H%M%S).$$" + electron_runtime_stage="$data_home/.electron-runtime.new.$install_tag" + electron_app_stage="$data_home/.electron.new.$install_tag" + comraw_stage="$data_home/.comraw.new.$install_tag" + trap 'rm -rf "$electron_runtime_stage" "$electron_app_stage" "$comraw_stage"' EXIT + + install -d "$data_home/bin" "$config_dir" "$user_bin" + install -m755 "$repo_root/target/release/wo" "$data_home/bin/wo" + install -m755 "$repo_root/target/release/wo-portal" "$data_home/bin/wo-portal" + install -m755 "$repo_root/target/release/xdg-desktop-portal-wo" "$data_home/bin/xdg-desktop-portal-wo" + if [ -x "$electron_source" ]; then + install -d "$electron_runtime_stage" + cp -a "$repo_root/electron/node_modules/electron/dist/." "$electron_runtime_stage/" + test -x "$electron_runtime_stage/electron" || { echo "Electron runtime copy is incomplete" >&2; exit 1; } + electron_bin="$electron_runtime/electron" + elif command -v electron >/dev/null 2>&1; then + electron_bin="$(command -v electron)" + else + echo "Electron runtime is missing. Install Electron or run: cd electron && node node_modules/electron/install.js" >&2 + exit 1 + fi + install -d "$electron_app_stage/dist" "$electron_app_stage/native" "$comraw_stage" + cp -a "$repo_root/electron/dist/." "$electron_app_stage/dist/" + cp -a "$repo_root/electron/native/." "$electron_app_stage/native/" + cp -a "$repo_root/comraw/dist/." "$comraw_stage/" + + replace_installed_dir() { + stage="$1" + destination="$2" + if [ -e "$destination" ] || [ -L "$destination" ]; then + # Keep the prior tree available to processes that were already running. + mv "$destination" "$destination.previous.$install_tag" + fi + mv "$stage" "$destination" + } + if [ -d "$electron_runtime_stage" ]; then + replace_installed_dir "$electron_runtime_stage" "$electron_runtime" + fi + replace_installed_dir "$electron_app_stage" "$electron_app" + replace_installed_dir "$comraw_stage" "$comraw_dir" + + cat > "$user_bin/wo" <&2 + exit 2 +fi + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +electron_source="$1" +cef_source="$2" + +git -C "$electron_source" apply --check "$repo_root/packaging/engines/wo-electron-v45.patch" +git -C "$cef_source" apply --check "$repo_root/packaging/engines/wo-cef.patch" +git -C "$electron_source" apply "$repo_root/packaging/engines/wo-electron-v45.patch" +git -C "$cef_source" apply "$repo_root/packaging/engines/wo-cef.patch" diff --git a/packaging/engines/cef-wo.gn b/packaging/engines/cef-wo.gn new file mode 100644 index 0000000..f2c82c2 --- /dev/null +++ b/packaging/engines/cef-wo.gn @@ -0,0 +1,10 @@ +is_debug = false +is_official_build = true +is_component_build = false +symbol_level = 0 +blink_symbol_level = 0 +v8_symbol_level = 0 +enable_printing = false +enable_pdf = false +enable_pdf_viewer = false +enable_spellcheck = false diff --git a/packaging/engines/electron-wo.gn b/packaging/engines/electron-wo.gn new file mode 100644 index 0000000..aa51566 --- /dev/null +++ b/packaging/engines/electron-wo.gn @@ -0,0 +1,9 @@ +import("//electron/build/args/release.gn") + +enable_printing = false +enable_pdf = false +enable_pdf_viewer = false +enable_spellcheck = false +symbol_level = 0 +blink_symbol_level = 0 +v8_symbol_level = 0 diff --git a/packaging/engines/wo-cef.patch b/packaging/engines/wo-cef.patch new file mode 100644 index 0000000..7a50f7e --- /dev/null +++ b/packaging/engines/wo-cef.patch @@ -0,0 +1,55 @@ +diff --git a/include/cef_browser.h b/include/cef_browser.h +index 2d31eca..21f40eb 100644 +--- a/include/cef_browser.h ++++ b/include/cef_browser.h +@@ -814,8 +814,10 @@ class CefBrowserHost : public virtual CefBaseRefCounted { + /// Set the maximum rate in frames per second (fps) that CefRenderHandler:: + /// OnPaint will be called for a windowless browser. The actual fps may be + /// lower if the browser cannot generate frames at the requested rate. The +- /// minimum value is 1 and the default value is 30. Can also be set at browser +- /// creation via CefBrowserSettings.windowless_frame_rate. ++ /// minimum value is 1 and the default value is 30. There is no fixed upper ++ /// frame-rate cap; callers should provide the refresh rate of the display ++ /// containing the window and update it when that rate changes. Can also be ++ /// set at browser creation via CefBrowserSettings.windowless_frame_rate. + /// + /*--cef()--*/ + virtual void SetWindowlessFrameRate(int frame_rate) = 0; +diff --git a/include/internal/cef_types.h b/include/internal/cef_types.h +index aef516c..fe916b4 100644 +--- a/include/internal/cef_types.h ++++ b/include/internal/cef_types.h +@@ -605,8 +605,10 @@ typedef struct _cef_browser_settings_t { + /// The maximum rate in frames per second (fps) that CefRenderHandler::OnPaint + /// will be called for a windowless browser. The actual fps may be lower if + /// the browser cannot generate frames at the requested rate. The minimum +- /// value is 1 and the default value is 30. This value can also be changed +- /// dynamically via CefBrowserHost::SetWindowlessFrameRate. ++ /// value is 1 and the default value is 30. Set a per-window display refresh ++ /// rate and update it when that window moves to a display with a different ++ /// refresh rate. This value can also be changed dynamically via ++ /// CefBrowserHost::SetWindowlessFrameRate. + /// + int windowless_frame_rate; + +diff --git a/libcef/browser/osr/render_widget_host_view_osr.cc b/libcef/browser/osr/render_widget_host_view_osr.cc +index 8135ee0..4e334ed 100644 +--- a/libcef/browser/osr/render_widget_host_view_osr.cc ++++ b/libcef/browser/osr/render_widget_host_view_osr.cc +@@ -7,6 +7,7 @@ + + #include + ++#include + #include + #include + +@@ -1820,7 +1821,7 @@ void CefRenderWidgetHostViewOSR::SetFrameRate() { + int frame_rate = + osr_util::ClampFrameRate(browser->settings().windowless_frame_rate); + +- frame_rate_threshold_us_ = 1000000 / frame_rate; ++ frame_rate_threshold_us_ = std::max(1, 1000000 / frame_rate); + + if (compositor_) { + compositor_->SetDisplayVSyncParameters( diff --git a/packaging/engines/wo-electron-v45.patch b/packaging/engines/wo-electron-v45.patch new file mode 100644 index 0000000..625b616 --- /dev/null +++ b/packaging/engines/wo-electron-v45.patch @@ -0,0 +1,30 @@ +diff --git a/docs/api/web-contents.md b/docs/api/web-contents.md +index 935ded4..7b68e69 100644 +--- a/docs/api/web-contents.md ++++ b/docs/api/web-contents.md +@@ -2263,7 +2263,10 @@ Returns `boolean` - If _offscreen rendering_ is enabled returns whether it is cu + * `fps` Integer + + If _offscreen rendering_ is enabled sets the frame rate to the specified number. +-When `webPreferences.offscreen.useSharedTexture` is `false` only values between 1 and 240 are accepted. ++The requested rate is not globally capped. Applications should set it to the ++refresh rate of the display containing the window and update it when that ++display's refresh rate changes. The actual rate may be lower when the renderer ++cannot produce frames quickly enough. + + #### `contents.getFrameRate()` + +diff --git a/shell/browser/osr/osr_render_widget_host_view.cc b/shell/browser/osr/osr_render_widget_host_view.cc +index dff1fcc..6536102 100644 +--- a/shell/browser/osr/osr_render_widget_host_view.cc ++++ b/shell/browser/osr/osr_render_widget_host_view.cc +@@ -899,9 +899,6 @@ void OffScreenRenderWidgetHostView::SetFrameRate(int frame_rate) { + } else { + if (frame_rate <= 0) + frame_rate = 1; +- if (!offscreen_use_shared_texture_ && frame_rate > 240) +- frame_rate = 240; +- + frame_rate_ = frame_rate; + } + diff --git a/packaging/ubuntu/README.md b/packaging/ubuntu/README.md new file mode 100644 index 0000000..2cc5ad3 --- /dev/null +++ b/packaging/ubuntu/README.md @@ -0,0 +1,22 @@ +# Ubuntu package + +Build an architecture-native `.deb` from the checkout with: + +```sh +packaging/ubuntu/build-deb.sh +``` + +The script builds the Wo compositor and portal binaries, builds the Electron +application and its DMA-BUF addon, builds `comraw`, and stages the Electron +runtime with the package. It writes the package to `target/ubuntu-package/`. +Set `WO_PACKAGE_VERSION` to override the version or `WO_DEB_BUILD_ROOT` to +choose another output directory. + +Build dependencies are Rust/Cargo, Bun, a C/C++ toolchain, `dpkg-dev`, and the +development headers for libinput, libudev, libseat, libdrm, GBM, EGL, Wayland, +PipeWire, and XKBCommon. The build also needs network access for locked Bun +dependencies and Electron's Linux runtime archive. + +Install the result with `sudo apt install ./target/ubuntu-package/wo_*.deb`. +The package installs the Wo Wayland session, its xdg-desktop-portal +implementation, and a launcher that selects the bundled Electron runtime. diff --git a/packaging/ubuntu/build-deb.sh b/packaging/ubuntu/build-deb.sh new file mode 100755 index 0000000..d067856 --- /dev/null +++ b/packaging/ubuntu/build-deb.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +package_version="${WO_PACKAGE_VERSION:-$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$repo_root/Cargo.toml" | head -n1)}" +package_arch="$(dpkg --print-architecture)" +build_root="${WO_DEB_BUILD_ROOT:-$repo_root/target/ubuntu-package}" +stage="$build_root/root" +deb="$build_root/wo_${package_version}_${package_arch}.deb" + +for tool in cargo bun dpkg dpkg-deb; do + command -v "$tool" >/dev/null 2>&1 || { + echo "Missing build tool: $tool" >&2 + exit 1 + } +done + +if [[ ! -d "$repo_root/comraw" ]]; then + echo "Missing comraw source tree at $repo_root/comraw" >&2 + exit 1 +fi + +echo "Building Wo release binaries" +(cd "$repo_root" && cargo build --release --locked --bins) + +echo "Building Electron app and native DMA-BUF addon" +(cd "$repo_root/electron" && bun install --frozen-lockfile && bun run build && bun run build-native) + +echo "Building comraw" +(cd "$repo_root/comraw" && bun install --frozen-lockfile) +rm -rf "$repo_root/comraw/node_modules/@wl-wo/wo-types" +mkdir -p "$repo_root/comraw/node_modules/@wl-wo" +cp -a "$repo_root/electron/types" "$repo_root/comraw/node_modules/@wl-wo/wo-types" +(cd "$repo_root/comraw" && bun run build) + +rm -rf "$stage" +install -d "$stage/DEBIAN" "$stage/usr/bin" "$stage/usr/lib/wo/bin" \ + "$stage/usr/lib/wo/electron" "$stage/usr/lib/wo/electron-runtime" \ + "$stage/usr/lib/wo/comraw" "$stage/usr/share/wayland-sessions" \ + "$stage/usr/share/xdg-desktop-portal/portals" "$stage/usr/share/dbus-1/services" \ + "$stage/usr/share/doc/wo" + +install -m755 "$repo_root/target/release/wo" "$stage/usr/lib/wo/bin/wo" +install -m755 "$repo_root/target/release/wo-portal" "$stage/usr/bin/wo-portal" +install -m755 "$repo_root/target/release/xdg-desktop-portal-wo" "$stage/usr/bin/xdg-desktop-portal-wo" + +install -m755 "$repo_root/electron/node_modules/electron/dist/electron" \ + "$stage/usr/lib/wo/electron-runtime/electron" +cp -a "$repo_root/electron/node_modules/electron/dist/." "$stage/usr/lib/wo/electron-runtime/" +cp -a "$repo_root/electron/dist" "$stage/usr/lib/wo/electron/" +cp -a "$repo_root/electron/native" "$stage/usr/lib/wo/electron/" +cp -a "$repo_root/comraw/dist" "$stage/usr/lib/wo/comraw/" + +cat > "$stage/usr/bin/wo" <<'EOF' +#!/bin/sh +export WO_DATA_DIR=/usr/lib/wo +export WO_ELECTRON_PATH=/usr/lib/wo/electron-runtime/electron +export WO_ELECTRON_APP_DIR=/usr/lib/wo/electron +exec /usr/lib/wo/bin/wo "$@" +EOF +chmod 755 "$stage/usr/bin/wo" + +install -m644 "$repo_root/packaging/wayland-sessions/wo.desktop" \ + "$stage/usr/share/wayland-sessions/wo.desktop" +install -m644 "$repo_root/packaging/portals/wo.portal" \ + "$stage/usr/share/xdg-desktop-portal/portals/wo.portal" +install -m644 "$repo_root/packaging/dbus-services/org.freedesktop.impl.portal.desktop.wo.service" \ + "$stage/usr/share/dbus-1/services/org.freedesktop.impl.portal.desktop.wo.service" +install -m644 "$repo_root/config.example.toml" "$stage/usr/share/doc/wo/config.example.toml" + +sed -e "s/@VERSION@/$package_version/" -e "s/@ARCH@/$package_arch/" \ + "$repo_root/packaging/ubuntu/control.in" > "$stage/DEBIAN/control" +dpkg-deb --build --root-owner-group "$stage" "$deb" +echo "Built $deb" diff --git a/packaging/ubuntu/control.in b/packaging/ubuntu/control.in new file mode 100644 index 0000000..47bfdbc --- /dev/null +++ b/packaging/ubuntu/control.in @@ -0,0 +1,16 @@ +Package: wo +Version: @VERSION@ +Section: x11 +Priority: optional +Architecture: @ARCH@ +Maintainer: Stephen E. Hellings +Description: Electron based Wayland compositor + Wo is a Wayland compositor that hosts Electron surfaces and provides a + desktop portal implementation for screen sharing. +Depends: libc6, libstdc++6, libgcc-s1, libdrm2, libinput10, libseat1, + libxkbcommon0, libgbm1, libegl1, libwayland-client0, libwayland-server0, + libpipewire-0.3-0 | libpipewire-0.3-0t64, libnss3, + libgtk-3-0 | libgtk-3-0t64, libatk1.0-0 | libatk1.0-0t64, + libatk-bridge2.0-0 | libatk-bridge-2.0-0t64, + libx11-xcb1, libxcb-dri3-0, libxss1, libasound2 | libasound2t64, + xdg-desktop-portal, dbus, xwayland, python3 diff --git a/src/backend.rs b/src/backend.rs index b667445..67dd70f 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -3,9 +3,9 @@ //! This module provides a trait-based abstraction for rendering that works //! with both Nested (GLES via winit) and DRM/KMS backends. +use crate::dmabuf::ElectronTexture; use anyhow::Result; use smithay::utils::{Physical, Rectangle}; -use crate::dmabuf::ElectronTexture; /// Common interface for rendering frames across different backends #[allow(dead_code)] diff --git a/src/bin/wo-portal.rs b/src/bin/wo-portal.rs index 9e97816..6955a83 100644 --- a/src/bin/wo-portal.rs +++ b/src/bin/wo-portal.rs @@ -1,9 +1,3 @@ -/// xdg-desktop-portal-wo - Standalone portal service for Wo compositor -/// -/// This is a standalone D-Bus service that implements the xdg-desktop-portal -/// interfaces for screen capture. Applications can use this to request screen -/// or window capture without direct compositor integration. - use anyhow::Result; use std::io::{Read, Write}; use std::os::unix::net::UnixStream; @@ -11,17 +5,19 @@ use std::sync::Arc; use std::sync::Mutex; use std::time::Duration; use tracing::{info, warn}; -use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; -use zbus::{Connection, interface, fdo}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; +use zbus::{fdo, interface, Connection}; -use wo::state::portal::{WoPortal, CaptureRequest, SourceType}; +use wo::state::portal::{CaptureRequest, SourceType, WoPortal}; fn request_screen_share_consent( app_id: &str, session_id: &str, requested_source: SourceType, ) -> Result)>> { - let mut stream = UnixStream::connect("/tmp/wo-portal-ui.sock")?; + let runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); + let mut stream = + UnixStream::connect(std::path::Path::new(&runtime_dir).join("wo-portal-ui.sock"))?; stream.set_read_timeout(Some(Duration::from_secs(95)))?; let requested = match requested_source { @@ -49,12 +45,19 @@ fn request_screen_share_consent( } let json: serde_json::Value = serde_json::from_str(response.trim())?; - let allowed = json.get("allowed").and_then(|v| v.as_bool()).unwrap_or(false); + let allowed = json + .get("allowed") + .and_then(|v| v.as_bool()) + .unwrap_or(false); if !allowed { return Ok(None); } - let source = match json.get("sourceType").and_then(|v| v.as_str()).unwrap_or("Monitor") { + let source = match json + .get("sourceType") + .and_then(|v| v.as_str()) + .unwrap_or("Monitor") + { "Window" => SourceType::Window, "Virtual" => SourceType::Virtual, _ => SourceType::Monitor, @@ -79,12 +82,15 @@ impl ScreenCastImpl { async fn create_session( &self, #[zbus(header)] _hdr: zbus::message::Header<'_>, - _handle: zbus::zvariant::ObjectPath<'_>, - session_handle: zbus::zvariant::ObjectPath<'_>, + _handle: zbus::ObjectPath<'_>, + session_handle: zbus::ObjectPath<'_>, app_id: &str, - _options: std::collections::HashMap>, - ) -> fdo::Result<(u32, std::collections::HashMap)> { - info!("CreateSession called: app_id={}, session={:?}", app_id, session_handle); + _options: std::collections::HashMap>, + ) -> fdo::Result<(u32, std::collections::HashMap)> { + info!( + "CreateSession called: app_id={}, session={:?}", + app_id, session_handle + ); let internal_session_id = self.portal.create_session(); let external_session_id = session_handle @@ -98,9 +104,9 @@ impl ScreenCastImpl { .unwrap() .insert(external_session_id, internal_session_id); - let response: std::collections::HashMap = + let response: std::collections::HashMap = std::collections::HashMap::new(); - + Ok((0, response)) // 0 = success } @@ -108,13 +114,13 @@ impl ScreenCastImpl { async fn select_sources( &self, #[zbus(header)] _hdr: zbus::message::Header<'_>, - _handle: zbus::zvariant::ObjectPath<'_>, - session_handle: zbus::zvariant::ObjectPath<'_>, + _handle: zbus::ObjectPath<'_>, + session_handle: zbus::ObjectPath<'_>, app_id: &str, - options: std::collections::HashMap>, - ) -> fdo::Result<(u32, std::collections::HashMap)> { + options: std::collections::HashMap>, + ) -> fdo::Result<(u32, std::collections::HashMap)> { info!("SelectSources called for session: {:?}", session_handle); - + // Extract source type from options let source_type = if let Some(types) = options.get("types") { if let Ok(type_val) = types.downcast_ref::() { @@ -154,8 +160,11 @@ impl ScreenCastImpl { }; let Some((selected_source, selected_target)) = consent else { - info!("Screen share request denied/cancelled for session: {}", session_id); - let response: std::collections::HashMap = + info!( + "Screen share request denied/cancelled for session: {}", + session_id + ); + let response: std::collections::HashMap = std::collections::HashMap::new(); return Ok((1, response)); }; @@ -170,12 +179,15 @@ impl ScreenCastImpl { }; if let Err(e) = self.portal.select_sources(&session_id, request) { - return Err(fdo::Error::Failed(format!("Failed to select sources: {}", e))); + return Err(fdo::Error::Failed(format!( + "Failed to select sources: {}", + e + ))); } - let response: std::collections::HashMap = + let response: std::collections::HashMap = std::collections::HashMap::new(); - + Ok((0, response)) } @@ -183,12 +195,12 @@ impl ScreenCastImpl { async fn start( &self, #[zbus(header)] _hdr: zbus::message::Header<'_>, - _handle: zbus::zvariant::ObjectPath<'_>, - session_handle: zbus::zvariant::ObjectPath<'_>, + _handle: zbus::ObjectPath<'_>, + session_handle: zbus::ObjectPath<'_>, _app_id: &str, _parent_window: &str, - _options: std::collections::HashMap>, - ) -> fdo::Result<(u32, std::collections::HashMap)> { + _options: std::collections::HashMap>, + ) -> fdo::Result<(u32, std::collections::HashMap)> { info!("Start called for session: {:?}", session_handle); let external_session_id = session_handle @@ -210,40 +222,59 @@ impl ScreenCastImpl { match self.portal.start_session(&session_id, width, height) { Ok(capture_response) => { - use zbus::zvariant::OwnedValue; - - let mut response: std::collections::HashMap = + use zbus::{OwnedVariant, Variant}; + + let mut response: std::collections::HashMap = std::collections::HashMap::new(); - - - // Log stream activation and frame queueing begins + let mut streams = Vec::with_capacity(capture_response.streams.len()); + for stream in &capture_response.streams { let src_type = match stream.source_type { wo::state::portal::SourceType::Monitor => "Monitor", wo::state::portal::SourceType::Window => "Window", wo::state::portal::SourceType::Virtual => "Virtual", }; - info!("Activated stream {}: {} [{}x{}] with frame queueing", - stream.node_id, src_type, stream.width, stream.height); + info!( + "Activated stream {}: {} [{}x{}] with frame queueing", + stream.node_id, src_type, stream.width, stream.height + ); + + let source_type = match stream.source_type { + wo::state::portal::SourceType::Monitor => 1u32, + wo::state::portal::SourceType::Window => 2u32, + wo::state::portal::SourceType::Virtual => 4u32, + }; + let mut properties = std::collections::HashMap::new(); + properties.insert( + "size".to_string(), + OwnedVariant::try_from(Variant::new(( + stream.width as i32, + stream.height as i32, + ))) + .map_err(|error| fdo::Error::Failed(error.to_string()))?, + ); + properties.insert("source_type".to_string(), OwnedVariant::from(source_type)); + if stream.source_type == wo::state::portal::SourceType::Monitor { + properties.insert( + "position".to_string(), + OwnedVariant::try_from(Variant::new((0i32, 0i32))) + .map_err(|error| fdo::Error::Failed(error.to_string()))?, + ); + } + streams.push((stream.node_id, properties)); } - - // Return success status - actual streaming happens via frame queueing - let stream_count = capture_response.streams.len() as i32; - response.insert("status".to_string(), OwnedValue::from(0i32)); - response.insert("stream_count".to_string(), OwnedValue::from(stream_count)); - if let Some(first) = capture_response.streams.first() { - response.insert("primary_node_id".to_string(), OwnedValue::from(first.node_id as i32)); - } - - for stream in &capture_response.streams { - info!(" Frame queue for stream {}: 0/{} frames queued", stream.node_id, 30); - } - + + response.insert( + "streams".to_string(), + OwnedVariant::try_from(Variant::new(streams)) + .map_err(|error| fdo::Error::Failed(error.to_string()))?, + ); Ok((0, response)) } - Err(e) => { - Err(fdo::Error::Failed(format!("Failed to start session: {}", e))) - } + Err(e) => Err(fdo::Error::Failed(format!( + "Failed to start session: {}", + e + ))), } } } @@ -257,17 +288,10 @@ async fn main() -> Result<()> { info!("xdg-desktop-portal-wo starting"); - // Load config to get compositor IPC socket path - let config = wo::config::Config::load().unwrap_or_else(|_| { - info!("Using default config"); - wo::config::Config::default() - }); + // Wo streams the compositor's rendered frames directly into PipeWire. + // The binary compositor IPC socket is not a JSON control channel. + let portal = Arc::new(WoPortal::new()); - // Create the portal backend with compositor socket - let portal = Arc::new( - WoPortal::new().with_compositor_socket(config.compositor.ipc_socket.clone()) - ); - // Connect to the session bus let connection = Connection::session().await?; @@ -289,7 +313,6 @@ async fn main() -> Result<()> { .await?; info!("Portal service registered as org.freedesktop.impl.portal.desktop.wo"); - info!("Compositor IPC socket: {}", config.compositor.ipc_socket); info!("Listening for screen capture requests..."); // Keep the service running diff --git a/src/config.rs b/src/config.rs index 67559cc..a0f92e0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,7 @@ +use anyhow::{Context, Result}; use serde::Deserialize; use std::collections::HashMap; use std::path::PathBuf; -use anyhow::{Context, Result}; /// Compositor configuration #[derive(Debug, Clone, Deserialize)] @@ -83,7 +83,7 @@ pub struct CompositorConfig { #[serde(default)] pub applications: Vec, - /// XCursor theme name (default: "breeze_cursors") + /// XCursor theme name (defaults to XCURSOR_THEME, then Adwaita) #[serde(default = "default_cursor_theme")] pub cursor_theme: String, @@ -92,10 +92,9 @@ pub struct CompositorConfig { pub cursor_size: u32, /// Extra environment variables to set before spawning child processes. - /// Defaults include DISPLAY=":0" so X11 applications (e.g. Steam) can - /// fall back to a sensible value when XWayland hasn't started yet. - /// XWayland will override DISPLAY with the actual value once ready. - #[serde(default = "default_environment")] + /// DISPLAY is deliberately not guessed: Wo publishes the actual XWayland + /// display after its server is ready. + #[serde(default)] pub environment: HashMap, } @@ -118,7 +117,7 @@ impl Default for CompositorConfig { applications: vec![], cursor_theme: default_cursor_theme(), cursor_size: default_cursor_size(), - environment: default_environment(), + environment: HashMap::new(), } } } @@ -126,11 +125,15 @@ impl Default for CompositorConfig { #[derive(Debug, Clone, Deserialize, serde::Serialize)] pub struct WindowConfig { pub name: String, + /// Set internally for compositor-owned root surfaces. Never read from + /// user configuration or serialized to the Electron window payload. + #[serde(skip)] + pub system_shell: bool, pub url: Option, pub html: Option, pub css: Option, pub js: Option, - + #[serde(default = "default_width")] pub width: u32, #[serde(default = "default_height")] @@ -264,9 +267,15 @@ impl ApplicationConfig { // Defaults -fn default_socket_name() -> String { "wo-0".into() } -fn default_electron_path() -> String { "electron".into() } -fn default_background() -> [f32; 4] { [0.1, 0.1, 0.1, 1.0] } +fn default_socket_name() -> String { + "wo-0".into() +} +fn default_electron_path() -> String { + std::env::var("WO_ELECTRON_PATH").unwrap_or_else(|_| "electron".into()) +} +fn default_background() -> [f32; 4] { + [0.1, 0.1, 0.1, 1.0] +} /// Returns $XDG_RUNTIME_DIR, falling back to /run/user/. fn runtime_dir() -> String { @@ -274,23 +283,41 @@ fn runtime_dir() -> String { .unwrap_or_else(|_| format!("/run/user/{}", unsafe { libc::getuid() })) } -fn default_ipc_socket() -> String { format!("{}/wo-ipc.sock", runtime_dir()) } -fn default_portal_socket() -> String { format!("{}/wo-portal.sock", runtime_dir()) } -fn default_width() -> u32 { 1920 } -fn default_height() -> u32 { 1080 } -fn default_nested_width() -> u32 { 1920 } -fn default_nested_height() -> u32 { 1080 } -fn default_fps() -> u32 { 60 } -fn default_format() -> String { "ARGB8888".into() } -fn default_asset_prefix() -> String { "file".into() } -fn default_true() -> bool { true } -fn default_cursor_theme() -> String { "breeze_cursors".into() } -fn default_cursor_size() -> u32 { 24 } - -fn default_environment() -> HashMap { - let mut env = HashMap::new(); - env.insert("DISPLAY".into(), ":0".into()); - env +fn default_ipc_socket() -> String { + format!("{}/wo-ipc.sock", runtime_dir()) +} +fn default_portal_socket() -> String { + format!("{}/wo-portal.sock", runtime_dir()) +} +fn default_width() -> u32 { + 1920 +} +fn default_height() -> u32 { + 1080 +} +fn default_nested_width() -> u32 { + 1920 +} +fn default_nested_height() -> u32 { + 1080 +} +fn default_fps() -> u32 { + 0 +} +fn default_format() -> String { + "ARGB8888".into() +} +fn default_asset_prefix() -> String { + "file".into() +} +fn default_true() -> bool { + true +} +fn default_cursor_theme() -> String { + std::env::var("XCURSOR_THEME").unwrap_or_else(|_| "Adwaita".into()) +} +fn default_cursor_size() -> u32 { + 24 } fn expand_path(s: &str) -> String { @@ -305,11 +332,13 @@ impl Config { // 1. WO_CONFIG environment variable // 2. ./config.toml (current directory) // 3. ~/.config/wo/config.toml (user config) - + // 4. Installed package defaults (Ubuntu/Debian) + let paths = vec![ std::env::var("WO_CONFIG").ok().map(PathBuf::from), Some(PathBuf::from("./config.toml")), Some(config_path()), + Some(PathBuf::from("/usr/share/doc/wo/config.example.toml")), ]; for path_opt in paths.into_iter().flatten() { @@ -320,10 +349,8 @@ impl Config { let mut config: Self = toml::from_str(&text) .with_context(|| format!("parsing config {path_opt:?}"))?; - config.compositor.ipc_socket = - expand_path(&config.compositor.ipc_socket); - config.compositor.portal_socket = - expand_path(&config.compositor.portal_socket); + config.compositor.ipc_socket = expand_path(&config.compositor.ipc_socket); + config.compositor.portal_socket = expand_path(&config.compositor.portal_socket); return Ok(config); } } @@ -337,16 +364,15 @@ impl Config { } /// Apply configured environment variables to the current process so all - /// subsequently-spawned children inherit them. Existing env vars are - /// **not** overwritten so that, e.g., a DISPLAY already set by the - /// parent session or XWayland takes priority. + /// subsequently-spawned children inherit them. Existing env vars are + /// preserved, including the active parent-session DISPLAY. pub fn apply_environment(&self) { for (key, value) in &self.compositor.environment { if std::env::var(key).is_err() { - tracing::info!("Setting environment {key}={value} (from config default)"); + tracing::info!("Setting environment {key}={value} (from config)"); std::env::set_var(key, value); } else { - tracing::debug!("Environment {key} already set, skipping config default"); + tracing::debug!("Environment {key} already set, preserving it"); } } } @@ -357,6 +383,9 @@ fn config_path() -> PathBuf { } fn dirs_config() -> PathBuf { + if let Some(config_home) = std::env::var_os("XDG_CONFIG_HOME") { + return PathBuf::from(config_home).join("wo"); + } let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into()); PathBuf::from(home).join(".config/wo") } @@ -372,3 +401,65 @@ impl Default for Config { } } } + +#[cfg(test)] +mod tests { + use super::Config; + + #[test] + fn empty_config_uses_headless_safe_defaults() { + let config: Config = toml::from_str("").expect("empty config should deserialize"); + + assert!(config.windows.is_empty()); + assert!(config.root.is_empty()); + assert!(!config.compositor.enable_portal); + assert_eq!(config.compositor.nested_width, 1920); + assert_eq!(config.compositor.nested_height, 1080); + assert_eq!(config.compositor.socket_name, "wo-0"); + } + + #[test] + fn window_defaults_and_explicit_portal_options_deserialize() { + let config: Config = toml::from_str( + r#" + [compositor] + enable_portal = true + nested = true + nested_width = 1280 + nested_height = 720 + + [[windows]] + name = "overlay" + html = "
ready
" + width = 640 + height = 360 + "#, + ) + .expect("valid headless config should deserialize"); + + assert!(config.compositor.enable_portal); + assert_eq!(config.compositor.nested, Some(true)); + assert_eq!(config.compositor.nested_width, 1280); + assert_eq!(config.compositor.nested_height, 720); + assert_eq!(config.windows.len(), 1); + let window = &config.windows[0]; + assert_eq!(window.name, "overlay"); + assert_eq!(window.width, 640); + assert_eq!(window.height, 360); + assert!(window.interactive); + assert!(!window.focusable); + } + + #[test] + fn invalid_window_dimensions_are_rejected() { + let error = toml::from_str::( + r#" + [[windows]] + name = "invalid" + width = "wide" + "#, + ); + + assert!(error.is_err()); + } +} diff --git a/src/cursor.rs b/src/cursor.rs index 144b601..8d10ced 100644 --- a/src/cursor.rs +++ b/src/cursor.rs @@ -4,12 +4,12 @@ //! GLES textures on first use, caching the result for subsequent frames. use smithay::backend::allocator::Fourcc; +use smithay::backend::renderer::ImportMem; use smithay::backend::renderer::gles::GlesRenderer; use smithay::backend::renderer::gles::GlesTexture; -use smithay::backend::renderer::ImportMem; use smithay::utils::Size; use std::collections::HashMap; -use tracing::{info, warn}; +use tracing::info; /// Cached cursor texture with its hotspot offset. #[derive(Clone)] @@ -23,7 +23,7 @@ pub struct CursorImage { /// Manages cursor theme loading and GL texture caching. pub struct CursorThemeManager { - theme: xcursor::CursorTheme, + themes: Vec, size: u32, cache: HashMap>, } @@ -31,13 +31,24 @@ pub struct CursorThemeManager { impl CursorThemeManager { /// Load an XCursor theme by name with the given nominal cursor size. pub fn new(theme_name: &str, size: u32) -> Self { - info!( - "Loading cursor theme '{}' (size {})", - theme_name, size - ); - let theme = xcursor::CursorTheme::load(theme_name); + let mut names = vec![theme_name.to_owned()]; + for name in ["default", "Adwaita", "DMZ-White", "breeze"] { + if !names + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(name)) + { + names.push(name.to_owned()); + } + } + let themes = names + .iter() + .map(|name| { + info!("Loading cursor theme '{}' (size {})", name, size); + xcursor::CursorTheme::load(name) + }) + .collect(); Self { - theme, + themes, size, cache: HashMap::new(), } @@ -47,11 +58,7 @@ impl CursorThemeManager { /// /// Returns `None` if the icon cannot be found in the theme or the texture /// import fails. - pub fn get_cursor( - &mut self, - name: &str, - renderer: &mut GlesRenderer, - ) -> Option<&CursorImage> { + pub fn get_cursor(&mut self, name: &str, renderer: &mut GlesRenderer) -> Option<&CursorImage> { if !self.cache.contains_key(name) { let img = self.load_cursor(name, renderer); self.cache.insert(name.to_string(), img); @@ -59,60 +66,78 @@ impl CursorThemeManager { self.cache.get(name).and_then(|opt| opt.as_ref()) } - fn load_cursor( - &self, - name: &str, - renderer: &mut GlesRenderer, - ) -> Option { - let icon_path = self.theme.load_icon(name).or_else(|| { - // Try common XCursor aliases - let fallback = match name { - "default" => Some("left_ptr"), - "pointer" => Some("hand2"), - "text" => Some("xterm"), - "wait" => Some("watch"), - "progress" => Some("left_ptr_watch"), - "help" => Some("question_arrow"), - "crosshair" => Some("cross"), - "move" => Some("fleur"), - "not-allowed" => Some("crossed_circle"), - "no-drop" => Some("crossed_circle"), - "grab" => Some("openhand"), - "grabbing" => Some("closedhand"), - "e-resize" => Some("right_side"), - "w-resize" => Some("left_side"), - "n-resize" => Some("top_side"), - "s-resize" => Some("bottom_side"), - "ne-resize" => Some("top_right_corner"), - "nw-resize" => Some("top_left_corner"), - "se-resize" => Some("bottom_right_corner"), - "sw-resize" => Some("bottom_left_corner"), - "ew-resize" => Some("sb_h_double_arrow"), - "ns-resize" => Some("sb_v_double_arrow"), - "nesw-resize" => Some("fd_double_arrow"), - "nwse-resize" => Some("bd_double_arrow"), - "col-resize" => Some("sb_h_double_arrow"), - "row-resize" => Some("sb_v_double_arrow"), - "all-resize" | "all-scroll" => Some("fleur"), - "context-menu" => Some("left_ptr"), - "vertical-text" => Some("xterm"), - "alias" => Some("link"), - "copy" => Some("copy"), - "cell" => Some("plus"), - "zoom-in" => Some("zoom_in"), - "zoom-out" => Some("zoom_out"), - _ => None, - }; - fallback.and_then(|fb| self.theme.load_icon(fb)) - })?; + fn load_cursor(&self, name: &str, renderer: &mut GlesRenderer) -> Option { + let icon_path = self + .themes + .iter() + .find_map(|theme| theme.load_icon(name)) + .or_else(|| { + // Try common XCursor aliases + let fallback = match name { + "default" => Some("left_ptr"), + "pointer" => Some("hand2"), + "text" => Some("xterm"), + "wait" => Some("watch"), + "progress" => Some("left_ptr_watch"), + "help" => Some("question_arrow"), + "crosshair" => Some("cross"), + "move" => Some("fleur"), + "not-allowed" => Some("crossed_circle"), + "no-drop" => Some("crossed_circle"), + "grab" => Some("openhand"), + "grabbing" => Some("closedhand"), + "e-resize" => Some("right_side"), + "w-resize" => Some("left_side"), + "n-resize" => Some("top_side"), + "s-resize" => Some("bottom_side"), + "ne-resize" => Some("top_right_corner"), + "nw-resize" => Some("top_left_corner"), + "se-resize" => Some("bottom_right_corner"), + "sw-resize" => Some("bottom_left_corner"), + "ew-resize" => Some("sb_h_double_arrow"), + "ns-resize" => Some("sb_v_double_arrow"), + "nesw-resize" => Some("fd_double_arrow"), + "nwse-resize" => Some("bd_double_arrow"), + "col-resize" => Some("sb_h_double_arrow"), + "row-resize" => Some("sb_v_double_arrow"), + "all-resize" | "all-scroll" => Some("fleur"), + "context-menu" => Some("left_ptr"), + "vertical-text" => Some("xterm"), + "alias" => Some("link"), + "copy" => Some("copy"), + "cell" => Some("plus"), + "zoom-in" => Some("zoom_in"), + "zoom-out" => Some("zoom_out"), + _ => None, + }; + fallback.and_then(|fb| self.themes.iter().find_map(|theme| theme.load_icon(fb))) + }); - let data = std::fs::read(&icon_path).ok()?; - let images = xcursor::parser::parse_xcursor(&data)?; + // A root DRM session has no host window-system cursor to fall back to. + // Keep a small built-in pointer available when the configured theme is + // missing (common in minimal installs) or lacks an individual shape. + let Some(icon_path) = icon_path else { + info!( + "Cursor '{}' is missing from installed themes; using built-in pointer", + name + ); + return self.load_builtin_pointer(renderer); + }; + + let Some(data) = std::fs::read(&icon_path).ok() else { + return self.load_builtin_pointer(renderer); + }; + let Some(images) = xcursor::parser::parse_xcursor(&data) else { + return self.load_builtin_pointer(renderer); + }; // Pick the image closest to our target size. let best = images .iter() - .min_by_key(|img| (img.size as i32 - self.size as i32).unsigned_abs())?; + .min_by_key(|img| (img.size as i32 - self.size as i32).unsigned_abs()); + let Some(best) = best else { + return self.load_builtin_pointer(renderer); + }; // XCursor pixels are ARGB (native byte order). Convert to RGBA for // GL import with Fourcc::Abgr8888 (which is RGBA in memory on little-endian). @@ -147,9 +172,70 @@ impl CursorThemeManager { }) } Err(e) => { - warn!("Failed to import cursor texture for '{}': {e}", name); - None + info!( + "Could not import themed cursor '{}': {e}; using built-in pointer", + name + ); + self.load_builtin_pointer(renderer) } } } + + fn load_builtin_pointer(&self, renderer: &mut GlesRenderer) -> Option { + const WIDTH: usize = 16; + const HEIGHT: usize = 24; + const POLYGON: [(f32, f32); 7] = [ + (0.5, 0.5), + (0.5, 18.0), + (4.4, 14.0), + (8.2, 23.0), + (11.6, 21.3), + (7.8, 12.0), + (14.0, 12.0), + ]; + let inside = |x: f32, y: f32| { + let mut hit = false; + let mut j = POLYGON.len() - 1; + for i in 0..POLYGON.len() { + let (xi, yi) = POLYGON[i]; + let (xj, yj) = POLYGON[j]; + if (yi > y) != (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi { + hit = !hit; + } + j = i; + } + hit + }; + + let mut rgba = vec![0u8; WIDTH * HEIGHT * 4]; + for y in 0..HEIGHT { + for x in 0..WIDTH { + if !inside(x as f32 + 0.5, y as f32 + 0.5) { + continue; + } + let edge = [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0)] + .iter() + .any(|(dx, dy)| !inside(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy)); + let value = if edge { 0 } else { 255 }; + let offset = (y * WIDTH + x) * 4; + rgba[offset..offset + 4].copy_from_slice(&[value, value, value, 255]); + } + } + + renderer + .import_memory( + &rgba, + Fourcc::Abgr8888, + Size::from((WIDTH as i32, HEIGHT as i32)), + false, + ) + .ok() + .map(|texture| CursorImage { + texture, + width: WIDTH as u32, + height: HEIGHT as u32, + xhot: 0, + yhot: 0, + }) + } } diff --git a/src/dmabuf.rs b/src/dmabuf.rs index 9145d95..5a44555 100644 --- a/src/dmabuf.rs +++ b/src/dmabuf.rs @@ -8,13 +8,18 @@ /// On the export side (serving Wayland clients) the compositor creates a /// `zwp_linux_dmabuf_v1` buffer from the same backing GBM BO so that other /// Wayland clients can access the pixel data zero-copy. - use std::collections::HashMap; use anyhow::{Context, Result}; use smithay::backend::{ - allocator::{dmabuf::{Dmabuf, DmabufFlags}, Fourcc, Modifier}, - renderer::{gles::{GlesRenderer, GlesTexture}, ImportDma}, + allocator::{ + dmabuf::{Dmabuf, DmabufFlags}, + Fourcc, Modifier, + }, + renderer::{ + gles::{GlesRenderer, GlesTexture}, + ImportDma, + }, }; use crate::electron::ElectronFrame; @@ -22,12 +27,12 @@ use crate::electron::ElectronFrame; /// In-flight texture imported from an Electron DMABUF frame. #[allow(dead_code)] pub struct ElectronTexture { - pub name: String, - pub width: u32, - pub height: u32, + pub name: String, + pub width: u32, + pub height: u32, pub texture: GlesTexture, /// Keep-alive handle so the underlying DMABUF fds stay open. - pub dmabuf: Dmabuf, + pub dmabuf: Dmabuf, } /// Cached DMABUF for a window with mutable pixel storage. @@ -55,8 +60,7 @@ impl WindowDmabufCache { .context("seeking memfd to start")?; // Write new pixel data - unistd::write(&self.memfd_fd, pixels) - .context("writing pixels to memfd")?; + unistd::write(&self.memfd_fd, pixels).context("writing pixels to memfd")?; Ok(()) } @@ -65,10 +69,10 @@ impl WindowDmabufCache { /// Cached DMABUF data for Electron frames (texture is re-imported each frame to avoid /// lifetime issues with GlesRenderer contexts). pub struct CachedDmabufFrame { - pub name: String, - pub width: u32, - pub height: u32, - pub dmabuf: Dmabuf, + pub name: String, + pub width: u32, + pub height: u32, + pub dmabuf: Dmabuf, } /// Create a cached DMABUF for window pixel storage with in-place update capability. @@ -86,18 +90,16 @@ pub fn create_window_dmabuf_cache( // Create a memfd for the pixel data let memfd_fd = nix::sys::memfd::memfd_create( std::ffi::CStr::from_bytes_with_nul(b"wayland_window\0").unwrap(), - nix::sys::memfd::MFdFlags::MFD_CLOEXEC + nix::sys::memfd::MFdFlags::MFD_CLOEXEC, ) .context("creating memfd for window DMABUF")?; // Truncate to size let size = pixels.len() as u64; - nix::unistd::ftruncate(&memfd_fd, size as i64) - .context("truncating memfd")?; + nix::unistd::ftruncate(&memfd_fd, size as i64).context("truncating memfd")?; // Write initial pixel data - nix::unistd::write(&memfd_fd, pixels) - .context("writing initial pixels to memfd")?; + nix::unistd::write(&memfd_fd, pixels).context("writing initial pixels to memfd")?; // Seek back to start for DMABUF builder nix::unistd::lseek(&memfd_fd, 0, nix::unistd::Whence::SeekSet) @@ -129,7 +131,7 @@ pub fn create_window_dmabuf_cache( } /// Convert an [`ElectronFrame`] into a texture and import it into the renderer. -/// +/// /// Now returns just the texture for immediate rendering; use `cache_frame_for_reimport` /// to cache the DMABUF for later re-importing. pub fn import_electron_frame( @@ -137,10 +139,15 @@ pub fn import_electron_frame( frame: ElectronFrame, ) -> Result<(ElectronTexture, CachedDmabufFrame)> { use tracing::{debug, trace}; - - trace!("Importing DMABUF frame: {}x{} format=0x{:08X} planes={}", - frame.width, frame.height, frame.format, frame.planes.len()); - + + trace!( + "Importing DMABUF frame: {}x{} format=0x{:08X} planes={}", + frame.width, + frame.height, + frame.format, + frame.planes.len() + ); + let fourcc: Fourcc = fourcc_from_u32(frame.format) .with_context(|| format!("unknown fourcc 0x{:08X}", frame.format))?; @@ -164,26 +171,31 @@ pub fn import_electron_frame( for (idx, plane) in frame.planes.into_iter().enumerate() { let dup_fd = nix::unistd::dup(&plane.fd) .with_context(|| format!("Failed to dup plane {} fd", idx))?; - + if !builder.add_plane(dup_fd, idx as u32, plane.offset, plane.stride) { debug!("Failed to add plane {} to DMABUF", idx); anyhow::bail!("too many planes in DMABUF frame"); } - trace!("Plane {}: offset={} stride={}", idx, plane.offset, plane.stride); + trace!( + "Plane {}: offset={} stride={}", + idx, + plane.offset, + plane.stride + ); } let dmabuf = builder.build().context("building DMABUF")?; trace!("DMABUF built successfully"); - + let texture = renderer .import_dmabuf(&dmabuf, None) .context("importing DMABUF into renderer")?; - + trace!("DMABUF imported into renderer successfully"); - + // Create a cache-friendly version with fresh DMABUF for later re-import let cache_dmabuf = dmabuf.clone(); - + Ok(( ElectronTexture { name: frame.name.clone(), @@ -193,8 +205,8 @@ pub fn import_electron_frame( dmabuf, }, CachedDmabufFrame { - name: frame.name, - width: frame.width, + name: frame.name, + width: frame.width, height: frame.height, dmabuf: cache_dmabuf, }, @@ -232,20 +244,16 @@ pub fn create_temp_dmabuf_from_pixels( use smithay::backend::allocator::Modifier; // Create a memfd for the pixel data - let memfd_fd = nix::sys::memfd::memfd_create( - "wayland_window", - nix::sys::memfd::MFdFlags::MFD_CLOEXEC - ) - .context("creating memfd for DMABUF pixels")?; + let memfd_fd = + nix::sys::memfd::memfd_create("wayland_window", nix::sys::memfd::MFdFlags::MFD_CLOEXEC) + .context("creating memfd for DMABUF pixels")?; // Truncate to size let size = pixels.len() as u64; - nix::unistd::ftruncate(&memfd_fd, size as i64) - .context("truncating memfd")?; + nix::unistd::ftruncate(&memfd_fd, size as i64).context("truncating memfd")?; // Write pixel data directly using nix::unistd::write (no File wrapper) - nix::unistd::write(&memfd_fd, pixels) - .context("writing pixels to memfd")?; + nix::unistd::write(&memfd_fd, pixels).context("writing pixels to memfd")?; // Seek back to start nix::unistd::lseek(&memfd_fd, 0, nix::unistd::Whence::SeekSet) @@ -265,8 +273,7 @@ pub fn create_temp_dmabuf_from_pixels( builder.build().context("building DMABUF from pixels") } -/// Export an offscreen texture as a DMABUF for zero-copy transmission. -/// Used to export Wayland window textures to comraw via IPC using individual DMABUFs. +/// Direct EGL texture export is currently unsupported. #[allow(dead_code)] pub fn export_texture_as_dmabuf( _renderer: &GlesRenderer, @@ -274,11 +281,6 @@ pub fn export_texture_as_dmabuf( _width: u32, _height: u32, ) -> Result { - // For now, we'll read pixels and create a DMABUF from them - // A more optimal approach would use EGLImage to back the DMABUF, but - // this is simpler and still zero-copy at the IPC boundary - // This would need a mutable reference, which we don't have - // So instead, for each window we'll create DMABUF on-demand after pixelreads anyhow::bail!("texture export not directly supported; use create_temp_dmabuf_from_pixels after reading pixels") } @@ -318,11 +320,11 @@ fn fourcc_from_u32(v: u32) -> Option { #[allow(dead_code)] pub fn format_str_to_fourcc(s: &str) -> Option { match s.to_uppercase().as_str() { - "ARGB8888" => Some(u32::from_le_bytes(*b"AR24")), // DRM_FORMAT_ARGB8888 - "BGRA8888" => Some(u32::from_le_bytes(*b"BA24")), // DRM_FORMAT_BGRA8888 - "XRGB8888" => Some(u32::from_le_bytes(*b"XR24")), // DRM_FORMAT_XRGB8888 - "ABGR8888" => Some(u32::from_le_bytes(*b"AB24")), // DRM_FORMAT_ABGR8888 - "XBGR8888" => Some(u32::from_le_bytes(*b"XB24")), // DRM_FORMAT_XBGR8888 - _ => None, + "ARGB8888" => Some(u32::from_le_bytes(*b"AR24")), // DRM_FORMAT_ARGB8888 + "BGRA8888" => Some(u32::from_le_bytes(*b"BA24")), // DRM_FORMAT_BGRA8888 + "XRGB8888" => Some(u32::from_le_bytes(*b"XR24")), // DRM_FORMAT_XRGB8888 + "ABGR8888" => Some(u32::from_le_bytes(*b"AB24")), // DRM_FORMAT_ABGR8888 + "XBGR8888" => Some(u32::from_le_bytes(*b"XB24")), // DRM_FORMAT_XBGR8888 + _ => None, } } diff --git a/src/electron.rs b/src/electron.rs index b53ea62..bf10003 100644 --- a/src/electron.rs +++ b/src/electron.rs @@ -1,29 +1,30 @@ /// IPC protocol between the Wo compositor and the Electron helper process. - use std::{ + collections::HashMap, + io::IoSliceMut, os::fd::{FromRawFd, OwnedFd}, os::unix::{ io::{AsRawFd, RawFd}, net::UnixListener, }, - path::Path, - io::{IoSliceMut}, + path::{Path, PathBuf}, sync::{Arc, Mutex}, - collections::HashMap, }; - use anyhow::{bail, Context, Result}; use nix::{ cmsg_space, sys::{ - socket::{recvmsg, send, setsockopt, sockopt::ReceiveTimeout, ControlMessage, ControlMessageOwned, MsgFlags, UnixAddr}, + socket::{ + recvmsg, send, setsockopt, sockopt::ReceiveTimeout, ControlMessage, + ControlMessageOwned, MsgFlags, UnixAddr, + }, time::TimeVal, }, unistd, }; use tokio::sync::mpsc::{self, error::TrySendError}; -use tracing::{debug, error, info, warn, trace}; +use tracing::{debug, error, info, trace, warn}; use crate::config::WindowConfig; @@ -34,23 +35,25 @@ pub const MAGIC_MOUSE_BUTTON: u32 = 0x574F4D42; pub const MAGIC_KEYBOARD: u32 = 0x574F4B42; pub const MAGIC_SCROLL: u32 = 0x574F5343; pub const MAGIC_ACTION: u32 = 0x574F4341; -pub const MAGIC_FOCUS_CHANGE: u32 = 0x574F4643; // "WOFC" -pub const MAGIC_WINDOW_META: u32 = 0x574F574D; // "WOWM" -pub const MAGIC_WINDOW_POS: u32 = 0x574F5750; // "WOWP" -pub const MAGIC_SYSCALL: u32 = 0x574F5359; // "WOSY" - fixed conflict! -pub const MAGIC_FRAME_ACK: u32 = 0x574F4641; // "WOFA" +pub const MAGIC_FOCUS_CHANGE: u32 = 0x574F4643; // "WOFC" +pub const MAGIC_WINDOW_META: u32 = 0x574F574D; // "WOWM" +pub const MAGIC_WINDOW_POS: u32 = 0x574F5750; // "WOWP" +pub const MAGIC_SYSCALL: u32 = 0x574F5359; // "WOSY" - fixed conflict! +pub const MAGIC_FRAME_ACK: u32 = 0x574F4641; // "WOFA" pub const MAGIC_SURFACE_BUFFER: u32 = 0x574F5342; // "WOSB" — Wayland surface pixels sent to Electron -pub const MAGIC_SHM_BUFFER: u32 = 0x574F534D; // "WOSM" — Wayland SHM surface sent to Electron via process FD +pub const MAGIC_SHM_BUFFER: u32 = 0x574F534D; // "WOSM" — Wayland SHM surface sent to Electron via process FD pub const MAGIC_DMABUF_FRAME: u32 = 0x574F4446; // "WODF" — Wayland surface DMABUF fds sent to Electron -pub const MAGIC_FORWARD_POINTER: u32 = 0x574F5045; // "WOPE" +pub const MAGIC_FORWARD_POINTER: u32 = 0x574F5045; // "WOPE" pub const MAGIC_FORWARD_KEYBOARD: u32 = 0x574F4B45; // "WOKE" pub const MAGIC_FORWARD_RELATIVE_POINTER: u32 = 0x574F5245; // "WORE" pub const MAGIC_FORWARD_POINTER_BUTTON: u32 = 0x574F5042; // "WOPB" pub const MAGIC_FORWARD_POINTER_SCROLL: u32 = 0x574F5053; // "WOPS" +pub const MAGIC_FORWARD_POINTER_LEAVE: u32 = 0x574F504E; // "WOPN" pub const MAGIC_POINTER_LOCK_REQUEST: u32 = 0x574F504C; // "WOPL" — server-to-client pointer lock request pub const MAGIC_ENV_UPDATE: u32 = 0x574F4555; // "WOEU" — environment variable update broadcast pub const MAGIC_SCREENCOPY_EVENT: u32 = 0x574F5345; // "WOSE" — screencopy capture event notification - +pub const MAGIC_WINDOW_SIZE: u32 = 0x574F525A; // "WORZ" — offscreen Electron window size +pub const MAGIC_SITE_HASHES: u32 = 0x574F5348; // "WOSH" — verified site hashes, compositor-owned association #[repr(C, packed)] #[derive(Clone, Copy, Debug)] @@ -65,9 +68,9 @@ pub struct PlaneInfoWire { /// Parsed plane description with the received file descriptor. #[derive(Debug)] pub struct PlaneInfo { - pub fd: OwnedFd, - pub offset: u32, - pub stride: u32, + pub fd: OwnedFd, + pub offset: u32, + pub stride: u32, pub modifier: u64, } @@ -75,12 +78,12 @@ pub struct PlaneInfo { #[derive(Debug)] pub struct ElectronFrame { /// Which window this frame belongs to (matches `WindowConfig::name`). - pub name: String, - pub seq: u64, - pub width: u32, - pub height: u32, - pub format: u32, // DRM fourcc - pub planes: Vec, + pub name: String, + pub seq: u64, + pub width: u32, + pub height: u32, + pub format: u32, // DRM fourcc + pub planes: Vec, } /// Window position update from client @@ -97,8 +100,23 @@ pub struct WindowPositionUpdate { #[derive(Debug, Clone)] pub struct SyscallRequest { pub window_name: String, - pub syscall_type: String, // "exec", "read", "write", etc. - pub payload: String, // JSON payload with request parameters + pub syscall_type: String, // "exec", "read", "write", etc. + pub payload: String, // JSON payload with request parameters +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SiteHashesUpdate { + pub page_url: String, + pub index_bytes: Vec, + pub styles_bytes: Vec, +} + +#[derive(Debug, Clone)] +pub struct SiteHashes { + pub page_url: String, + pub index_sha256: String, + pub styles_sha256: String, } /// Messages that can be received from Electron clients @@ -107,17 +125,51 @@ pub enum ElectronMessage { Frame(ElectronFrame), WindowPosition(WindowPositionUpdate), Syscall(SyscallRequest), + SiteHashes { + window_name: String, + update: SiteHashesUpdate, + }, Action(CompositorActionMessage), /// Forwarded pointer motion from web UI canvas (window-local coordinates) - ForwardedPointer { window_name: String, x: f64, y: f64 }, + ForwardedPointer { + window_name: String, + x: f64, + y: f64, + }, /// Forwarded keyboard event from web UI canvas (evdev keycode) - ForwardedKeyboard { window_name: String, key: u32, pressed: bool, time: u32 }, + ForwardedKeyboard { + window_name: String, + key: u32, + pressed: bool, + time: u32, + }, /// Forwarded relative motion from web UI canvas - ForwardedRelativePointer { window_name: String, dx: f64, dy: f64 }, + ForwardedRelativePointer { + window_name: String, + dx: f64, + dy: f64, + }, /// Forwarded pointer button from web UI - ForwardedPointerButton { window_name: String, x: f64, y: f64, button: u32, pressed: bool, time: u32 }, + ForwardedPointerButton { + window_name: String, + x: f64, + y: f64, + button: u32, + pressed: bool, + time: u32, + }, /// Forwarded pointer scroll from web UI - ForwardedPointerScroll { window_name: String, dx: f64, dy: f64 }, + ForwardedPointerScroll { + window_name: String, + x: f64, + y: f64, + dx: f64, + dy: f64, + time: u32, + }, + ForwardedPointerLeave { + window_name: String, + }, } /// Actions that Electron clients can request from the compositor. @@ -126,7 +178,10 @@ pub enum CompositorAction { /// Request the compositor to quit Quit { code: i32 }, /// Custom extension action with JSON payload - Custom { action: String, payload: Option }, + Custom { + action: String, + payload: Option, + }, } /// A message sent from an Electron client to the compositor. @@ -139,17 +194,49 @@ pub struct CompositorActionMessage { /// Input event types that can be sent to Electron clients. #[derive(Debug, Clone)] pub enum ElectronInputEvent { - MouseMove { x: f64, y: f64 }, - MouseButton { button: u32, pressed: bool, time: u32 }, - Keyboard { key: u32, pressed: bool, time: u32 }, - Scroll { vertical: i32, horizontal: i32, time: u32 }, - FocusChange { window_name: String, focused: bool }, - WindowMetadata { metadata: String }, // JSON payload - PointerLockRequest { window_name: String, lock: bool }, + MouseMove { + x: f64, + y: f64, + }, + MouseButton { + button: u32, + pressed: bool, + time: u32, + }, + Keyboard { + key: u32, + pressed: bool, + time: u32, + }, + Scroll { + vertical: i32, + horizontal: i32, + time: u32, + }, + FocusChange { + window_name: String, + focused: bool, + }, + WindowMetadata { + metadata: String, + }, // JSON payload + PointerLockRequest { + window_name: String, + lock: bool, + }, /// Environment variable update (e.g. DISPLAY after XWayland is ready). - EnvUpdate { vars: String }, + EnvUpdate { + vars: String, + }, /// Screencopy capture event (active/inactive notification). - ScreencopyEvent { active: bool, client_count: u32 }, + ScreencopyEvent { + active: bool, + client_count: u32, + }, + WindowSize { + width: u32, + height: u32, + }, } /// A bidirectional connection to an Electron client. @@ -173,8 +260,7 @@ impl ElectronClientConnection { /// Non-blocking variant: drops the message silently on EAGAIN. /// Uses try_lock so the caller never blocks waiting for write_lock. - /// If a concurrent write is in progress the message is dropped, - /// which is acceptable for input events and metadata broadcasts. + /// If a concurrent write is in progress the optional message is dropped. fn write_bytes_nonblocking(&self, buf: &[u8]) -> Result<()> { let _guard = match self.write_lock.try_lock() { Ok(g) => g, @@ -183,10 +269,15 @@ impl ElectronClientConnection { write_nonblocking(self.fd.as_raw_fd(), buf) } - /// Send a FRAME_ACK for `seq` to unblock Electron's inFlight counter. - /// Uses a blocking write: the message is 12 bytes on a local Unix socket so - /// it completes in nanoseconds, and dropping ACKs would permanently stall - /// Electron's backpressure mechanism (inFlightFrameSeqs never decrements). + /// Input events take the short write lock so a concurrent frame or + /// metadata write cannot make a key/button transition disappear. + fn write_input_bytes(&self, buf: &[u8]) -> Result<()> { + let _guard = self.write_lock.lock().unwrap(); + write_nonblocking(self.fd.as_raw_fd(), buf) + } + + /// Send a FRAME_ACK after the compositor imports or drops the frame. + /// This lets Electron safely release the source DMA-BUF only after use. pub fn write_frame_ack(&self, seq: u64) -> Result<()> { let mut buf = [0u8; 12]; buf[0..4].copy_from_slice(&MAGIC_FRAME_ACK.to_le_bytes()); @@ -246,7 +337,8 @@ impl ElectronClientConnection { ) -> Result<()> { let name_bytes = window_name.as_bytes(); let num_rects = damage_rects.len() as u32; - let header_len = 4 + 4 + name_bytes.len() + 4 + 4 + 4 + 4 + 4 + 4 + (damage_rects.len() * 16); + let header_len = + 4 + 4 + name_bytes.len() + 4 + 4 + 4 + 4 + 4 + 4 + (damage_rects.len() * 16); let mut buf = vec![0u8; header_len]; let mut off = 0; buf[off..off + 4].copy_from_slice(&MAGIC_SHM_BUFFER.to_le_bytes()); @@ -366,10 +458,7 @@ impl ElectronClientConnection { }; // Build ancillary data for file descriptors - let fds: Vec = dmabuf - .handles() - .map(|h| h.as_raw_fd()) - .collect(); + let fds: Vec = dmabuf.handles().map(|h| h.as_raw_fd()).collect(); if fds.is_empty() { // No FDs to send, just send metadata @@ -400,7 +489,6 @@ impl ElectronClientConnection { /// Send an input event to the Electron client. pub fn send_input_event(&self, event: &ElectronInputEvent) -> Result<()> { - match event { ElectronInputEvent::MouseMove { x, y } => { let mut buf = [0u8; 20]; @@ -408,15 +496,19 @@ impl ElectronClientConnection { buf[4..12].copy_from_slice(&x.to_le_bytes()); buf[12..20].copy_from_slice(&y.to_le_bytes()); self.write_bytes(&buf)?; - }, - ElectronInputEvent::MouseButton { button, pressed, time } => { + } + ElectronInputEvent::MouseButton { + button, + pressed, + time, + } => { let mut buf = [0u8; 16]; buf[0..4].copy_from_slice(&MAGIC_MOUSE_BUTTON.to_le_bytes()); buf[4..8].copy_from_slice(&button.to_le_bytes()); buf[8..12].copy_from_slice(&(*pressed as u32).to_le_bytes()); buf[12..16].copy_from_slice(&time.to_le_bytes()); self.write_bytes(&buf)?; - }, + } ElectronInputEvent::Keyboard { key, pressed, time } => { let mut buf = [0u8; 16]; buf[0..4].copy_from_slice(&MAGIC_KEYBOARD.to_le_bytes()); @@ -424,16 +516,23 @@ impl ElectronClientConnection { buf[8..12].copy_from_slice(&(*pressed as u32).to_le_bytes()); buf[12..16].copy_from_slice(&time.to_le_bytes()); self.write_bytes(&buf)?; - }, - ElectronInputEvent::Scroll { vertical, horizontal, time } => { + } + ElectronInputEvent::Scroll { + vertical, + horizontal, + time, + } => { let mut buf = [0u8; 16]; buf[0..4].copy_from_slice(&MAGIC_SCROLL.to_le_bytes()); buf[4..8].copy_from_slice(&vertical.to_le_bytes()); buf[8..12].copy_from_slice(&horizontal.to_le_bytes()); buf[12..16].copy_from_slice(&time.to_le_bytes()); self.write_bytes(&buf)?; - }, - ElectronInputEvent::FocusChange { window_name, focused } => { + } + ElectronInputEvent::FocusChange { + window_name, + focused, + } => { let name_bytes = window_name.as_bytes(); let mut buf = vec![0u8; 12 + name_bytes.len()]; buf[0..4].copy_from_slice(&MAGIC_FOCUS_CHANGE.to_le_bytes()); @@ -441,7 +540,7 @@ impl ElectronClientConnection { buf[8..12].copy_from_slice(&(name_bytes.len() as u32).to_le_bytes()); buf[12..].copy_from_slice(name_bytes); self.write_bytes(&buf)?; - }, + } ElectronInputEvent::WindowMetadata { metadata } => { let meta_bytes = metadata.as_bytes(); let mut buf = vec![0u8; 8 + meta_bytes.len()]; @@ -449,7 +548,7 @@ impl ElectronClientConnection { buf[4..8].copy_from_slice(&(meta_bytes.len() as u32).to_le_bytes()); buf[8..].copy_from_slice(meta_bytes); self.write_bytes(&buf)?; - }, + } ElectronInputEvent::PointerLockRequest { window_name, lock } => { let name_bytes = window_name.as_bytes(); let mut buf = vec![0u8; 12 + name_bytes.len()]; @@ -458,7 +557,7 @@ impl ElectronClientConnection { buf[8..12].copy_from_slice(&(name_bytes.len() as u32).to_le_bytes()); buf[12..].copy_from_slice(name_bytes); self.write_bytes(&buf)?; - }, + } ElectronInputEvent::EnvUpdate { vars } => { let var_bytes = vars.as_bytes(); let mut buf = vec![0u8; 8 + var_bytes.len()]; @@ -466,14 +565,24 @@ impl ElectronClientConnection { buf[4..8].copy_from_slice(&(var_bytes.len() as u32).to_le_bytes()); buf[8..].copy_from_slice(var_bytes); self.write_bytes(&buf)?; - }, - ElectronInputEvent::ScreencopyEvent { active, client_count } => { + } + ElectronInputEvent::ScreencopyEvent { + active, + client_count, + } => { let mut buf = [0u8; 12]; buf[0..4].copy_from_slice(&MAGIC_SCREENCOPY_EVENT.to_le_bytes()); buf[4..8].copy_from_slice(&(*active as u32).to_le_bytes()); buf[8..12].copy_from_slice(&client_count.to_le_bytes()); self.write_bytes(&buf)?; - }, + } + ElectronInputEvent::WindowSize { width, height } => { + let mut buf = [0u8; 12]; + buf[0..4].copy_from_slice(&MAGIC_WINDOW_SIZE.to_le_bytes()); + buf[4..8].copy_from_slice(&width.to_le_bytes()); + buf[8..12].copy_from_slice(&height.to_le_bytes()); + self.write_bytes(&buf)?; + } } Ok(()) } @@ -489,33 +598,44 @@ impl ElectronClientConnection { buf[0..4].copy_from_slice(&MAGIC_MOUSE_MOVE.to_le_bytes()); buf[4..12].copy_from_slice(&x.to_le_bytes()); buf[12..20].copy_from_slice(&y.to_le_bytes()); - self.write_bytes_nonblocking(&buf) - }, - ElectronInputEvent::MouseButton { button, pressed, time } => { + self.write_input_bytes(&buf) + } + ElectronInputEvent::MouseButton { + button, + pressed, + time, + } => { let mut buf = [0u8; 16]; buf[0..4].copy_from_slice(&MAGIC_MOUSE_BUTTON.to_le_bytes()); buf[4..8].copy_from_slice(&button.to_le_bytes()); buf[8..12].copy_from_slice(&(*pressed as u32).to_le_bytes()); buf[12..16].copy_from_slice(&time.to_le_bytes()); - self.write_bytes_nonblocking(&buf) - }, + self.write_input_bytes(&buf) + } ElectronInputEvent::Keyboard { key, pressed, time } => { let mut buf = [0u8; 16]; buf[0..4].copy_from_slice(&MAGIC_KEYBOARD.to_le_bytes()); buf[4..8].copy_from_slice(&key.to_le_bytes()); buf[8..12].copy_from_slice(&(*pressed as u32).to_le_bytes()); buf[12..16].copy_from_slice(&time.to_le_bytes()); - self.write_bytes_nonblocking(&buf) - }, - ElectronInputEvent::Scroll { vertical, horizontal, time } => { + self.write_input_bytes(&buf) + } + ElectronInputEvent::Scroll { + vertical, + horizontal, + time, + } => { let mut buf = [0u8; 16]; buf[0..4].copy_from_slice(&MAGIC_SCROLL.to_le_bytes()); buf[4..8].copy_from_slice(&vertical.to_le_bytes()); buf[8..12].copy_from_slice(&horizontal.to_le_bytes()); buf[12..16].copy_from_slice(&time.to_le_bytes()); - self.write_bytes_nonblocking(&buf) - }, - ElectronInputEvent::FocusChange { window_name, focused } => { + self.write_input_bytes(&buf) + } + ElectronInputEvent::FocusChange { + window_name, + focused, + } => { let name_bytes = window_name.as_bytes(); let mut buf = vec![0u8; 12 + name_bytes.len()]; buf[0..4].copy_from_slice(&MAGIC_FOCUS_CHANGE.to_le_bytes()); @@ -523,7 +643,7 @@ impl ElectronClientConnection { buf[8..12].copy_from_slice(&(*focused as u32).to_le_bytes()); buf[12..].copy_from_slice(name_bytes); self.write_bytes_nonblocking(&buf) - }, + } ElectronInputEvent::WindowMetadata { metadata } => { let meta_bytes = metadata.as_bytes(); let mut buf = vec![0u8; 8 + meta_bytes.len()]; @@ -531,7 +651,7 @@ impl ElectronClientConnection { buf[4..8].copy_from_slice(&(meta_bytes.len() as u32).to_le_bytes()); buf[8..].copy_from_slice(meta_bytes); self.write_bytes_nonblocking(&buf) - }, + } ElectronInputEvent::PointerLockRequest { window_name, lock } => { let name_bytes = window_name.as_bytes(); let mut buf = vec![0u8; 12 + name_bytes.len()]; @@ -549,29 +669,42 @@ impl ElectronClientConnection { buf[8..].copy_from_slice(var_bytes); self.write_bytes_nonblocking(&buf) } - ElectronInputEvent::ScreencopyEvent { active, client_count } => { + ElectronInputEvent::ScreencopyEvent { + active, + client_count, + } => { let mut buf = [0u8; 12]; buf[0..4].copy_from_slice(&MAGIC_SCREENCOPY_EVENT.to_le_bytes()); buf[4..8].copy_from_slice(&(*active as u32).to_le_bytes()); buf[8..12].copy_from_slice(&client_count.to_le_bytes()); self.write_bytes_nonblocking(&buf) } + ElectronInputEvent::WindowSize { width, height } => { + let mut buf = [0u8; 12]; + buf[0..4].copy_from_slice(&MAGIC_WINDOW_SIZE.to_le_bytes()); + buf[4..8].copy_from_slice(&width.to_le_bytes()); + buf[8..12].copy_from_slice(&height.to_le_bytes()); + self.write_bytes_nonblocking(&buf) + } } } } - - - pub struct ElectronProcess { - pub name: String, + pub name: String, pub config: WindowConfig, - pub child: std::process::Child, + pub child: std::process::Child, +} + +pub fn app_dir() -> PathBuf { + std::env::var_os("WO_ELECTRON_APP_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("electron")) } impl ElectronProcess { /// Spawn `electron ` for the given window config. - /// The app dir is resolved to `/wo-electron`. + /// The caller resolves the source-tree or packaged app directory. /// /// `render_node` is the DRI render node (e.g. `/dev/dri/renderD128`) that /// matches the GPU the compositor opened. The native `wo_dmabuf.node` @@ -583,42 +716,47 @@ impl ElectronProcess { app_dir: &Path, ipc_socket: &str, render_node: &str, + ozone_platform: &str, ) -> Result { use std::process::{Command, Stdio}; - - tracing::info!("Spawning Electron for window '{}' with config: {:?}", config.name, config); - let serialized = serde_json::to_string(config) - .context("serializing window config")?; - + tracing::info!( + "Spawning Electron for window '{}' with config: {:?}", + config.name, + config + ); + + let serialized = serde_json::to_string(config).context("serializing window config")?; + tracing::info!("Serialized config: {}", serialized); let main_js = app_dir.join("dist/main.js"); - - let child = Command::new(electron_bin) + let electron_bin = + std::env::var("WO_ELECTRON_PATH").unwrap_or_else(|_| electron_bin.to_owned()); + // Let Chromium choose the GPU backend by default. Explicit ANGLE, + // desktop GL, and Vulkan overrides remain available for diagnosis and + // drivers that need a pinned implementation. + let default_gpu_backend = "auto"; + let gpu_backend = std::env::var("WO_ELECTRON_GPU_BACKEND") + .unwrap_or_else(|_| default_gpu_backend.to_owned()); + let electron_ozone_platform = std::env::var("WO_ELECTRON_OZONE_PLATFORM") + .ok() + .filter(|platform| matches!(platform.as_str(), "wayland" | "x11")) + .unwrap_or_else(|| ozone_platform.to_owned()); + + let child = Command::new(&electron_bin) .env("WO_IPC_SOCKET", ipc_socket) .env("WO_WINDOW_CONFIG", &serialized) + .env( + "WO_SYSTEM_SHELL", + if config.system_shell { "1" } else { "0" }, + ) .env("WO_DRM_RENDER_NODE", render_node) - // ── Chromium flags ────────────────────────────────────── - // These MUST appear before the app script on the command - // line. Electron treats everything after the script path - // as application arguments (process.argv), NOT Chromium - // switches. Flags here also reach the GPU subprocess - // (critical for --in-process-gpu and --use-gl). - // - // Wayland Ozone platform: Electron connects to our own - // Wayland compositor (WAYLAND_DISPLAY is already set in - // the env) for GPU/EGL initialisation. - .arg("--ozone-platform=wayland") - .arg("--no-sandbox") - .arg("--disable-gpu-sandbox") - .arg("--use-gl=angle") - .arg("--use-angle=vulkan") - .arg("--disable-gpu-vsync") - .arg("--enable-features=CanvasOopRasterization,Vulkan,VaapiVideoDecoder,RawDraw,SharedArrayBuffer,DefaultANGLEVulkan,VulkanFromANGLE") + .env("WO_ELECTRON_OZONE_PLATFORM", &electron_ozone_platform) + .env("WO_ELECTRON_GPU_BACKEND", gpu_backend) + .arg(format!("--ozone-platform={electron_ozone_platform}")) .arg("--num-raster-threads=4") .arg("--disable-renderer-backgrounding") - // ── App entry point (must come after Chromium flags) ──── .arg(&main_js) .stdin(Stdio::null()) .stdout(Stdio::inherit()) @@ -626,7 +764,7 @@ impl ElectronProcess { .spawn() .with_context(|| format!("spawning electron for window '{}'", config.name))?; - info!(window = %config.name, pid = child.id(), render_node, "spawned Electron process"); + info!(window = %config.name, pid = child.id(), render_node, ozone = %electron_ozone_platform, "spawned Electron process"); Ok(Self { name: config.name.clone(), @@ -646,7 +784,6 @@ impl Drop for ElectronProcess { } } - pub struct ElectronIpc { pub tx: mpsc::Sender, /// Map of window name -> client connection for sending input events. @@ -719,7 +856,10 @@ impl ElectronIpc { }; for client in &snapshot { if let Err(e) = client.try_send_input_event(&event) { - warn!("metadata broadcast to '{}' failed (stream may be corrupted): {e:#}", client.name); + warn!( + "metadata broadcast to '{}' failed (stream may be corrupted): {e:#}", + client.name + ); } } Ok(()) @@ -755,7 +895,10 @@ impl ElectronIpc { }; for client in &snapshot { if let Err(e) = client.try_send_input_event(&event) { - warn!("screencopy event broadcast to '{}' failed: {e:#}", client.name); + warn!( + "screencopy event broadcast to '{}' failed: {e:#}", + client.name + ); } } Ok(()) @@ -779,7 +922,10 @@ impl ElectronIpc { buf[4..8].copy_from_slice(&(response_bytes.len() as u32).to_le_bytes()); buf[8..].copy_from_slice(response_bytes); if let Err(e) = client.write_bytes_nonblocking(&buf) { - warn!("syscall response to '{}' dropped (socket full): {e:#}", client.name); + warn!( + "syscall response to '{}' dropped (socket full): {e:#}", + client.name + ); } } Ok(()) @@ -842,7 +988,15 @@ impl ElectronIpc { }; // clients Mutex released — safe to do I/O. for client in &snapshot { - if let Err(e) = client.send_shm_buffer(window_name, width, height, stride, pid, memfd_fd, damage_rects) { + if let Err(e) = client.send_shm_buffer( + window_name, + width, + height, + stride, + pid, + memfd_fd, + damage_rects, + ) { debug!("shm buffer to '{}' dropped: {e:#}", client.name); } } @@ -871,7 +1025,6 @@ impl ElectronIpc { } } - fn handle_connection( fd: RawFd, tx: mpsc::Sender, @@ -927,16 +1080,16 @@ fn handle_connection( match msg { ElectronMessage::Frame(frame) => { let seq = frame.seq; - info!(window = %name, seq, "received FRAME, sending ACK"); - if let Err(e) = client.write_frame_ack(seq) { - error!(window = %name, seq, "failed to send FRAME_ACK: {e:#}"); - } + trace!(window = %name, seq, "received FRAME; ACK after import or drop"); match tx.try_send(ElectronMessage::Frame(frame)) { Ok(()) => { trace!(window = %name, "frame queued for compositor"); } Err(TrySendError::Full(_)) => { - debug!(window = %name, seq, "dropping frame (channel full, already ACKed)"); + debug!(window = %name, seq, "dropping frame because the compositor channel is full"); + if let Err(e) = client.write_frame_ack(seq) { + error!(window = %name, seq, "failed to ACK dropped frame: {e:#}"); + } } Err(TrySendError::Closed(_)) => { warn!(window = %name, "compositor channel closed, exiting IPC reader"); @@ -992,19 +1145,25 @@ fn recv_message(fd: RawFd, name: &str, buf: &mut [u8]) -> Result 256 { bail!("window name too long in FRAME: {name_len}"); } + if name_len > 256 { + bail!("window name too long in FRAME: {name_len}"); + } // Read the window name - read_exact(fd, &mut buf[8..8+name_len]).context("reading FRAME window name")?; - let frame_name = String::from_utf8_lossy(&buf[8..8+name_len]).into_owned(); + read_exact(fd, &mut buf[8..8 + name_len]).context("reading FRAME window name")?; + let frame_name = String::from_utf8_lossy(&buf[8..8 + name_len]).into_owned(); // Rest of frame header: seq(8) + width(4) + height(4) + format(4) + num_planes(4) const REST_HEADER: usize = 8 + 4 + 4 + 4 + 4; read_exact(fd, &mut buf[..REST_HEADER]).context("reading FRAME header fields")?; - let frame = parse_frame_message(fd, &frame_name, buf) - .context("parsing FRAME message data")?; - trace!("recv_message: FRAME parsed successfully: {}x{}", frame.width, frame.height); + let frame = + parse_frame_message(fd, &frame_name, buf).context("parsing FRAME message data")?; + trace!( + "recv_message: FRAME parsed successfully: {}x{}", + frame.width, + frame.height + ); Ok(ElectronMessage::Frame(frame)) } MAGIC_WINDOW_POS => { @@ -1012,19 +1171,50 @@ fn recv_message(fd: RawFd, name: &str, buf: &mut [u8]) -> Result 256 { bail!("window name too long in WINDOW_POS: {name_len}"); } + if name_len > 256 { + bail!("window name too long in WINDOW_POS: {name_len}"); + } let mut pos_buf = vec![0u8; name_len + 16]; read_exact(fd, &mut pos_buf).context("reading WINDOW_POS data")?; - let x = i32::from_le_bytes(pos_buf[name_len..name_len+4].try_into().unwrap()); - let y = i32::from_le_bytes(pos_buf[name_len+4..name_len+8].try_into().unwrap()); - let width = u32::from_le_bytes(pos_buf[name_len+8..name_len+12].try_into().unwrap()); - let height = u32::from_le_bytes(pos_buf[name_len+12..name_len+16].try_into().unwrap()); + let x = i32::from_le_bytes(pos_buf[name_len..name_len + 4].try_into().unwrap()); + let y = i32::from_le_bytes(pos_buf[name_len + 4..name_len + 8].try_into().unwrap()); + let width = + u32::from_le_bytes(pos_buf[name_len + 8..name_len + 12].try_into().unwrap()); + let height = + u32::from_le_bytes(pos_buf[name_len + 12..name_len + 16].try_into().unwrap()); Ok(ElectronMessage::WindowPosition(WindowPositionUpdate { window_name: name.to_string(), - x, y, width, height, + x, + y, + width, + height, })) } + MAGIC_SITE_HASHES => { + read_exact(fd, &mut buf[4..12]).context("reading SITE_HASHES header")?; + let name_len = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize; + let payload_len = u32::from_le_bytes(buf[8..12].try_into().unwrap()) as usize; + if name_len > 256 || payload_len > 24 * 1024 * 1024 { + bail!("SITE_HASHES message too large: name={name_len}, payload={payload_len}"); + } + let mut name_buf = vec![0; name_len]; + read_exact(fd, &mut name_buf).context("reading SITE_HASHES window name")?; + let mut payload = vec![0; payload_len]; + read_exact(fd, &mut payload).context("reading SITE_HASHES payload")?; + let update: SiteHashesUpdate = + serde_json::from_slice(&payload).context("decoding SITE_HASHES payload")?; + let window_name = String::from_utf8_lossy(&name_buf).into_owned(); + if update.index_bytes.len() > 4 * 1024 * 1024 + || update.styles_bytes.len() > 1 * 1024 * 1024 + { + bail!("SITE_HASHES asset exceeds size limit"); + } + Ok(ElectronMessage::SiteHashes { + window_name, + update, + }) + } MAGIC_SYSCALL => { trace!("recv_message: parsing SYSCALL message"); // Syscall: magic(4) + type_len(4) + payload_len(4) + type_str + payload_str @@ -1033,7 +1223,11 @@ fn recv_message(fd: RawFd, name: &str, buf: &mut [u8]) -> Result 256 || payload_len > 1024 * 1024 { - bail!("syscall message too large: type_len={} payload_len={}", type_len, payload_len); + bail!( + "syscall message too large: type_len={} payload_len={}", + type_len, + payload_len + ); } read_exact(fd, &mut buf[..type_len]).context("reading SYSCALL type")?; @@ -1079,7 +1273,8 @@ fn recv_message(fd: RawFd, name: &str, buf: &mut [u8]) -> Result(p) { @@ -1103,65 +1298,159 @@ fn recv_message(fd: RawFd, name: &str, buf: &mut [u8]) -> Result 256 { bail!("window name too long in FORWARD_POINTER: {window_len}"); } + if window_len > 256 { + bail!("window name too long in FORWARD_POINTER: {window_len}"); + } let mut data_buf = vec![0u8; window_len + 16]; read_exact(fd, &mut data_buf).context("reading FORWARD_POINTER data")?; let window_name = String::from_utf8_lossy(&data_buf[..window_len]).into_owned(); - let x = f64::from_le_bytes(data_buf[window_len..window_len+8].try_into().unwrap()); - let y = f64::from_le_bytes(data_buf[window_len+8..window_len+16].try_into().unwrap()); + let x = f64::from_le_bytes(data_buf[window_len..window_len + 8].try_into().unwrap()); + let y = f64::from_le_bytes( + data_buf[window_len + 8..window_len + 16] + .try_into() + .unwrap(), + ); Ok(ElectronMessage::ForwardedPointer { window_name, x, y }) } MAGIC_FORWARD_KEYBOARD => { trace!("recv_message: parsing FORWARD_KEYBOARD message"); read_exact(fd, &mut buf[4..8]).context("reading FORWARD_KEYBOARD window_len")?; let window_len = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize; - if window_len > 256 { bail!("window name too long in FORWARD_KEYBOARD: {window_len}"); } + if window_len > 256 { + bail!("window name too long in FORWARD_KEYBOARD: {window_len}"); + } let mut data_buf = vec![0u8; window_len + 12]; read_exact(fd, &mut data_buf).context("reading FORWARD_KEYBOARD data")?; let window_name = String::from_utf8_lossy(&data_buf[..window_len]).into_owned(); - let key = u32::from_le_bytes(data_buf[window_len..window_len+4].try_into().unwrap()); - let pressed = u32::from_le_bytes(data_buf[window_len+4..window_len+8].try_into().unwrap()) != 0; - let time = u32::from_le_bytes(data_buf[window_len+8..window_len+12].try_into().unwrap()); - Ok(ElectronMessage::ForwardedKeyboard { window_name, key, pressed, time }) + let key = u32::from_le_bytes(data_buf[window_len..window_len + 4].try_into().unwrap()); + let pressed = + u32::from_le_bytes(data_buf[window_len + 4..window_len + 8].try_into().unwrap()) + != 0; + let time = u32::from_le_bytes( + data_buf[window_len + 8..window_len + 12] + .try_into() + .unwrap(), + ); + Ok(ElectronMessage::ForwardedKeyboard { + window_name, + key, + pressed, + time, + }) } MAGIC_FORWARD_RELATIVE_POINTER => { trace!("recv_message: parsing FORWARD_RELATIVE_POINTER message"); - read_exact(fd, &mut buf[4..8]).context("reading FORWARD_RELATIVE_POINTER window_len")?; + read_exact(fd, &mut buf[4..8]) + .context("reading FORWARD_RELATIVE_POINTER window_len")?; let window_len = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize; - if window_len > 256 { bail!("window name too long in FORWARD_RELATIVE_POINTER: {window_len}"); } + if window_len > 256 { + bail!("window name too long in FORWARD_RELATIVE_POINTER: {window_len}"); + } let mut data_buf = vec![0u8; window_len + 16]; read_exact(fd, &mut data_buf).context("reading FORWARD_RELATIVE_POINTER data")?; let window_name = String::from_utf8_lossy(&data_buf[..window_len]).into_owned(); - let dx = f64::from_le_bytes(data_buf[window_len..window_len+8].try_into().unwrap()); - let dy = f64::from_le_bytes(data_buf[window_len+8..window_len+16].try_into().unwrap()); - Ok(ElectronMessage::ForwardedRelativePointer { window_name, dx, dy }) + let dx = f64::from_le_bytes(data_buf[window_len..window_len + 8].try_into().unwrap()); + let dy = f64::from_le_bytes( + data_buf[window_len + 8..window_len + 16] + .try_into() + .unwrap(), + ); + Ok(ElectronMessage::ForwardedRelativePointer { + window_name, + dx, + dy, + }) } MAGIC_FORWARD_POINTER_BUTTON => { trace!("recv_message: parsing FORWARD_POINTER_BUTTON message"); read_exact(fd, &mut buf[4..8]).context("reading FORWARD_POINTER_BUTTON window_len")?; let window_len = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize; - if window_len > 256 { bail!("window name too long in FORWARD_POINTER_BUTTON: {window_len}"); } + if window_len > 256 { + bail!("window name too long in FORWARD_POINTER_BUTTON: {window_len}"); + } let mut data_buf = vec![0u8; window_len + 28]; read_exact(fd, &mut data_buf).context("reading FORWARD_POINTER_BUTTON data")?; let window_name = String::from_utf8_lossy(&data_buf[..window_len]).into_owned(); - let x = f64::from_le_bytes(data_buf[window_len..window_len+8].try_into().unwrap()); - let y = f64::from_le_bytes(data_buf[window_len+8..window_len+16].try_into().unwrap()); - let button = u32::from_le_bytes(data_buf[window_len+16..window_len+20].try_into().unwrap()); - let pressed = u32::from_le_bytes(data_buf[window_len+20..window_len+24].try_into().unwrap()) != 0; - let time = u32::from_le_bytes(data_buf[window_len+24..window_len+28].try_into().unwrap()); - Ok(ElectronMessage::ForwardedPointerButton { window_name, x, y, button, pressed, time }) + let x = f64::from_le_bytes(data_buf[window_len..window_len + 8].try_into().unwrap()); + let y = f64::from_le_bytes( + data_buf[window_len + 8..window_len + 16] + .try_into() + .unwrap(), + ); + let button = u32::from_le_bytes( + data_buf[window_len + 16..window_len + 20] + .try_into() + .unwrap(), + ); + let pressed = u32::from_le_bytes( + data_buf[window_len + 20..window_len + 24] + .try_into() + .unwrap(), + ) != 0; + let time = u32::from_le_bytes( + data_buf[window_len + 24..window_len + 28] + .try_into() + .unwrap(), + ); + Ok(ElectronMessage::ForwardedPointerButton { + window_name, + x, + y, + button, + pressed, + time, + }) } MAGIC_FORWARD_POINTER_SCROLL => { trace!("recv_message: parsing FORWARD_POINTER_SCROLL message"); read_exact(fd, &mut buf[4..8]).context("reading FORWARD_POINTER_SCROLL window_len")?; let window_len = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize; - if window_len > 256 { bail!("window name too long in FORWARD_POINTER_SCROLL: {window_len}"); } - let mut data_buf = vec![0u8; window_len + 16]; + if window_len > 256 { + bail!("window name too long in FORWARD_POINTER_SCROLL: {window_len}"); + } + let mut data_buf = vec![0u8; window_len + 36]; read_exact(fd, &mut data_buf).context("reading FORWARD_POINTER_SCROLL data")?; let window_name = String::from_utf8_lossy(&data_buf[..window_len]).into_owned(); - let dx = f64::from_le_bytes(data_buf[window_len..window_len+8].try_into().unwrap()); - let dy = f64::from_le_bytes(data_buf[window_len+8..window_len+16].try_into().unwrap()); - Ok(ElectronMessage::ForwardedPointerScroll { window_name, dx, dy }) + let x = f64::from_le_bytes(data_buf[window_len..window_len + 8].try_into().unwrap()); + let y = f64::from_le_bytes( + data_buf[window_len + 8..window_len + 16] + .try_into() + .unwrap(), + ); + let dx = f64::from_le_bytes( + data_buf[window_len + 16..window_len + 24] + .try_into() + .unwrap(), + ); + let dy = f64::from_le_bytes( + data_buf[window_len + 24..window_len + 32] + .try_into() + .unwrap(), + ); + let time = u32::from_le_bytes( + data_buf[window_len + 32..window_len + 36] + .try_into() + .unwrap(), + ); + Ok(ElectronMessage::ForwardedPointerScroll { + window_name, + x, + y, + dx, + dy, + time, + }) + } + MAGIC_FORWARD_POINTER_LEAVE => { + read_exact(fd, &mut buf[4..8]).context("reading FORWARD_POINTER_LEAVE window_len")?; + let window_len = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize; + if window_len > 256 { + bail!("window name too long in FORWARD_POINTER_LEAVE: {window_len}"); + } + let mut name_buf = vec![0u8; window_len]; + read_exact(fd, &mut name_buf).context("reading FORWARD_POINTER_LEAVE data")?; + let window_name = String::from_utf8_lossy(&name_buf).into_owned(); + Ok(ElectronMessage::ForwardedPointerLeave { window_name }) } _ => { error!("recv_message: unknown magic 0x{:08X}", magic); @@ -1172,11 +1461,11 @@ fn recv_message(fd: RawFd, name: &str, buf: &mut [u8]) -> Result Result { // Buffer contains: seq(8) + width(4) + height(4) + format(4) + num_planes(4) - - let seq = u64::from_le_bytes(buf[0..8].try_into().unwrap()); - let width = u32::from_le_bytes(buf[8..12].try_into().unwrap()); - let height = u32::from_le_bytes(buf[12..16].try_into().unwrap()); - let format = u32::from_le_bytes(buf[16..20].try_into().unwrap()); + + let seq = u64::from_le_bytes(buf[0..8].try_into().unwrap()); + let width = u32::from_le_bytes(buf[8..12].try_into().unwrap()); + let height = u32::from_le_bytes(buf[12..16].try_into().unwrap()); + let format = u32::from_le_bytes(buf[16..20].try_into().unwrap()); let num_planes = u32::from_le_bytes(buf[20..24].try_into().unwrap()) as usize; if num_planes == 0 || num_planes > 4 { @@ -1197,23 +1486,32 @@ fn parse_frame_message(fd: RawFd, name: &str, buf: &mut [u8]) -> Result Result<()> { - use std::os::unix::io::FromRawFd; use std::io::Read; + use std::os::unix::io::FromRawFd; // SAFETY: fd is valid for the lifetime of this call. let mut f = unsafe { std::fs::File::from_raw_fd(fd) }; let r = f.read_exact(buf); @@ -1222,8 +1520,8 @@ fn read_exact(fd: RawFd, buf: &mut [u8]) -> Result<()> { } fn write_all(fd: RawFd, buf: &[u8]) -> Result<()> { - use std::os::unix::io::FromRawFd; use std::io::Write; + use std::os::unix::io::FromRawFd; // SAFETY: fd is valid for the lifetime of this call. let mut f = unsafe { std::fs::File::from_raw_fd(fd) }; let r = f.write_all(buf); @@ -1306,24 +1604,42 @@ fn recv_hello(fd: RawFd, buf: &mut [u8]) -> Result { } fn recv_fds(sock_fd: RawFd, count: usize) -> Result> { - let mut cmsg_buf = cmsg_space!([RawFd; 4]); - let mut iov_buf = [0u8; 1]; - let mut iov = [IoSliceMut::new(&mut iov_buf[..])]; - - let msg = recvmsg::(sock_fd, &mut iov, Some(&mut cmsg_buf), MsgFlags::empty()) - .context("recvmsg for fds")?; - let mut fds: Vec = Vec::new(); - for cmsg in msg.cmsgs().context("iterating control messages")? { - if let ControlMessageOwned::ScmRights(received) = cmsg { - for fd in received { - let owned = unsafe { OwnedFd::from_raw_fd(fd) }; - fds.push(owned); + while fds.len() < count { + let mut cmsg_buf = cmsg_space!([RawFd; 4]); + let mut marker = [0u8; 1]; + let mut iov = [IoSliceMut::new(&mut marker)]; + let msg = recvmsg::( + sock_fd, + &mut iov, + Some(&mut cmsg_buf), + MsgFlags::MSG_CMSG_CLOEXEC, + ) + .context("recvmsg for DMA-BUF plane fd")?; + let bytes_read = msg.bytes; + if bytes_read == 0 { + bail!("IPC socket closed while receiving DMA-BUF plane fds"); + } + + let mut received_any = false; + for cmsg in msg.cmsgs().context("iterating control messages")? { + if let ControlMessageOwned::ScmRights(received) = cmsg { + for fd in received { + fds.push(unsafe { OwnedFd::from_raw_fd(fd) }); + received_any = true; + } } } - } - if fds.len() < count { - bail!("received {} fds, need {}", fds.len(), count); + drop(iov); + if marker[0] != 0 { + bail!( + "unexpected byte in DMA-BUF descriptor marker: {}", + marker[0] + ); + } + if !received_any { + bail!("DMA-BUF plane marker arrived without an SCM_RIGHTS descriptor"); + } } fds.truncate(count); Ok(fds) diff --git a/src/handlers/compositor.rs b/src/handlers/compositor.rs index 573dd57..3044b8a 100644 --- a/src/handlers/compositor.rs +++ b/src/handlers/compositor.rs @@ -28,11 +28,16 @@ impl CompositorHandler for WoState { if let Some(data) = client.get_data::() { return &data.compositor_state; } - static LOGGED_ONCE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + static LOGGED_ONCE: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); if !LOGGED_ONCE.swap(true, std::sync::atomic::Ordering::Relaxed) { - tracing::info!("Client missing ClientData (likely XWayland), using fallback compositor state"); + tracing::info!( + "Client missing ClientData (likely XWayland), using fallback compositor state" + ); } else { - tracing::debug!("Client missing ClientData (likely XWayland), using fallback compositor state"); + tracing::debug!( + "Client missing ClientData (likely XWayland), using fallback compositor state" + ); } static FALLBACK: std::sync::OnceLock = std::sync::OnceLock::new(); FALLBACK.get_or_init(CompositorClientState::default) @@ -52,7 +57,11 @@ impl CompositorHandler for WoState { if let Some(window) = self .space .elements() - .find(|w| w.toplevel().map(|t| t.wl_surface() == &root).unwrap_or(false)) + .find(|w| { + w.toplevel() + .map(|t| t.wl_surface() == &root) + .unwrap_or(false) + }) .cloned() { window.on_commit(); @@ -71,11 +80,14 @@ impl CompositorHandler for WoState { window.on_commit(); self.dirty_surfaces.insert(root.clone()); } - } self.popup_manager.commit(surface); - crate::handlers::xdg_shell::ensure_initial_configure(surface, &self.space, &mut self.popup_manager); + crate::handlers::xdg_shell::ensure_initial_configure( + surface, + &self.space, + &mut self.popup_manager, + ); if self.debug_new_toplevel { tracing::info!("commit handler EXIT"); } diff --git a/src/handlers/dmabuf_handler.rs b/src/handlers/dmabuf_handler.rs index 66bee87..8908955 100644 --- a/src/handlers/dmabuf_handler.rs +++ b/src/handlers/dmabuf_handler.rs @@ -1,9 +1,9 @@ +use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::{ backend::allocator::dmabuf::Dmabuf, delegate_dmabuf, - wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, DmabufFeedback, ImportNotifier} + wayland::dmabuf::{DmabufFeedback, DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier}, }; -use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use crate::state::WoState; @@ -18,7 +18,7 @@ impl DmabufHandler for WoState { _dmabuf: Dmabuf, notifier: ImportNotifier, ) { - tracing::info!("dmabuf_imported: accepting DMABUF unconditionally"); + tracing::debug!("dmabuf_imported: accepting renderer-advertised DMABUF"); // Frames from Electron arrive via the custom IPC channel, not via the // Wayland DMABUF protocol. However, native Wayland clients (and Electron's // own GPU subprocess when it connects as a Wayland client) import DMABUFs @@ -29,7 +29,6 @@ impl DmabufHandler for WoState { // failure response to the client, preventing it from creating DMABUF-backed // wl_buffers entirely and causing the app (and rendering) to freeze. let _ = notifier.successful::(); - tracing::info!("dmabuf_imported: done"); } fn new_surface_feedback( diff --git a/src/handlers/input.rs b/src/handlers/input.rs index 664b822..49b6028 100644 --- a/src/handlers/input.rs +++ b/src/handlers/input.rs @@ -3,6 +3,7 @@ use smithay::{ AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, InputBackend, InputEvent, KeyState, KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, }, + desktop::WindowSurfaceType, input::keyboard::FilterResult, utils::SERIAL_COUNTER, }; @@ -155,11 +156,8 @@ impl WoState { let delta = (event.delta_x(), event.delta_y()); let current = self.pointer_location; - let new_pos: smithay::utils::Point = ( - (current.x + delta.0).clamp(0.0, self.output_size.0 as f64), - (current.y + delta.1).clamp(0.0, self.output_size.1 as f64), - ) - .into(); + let new_pos: smithay::utils::Point = + self.clamp_pointer_to_monitors((current.x + delta.0, current.y + delta.1).into()); // Hardware is the sole source of truth for pointer position. self.pointer_location = new_pos; @@ -241,11 +239,7 @@ impl WoState { .unwrap_or(initial_window_location); self.space .map_element(window.clone(), (new_x, new_y), false); - self.translate_dialog_children( - &window, - new_x - prev_loc.x, - new_y - prev_loc.y, - ); + self.translate_dialog_children(&window, new_x - prev_loc.x, new_y - prev_loc.y); self.metadata_dirty = true; } } @@ -256,9 +250,16 @@ impl WoState { } fn on_pointer_motion_abs(&mut self, event: B::PointerMotionAbsoluteEvent) { - let new_pos: smithay::utils::Point = event - .position_transformed((self.output_size.0 as i32, self.output_size.1 as i32).into()) - .into(); + let bounds = self.desktop_bounds(); + let transformed = event.position_transformed(bounds.size); + let new_pos: smithay::utils::Point = self + .clamp_pointer_to_monitors( + ( + transformed.x + bounds.loc.x as f64, + transformed.y + bounds.loc.y as f64, + ) + .into(), + ); // Hardware is the sole source of truth for pointer position. self.pointer_location = new_pos; @@ -339,11 +340,7 @@ impl WoState { .unwrap_or(initial_window_location); self.space .map_element(window.clone(), (new_x, new_y), false); - self.translate_dialog_children( - &window, - new_x - prev_loc.x, - new_y - prev_loc.y, - ); + self.translate_dialog_children(&window, new_x - prev_loc.x, new_y - prev_loc.y); self.metadata_dirty = true; } } @@ -405,12 +402,16 @@ impl WoState { let local_x = self.pointer_location.x - win_loc.x as f64; let local_y = self.pointer_location.y - win_loc.y as f64; window - .surface_under(Point::::from((local_x, local_y)), WindowSurfaceType::ALL) + .surface_under( + Point::::from((local_x, local_y)), + WindowSurfaceType::ALL, + ) .map(|(s, offset)| { let surface_global: Point = ( win_loc.x as f64 + offset.x as f64, win_loc.y as f64 + offset.y as f64, - ).into(); + ) + .into(); (s, surface_global) }) }); @@ -452,7 +453,9 @@ impl WoState { // pointer_button action will arrive shortly and set the // correct Wayland surface focus. Clearing it here would // cause focus to bounce (clear → set → clear → set). - let has_native_focus = self.seat.get_keyboard() + let has_native_focus = self + .seat + .get_keyboard() .map(|kb| kb.current_focus().is_some()) .unwrap_or(false); if !has_native_focus { @@ -488,17 +491,6 @@ impl WoState { let keyboard = self.seat.get_keyboard().unwrap(); keyboard.set_focus(self, Some(surface.clone()), serial); self.keyboard_window_focus = None; - - let pointer = self.seat.get_pointer().unwrap(); - pointer.button( - self, - &smithay::input::pointer::ButtonEvent { - button: button_code, - state: button_state, - serial, - time, - }, - ); } } else { // Clicked empty space: clear focus. @@ -507,9 +499,61 @@ impl WoState { keyboard.set_focus(self, None, serial); } } + + if let Some(pointer) = self.seat.get_pointer() { + pointer.button( + self, + &smithay::input::pointer::ButtonEvent { + button: button_code, + state: button_state, + serial, + time, + }, + ); + pointer.frame(self); + } } return; } + + if button_state == ButtonState::Pressed { + use smithay::utils::{Logical, Point}; + let pos: Point = self.pointer_location; + let under_surface = self.space.element_under(pos); + + if let Some((window, _)) = under_surface { + let surface = window + .x11_surface() + .and_then(|x11| x11.wl_surface()) + .or_else(|| { + window + .toplevel() + .map(|toplevel| toplevel.wl_surface().clone()) + }); + if let (Some(surface), Some(keyboard)) = (surface, self.seat.get_keyboard()) { + keyboard.set_focus(self, Some(surface), serial); + self.keyboard_window_focus = None; + } + } else { + self.keyboard_window_focus = None; + if let Some(keyboard) = self.seat.get_keyboard() { + keyboard.set_focus(self, None, serial); + } + } + } + + if let Some(pointer) = self.seat.get_pointer() { + pointer.button( + self, + &smithay::input::pointer::ButtonEvent { + button: button_code, + state: button_state, + serial, + time, + }, + ); + pointer.frame(self); + } } fn on_pointer_axis(&mut self, event: B::PointerAxisEvent) { @@ -539,17 +583,54 @@ impl WoState { if vertical != 0 || horizontal != 0 { if let Some(client) = ipc.clients.lock().unwrap().get(win_name).cloned() { // Non-blocking send. - let _ = client.try_send_input_event(&ElectronInputEvent::Scroll { - vertical, - horizontal, - time, - }); + if client + .try_send_input_event(&ElectronInputEvent::Scroll { + vertical, + horizontal, + time, + }) + .is_ok() + { + return; + } } } } } + self.dispatch_native_pointer_axis::(event, time); return; } + + self.dispatch_native_pointer_axis::(event, time); + } + + fn dispatch_native_pointer_axis( + &mut self, + event: B::PointerAxisEvent, + time: u32, + ) { + use smithay::input::pointer::AxisFrame; + + let Some(pointer) = self.seat.get_pointer() else { + return; + }; + + let mut frame = AxisFrame::new(time).source(event.source()); + for axis in [Axis::Horizontal, Axis::Vertical] { + if let Some(value) = event.amount(axis) { + frame = frame.value(axis, value); + } + if let Some(v120) = event.amount_v120(axis) { + frame = frame.v120(axis, v120.round() as i32); + if event.amount(axis).is_none() { + frame = frame.value(axis, v120 / 120.0 * 15.0); + } + } + frame = frame.relative_direction(axis, event.relative_direction(axis)); + } + + pointer.axis(self, frame); + pointer.frame(self); } /// Forward a pointer motion event from the client (comraw/Electron) to the @@ -579,21 +660,29 @@ impl WoState { let win_loc = self.space.element_location(&window).unwrap_or_default(); // Compute a synthetic global location from the client-provided - // surface-local coords. This is used ONLY for the MotionEvent so - // Smithay derives the correct wl_pointer surface-local coordinates. + // client-area coords. This is used only for MotionEvent so Smithay + // derives the correct wl_pointer surface-local coordinates. // pointer_location itself is NOT modified. - let dispatch_location: Point = ( - win_loc.x as f64 + x, - win_loc.y as f64 + y, - ).into(); + // The shell reports coordinates relative to the visible surface area. + // Convert them to window-element coordinates; geometry.loc marks the + // content inset inside that element (for example client shadow pixels). + let geometry_offset = window.geometry().loc; + let surface_x = x + geometry_offset.x as f64; + let surface_y = y + geometry_offset.y as f64; + let dispatch_location: Point = + (win_loc.x as f64 + surface_x, win_loc.y as f64 + surface_y).into(); let focus = window - .surface_under(Point::::from((x, y)), WindowSurfaceType::ALL) + .surface_under( + Point::::from((surface_x, surface_y)), + WindowSurfaceType::ALL, + ) .map(|(s, offset)| { let focus_pos: Point = ( win_loc.x as f64 + offset.x as f64, win_loc.y as f64 + offset.y as f64, - ).into(); + ) + .into(); (s, focus_pos) }); @@ -757,19 +846,32 @@ impl WoState { } /// Forward a pointer scroll event from the web UI. - pub fn handle_forwarded_pointer_scroll(&mut self, window_name: &str, dx: f64, dy: f64) { + pub fn handle_forwarded_pointer_scroll( + &mut self, + window_name: &str, + x: f64, + y: f64, + dx: f64, + dy: f64, + time: u32, + ) { use smithay::input::pointer::AxisFrame; if self.find_wayland_window(window_name).is_none() { return; } + // Position the Wayland pointer from the same wheel event. Keeping + // motion+axis in one IPC message prevents a wheel from arriving while + // Smithay still has focus on the previous surface. + self.handle_forwarded_pointer_event(window_name, x, y); + let pointer = match self.seat.get_pointer() { Some(p) => p, None => return, }; - let mut frame = AxisFrame::new(0).source(AxisSource::Wheel); + let mut frame = AxisFrame::new(time).source(AxisSource::Wheel); if dx != 0.0 { frame = frame.value(Axis::Horizontal, dx); } @@ -780,30 +882,83 @@ impl WoState { pointer.frame(self); } + /// End forwarded pointer focus after the web shell leaves a native window. + pub fn handle_forwarded_pointer_leave(&mut self, window_name: &str) { + let Some(window) = self.find_wayland_window(window_name) else { + return; + }; + let Some(pointer) = self.seat.get_pointer() else { + return; + }; + let surface = window + .toplevel() + .map(|toplevel| toplevel.wl_surface().clone()) + .or_else(|| window.x11_surface().and_then(|x11| x11.wl_surface())); + let pointer_locked = surface.as_ref().is_some_and(|surface| { + smithay::wayland::pointer_constraints::with_pointer_constraint( + surface, + &pointer, + |constraint| { + constraint.is_some_and(|constraint| { + matches!( + &*constraint, + smithay::wayland::pointer_constraints::PointerConstraint::Locked(_) + ) && constraint.is_active() + }) + }, + ) + }); + let serial = SERIAL_COUNTER.next_serial(); + pointer.motion( + self, + None, + &smithay::input::pointer::MotionEvent { + location: self.pointer_location, + serial, + time: 0, + }, + ); + pointer.frame(self); + // X11/Wayland applications can leave the shared cursor set to Hidden + // after the shell retakes pointer dispatch. Restore the normal desktop + // cursor unless a client still owns an active pointer lock. + if !pointer_locked { + self.cursor_status = smithay::input::pointer::CursorImageStatus::default_named(); + } + } + /// Forward relative motion received from the web UI to the Wayland surface. pub fn handle_forwarded_relative_pointer_event(&mut self, window_name: &str, dx: f64, dy: f64) { use smithay::input::pointer::RelativeMotionEvent; + use smithay::utils::{Logical, Point}; let window = match self.find_wayland_window(window_name) { Some(w) => w, None => return, }; - let surface = window - .toplevel() - .map(|t| t.wl_surface().clone()) - .or_else(|| window.x11_surface().and_then(|x| x.wl_surface().clone())); + let Some(pointer) = self.seat.get_pointer() else { + return; + }; + let Some(window_location) = self.space.element_location(&window) else { + return; + }; + let pointer_in_window: Point = + self.pointer_location - window_location.to_f64() + window.geometry().loc.to_f64(); + let Some((surface, surface_offset)) = + window.surface_under(pointer_in_window, WindowSurfaceType::ALL) + else { + return; + }; - if let Some(surface) = surface { - if let Some(pointer) = self.seat.get_pointer() { - let event = RelativeMotionEvent { - utime: 0, - delta: (dx, dy).into(), - delta_unaccel: (dx, dy).into(), - }; - pointer.relative_motion(self, Some((surface, (0.0, 0.0).into())), &event); - pointer.frame(self); - } - } + let surface_origin: Point = + window_location.to_f64() + surface_offset.to_f64(); + let event = RelativeMotionEvent { + utime: 0, + delta: (dx, dy).into(), + delta_unaccel: (dx, dy).into(), + }; + pointer.relative_motion(self, Some((surface, surface_origin)), &event); + pointer.frame(self); } } diff --git a/src/handlers/output.rs b/src/handlers/output.rs index a931703..a0b3317 100644 --- a/src/handlers/output.rs +++ b/src/handlers/output.rs @@ -21,15 +21,15 @@ impl WoState { let output = Output::new( "wo-output".to_string(), PhysicalProperties { - size: (0, 0).into(), + size: (0, 0).into(), subpixel: Subpixel::Unknown, - make: "Wo".into(), - model: "Virtual".into(), + make: "Wo".into(), + model: "Virtual".into(), }, ); let mode = smithay::output::Mode { - size: (width, height).into(), + size: (width, height).into(), refresh: refresh_mhz, }; diff --git a/src/handlers/screencopy.rs b/src/handlers/screencopy.rs index 4e0c4ee..a3ff502 100644 --- a/src/handlers/screencopy.rs +++ b/src/handlers/screencopy.rs @@ -2,7 +2,6 @@ /// /// Implements `zwlr_screencopy_manager_v1` allowing clients like grim, OBS, etc. /// to request frame copies of outputs or output regions. - use std::sync::{Arc, Mutex}; use tracing::{debug, info, warn}; use wayland_protocols_wlr::screencopy::v1::server::{ @@ -10,8 +9,8 @@ use wayland_protocols_wlr::screencopy::v1::server::{ zwlr_screencopy_manager_v1::{self, ZwlrScreencopyManagerV1}, }; use wayland_server::{ - Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, protocol::{wl_buffer::WlBuffer, wl_shm}, + Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, }; use crate::state::WoState; @@ -124,7 +123,9 @@ impl Dispatch for WoState { send_buffer_info(&frame_resource, w, h); debug!( "CaptureOutput: {}x{}, cursor_overlay={}", - w, h, overlay_cursor != 0 + w, + h, + overlay_cursor != 0 ); } zwlr_screencopy_manager_v1::Request::CaptureOutputRegion { @@ -156,7 +157,11 @@ impl Dispatch for WoState { send_buffer_info(&frame_resource, rw as u32, rh as u32); debug!( "CaptureOutputRegion: ({},{}) {}x{}, cursor_overlay={}", - rx, ry, rw, rh, overlay_cursor != 0 + rx, + ry, + rw, + rh, + overlay_cursor != 0 ); } zwlr_screencopy_manager_v1::Request::Destroy => { @@ -172,20 +177,11 @@ fn send_buffer_info(frame: &ZwlrScreencopyFrameV1, width: u32, height: u32) { let stride = width * 4; // ARGB8888 / BGRx // SHM buffer support (always available) - frame.buffer( - wl_shm::Format::Argb8888, - width, - height, - stride, - ); + frame.buffer(wl_shm::Format::Argb8888, width, height, stride); // DMABUF support (v3) if frame.version() >= 3 { - frame.linux_dmabuf( - drm_fourcc::DrmFourcc::Argb8888 as u32, - width, - height, - ); + frame.linux_dmabuf(drm_fourcc::DrmFourcc::Argb8888 as u32, width, height); frame.buffer_done(); } } diff --git a/src/handlers/xdg_decoration.rs b/src/handlers/xdg_decoration.rs index 2ee099d..54b950b 100644 --- a/src/handlers/xdg_decoration.rs +++ b/src/handlers/xdg_decoration.rs @@ -13,11 +13,12 @@ use crate::state::WoState; impl XdgDecorationHandler for WoState { fn new_decoration(&mut self, toplevel: ToplevelSurface) { tracing::info!("new_decoration: client attached xdg_decoration"); - + self.metadata_dirty = true; + let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32); let suggest_w = (ow * 2 / 3).max(640).min(1600); let suggest_h = (oh * 2 / 3).max(480).min(1000); - + toplevel.with_pending_state(|state| { state.size = Some((suggest_w, suggest_h).into()); }); @@ -25,11 +26,11 @@ impl XdgDecorationHandler for WoState { fn request_mode(&mut self, toplevel: ToplevelSurface, mode: Mode) { tracing::info!("request_mode: client requests {:?}", mode); - + let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32); let suggest_w = (ow * 2 / 3).max(640).min(1600); let suggest_h = (oh * 2 / 3).max(480).min(1000); - + toplevel.with_pending_state(|state| { state.decoration_mode = Some(mode); if state.size.is_none() { @@ -44,16 +45,17 @@ impl XdgDecorationHandler for WoState { self.ssd_windows.remove(&toplevel.wl_surface().id()); } } + self.metadata_dirty = true; toplevel.send_configure(); } fn unset_mode(&mut self, toplevel: ToplevelSurface) { tracing::info!("unset_mode: reverting to default client-side decorations"); - + let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32); let suggest_w = (ow * 2 / 3).max(640).min(1600); let suggest_h = (oh * 2 / 3).max(480).min(1000); - + toplevel.with_pending_state(|state| { state.decoration_mode = None; if state.size.is_none() { @@ -61,6 +63,7 @@ impl XdgDecorationHandler for WoState { } }); self.ssd_windows.remove(&toplevel.wl_surface().id()); + self.metadata_dirty = true; toplevel.send_configure(); } } diff --git a/src/handlers/xdg_shell.rs b/src/handlers/xdg_shell.rs index ee2a392..0ebf844 100644 --- a/src/handlers/xdg_shell.rs +++ b/src/handlers/xdg_shell.rs @@ -1,7 +1,11 @@ +use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::ResizeEdge; use smithay::{ delegate_xdg_shell, desktop::{PopupManager, Space, Window}, - reexports::wayland_server::{protocol::{wl_output, wl_seat}, Resource}, + reexports::wayland_server::{ + protocol::{wl_output, wl_seat}, + Resource, + }, utils::Serial, wayland::{ compositor::with_states, @@ -11,7 +15,6 @@ use smithay::{ }, }, }; -use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::ResizeEdge; use crate::state::WoState; @@ -31,7 +34,8 @@ impl XdgShellHandler for WoState { fn new_toplevel(&mut self, surface: ToplevelSurface) { tracing::info!("new_toplevel ENTER"); let app_id = with_states(surface.wl_surface(), |states| { - states.data_map + states + .data_map .get::() .and_then(|d| d.lock().ok().and_then(|data| data.app_id.clone())) }); @@ -42,8 +46,10 @@ impl XdgShellHandler for WoState { let oid = surface.wl_surface().id(); let stable_name = format!("wayland-{}", self.next_wayland_id); self.next_wayland_id += 1; - self.wayland_window_names.insert(oid.clone(), stable_name.clone()); - self.wayland_name_to_id.insert(stable_name.clone(), oid.clone()); + self.wayland_window_names + .insert(oid.clone(), stable_name.clone()); + self.wayland_name_to_id + .insert(stable_name.clone(), oid.clone()); tracing::info!(name = %stable_name, "assigned stable window name"); // Mark this Wayland window as mapped/visible for metadata and frontend self.window_mapped.insert(stable_name.clone(), true); @@ -63,7 +69,6 @@ impl XdgShellHandler for WoState { }) }); - let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32); let location = if let Some(ref parent_wl) = parent_surface { @@ -94,7 +99,7 @@ impl XdgShellHandler for WoState { // (clients that use xdg_decoration will get a sized configure later). if let Some(toplevel) = window.toplevel() { toplevel.send_configure(); - + // Give keyboard focus to newly mapped windows (unless they're dialogs with parents) // This ensures games and apps receive keyboard input immediately if parent_surface.is_none() { @@ -115,7 +120,8 @@ impl XdgShellHandler for WoState { // absolute popup rectangle and clamp it back into [0, output_size]. let parent_loc: Option> = smithay::wayland::compositor::with_states(surface.wl_surface(), |states| { - states.data_map + states + .data_map .get::() .and_then(|d| d.lock().ok().and_then(|locked| locked.parent.clone())) }) @@ -151,7 +157,10 @@ impl XdgShellHandler for WoState { if let Err(e) = surface.send_configure() { tracing::warn!("failed to send initial popup configure: {e}"); } - if let Err(e) = self.popup_manager.track_popup(smithay::desktop::PopupKind::Xdg(surface)) { + if let Err(e) = self + .popup_manager + .track_popup(smithay::desktop::PopupKind::Xdg(surface)) + { tracing::warn!("failed tracking popup: {e}"); } } @@ -171,7 +180,9 @@ impl XdgShellHandler for WoState { fn move_request(&mut self, surface: ToplevelSurface, _seat: wl_seat::WlSeat, _serial: Serial) { // Find the window element for this surface and start tracking a move. - let window = self.space.elements() + let window = self + .space + .elements() .find(|w| w.toplevel().map(|t| t == &surface).unwrap_or(false)) .cloned(); @@ -188,11 +199,13 @@ impl XdgShellHandler for WoState { fn resize_request( &mut self, surface: ToplevelSurface, - _seat: wl_seat::WlSeat, - _serial: Serial, - edges: ResizeEdge, + _seat: wl_seat::WlSeat, + _serial: Serial, + edges: ResizeEdge, ) { - let window = self.space.elements() + let window = self + .space + .elements() .find(|w| w.toplevel().map(|t| t == &surface).unwrap_or(false)) .cloned(); @@ -210,30 +223,45 @@ impl XdgShellHandler for WoState { } } - fn grab(&mut self, _surface: PopupSurface, _seat: wl_seat::WlSeat, _serial: Serial) { } + fn grab(&mut self, _surface: PopupSurface, _seat: wl_seat::WlSeat, _serial: Serial) {} - fn fullscreen_request(&mut self, surface: ToplevelSurface, _output: Option) { - let window = self.space.elements() + fn fullscreen_request( + &mut self, + surface: ToplevelSurface, + _output: Option, + ) { + let window = self + .space + .elements() .find(|w| w.toplevel().map(|t| t == &surface).unwrap_or(false)) .cloned(); if let Some(window) = window { use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::State as XdgState; - let th = self.title_h_for_window(&window); - let y_offset = th.max(PANEL_H); let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32); - let content_h = (oh - y_offset).max(1); + let name = self.wayland_window_name(&window).cloned(); + if let Some(ref name) = name { + if !self.fullscreen_windows.contains(name) { + let loc = self.space.element_location(&window).unwrap_or_default(); + let size = window.geometry().size; + self.fullscreen_restore.insert( + name.clone(), + (loc.x, loc.y, size.w.max(1) as u32, size.h.max(1) as u32), + ); + } + self.fullscreen_windows.insert(name.clone()); + } surface.with_pending_state(|state| { state.states.set(XdgState::Fullscreen); state.states.unset(XdgState::Maximized); - state.size = Some((ow, content_h).into()); + state.size = Some((ow, oh).into()); }); surface.send_configure(); - self.space.map_element(window.clone(), (0, y_offset), false); + self.space.map_element(window.clone(), (0, 0), false); self.metadata_dirty = true; - + // Automatically give keyboard focus to fullscreen windows (important for games) let serial = smithay::utils::SERIAL_COUNTER.next_serial(); if let Some(keyboard) = self.seat.get_keyboard() { @@ -243,24 +271,33 @@ impl XdgShellHandler for WoState { } fn unfullscreen_request(&mut self, surface: ToplevelSurface) { - let window = self.space.elements() + let window = self + .space + .elements() .find(|w| w.toplevel().map(|t| t == &surface).unwrap_or(false)) .cloned(); - if let Some(_window) = window { + if let Some(window) = window { use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::State as XdgState; - let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32); - let suggest_w = (ow * 2 / 3).max(640).min(1600); - let suggest_h = (oh * 2 / 3).max(480).min(1000); + let name = self.wayland_window_name(&window).cloned(); + let restore = name + .as_ref() + .and_then(|name| self.fullscreen_restore.remove(name)); + if let Some(ref name) = name { + self.fullscreen_windows.remove(name); + } + let (x, y, width, height) = restore.unwrap_or_else(|| { + let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32); + ((ow / 6), (oh / 6), (ow * 2 / 3) as u32, (oh * 2 / 3) as u32) + }); surface.with_pending_state(|state| { state.states.unset(XdgState::Fullscreen); - if state.size.is_none() { - state.size = Some((suggest_w, suggest_h).into()); - } + state.size = Some((width as i32, height as i32).into()); }); surface.send_configure(); + self.space.map_element(window, (x, y), false); self.metadata_dirty = true; } } @@ -273,6 +310,8 @@ impl XdgShellHandler for WoState { let oid = surface.wl_surface().id(); if let Some(name) = self.wayland_window_names.remove(&oid) { self.wayland_name_to_id.remove(&name); + self.fullscreen_restore.remove(&name); + self.fullscreen_windows.remove(&name); if self.keyboard_window_focus.as_deref() == Some(&name) { self.keyboard_window_focus = None; } @@ -293,11 +332,7 @@ impl XdgShellHandler for WoState { let to_remove = self .all_windows .iter() - .find(|w| { - w.toplevel() - .map(|t| t == &surface) - .unwrap_or(false) - }) + .find(|w| w.toplevel().map(|t| t == &surface).unwrap_or(false)) .cloned(); if let Some(window) = to_remove { @@ -331,10 +366,9 @@ pub enum GrabState { /// Send the initial configure to a surface if it hasn't received one yet. pub fn ensure_initial_configure( surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, - space: &Space, - popups: &mut PopupManager, + space: &Space, + popups: &mut PopupManager, ) { - if let Some(window) = space .elements() .find(|w| { diff --git a/src/handlers/xwayland.rs b/src/handlers/xwayland.rs index 379fafd..bcac1d5 100644 --- a/src/handlers/xwayland.rs +++ b/src/handlers/xwayland.rs @@ -3,20 +3,18 @@ //! Implements [`XwmHandler`] and [`XWaylandShellHandler`] so that X11 clients //! running under XWayland can be managed by the compositor. +use smithay::reexports::wayland_server::Resource; +use smithay::xwayland::xwm::X11Window; use smithay::{ desktop::Window, utils::{Logical, Rectangle}, wayland::xwayland_shell::{XWaylandShellHandler, XWaylandShellState}, - xwayland::xwm::{ - Reorder, ResizeEdge as X11ResizeEdge, X11Surface, X11Wm, XwmHandler, XwmId, - }, + xwayland::xwm::{Reorder, ResizeEdge as X11ResizeEdge, X11Surface, X11Wm, XwmHandler, XwmId}, }; -use smithay::xwayland::xwm::X11Window; -use smithay::reexports::wayland_server::Resource; use tracing::{info, warn}; -use crate::state::WoState; use crate::handlers::xdg_shell::PANEL_H; +use crate::state::WoState; impl XwmHandler for WoState { fn xwm_state(&mut self, _xwm: XwmId) -> &mut X11Wm { @@ -51,20 +49,21 @@ impl XwmHandler for WoState { } let stable_name = format!("x11-{}", window.window_id()); - self.x11_window_names.insert(window.window_id(), stable_name.clone()); + self.x11_window_names + .insert(window.window_id(), stable_name.clone()); let parent_x11_id = window.is_transient_for(); let geo = window.geometry(); - + let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32); - + let location = if let Some(parent_id) = parent_x11_id { info!( window_id = window.window_id(), parent_id = parent_id, "XWayland: window is transient-for (dialog)" ); - + let parent_center = self .space .elements() @@ -79,19 +78,19 @@ impl XwmHandler for WoState { Some((loc.x + geo.size.w / 2, loc.y + geo.size.h / 2)) }) .unwrap_or_else(|| (ow / 2, oh / 2)); - + let dialog_y = (parent_center.1 - 75).max(PANEL_H); (parent_center.0 - 200, dialog_y) } else { let adjusted_y = geo.loc.y.max(PANEL_H); (geo.loc.x, adjusted_y) }; - + let win = Window::new_x11_window(window.clone()); self.all_windows.push(win.clone()); self.space.map_element(win, location, true); self.metadata_dirty = true; - + if parent_x11_id.is_none() { if let Some(wl_surface) = window.wl_surface() { let serial = smithay::utils::SERIAL_COUNTER.next_serial(); @@ -122,10 +121,7 @@ impl XwmHandler for WoState { } fn unmapped_window(&mut self, _xwm: XwmId, window: X11Surface) { - info!( - window_id = window.window_id(), - "XWayland: window unmapped" - ); + info!(window_id = window.window_id(), "XWayland: window unmapped"); if let Some(name) = self.x11_window_names.get(&window.window_id()).cloned() { if self.keyboard_window_focus.as_deref() == Some(&name) { @@ -145,11 +141,15 @@ impl XwmHandler for WoState { } } - let to_remove = self.space.elements().find(|w| { - w.x11_surface() - .map(|x| x.window_id() == window.window_id()) - .unwrap_or(false) - }).cloned(); + let to_remove = self + .space + .elements() + .find(|w| { + w.x11_surface() + .map(|x| x.window_id() == window.window_id()) + .unwrap_or(false) + }) + .cloned(); if let Some(w) = to_remove { self.space.unmap_elem(&w); @@ -160,10 +160,7 @@ impl XwmHandler for WoState { } fn destroyed_window(&mut self, _xwm: XwmId, window: X11Surface) { - info!( - window_id = window.window_id(), - "XWayland: window destroyed" - ); + info!(window_id = window.window_id(), "XWayland: window destroyed"); if let Some(name) = self.x11_window_names.get(&window.window_id()).cloned() { if self.keyboard_window_focus.as_deref() == Some(&name) { @@ -183,13 +180,20 @@ impl XwmHandler for WoState { } } - self.x11_window_names.remove(&window.window_id()); + if let Some(name) = self.x11_window_names.remove(&window.window_id()) { + self.fullscreen_restore.remove(&name); + self.fullscreen_windows.remove(&name); + } - let to_remove = self.space.elements().find(|w| { - w.x11_surface() - .map(|x| x.window_id() == window.window_id()) - .unwrap_or(false) - }).cloned(); + let to_remove = self + .space + .elements() + .find(|w| { + w.x11_surface() + .map(|x| x.window_id() == window.window_id()) + .unwrap_or(false) + }) + .cloned(); if let Some(w) = to_remove { self.space.unmap_elem(&w); self.all_windows.retain(|win| win != &w); @@ -219,7 +223,8 @@ impl XwmHandler for WoState { _window: X11Surface, _geometry: Rectangle, _above: Option, - ) {} + ) { + } fn resize_request( &mut self, @@ -227,7 +232,8 @@ impl XwmHandler for WoState { _window: X11Surface, _button: u32, _resize_edge: X11ResizeEdge, - ) {} + ) { + } fn move_request(&mut self, _xwm: XwmId, _window: X11Surface, _button: u32) {} @@ -242,32 +248,36 @@ impl XwmHandler for WoState { return; } - let th = if let Some(wl_surface) = window.wl_surface() { - if self.ssd_windows.contains(&wl_surface.id()) { - 30 - } else { - 0 - } - } else { - 0 - }; - let y_offset = th.max(PANEL_H); let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32); - let content_h = (oh - y_offset).max(1); + let name = self.x11_window_names.get(&window.window_id()).cloned(); + let win = self + .space + .elements() + .find(|w| { + w.x11_surface() + .map(|x| x.window_id() == window.window_id()) + .unwrap_or(false) + }) + .cloned(); + if let (Some(name), Some(win)) = (name.as_ref(), win.as_ref()) { + if !self.fullscreen_windows.contains(name) { + let loc = self.space.element_location(win).unwrap_or_default(); + let size = window.geometry().size; + self.fullscreen_restore.insert( + name.clone(), + (loc.x, loc.y, size.w.max(1) as u32, size.h.max(1) as u32), + ); + } + self.fullscreen_windows.insert(name.clone()); + } #[allow(deprecated)] - if let Err(e) = window.configure(Some(Rectangle::from_loc_and_size((0, y_offset), (ow, content_h)))) { + if let Err(e) = window.configure(Some(Rectangle::from_loc_and_size((0, 0), (ow, oh)))) { warn!("XWayland: fullscreen configure failed: {e}"); } - let win = self.space.elements().find(|w| { - w.x11_surface() - .map(|x| x.window_id() == window.window_id()) - .unwrap_or(false) - }).cloned(); - if let Some(w) = win { - self.space.map_element(w, (0, y_offset), false); + self.space.map_element(w, (0, 0), false); } // Automatically give keyboard focus to fullscreen windows (important for games) @@ -297,14 +307,37 @@ impl XwmHandler for WoState { return; } - let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32); - let suggest_w = (ow * 2 / 3).max(640).min(1600); - let suggest_h = (oh * 2 / 3).max(480).min(1000); + let name = self.x11_window_names.get(&window.window_id()).cloned(); + let restore = name + .as_ref() + .and_then(|name| self.fullscreen_restore.remove(name)); + if let Some(ref name) = name { + self.fullscreen_windows.remove(name); + } + let (x, y, width, height) = restore.unwrap_or_else(|| { + let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32); + (ow / 6, oh / 6, (ow * 2 / 3) as u32, (oh * 2 / 3) as u32) + }); #[allow(deprecated)] - if let Err(e) = window.configure(Some(Rectangle::from_loc_and_size((0, 0), (suggest_w, suggest_h)))) { + if let Err(e) = window.configure(Some(Rectangle::from_loc_and_size( + (x, y), + (width as i32, height as i32), + ))) { warn!("XWayland: unfullscreen configure failed: {e}"); } + let mapped_window = self + .space + .elements() + .find(|w| { + w.x11_surface() + .map(|x| x.window_id() == window.window_id()) + .unwrap_or(false) + }) + .cloned(); + if let Some(win) = mapped_window { + self.space.map_element(win, (x, y), false); + } self.metadata_dirty = true; } } diff --git a/src/main.rs b/src/main.rs index 6a8cd9b..ed37af9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,13 +25,13 @@ use smithay::{ Swapchain, }, drm::{ - gbm::{framebuffer_from_bo, GbmFramebuffer}, DrmDevice, DrmDeviceFd, DrmNode, PlaneConfig, - PlaneState, + gbm::{framebuffer_from_bo, GbmFramebuffer}, + DrmDevice, DrmDeviceFd, DrmNode, PlaneConfig, PlaneState, }, egl::{EGLContext, EGLDisplay}, libinput::{LibinputInputBackend, LibinputSessionInterface}, renderer::{gles::GlesRenderer, Bind, Color32F, Frame, ImportDma, Renderer}, - session::{Session, libseat::LibSeatSession}, + session::{libseat::LibSeatSession, Session}, udev::primary_gpu, }, output::{Mode as OutputMode, Output, PhysicalProperties, Scale, Subpixel}, @@ -41,21 +41,55 @@ use smithay::{ wayland_server::{Display, ListeningSocket}, }, utils::{Buffer, DeviceFd, Physical, Rectangle, Transform}, - wayland::{ - xdg_activation::XdgActivationHandler, - xwayland_shell::XWaylandShellState, - }, + wayland::{xdg_activation::XdgActivationHandler, xwayland_shell::XWaylandShellState}, xwayland::{xwm::X11Wm, XWayland, XWaylandEvent}, }; -use std::path::Path; use std::{ io::Write, + path::PathBuf, time::{Duration, Instant}, }; use tracing::{error, info, trace, warn}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; use wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_frame_v1; +/// Resolve a companion executable in either the user install layout (next to +/// `wo`) or a distro layout where helpers live in PATH. +pub(crate) fn companion_binary(name: &str) -> PathBuf { + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + let sibling = parent.join(name); + if sibling.is_file() { + return sibling; + } + } + } + + if let Some(path) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&path) { + let candidate = dir.join(name); + if candidate.is_file() { + return candidate; + } + } + } + let system_candidate = PathBuf::from("/usr/bin").join(name); + if system_candidate.is_file() { + return system_candidate; + } + PathBuf::from(name) +} + +pub(crate) fn command_uses_shell(command: &str) -> bool { + command.chars().any(char::is_whitespace) + || command.contains('\'') + || command.contains('"') + || command.contains('$') + || command.contains('|') + || command.contains('&') + || command.contains(';') +} + pub mod state; pub mod syscall; @@ -66,6 +100,44 @@ use crate::{ state::{BackendData, WoState}, }; +/// Spawn zero-delay configured services before root pages are loaded. This is +/// generic command handling; package-specific paths remain in the config. +pub(crate) fn spawn_immediate_autostarts(config: &mut Config) { + let mut later = Vec::new(); + for task in config.autostart.drain(..) { + if task.delay > 0 + || task.restart + || task.command.starts_with("wo://window/") + || task.command == "wo://portal/start" + { + later.push(task); + continue; + } + + let result = if let Some(path) = task.command.strip_prefix("file://") { + std::process::Command::new(path).spawn() + } else if command_uses_shell(&task.command) { + std::process::Command::new("sh") + .arg("-c") + .arg(&task.command) + .spawn() + } else { + std::process::Command::new(&task.command).spawn() + }; + match result { + Ok(child) => info!( + name = %task.name, + pid = child.id(), + "Started configured service before root windows" + ), + Err(e) => { + error!(name = %task.name, command = %task.command, "Failed to start configured service: {e}") + } + } + } + config.autostart = later; +} + struct EventLoopData { display: Display, state: WoState, @@ -88,8 +160,8 @@ struct EventLoopData { /// per-window frame stats if needed. #[allow(dead_code)] frame_timestamps: std::collections::HashMap, - gles_tex_cache: std::collections::HashMap, - input_events_pending: bool, + gles_tex_cache: + std::collections::HashMap, /// Track previous cursor position to detect cursor movement for damage tracking. last_cursor_pos: (f64, f64), session: smithay::backend::session::libseat::LibSeatSession, @@ -113,6 +185,36 @@ struct EventLoopData { /// The original is consumed by `LibinputInputBackend`; this clone lets us /// call `suspend()` / `resume()` from the session notifier callback. libinput_context: Libinput, + /// Additional physical outputs. The primary pipeline remains in the + /// fields above so the legacy event path stays simple while each monitor + /// gets an independent scanout buffer and page-flip state. + additional_outputs: Vec, + primary_connector: smithay::reexports::drm::control::connector::Handle, + swapchain_format: Fourcc, + swapchain_modifiers: Vec, + active_output: Output, + active_origin: smithay::utils::Point, + force_output_render: bool, +} + +struct AdditionalOutput { + monitor: crate::state::MonitorOutput, + connector: smithay::reexports::drm::control::connector::Handle, + swapchain: Swapchain>, + drm_surface: smithay::backend::drm::DrmSurface, + output_rect: Rectangle, + src_rect: Rectangle, + frame_time: Duration, + width: u32, + height: u32, + last_render: Instant, + first_render: bool, + vblank_pending: bool, + vblank_since: Option, + consecutive_flip_failures: u32, + total_flips: u64, + current_slot: Option>, + pending_slot: Option>, } /// Newtype wrapper around `Option` for the DRM calloop event @@ -124,34 +226,90 @@ struct DrmLoopData(Option); impl std::ops::Deref for DrmLoopData { type Target = Option; - fn deref(&self) -> &Self::Target { &self.0 } + fn deref(&self) -> &Self::Target { + &self.0 + } } impl std::ops::DerefMut for DrmLoopData { - fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } } // XwmHandler delegation so X11Wm calloop sources work on the DRM event loop. impl smithay::xwayland::xwm::XwmHandler for DrmLoopData { fn xwm_state(&mut self, xwm: smithay::xwayland::xwm::XwmId) -> &mut X11Wm { - self.0.as_mut().expect("EventLoopData initialized").state.xwm_state(xwm) + self.0 + .as_mut() + .expect("EventLoopData initialized") + .state + .xwm_state(xwm) } - fn new_window(&mut self, xwm: smithay::xwayland::xwm::XwmId, window: smithay::xwayland::xwm::X11Surface) { - self.0.as_mut().expect("EventLoopData initialized").state.new_window(xwm, window) + fn new_window( + &mut self, + xwm: smithay::xwayland::xwm::XwmId, + window: smithay::xwayland::xwm::X11Surface, + ) { + self.0 + .as_mut() + .expect("EventLoopData initialized") + .state + .new_window(xwm, window) } - fn new_override_redirect_window(&mut self, xwm: smithay::xwayland::xwm::XwmId, window: smithay::xwayland::xwm::X11Surface) { - self.0.as_mut().expect("EventLoopData initialized").state.new_override_redirect_window(xwm, window) + fn new_override_redirect_window( + &mut self, + xwm: smithay::xwayland::xwm::XwmId, + window: smithay::xwayland::xwm::X11Surface, + ) { + self.0 + .as_mut() + .expect("EventLoopData initialized") + .state + .new_override_redirect_window(xwm, window) } - fn map_window_request(&mut self, xwm: smithay::xwayland::xwm::XwmId, window: smithay::xwayland::xwm::X11Surface) { - self.0.as_mut().expect("EventLoopData initialized").state.map_window_request(xwm, window) + fn map_window_request( + &mut self, + xwm: smithay::xwayland::xwm::XwmId, + window: smithay::xwayland::xwm::X11Surface, + ) { + self.0 + .as_mut() + .expect("EventLoopData initialized") + .state + .map_window_request(xwm, window) } - fn mapped_override_redirect_window(&mut self, xwm: smithay::xwayland::xwm::XwmId, window: smithay::xwayland::xwm::X11Surface) { - self.0.as_mut().expect("EventLoopData initialized").state.mapped_override_redirect_window(xwm, window) + fn mapped_override_redirect_window( + &mut self, + xwm: smithay::xwayland::xwm::XwmId, + window: smithay::xwayland::xwm::X11Surface, + ) { + self.0 + .as_mut() + .expect("EventLoopData initialized") + .state + .mapped_override_redirect_window(xwm, window) } - fn unmapped_window(&mut self, xwm: smithay::xwayland::xwm::XwmId, window: smithay::xwayland::xwm::X11Surface) { - self.0.as_mut().expect("EventLoopData initialized").state.unmapped_window(xwm, window) + fn unmapped_window( + &mut self, + xwm: smithay::xwayland::xwm::XwmId, + window: smithay::xwayland::xwm::X11Surface, + ) { + self.0 + .as_mut() + .expect("EventLoopData initialized") + .state + .unmapped_window(xwm, window) } - fn destroyed_window(&mut self, xwm: smithay::xwayland::xwm::XwmId, window: smithay::xwayland::xwm::X11Surface) { - self.0.as_mut().expect("EventLoopData initialized").state.destroyed_window(xwm, window) + fn destroyed_window( + &mut self, + xwm: smithay::xwayland::xwm::XwmId, + window: smithay::xwayland::xwm::X11Surface, + ) { + self.0 + .as_mut() + .expect("EventLoopData initialized") + .state + .destroyed_window(xwm, window) } fn configure_request( &mut self, @@ -163,7 +321,11 @@ impl smithay::xwayland::xwm::XwmHandler for DrmLoopData { h: Option, reorder: Option, ) { - self.0.as_mut().expect("EventLoopData initialized").state.configure_request(xwm, window, x, y, w, h, reorder) + self.0 + .as_mut() + .expect("EventLoopData initialized") + .state + .configure_request(xwm, window, x, y, w, h, reorder) } fn configure_notify( &mut self, @@ -172,7 +334,11 @@ impl smithay::xwayland::xwm::XwmHandler for DrmLoopData { geometry: smithay::utils::Rectangle, above: Option, ) { - self.0.as_mut().expect("EventLoopData initialized").state.configure_notify(xwm, window, geometry, above) + self.0 + .as_mut() + .expect("EventLoopData initialized") + .state + .configure_notify(xwm, window, geometry, above) } fn resize_request( &mut self, @@ -181,22 +347,57 @@ impl smithay::xwayland::xwm::XwmHandler for DrmLoopData { button: u32, resize_edge: smithay::xwayland::xwm::ResizeEdge, ) { - self.0.as_mut().expect("EventLoopData initialized").state.resize_request(xwm, window, button, resize_edge) + self.0 + .as_mut() + .expect("EventLoopData initialized") + .state + .resize_request(xwm, window, button, resize_edge) } - fn move_request(&mut self, xwm: smithay::xwayland::xwm::XwmId, window: smithay::xwayland::xwm::X11Surface, button: u32) { - self.0.as_mut().expect("EventLoopData initialized").state.move_request(xwm, window, button) + fn move_request( + &mut self, + xwm: smithay::xwayland::xwm::XwmId, + window: smithay::xwayland::xwm::X11Surface, + button: u32, + ) { + self.0 + .as_mut() + .expect("EventLoopData initialized") + .state + .move_request(xwm, window, button) } - fn fullscreen_request(&mut self, xwm: smithay::xwayland::xwm::XwmId, window: smithay::xwayland::xwm::X11Surface) { - self.0.as_mut().expect("EventLoopData initialized").state.fullscreen_request(xwm, window) + fn fullscreen_request( + &mut self, + xwm: smithay::xwayland::xwm::XwmId, + window: smithay::xwayland::xwm::X11Surface, + ) { + self.0 + .as_mut() + .expect("EventLoopData initialized") + .state + .fullscreen_request(xwm, window) } - fn unfullscreen_request(&mut self, xwm: smithay::xwayland::xwm::XwmId, window: smithay::xwayland::xwm::X11Surface) { - self.0.as_mut().expect("EventLoopData initialized").state.unfullscreen_request(xwm, window) + fn unfullscreen_request( + &mut self, + xwm: smithay::xwayland::xwm::XwmId, + window: smithay::xwayland::xwm::X11Surface, + ) { + self.0 + .as_mut() + .expect("EventLoopData initialized") + .state + .unfullscreen_request(xwm, window) } } impl smithay::wayland::xwayland_shell::XWaylandShellHandler for DrmLoopData { - fn xwayland_shell_state(&mut self) -> &mut smithay::wayland::xwayland_shell::XWaylandShellState { - self.0.as_mut().expect("EventLoopData initialized").state.xwayland_shell_state() + fn xwayland_shell_state( + &mut self, + ) -> &mut smithay::wayland::xwayland_shell::XWaylandShellState { + self.0 + .as_mut() + .expect("EventLoopData initialized") + .state + .xwayland_shell_state() } fn surface_associated( &mut self, @@ -204,7 +405,11 @@ impl smithay::wayland::xwayland_shell::XWaylandShellHandler for DrmLoopData { wl_surface: smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, window: smithay::xwayland::xwm::X11Surface, ) { - self.0.as_mut().expect("EventLoopData initialized").state.surface_associated(xwm, wl_surface, window) + self.0 + .as_mut() + .expect("EventLoopData initialized") + .state + .surface_associated(xwm, wl_surface, window) } } @@ -399,12 +604,23 @@ fn main() -> Result<(), anyhow::Error> { registry.init(); } + // `wo portals` is the supported entry point for launching Wo's xdg-desktop-portal backend. + if std::env::args().nth(1).as_deref() == Some("portals") { + let portal = companion_binary("xdg-desktop-portal-wo"); + let status = std::process::Command::new(&portal) + .status() + .with_context(|| format!("starting portal backend {}", portal.display()))?; + anyhow::ensure!(status.success(), "portal backend exited with {status}"); + return Ok(()); + } + info!("wo compositor starting"); let config = Config::load().context("loading config")?; - // Apply configured environment variables (e.g. DISPLAY=:0) so that all - // children inherit them. XWayland will override DISPLAY later if needed. + // Apply explicit environment overrides. Nested startup temporarily hides + // DISPLAY when a host Wayland socket is available; DRM startup clears it + // before creating Wo's own XWayland server. config.apply_environment(); // Determine backend priority: nested (gles) is prioritized with DRM fallback @@ -428,6 +644,82 @@ fn main() -> Result<(), anyhow::Error> { /// Render a single frame - called by VBlank events fn render_frame(data: &mut EventLoopData) { + let redraw_all_outputs = data.first_render + || !data.state.damaged_windows.is_empty() + || !data.state.dirty_surfaces.is_empty() + || (data.state.pointer_location.x, data.state.pointer_location.y) != data.last_cursor_pos + || data + .state + .screencopy_state + .as_ref() + .is_some_and(|s| s.has_pending_frames()) + || data + .state + .portal + .as_ref() + .is_some_and(|p| !p.get_active_sessions().is_empty()); + render_frame_single(data); + if !redraw_all_outputs { + return; + } + + for index in 0..data.additional_outputs.len() { + let monitor = data.additional_outputs[index].monitor.clone(); + { + let output = &mut data.additional_outputs[index]; + std::mem::swap(&mut data.swapchain, &mut output.swapchain); + std::mem::swap(&mut data.drm_surface, &mut output.drm_surface); + std::mem::swap(&mut data.output_rect, &mut output.output_rect); + std::mem::swap(&mut data.src_rect, &mut output.src_rect); + std::mem::swap(&mut data.frame_time, &mut output.frame_time); + std::mem::swap(&mut data.width, &mut output.width); + std::mem::swap(&mut data.height, &mut output.height); + std::mem::swap(&mut data.last_render, &mut output.last_render); + std::mem::swap(&mut data.first_render, &mut output.first_render); + std::mem::swap(&mut data.vblank_pending, &mut output.vblank_pending); + std::mem::swap(&mut data.vblank_since, &mut output.vblank_since); + std::mem::swap( + &mut data.consecutive_flip_failures, + &mut output.consecutive_flip_failures, + ); + std::mem::swap(&mut data.total_flips, &mut output.total_flips); + std::mem::swap(&mut data.current_slot, &mut output.current_slot); + std::mem::swap(&mut data.pending_slot, &mut output.pending_slot); + } + + let primary_output = data.active_output.clone(); + let primary_origin = data.active_origin; + data.active_output = monitor.output; + data.active_origin = monitor.location; + data.force_output_render = true; + render_frame_single(data); + data.force_output_render = false; + data.active_output = primary_output; + data.active_origin = primary_origin; + + let output = &mut data.additional_outputs[index]; + std::mem::swap(&mut data.pending_slot, &mut output.pending_slot); + std::mem::swap(&mut data.current_slot, &mut output.current_slot); + std::mem::swap(&mut data.total_flips, &mut output.total_flips); + std::mem::swap( + &mut data.consecutive_flip_failures, + &mut output.consecutive_flip_failures, + ); + std::mem::swap(&mut data.vblank_since, &mut output.vblank_since); + std::mem::swap(&mut data.vblank_pending, &mut output.vblank_pending); + std::mem::swap(&mut data.first_render, &mut output.first_render); + std::mem::swap(&mut data.last_render, &mut output.last_render); + std::mem::swap(&mut data.height, &mut output.height); + std::mem::swap(&mut data.width, &mut output.width); + std::mem::swap(&mut data.frame_time, &mut output.frame_time); + std::mem::swap(&mut data.src_rect, &mut output.src_rect); + std::mem::swap(&mut data.output_rect, &mut output.output_rect); + std::mem::swap(&mut data.drm_surface, &mut output.drm_surface); + std::mem::swap(&mut data.swapchain, &mut output.swapchain); + } +} + +fn render_frame_single(data: &mut EventLoopData) { // Bail if session is paused (VT switched away) if !data.active { trace!("Skipping render: session inactive"); @@ -457,24 +749,20 @@ fn render_frame(data: &mut EventLoopData) { } } - // Frame pacing: respect adaptive timing for input responsiveness - let adaptive_frame_time = if data.input_events_pending { - Duration::from_millis(8) - } else { - data.frame_time - }; - - if !data.first_render && data.last_render.elapsed() < adaptive_frame_time { + // Follow the active output's refresh interval even while input is busy. + // The old 8 ms input interval capped rendering around 125 Hz on high + // refresh displays, because libinput events keep this flag asserted. + if !data.first_render && data.last_render.elapsed() < data.frame_time { return; } data.last_render = std::time::Instant::now(); - data.input_events_pending = false; // First render: mark all windows damaged and log diagnostic state if data.first_render { info!( "First render: output={}x{}, windows={}, bg={:?}", - data.width, data.height, + data.width, + data.height, data.state.config.windows.len(), data.bg, ); @@ -492,9 +780,15 @@ fn render_frame(data: &mut EventLoopData) { } // Check for pending screencopy requests that require a fresh render - let has_screencopy = data.state.screencopy_state.as_ref() + let has_screencopy = data + .state + .screencopy_state + .as_ref() .map_or(false, |s| s.has_pending_frames()); - let has_portal_streams = data.state.portal.as_ref() + let has_portal_streams = data + .state + .portal + .as_ref() .map_or(false, |p| !p.get_active_sessions().is_empty()); // Skip the expensive render+flip cycle when nothing visual has changed. @@ -504,7 +798,8 @@ fn render_frame(data: &mut EventLoopData) { || !data.state.dirty_surfaces.is_empty() || cursor_moved || has_screencopy - || has_portal_streams; + || has_portal_streams + || data.force_output_render; if !frame_dirty { return; @@ -557,30 +852,38 @@ fn render_frame(data: &mut EventLoopData) { // Prepare Wayland surface elements before starting the frame. // When Electron IPC is active, surfaces are exported via SHM/DMABUF // to the client — skip the expensive GL import here. - use smithay::backend::renderer::element::{surface::render_elements_from_surface_tree, surface::WaylandSurfaceRenderElement, Kind, RenderElement}; + use smithay::backend::renderer::element::{ + surface::render_elements_from_surface_tree, surface::WaylandSurfaceRenderElement, Kind, + RenderElement, + }; use smithay::utils::Scale as ScaleF; - + let scale = ScaleF::from(1.0); let has_electron = data.state.electron_ipc.is_some(); let mut wayland_elements: Vec> = Vec::new(); - + if !has_electron { for window in data.state.space.elements() { if let Some(location) = data.state.space.element_location(window) { - let surface = window.toplevel() + let surface = window + .toplevel() .map(|t| t.wl_surface().clone()) .or_else(|| window.x11_surface().and_then(|x| x.wl_surface().clone())); - + if let Some(surface) = surface { - let elements: Vec> = render_elements_from_surface_tree( - renderer, - &surface, - (location.x, location.y), - scale, - 1.0, - Kind::Unspecified, - ); - + let elements: Vec> = + render_elements_from_surface_tree( + renderer, + &surface, + ( + location.x - data.active_origin.x, + location.y - data.active_origin.y, + ), + scale, + 1.0, + Kind::Unspecified, + ); + wayland_elements.extend(elements); } } @@ -589,11 +892,16 @@ fn render_frame(data: &mut EventLoopData) { // Prepare cursor elements from the cursor surface (set by Wayland clients) let cursor_elements: Vec> = - if let smithay::input::pointer::CursorImageStatus::Surface(ref cursor_surface) = data.state.cursor_status { + if let smithay::input::pointer::CursorImageStatus::Surface(ref cursor_surface) = + data.state.cursor_status + { render_elements_from_surface_tree( renderer, cursor_surface, - (data.state.pointer_location.x as i32, data.state.pointer_location.y as i32), + ( + data.state.pointer_location.x as i32 - data.active_origin.x, + data.state.pointer_location.y as i32 - data.active_origin.y, + ), scale, 1.0, Kind::Cursor, @@ -603,12 +911,23 @@ fn render_frame(data: &mut EventLoopData) { }; // Load themed cursor texture for Named cursor status - let themed_cursor = if let smithay::input::pointer::CursorImageStatus::Named(ref icon) = data.state.cursor_status { - data.state.cursor_theme_manager.get_cursor(icon.name(), renderer).cloned() + let themed_cursor = if let smithay::input::pointer::CursorImageStatus::Named(ref icon) = + data.state.cursor_status + { + data.state + .cursor_theme_manager + .get_cursor(icon.name(), renderer) + .cloned() } else if cursor_elements.is_empty() { // No client surface and not Hidden — use default theme cursor as fallback - if !matches!(data.state.cursor_status, smithay::input::pointer::CursorImageStatus::Hidden) { - data.state.cursor_theme_manager.get_cursor("default", renderer).cloned() + if !matches!( + data.state.cursor_status, + smithay::input::pointer::CursorImageStatus::Hidden + ) { + data.state + .cursor_theme_manager + .get_cursor("default", renderer) + .cloned() } else { None } @@ -624,11 +943,8 @@ fn render_frame(data: &mut EventLoopData) { } }; - let mut frame = match renderer.render( - &mut target, - data.output_rect.size, - Transform::Normal, - ) { + let mut frame = match renderer.render(&mut target, data.output_rect.size, Transform::Normal) + { Ok(f) => f, Err(e) => { warn!("Failed to start frame: {e}"); @@ -648,20 +964,26 @@ fn render_frame(data: &mut EventLoopData) { let mut rendered_tex_count = 0u32; for win_cfg in sorted_windows { if let Some(texture) = data.gles_tex_cache.get(&win_cfg.name) { - let (x, y, _w, _h) = data.state + let (x, y, _w, _h) = data + .state .window_positions .get(&win_cfg.name) .copied() .unwrap_or((win_cfg.x, win_cfg.y, win_cfg.width, win_cfg.height)); - let mapped = data.state.window_mapped.get(&win_cfg.name).copied().unwrap_or(true); + let mapped = data + .state + .window_mapped + .get(&win_cfg.name) + .copied() + .unwrap_or(true); if !mapped { continue; } if let Err(e) = frame.render_texture_at( texture, - (x, y).into(), + (x - data.active_origin.x, y - data.active_origin.y).into(), 1, 1.0, Transform::Normal, @@ -674,7 +996,10 @@ fn render_frame(data: &mut EventLoopData) { context_lost = true; return false; } - warn!("Electron texture rendering failed for {}: {e}", win_cfg.name); + warn!( + "Electron texture rendering failed for {}: {e}", + win_cfg.name + ); } else { rendered_tex_count += 1; } @@ -685,7 +1010,8 @@ fn render_frame(data: &mut EventLoopData) { if data.total_flips < 60 { info!( "render_frame #{}: textures_rendered={}, gles_cache={}, windows={}", - data.total_flips, rendered_tex_count, + data.total_flips, + rendered_tex_count, data.gles_tex_cache.len(), data.state.config.windows.len(), ); @@ -720,8 +1046,8 @@ fn render_frame(data: &mut EventLoopData) { } } } else if let Some(ref cur) = themed_cursor { - let cx = data.state.pointer_location.x as i32 - cur.xhot as i32; - let cy = data.state.pointer_location.y as i32 - cur.yhot as i32; + let cx = data.state.pointer_location.x as i32 - cur.xhot as i32 - data.active_origin.x; + let cy = data.state.pointer_location.y as i32 - cur.yhot as i32 - data.active_origin.y; if let Err(e) = frame.render_texture_at( &cur.texture, (cx, cy).into(), @@ -743,10 +1069,10 @@ fn render_frame(data: &mut EventLoopData) { for window in data.state.space.elements() { window.send_frame( - &data.state.backend.output, + &data.active_output, presented_at, Some(data.frame_time), - |_, _| Some(data.state.backend.output.clone()), + |_, _| Some(data.active_output.clone()), ); } @@ -754,10 +1080,10 @@ fn render_frame(data: &mut EventLoopData) { for surface in &dirty_surfaces { smithay::desktop::utils::send_frames_surface_tree( surface, - &data.state.backend.output, + &data.active_output, presented_at, Some(data.frame_time), - |_, _| Some(data.state.backend.output.clone()), + |_, _| Some(data.active_output.clone()), ); } @@ -776,39 +1102,39 @@ fn render_frame(data: &mut EventLoopData) { for surface in &dirty_surfaces { let surface_id = surface.id(); - let (window_name, window_geo) = if let Some(n) = - data.state.wayland_window_names.get(&surface_id).cloned() - { - let sid = surface_id.clone(); - let geo = data.state.space.elements().find_map(|w| { - let wl_id = w.toplevel() - .map(|t| t.wl_surface().id()) - .or_else(|| w.x11_surface().and_then(|x| x.wl_surface().map(|s| s.id()))); - if wl_id == Some(sid.clone()) { - data.state.space.element_geometry(w) - } else { - None + let (window_name, window_geo) = + if let Some(n) = data.state.wayland_window_names.get(&surface_id).cloned() { + let sid = surface_id.clone(); + let geo = data.state.space.elements().find_map(|w| { + let wl_id = w.toplevel().map(|t| t.wl_surface().id()).or_else(|| { + w.x11_surface().and_then(|x| x.wl_surface().map(|s| s.id())) + }); + if wl_id == Some(sid.clone()) { + data.state.space.element_geometry(w) + } else { + None + } + }); + (n, geo) + } else { + let sid = surface_id.clone(); + let x11_result = data.state.space.elements().find_map(|w| { + let x11 = w.x11_surface()?; + let wl = x11.wl_surface()?; + if wl.id() == sid.clone() { + let name = + data.state.x11_window_names.get(&x11.window_id()).cloned()?; + let geo = data.state.space.element_geometry(w); + Some((name, geo)) + } else { + None + } + }); + match x11_result { + Some((n, geo)) => (n, geo), + None => continue, } - }); - (n, geo) - } else { - let sid = surface_id.clone(); - let x11_result = data.state.space.elements().find_map(|w| { - let x11 = w.x11_surface()?; - let wl = x11.wl_surface()?; - if wl.id() == sid.clone() { - let name = data.state.x11_window_names.get(&x11.window_id()).cloned()?; - let geo = data.state.space.element_geometry(w); - Some((name, geo)) - } else { - None - } - }); - match x11_result { - Some((n, geo)) => (n, geo), - None => continue, - } - }; + }; let Some(_geo) = window_geo else { continue }; @@ -816,8 +1142,10 @@ fn render_frame(data: &mut EventLoopData) { let damage_rects: Vec = with_states(surface, |states| { let mut attrs = states.cached_state.get::(); let current = attrs.current(); - current.damage.iter().map(|d| { - match d { + current + .damage + .iter() + .map(|d| match d { Damage::Buffer(rect) => crate::state::DamageRect { x: rect.loc.x, y: rect.loc.y, @@ -833,8 +1161,8 @@ fn render_frame(data: &mut EventLoopData) { height: rect.size.h * scale, } } - } - }).collect() + }) + .collect() }); // Try SHM buffer: write directly to mmap'd memfd with BGRA→RGBA conversion @@ -849,12 +1177,16 @@ fn render_frame(data: &mut EventLoopData) { let bw = spec.width.max(0) as u32; let bh = spec.height.max(0) as u32; let pixel_bytes = src_stride * bh as usize; - if pixel_bytes == 0 || pixel_bytes > len { return; } + if pixel_bytes == 0 || pixel_bytes > len { + return; + } let dst_stride = (bw as usize) * 4; let dst_size = dst_stride * bh as usize; - let pair = data.state.window_shm_slots + let pair = data + .state + .window_shm_slots .entry(window_name.clone()) .or_insert_with(|| { crate::state::ShmSlotPair::new(dst_size) @@ -870,8 +1202,7 @@ fn render_frame(data: &mut EventLoopData) { let dst = unsafe { slot.as_mut_slice() }; let src = unsafe { std::slice::from_raw_parts(ptr, pixel_bytes) }; - let do_partial = !damage_rects.is_empty() - && damage_rects.len() < 64; + let do_partial = !damage_rects.is_empty() && damage_rects.len() < 64; if do_partial { for rect in &damage_rects { @@ -885,9 +1216,9 @@ fn render_frame(data: &mut EventLoopData) { for col in 0..rw { let si = src_row_off + col * 4; let di = dst_row_off + col * 4; - dst[di] = src[si + 2]; // R + dst[di] = src[si + 2]; // R dst[di + 1] = src[si + 1]; // G - dst[di + 2] = src[si]; // B + dst[di + 2] = src[si]; // B dst[di + 3] = src[si + 3]; // A } } @@ -899,9 +1230,9 @@ fn render_frame(data: &mut EventLoopData) { for col in 0..bw as usize { let si = src_row_off + col * 4; let di = dst_row_off + col * 4; - dst[di] = src[si + 2]; // R + dst[di] = src[si + 2]; // R dst[di + 1] = src[si + 1]; // G - dst[di + 2] = src[si]; // B + dst[di + 2] = src[si]; // B dst[di + 3] = src[si + 3]; // A } } @@ -909,7 +1240,8 @@ fn render_frame(data: &mut EventLoopData) { // Get the fd of the slot we just wrote to (write_slot_mut already flipped) let wrote_idx = 1 - pair.current_write_idx(); - let memfd_raw = std::os::unix::io::AsRawFd::as_raw_fd(&pair.slots[wrote_idx].fd); + let memfd_raw = + std::os::unix::io::AsRawFd::as_raw_fd(&pair.slots[wrote_idx].fd); result = Some((bw, bh, bw * 4, memfd_raw)); }); result @@ -918,8 +1250,12 @@ fn render_frame(data: &mut EventLoopData) { if let Some((bw, bh, stride, memfd_raw)) = shm_meta { if let Some(ref ipc) = data.state.electron_ipc { if let Err(e) = ipc.broadcast_shm_buffer( - &window_name, bw, bh, stride, - pid, memfd_raw, + &window_name, + bw, + bh, + stride, + pid, + memfd_raw, &damage_rects, ) { debug!("DRM shm buffer broadcast failed for {}: {}", window_name, e); @@ -945,13 +1281,15 @@ fn render_frame(data: &mut EventLoopData) { // Send frame callbacks for the cursor surface so the client keeps // animating it (e.g. spinning/loading cursors). - if let smithay::input::pointer::CursorImageStatus::Surface(ref cursor_surface) = data.state.cursor_status { + if let smithay::input::pointer::CursorImageStatus::Surface(ref cursor_surface) = + data.state.cursor_status + { smithay::desktop::utils::send_frames_surface_tree( cursor_surface, - &data.state.backend.output, + &data.active_output, presented_at, Some(data.frame_time), - |_, _| Some(data.state.backend.output.clone()), + |_, _| Some(data.active_output.clone()), ); } @@ -973,7 +1311,8 @@ fn render_frame(data: &mut EventLoopData) { // Ensure all GPU work targeting this BO completes before the DRM // subsystem starts scanning it out. glFlush (done by finish()) // only queues commands; glFinish blocks until they're done. - renderer.with_context(|gl| unsafe { gl.Finish() }) + renderer + .with_context(|gl| unsafe { gl.Finish() }) .unwrap_or_else(|e| warn!("glFinish failed: {e}")); // ── Fulfill pending wlr-screencopy frame requests ─────────────── @@ -988,7 +1327,10 @@ fn render_frame(data: &mut EventLoopData) { let mut fb_pixels = vec![0u8; fb_size]; let read_ok = renderer.with_context(|gl| unsafe { gl.ReadPixels( - 0, 0, fb_w, fb_h, + 0, + 0, + fb_w, + fb_h, gl::RGBA, gl::UNSIGNED_BYTE, fb_pixels.as_mut_ptr() as *mut _, @@ -1020,37 +1362,42 @@ fn render_frame(data: &mut EventLoopData) { let (rx, ry, rw, rh) = pf.region; // Copy the requested region into the client's SHM buffer - let copy_ok = with_buffer_contents_mut(&pf.buffer, |dst_ptr, dst_len, buffer_data| { - let dst = unsafe { std::slice::from_raw_parts_mut(dst_ptr, dst_len) }; - let dst_stride = buffer_data.stride as usize; - let src_stride = fb_w as usize * 4; + let copy_ok = with_buffer_contents_mut( + &pf.buffer, + |dst_ptr, dst_len, buffer_data| { + let dst = + unsafe { std::slice::from_raw_parts_mut(dst_ptr, dst_len) }; + let dst_stride = buffer_data.stride as usize; + let src_stride = fb_w as usize * 4; - for row in 0..rh as usize { - let src_y = ry as usize + row; - if src_y >= fb_h as usize { break; } - let src_off = src_y * src_stride + (rx as usize) * 4; - let dst_off = row * dst_stride; - let copy_len = (rw as usize * 4).min(dst_stride).min(src_stride - (rx as usize) * 4); - if src_off + copy_len <= fb_pixels.len() && dst_off + copy_len <= dst.len() { - dst[dst_off..dst_off + copy_len] - .copy_from_slice(&fb_pixels[src_off..src_off + copy_len]); + for row in 0..rh as usize { + let src_y = ry as usize + row; + if src_y >= fb_h as usize { + break; + } + let src_off = src_y * src_stride + (rx as usize) * 4; + let dst_off = row * dst_stride; + let copy_len = (rw as usize * 4) + .min(dst_stride) + .min(src_stride - (rx as usize) * 4); + if src_off + copy_len <= fb_pixels.len() + && dst_off + copy_len <= dst.len() + { + dst[dst_off..dst_off + copy_len].copy_from_slice( + &fb_pixels[src_off..src_off + copy_len], + ); + } } - } - }); + }, + ); if copy_ok.is_ok() { if pf.with_damage { - pf.frame.damage( - 0, 0, - rw as u32, rh as u32, - ); + pf.frame.damage(0, 0, rw as u32, rh as u32); } pf.frame.flags(zwlr_screencopy_frame_v1::Flags::empty()); - pf.frame.ready( - (tv_sec >> 32) as u32, - tv_sec as u32, - tv_nsec, - ); + pf.frame + .ready((tv_sec >> 32) as u32, tv_sec as u32, tv_nsec); } else { pf.frame.failed(); } @@ -1058,7 +1405,45 @@ fn render_frame(data: &mut EventLoopData) { // Push pixels to portal PipeWire streams for screen sharing if let Some(ref portal) = data.state.portal { - portal.push_pixels_to_streams(None, &fb_pixels); + if data.active_origin == (0, 0).into() { + portal.push_pixels_to_streams(None, &fb_pixels); + } + for window in data + .state + .config + .windows + .iter() + .chain(data.state.config.root.iter()) + { + let bounds = data + .state + .window_positions + .get(&window.name) + .copied() + .unwrap_or((window.x, window.y, window.width, window.height)); + let center_x = bounds.0.saturating_add((bounds.2 / 2) as i32); + let center_y = bounds.1.saturating_add((bounds.3 / 2) as i32); + if center_x < data.active_origin.x + || center_y < data.active_origin.y + || center_x >= data.active_origin.x.saturating_add(fb_w) + || center_y >= data.active_origin.y.saturating_add(fb_h) + { + continue; + } + let local_bounds = ( + bounds.0.saturating_sub(data.active_origin.x), + bounds.1.saturating_sub(data.active_origin.y), + bounds.2, + bounds.3, + ); + portal.push_window_capture( + &window.name, + &fb_pixels, + fb_w as u32, + fb_h as u32, + local_bounds, + ); + } } } else { for pf in pending { @@ -1075,7 +1460,10 @@ fn render_frame(data: &mut EventLoopData) { let mut fb_pixels = vec![0u8; fb_size]; let read_ok = renderer.with_context(|gl| unsafe { gl.ReadPixels( - 0, 0, fb_w, fb_h, + 0, + 0, + fb_w, + fb_h, gl::RGBA, gl::UNSIGNED_BYTE, fb_pixels.as_mut_ptr() as *mut _, @@ -1090,7 +1478,45 @@ fn render_frame(data: &mut EventLoopData) { pixel[2] = r; pixel[3] = a; } - portal.push_pixels_to_streams(None, &fb_pixels); + if data.active_origin == (0, 0).into() { + portal.push_pixels_to_streams(None, &fb_pixels); + } + for window in data + .state + .config + .windows + .iter() + .chain(data.state.config.root.iter()) + { + let bounds = data + .state + .window_positions + .get(&window.name) + .copied() + .unwrap_or((window.x, window.y, window.width, window.height)); + let center_x = bounds.0.saturating_add((bounds.2 / 2) as i32); + let center_y = bounds.1.saturating_add((bounds.3 / 2) as i32); + if center_x < data.active_origin.x + || center_y < data.active_origin.y + || center_x >= data.active_origin.x.saturating_add(fb_w) + || center_y >= data.active_origin.y.saturating_add(fb_h) + { + continue; + } + let local_bounds = ( + bounds.0.saturating_sub(data.active_origin.x), + bounds.1.saturating_sub(data.active_origin.y), + bounds.2, + bounds.3, + ); + portal.push_window_capture( + &window.name, + &fb_pixels, + fb_w as u32, + fb_h as u32, + local_bounds, + ); + } } } } @@ -1203,11 +1629,13 @@ fn render_frame(data: &mut EventLoopData) { data.pending_slot = Some(slot); data.total_flips += 1; if data.total_flips == 1 { - info!("First successful page flip (commit_pending={})", commit_pending); + info!( + "First successful page flip (commit_pending={})", + commit_pending + ); } } Err(e) => { - data.consecutive_flip_failures = data.consecutive_flip_failures.saturating_add(1); let page_flip_err = e.to_string(); let would_block = { let lowered = page_flip_err.to_ascii_lowercase(); @@ -1216,21 +1644,64 @@ fn render_frame(data: &mut EventLoopData) { || lowered.contains("eagain") }; + // VT switches revoke DRM access before libseat's PauseSession + // callback is necessarily dispatched. Do not issue a forced + // atomic commit in that window: it will usually fail with EACCES + // too and can turn an ordinary VT switch into noisy DRM errors. + // Pause all outputs until libseat reports activation. + if is_permission_denied_message(&page_flip_err) { + warn!( + "DRM page flip was denied while seat ownership is changing; pausing until libseat reactivates the session: {page_flip_err}" + ); + data.active = false; + data.vblank_pending = false; + data.vblank_since = None; + data.current_slot = None; + data.pending_slot = None; + for output in &mut data.additional_outputs { + output.vblank_pending = false; + output.vblank_since = None; + output.current_slot = None; + output.pending_slot = None; + } + return; + } + + data.consecutive_flip_failures = data.consecutive_flip_failures.saturating_add(1); + let page_flip_report = format!( "DRM page_flip failed (count={}): {}{}", data.consecutive_flip_failures, page_flip_err, - if would_block { " [WouldBlock/EAGAIN]" } else { "" } + if would_block { + " [WouldBlock/EAGAIN]" + } else { + "" + } ); warn!("{page_flip_report}. Attempting forced commit."); write_flip_failure_report(&page_flip_report); if let Err(commit_err) = data.drm_surface.commit([plane_state], true) { + if is_permission_denied_message(&commit_err.to_string()) { + warn!("DRM commit was denied while seat ownership is changing; pausing until libseat reactivates the session: {commit_err}"); + data.active = false; + data.vblank_pending = false; + data.vblank_since = None; + data.current_slot = None; + data.pending_slot = None; + for output in &mut data.additional_outputs { + output.vblank_pending = false; + output.vblank_since = None; + output.current_slot = None; + output.pending_slot = None; + } + return; + } error!("Fatal DRM failure: {commit_err}"); let commit_report = format!( "DRM commit failed after page_flip failure (count={}): {}", - data.consecutive_flip_failures, - commit_err + data.consecutive_flip_failures, commit_err ); write_flip_failure_report(&commit_report); data.vblank_pending = false; @@ -1258,7 +1729,235 @@ fn render_frame(data: &mut EventLoopData) { } } +/// Discover newly connected connectors and attach them to an unused CRTC. +/// DRM hotplug events are not exposed by Smithay's page-flip notifier, so the +/// compositor polls the resource list at a low rate. Existing outputs are kept +/// intact if a connector disappears; removing scanout safely requires a +/// completed disable commit and is deliberately handled as a separate step. +fn refresh_drm_outputs(data: &mut EventLoopData) { + let resources = match data.drm.resource_handles() { + Ok(resources) => resources, + Err(error) => { + warn!(%error, "could not refresh DRM resources"); + return; + } + }; + let known: std::collections::HashSet<_> = std::iter::once(data.primary_connector) + .chain( + data.additional_outputs + .iter() + .map(|output| output.connector), + ) + .collect(); + let mut used_crtcs: std::collections::HashSet<_> = std::iter::once(data.drm_surface.crtc()) + .chain( + data.additional_outputs + .iter() + .map(|output| output.drm_surface.crtc()), + ) + .collect(); + let mut x = data + .state + .monitors + .iter() + .map(|monitor| { + monitor + .location + .x + .saturating_add(monitor.size.0.min(i32::MAX as u32) as i32) + }) + .max() + .unwrap_or(data.width as i32); + let mut added = 0usize; + + for &connector_h in resources.connectors() { + if known.contains(&connector_h) { + continue; + } + let Ok(connector_info) = data.drm.get_connector(connector_h, true) else { + continue; + }; + if connector_info.state() != connector::State::Connected { + continue; + } + let Some(&mode) = connector_info.modes().iter().max_by_key(|mode| { + ( + mode.mode_type().contains(ModeTypeFlags::PREFERRED), + mode.size(), + ) + }) else { + continue; + }; + let crtc = connector_info + .encoders() + .iter() + .filter_map(|encoder_h| data.drm.get_encoder(*encoder_h).ok()) + .find_map(|encoder| encoder.crtc().filter(|crtc| !used_crtcs.contains(crtc))) + .or_else(|| { + connector_info + .encoders() + .iter() + .filter_map(|encoder_h| data.drm.get_encoder(*encoder_h).ok()) + .flat_map(|encoder| resources.filter_crtcs(encoder.possible_crtcs())) + .find(|crtc| !used_crtcs.contains(crtc)) + }); + let Some(crtc) = crtc else { + warn!(connector = ?connector_info.interface(), "hotplugged monitor has no free compatible CRTC"); + continue; + }; + let surface = match data.drm.create_surface(crtc, mode, &[connector_h]) { + Ok(surface) => surface, + Err(error) => { + warn!(connector = ?connector_info.interface(), %error, "could not create hotplugged DRM surface"); + continue; + } + }; + + let (mw, mh) = mode.size(); + let (width, height) = (mw as u32, mh as u32); + let name = format!( + "wo-{:?}-{}", + connector_info.interface(), + connector_info.interface_id() + ); + let output = Output::new( + name.clone(), + PhysicalProperties { + size: (0, 0).into(), + subpixel: Subpixel::Unknown, + make: "Wo".into(), + model: format!( + "{:?}-{}", + connector_info.interface(), + connector_info.interface_id() + ) + .into(), + }, + ); + let mode = OutputMode { + size: ( + width.min(i32::MAX as u32) as i32, + height.min(i32::MAX as u32) as i32, + ) + .into(), + refresh: mode.vrefresh() as i32 * 1000, + }; + output.change_current_state( + Some(mode), + Some(Transform::Normal), + Some(Scale::Integer(1)), + Some((x, 0).into()), + ); + output.set_preferred(mode); + output.create_global::(&data.display.handle()); + let output_rect: Rectangle = Rectangle::new( + (0, 0).into(), + ( + width.min(i32::MAX as u32) as i32, + height.min(i32::MAX as u32) as i32, + ) + .into(), + ); + let src_rect: Rectangle = + Rectangle::from_size((output_rect.size.w, output_rect.size.h).into()).to_f64(); + let refresh = mode.refresh as f64 / 1000.0; + let frame_time = if refresh > 1.0 { + Duration::from_secs_f64((1.0 / refresh).clamp(1.0 / 240.0, 1.0 / 30.0)) + } else { + Duration::from_millis(16) + }; + let gbm_device = match GbmDevice::new(data.drm_device_fd.clone()) { + Ok(device) => device, + Err(error) => { + warn!(%error, "could not create GBM device for hotplugged monitor"); + continue; + } + }; + let swapchain = Swapchain::new( + GbmAllocator::new( + gbm_device, + GbmBufferFlags::RENDERING | GbmBufferFlags::SCANOUT, + ), + width, + height, + data.swapchain_format, + data.swapchain_modifiers.clone(), + ); + let monitor = crate::state::MonitorOutput { + id: name, + output: output.clone(), + location: (x, 0).into(), + size: (width, height), + primary: false, + }; + let monitor_id = monitor.id.clone(); + data.state.space.map_output(&output, (x, 0)); + data.state.monitors.push(monitor.clone()); + data.additional_outputs.push(AdditionalOutput { + monitor, + connector: connector_h, + swapchain, + drm_surface: surface, + output_rect, + src_rect, + frame_time, + width, + height, + last_render: Instant::now(), + first_render: true, + vblank_pending: false, + vblank_since: None, + consecutive_flip_failures: 0, + total_flips: 0, + current_slot: None, + pending_slot: None, + }); + used_crtcs.insert(crtc); + x = x.saturating_add(width.min(i32::MAX as u32) as i32); + added += 1; + info!(name = %monitor_id, width, height, "activated hotplugged monitor"); + } + + if added > 0 { + let bounds = data.state.desktop_bounds(); + data.state.output_size = (bounds.size.w.max(0) as u32, bounds.size.h.max(0) as u32); + for root in data.state.config.root.clone() { + if let Some(window) = data + .state + .config + .windows + .iter_mut() + .find(|window| window.name == root.name) + { + window.x = bounds.loc.x; + window.y = bounds.loc.y; + window.width = data.state.output_size.0; + window.height = data.state.output_size.1; + } + data.state.window_positions.insert( + root.name, + ( + bounds.loc.x, + bounds.loc.y, + data.state.output_size.0, + data.state.output_size.1, + ), + ); + } + data.state.metadata_dirty = true; + data.force_output_render = true; + } +} + fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { + // A failed nested attempt restores the host DISPLAY for its children. + // Clear it again before root startup so XWayland owns the only DISPLAY + // value published to the desktop and its applications. + if std::env::var_os("DISPLAY").is_some() { + info!("Clearing host DISPLAY before starting root XWayland"); + std::env::remove_var("DISPLAY"); + } + let (session, session_notifier) = LibSeatSession::new().map_err(|err| { anyhow::anyhow!( "Failed to create libseat session: {err}. wo requires a seat-managed session to access DRM/input devices safely. Run from a proper login session (logind/seatd)." @@ -1297,6 +1996,14 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { data.active = false; data.vblank_pending = false; data.vblank_since = None; + data.current_slot = None; + data.pending_slot = None; + for output in &mut data.additional_outputs { + output.vblank_pending = false; + output.vblank_since = None; + output.current_slot = None; + output.pending_slot = None; + } data.libinput_context.suspend(); data.drm.pause(); } @@ -1307,6 +2014,10 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { data.active = false; data.vblank_pending = false; data.vblank_since = None; + for output in &mut data.additional_outputs { + output.vblank_pending = false; + output.vblank_since = None; + } return; } if let Err(()) = data.libinput_context.resume() { @@ -1316,6 +2027,16 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { data.active = true; data.vblank_pending = false; data.vblank_since = None; + data.consecutive_flip_failures = 0; + data.first_render = true; + for output in &mut data.additional_outputs { + output.vblank_pending = false; + output.vblank_since = None; + output.consecutive_flip_failures = 0; + output.first_render = true; + output.current_slot = None; + output.pending_slot = None; + } if let Err(e) = data.display.flush_clients() { warn!("Failed to flush Wayland clients on activation: {e}"); } @@ -1487,8 +2208,12 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { let perms_hint = match std::fs::metadata(gpu_path) { Ok(meta) => { use std::os::unix::fs::MetadataExt; - format!("(device node mode={:#o}, uid={}, gid={})", - meta.mode(), meta.uid(), meta.gid()) + format!( + "(device node mode={:#o}, uid={}, gid={})", + meta.mode(), + meta.uid(), + meta.gid() + ) } Err(e) => format!("(could not stat device: {e})"), }; @@ -1536,10 +2261,7 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { // capability. Use SMITHAY_USE_LEGACY=1 to force legacy mode if needed. let (mut drm, _drm_notifier) = DrmDevice::new(drm_device_fd.clone(), false) .map_err(|e| anyhow::anyhow!("creating DRM device: {e}"))?; - info!( - atomic = drm.is_atomic(), - "DRM device created" - ); + info!(atomic = drm.is_atomic(), "DRM device created"); let gbm_device = GbmDevice::new(drm_device_fd.clone()).context("creating GBM device")?; @@ -1592,6 +2314,107 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { let (w, h) = mode.size(); let (width, height) = (w as u32, h as u32); + // Discover other connected connectors and assign each a distinct CRTC. + // Outputs are laid out left-to-right in logical desktop coordinates; + // later output-management support can replace this deterministic default. + let mut used_crtcs = std::collections::HashSet::from([crtc_h]); + let mut next_x = width.min(i32::MAX as u32) as i32; + let mut discovered_outputs = Vec::new(); + for &candidate_h in resources.connectors().iter().filter(|&&h| h != connector_h) { + let Ok(candidate) = drm.get_connector(candidate_h, true) else { + continue; + }; + if candidate.state() != connector::State::Connected { + continue; + } + let Some(&candidate_mode) = candidate + .modes() + .iter() + .max_by_key(|m| (m.mode_type().contains(ModeTypeFlags::PREFERRED), m.size())) + else { + continue; + }; + + let candidate_crtc = candidate + .encoders() + .iter() + .filter_map(|&encoder_h| drm.get_encoder(encoder_h).ok()) + .find_map(|encoder| encoder.crtc().filter(|crtc| !used_crtcs.contains(crtc))) + .or_else(|| { + candidate + .encoders() + .iter() + .filter_map(|&encoder_h| drm.get_encoder(encoder_h).ok()) + .flat_map(|encoder| resources.filter_crtcs(encoder.possible_crtcs())) + .find(|crtc| !used_crtcs.contains(crtc)) + }); + let Some(candidate_crtc) = candidate_crtc else { + warn!(connector = ?candidate.interface(), "connected monitor has no unused compatible CRTC"); + continue; + }; + let Ok(surface) = drm.create_surface(candidate_crtc, candidate_mode, &[candidate_h]) else { + warn!(connector = ?candidate.interface(), "failed to create DRM surface for connected monitor"); + continue; + }; + + used_crtcs.insert(candidate_crtc); + let (mw, mh) = candidate_mode.size(); + let size = (mw as u32, mh as u32); + let output_name = format!( + "wo-{:?}-{}", + candidate.interface(), + candidate.interface_id() + ); + let monitor_output = Output::new( + output_name.clone(), + PhysicalProperties { + size: (0, 0).into(), + subpixel: Subpixel::Unknown, + make: "Wo".into(), + model: format!("{:?}-{}", candidate.interface(), candidate.interface_id()).into(), + }, + ); + let monitor_mode = OutputMode { + size: ( + size.0.min(i32::MAX as u32) as i32, + size.1.min(i32::MAX as u32) as i32, + ) + .into(), + refresh: candidate_mode.vrefresh() as i32 * 1000, + }; + monitor_output.change_current_state( + Some(monitor_mode), + Some(Transform::Normal), + Some(Scale::Integer(1)), + Some((next_x, 0).into()), + ); + monitor_output.set_preferred(monitor_mode); + monitor_output.create_global::(&dh); + discovered_outputs.push(( + crate::state::MonitorOutput { + id: output_name, + output: monitor_output, + location: (next_x, 0).into(), + size, + primary: false, + }, + surface, + candidate_h, + candidate_mode, + )); + next_x = next_x.saturating_add(size.0.min(i32::MAX as u32) as i32); + } + let desktop_width = discovered_outputs + .iter() + .fold(width, |current, (monitor, _, _, _)| { + current.max((monitor.location.x.max(0) as u32).saturating_add(monitor.size.0)) + }); + let desktop_height = discovered_outputs + .iter() + .fold(height, |current, (monitor, _, _, _)| { + current.max((monitor.location.y.max(0) as u32).saturating_add(monitor.size.1)) + }); + let output = Output::new( "wo-output".to_string(), PhysicalProperties { @@ -1628,15 +2451,18 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { let render_node = DrmNode::from_path(&gpu_path).context("resolving DRM node for dmabuf feedback")?; + spawn_immediate_autostarts(&mut config); + // Merge [[root]] entries into config.windows BEFORE WoState::new so that // render_frame, window_mapped, and respawn logic all see them. for root_cfg in &config.root.clone() { let mut full = root_cfg.clone(); + full.system_shell = true; full.x = 0; full.y = 0; - full.width = width; - full.height = height; - full.z_order = std::i32::MIN / 2; + full.width = desktop_width; + full.height = desktop_height; + full.z_order = i32::MIN / 2; config.windows.push(full); } @@ -1646,6 +2472,8 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { let (ipc, msg_rx) = ElectronIpc::listen(&config.compositor.ipc_socket).context("IPC socket")?; state.electron_ipc = Some(ipc); + let electron_render_node = render_node_for_card(&gpu_path); + let mut xwayland_spawn_failed = false; // Spawn XWayland for X11 client support (mirrors nested.rs) state.xwayland_shell_state = Some(XWaylandShellState::new::(&dh)); @@ -1663,6 +2491,12 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { let wm_handle = loop_handle.clone(); let xw_client = std::rc::Rc::new(std::cell::RefCell::new(Some(xw_client))); let xw_client_cb = xw_client.clone(); + let startup_windows = config.windows.clone(); + let startup_electron_bin = config.compositor.electron_path.clone(); + let startup_ipc_socket = config.compositor.ipc_socket.clone(); + let startup_app_dir = crate::electron::app_dir(); + let startup_render_node = electron_render_node.clone(); + let mut electron_started = false; if let Err(e) = loop_handle.insert_source(xwayland, move |event, _, drm_data: &mut DrmLoopData| { @@ -1691,9 +2525,47 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { Err(e) => error!("X11Wm::start_wm failed: {e:#}"), } } + // Wait for XWayland before publishing DISPLAY to + // applications. Electron itself uses the already + // bound native Wayland socket, not X11. + if !electron_started { + for win_cfg in &startup_windows { + match ElectronProcess::spawn( + win_cfg, + &startup_electron_bin, + &startup_app_dir, + &startup_ipc_socket, + &startup_render_node, + "wayland", + ) { + Ok(proc) => data.state.electron_processes.push(proc), + Err(e) => error!(window = %win_cfg.name, "failed to spawn Electron: {e:#}"), + } + } + electron_started = true; + } } XWaylandEvent::Error => { warn!("XWayland server encountered an error during startup"); + if !electron_started { + // Keep the desktop usable without XWayland; + // Chromium can still connect through the + // already-bound compositor Wayland socket. + for win_cfg in &startup_windows { + match ElectronProcess::spawn( + win_cfg, + &startup_electron_bin, + &startup_app_dir, + &startup_ipc_socket, + &startup_render_node, + "wayland", + ) { + Ok(proc) => data.state.electron_processes.push(proc), + Err(e) => error!(window = %win_cfg.name, "failed to spawn Electron: {e:#}"), + } + } + electron_started = true; + } } } }) @@ -1705,6 +2577,7 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { } Err(e) => { warn!("XWayland spawn failed (X11 support disabled): {e:#}"); + xwayland_spawn_failed = true; } } @@ -1720,23 +2593,29 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { // Propagate now; DISPLAY will be re-propagated once XWayland is ready. crate::state::propagate_environment(&["WAYLAND_DISPLAY"]); - let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("electron"); - let electron_render_node = render_node_for_card(&gpu_path); - - // All windows (including merged [[root]] entries) are now in config.windows. - for win_cfg in &config.windows { - match ElectronProcess::spawn( - win_cfg, - &config.compositor.electron_path, - &app_dir, - &config.compositor.ipc_socket, - &electron_render_node, - ) { - Ok(proc) => state.electron_processes.push(proc), - Err(e) => error!(window = %win_cfg.name, "failed to spawn Electron: {e:#}"), + let app_dir = crate::electron::app_dir(); + if xwayland_spawn_failed { + // The Wayland socket is now bound, so this fallback can start safely + // even when the optional XWayland server could not be created. + for win_cfg in &config.windows { + match ElectronProcess::spawn( + win_cfg, + &config.compositor.electron_path, + &app_dir, + &config.compositor.ipc_socket, + &electron_render_node, + "wayland", + ) { + Ok(proc) => state.electron_processes.push(proc), + Err(e) => error!(window = %win_cfg.name, "failed to spawn Electron: {e:#}"), + } } } + // Electron's Ozone backend connects directly to Wo's Wayland socket. The + // XWayland display remains available for legacy applications and is + // propagated separately when its server is ready. + let mut autostart_tasks: Vec<(crate::config::AutostartConfig, std::time::Instant)> = Vec::new(); let now = std::time::Instant::now(); for task in &config.autostart { @@ -1762,7 +2641,10 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { }; let mut swapchain_modifiers: Vec = Vec::new(); - for fmt in renderer_formats.iter().filter(|fmt| fmt.code == swapchain_format) { + for fmt in renderer_formats + .iter() + .filter(|fmt| fmt.code == swapchain_format) + { if !swapchain_modifiers.contains(&fmt.modifier) { swapchain_modifiers.push(fmt.modifier); } @@ -1779,32 +2661,132 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { let swapchain: Swapchain> = Swapchain::new( GbmAllocator::new( - gbm_device, + gbm_device.clone(), GbmBufferFlags::RENDERING | GbmBufferFlags::SCANOUT, ), width, height, swapchain_format, - swapchain_modifiers, + swapchain_modifiers.clone(), ); + let additional_outputs: Vec = discovered_outputs + .into_iter() + .map(|(monitor, surface, connector, mode)| { + let (output_width, output_height) = monitor.size; + let output_swapchain = Swapchain::new( + GbmAllocator::new( + gbm_device.clone(), + GbmBufferFlags::RENDERING | GbmBufferFlags::SCANOUT, + ), + output_width, + output_height, + swapchain_format, + swapchain_modifiers.clone(), + ); + let output_rect = Rectangle::new( + (0, 0).into(), + ( + output_width.min(i32::MAX as u32) as i32, + output_height.min(i32::MAX as u32) as i32, + ) + .into(), + ); + let src_rect: Rectangle = Rectangle::from_size( + ( + output_width.min(i32::MAX as u32) as i32, + output_height.min(i32::MAX as u32) as i32, + ) + .into(), + ) + .to_f64(); + let refresh_hz = mode.vrefresh() as f64; + let frame_time = if refresh_hz > 1.0 { + Duration::from_secs_f64((1.0 / refresh_hz).clamp(1.0 / 240.0, 1.0 / 30.0)) + } else { + Duration::from_millis(16) + }; + AdditionalOutput { + monitor, + connector, + swapchain: output_swapchain, + drm_surface: surface, + output_rect, + src_rect, + frame_time, + width: output_width, + height: output_height, + last_render: Instant::now(), + first_render: true, + vblank_pending: false, + vblank_since: None, + consecutive_flip_failures: 0, + total_flips: 0, + current_slot: None, + pending_slot: None, + } + }) + .collect(); + + for monitor in additional_outputs + .iter() + .map(|output| output.monitor.clone()) + { + state + .space + .map_output(&monitor.output, (monitor.location.x, monitor.location.y)); + info!( + name = %monitor.id, + x = monitor.location.x, + y = monitor.location.y, + width = monitor.size.0, + height = monitor.size.1, + "registered DRM monitor" + ); + state.monitors.push(monitor); + } + let desktop_bounds = state.desktop_bounds(); + state.output_size = ( + desktop_bounds.size.w.max(0) as u32, + desktop_bounds.size.h.max(0) as u32, + ); + for root in state.config.root.clone() { + if let Some(window) = state + .config + .windows + .iter_mut() + .find(|window| window.name == root.name) + { + window.x = desktop_bounds.loc.x; + window.y = desktop_bounds.loc.y; + window.width = state.output_size.0; + window.height = state.output_size.1; + } + state.window_positions.insert( + root.name, + ( + desktop_bounds.loc.x, + desktop_bounds.loc.y, + state.output_size.0, + state.output_size.1, + ), + ); + } + let output_rect: Rectangle = Rectangle::new((0, 0).into(), (width as i32, height as i32).into()); let src_rect: Rectangle = Rectangle::from_size((width as i32, height as i32).into()).to_f64(); let bg = Color32F::from(config.compositor.background); - // Initialize libinput for input events (only when session backend is available) let mut libinput_context = Libinput::new_with_udev(LibinputSessionInterface::from(session.clone())); libinput_context .udev_assign_seat(&session.seat()) .map_err(|()| anyhow::anyhow!("Failed to assign seat to libinput"))?; - // Keep a clone for suspend/resume on VT switch; the original is consumed by the backend. let libinput_context_for_vt = libinput_context.clone(); let libinput_backend = LibinputInputBackend::new(libinput_context); - // Prepare frame pacing using the active DRM mode refresh when available. let refresh_hz = (output_mode.refresh as f64) / 1000.0; let frame_time = if refresh_hz > 1.0 { Duration::from_secs_f64((1.0 / refresh_hz).clamp(1.0 / 240.0, 1.0 / 30.0)) @@ -1813,13 +2795,11 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { }; let last_render = std::time::Instant::now(); - // Register the Wayland display fd with calloop so that client messages - // (surface commits, etc.) wake the event loop. Without this, the loop - // only wakes on DRM VBlank / libinput / session events and rendering - // stalls when the VBlank cycle breaks. { use std::os::unix::io::AsFd; - let display_fd = display.as_fd().try_clone_to_owned() + let display_fd = display + .as_fd() + .try_clone_to_owned() .context("duping Wayland display fd for calloop")?; let wayland_display_source = calloop::generic::Generic::new( display_fd, @@ -1838,7 +2818,9 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { // the event loop promptly. { use std::os::unix::io::AsFd; - let listen_fd = wayland_socket.as_fd().try_clone_to_owned() + let listen_fd = wayland_socket + .as_fd() + .try_clone_to_owned() .context("duping Wayland listening socket fd for calloop")?; let listen_source = calloop::generic::Generic::new( listen_fd, @@ -1867,6 +2849,24 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { info!(?frame_time, "Periodic render timer registered"); } + // DRM page-flip events do not include connector hotplug notifications. + // Poll at a low rate so monitor attach is picked up without adding work to + // the frame loop. + { + let hotplug_timer = calloop::timer::Timer::from_duration(Duration::from_secs(2)); + loop_handle + .insert_source(hotplug_timer, |_deadline, _, drm_data: &mut DrmLoopData| { + if let Some(data) = drm_data.0.as_mut() { + if data.active { + refresh_drm_outputs(data); + } + } + calloop::timer::TimeoutAction::ToDuration(Duration::from_secs(2)) + }) + .map_err(|e| anyhow::anyhow!("Error inserting DRM hotplug timer: {e:?}"))?; + info!("DRM connector hotplug polling registered"); + } + // Move all state into EventLoopData for access in event loop callbacks let mut loop_data = EventLoopData { display, @@ -1887,7 +2887,6 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { first_render: true, wayland_socket, frame_timestamps: std::collections::HashMap::new(), - input_events_pending: false, last_cursor_pos: (0.0, 0.0), session, active: true, @@ -1900,6 +2899,13 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { current_slot: None, pending_slot: None, libinput_context: libinput_context_for_vt, + additional_outputs, + primary_connector: connector_h, + swapchain_format, + swapchain_modifiers, + active_output: output.clone(), + active_origin: (0, 0).into(), + force_output_render: false, }; let signal_for_handler = loop_signal.clone(); @@ -1913,21 +2919,28 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { .insert_source(_drm_notifier, |event, _, drm_data: &mut DrmLoopData| { let data = drm_data.0.as_mut().expect("EventLoopData initialized"); use smithay::backend::drm::DrmEvent; - if !data.active { - if let DrmEvent::VBlank(_) = event { + if let DrmEvent::VBlank(crtc) = &event { + if *crtc == data.drm_surface.crtc() { data.vblank_pending = false; data.vblank_since = None; data.current_slot = data.pending_slot.take(); + } else if let Some(output) = data + .additional_outputs + .iter_mut() + .find(|output| output.drm_surface.crtc() == *crtc) + { + output.vblank_pending = false; + output.vblank_since = None; + output.current_slot = output.pending_slot.take(); } + } + if !data.active { trace!("Ignoring DRM event while session is inactive"); return; } - if let DrmEvent::VBlank(_) = event { + if matches!(event, DrmEvent::VBlank(_)) { // The flip is complete: the pending buffer is now being scanned. - // Promote it to current (releasing the old scanout buffer). - data.vblank_pending = false; - data.vblank_since = None; - data.current_slot = data.pending_slot.take(); + // Its output slot was promoted above; render newly-damaged content. render_frame(data); } }) @@ -1940,7 +2953,6 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { move |event, _, drm_data: &mut DrmLoopData| { let data = drm_data.0.as_mut().expect("EventLoopData initialized"); data.state.process_input_event(event); - data.input_events_pending = true; }, ) .map_err(|e| anyhow::anyhow!("Error inserting libinput source: {e:?}"))?; @@ -1998,7 +3010,8 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { | ElectronMessage::ForwardedKeyboard { .. } | ElectronMessage::ForwardedRelativePointer { .. } | ElectronMessage::ForwardedPointerButton { .. } - | ElectronMessage::ForwardedPointerScroll { .. } => { + | ElectronMessage::ForwardedPointerScroll { .. } + | ElectronMessage::ForwardedPointerLeave { .. } => { input_batch.push(msg); } ElectronMessage::Frame(_) => { @@ -2026,8 +3039,20 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { ElectronMessage::ForwardedPointerButton { window_name, x, y, button, pressed, time } => { data.state.handle_forwarded_pointer_button(&window_name, x, y, button, pressed, time); } - ElectronMessage::ForwardedPointerScroll { window_name, dx, dy } => { - data.state.handle_forwarded_pointer_scroll(&window_name, dx, dy); + ElectronMessage::ForwardedPointerScroll { + window_name, + x, + y, + dx, + dy, + time, + } => { + data.state.handle_forwarded_pointer_scroll( + &window_name, x, y, dx, dy, time, + ); + } + ElectronMessage::ForwardedPointerLeave { window_name } => { + data.state.handle_forwarded_pointer_leave(&window_name); } _ => {} } @@ -2041,7 +3066,11 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { for msg in frames_batch { if let crate::electron::ElectronMessage::Frame(frame) = msg { let name = frame.name.clone(); - latest_frames.insert(name, frame); + if let Some(stale) = latest_frames.insert(name.clone(), frame) { + if let Some(ref ipc) = data.state.electron_ipc { + let _ = ipc.send_frame_ack(&name, stale.seq); + } + } } } @@ -2050,6 +3079,8 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { for (_name, frame) in latest_frames { let frame_name = frame.name.clone(); let frame_dims = (frame.width, frame.height); + let frame_seq = frame.seq; + let mut imported = false; if let Some(renderer_arc) = data.state.backend.renderer() { match renderer_arc.try_lock() { Ok(ref mut renderer) => { @@ -2059,6 +3090,7 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { data.state.damaged_windows.insert(frame_name.clone()); data.state.texture_cache.insert_dmabuf(cached); data.gles_tex_cache.insert(frame_name.clone(), tex.texture); + imported = true; if is_first { info!( "First DMABUF imported for '{}' ({}x{}, total_textures={})", @@ -2079,6 +3111,15 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { } } } + if imported { + if let Some(previous_seq) = data.state.current_frame_seq.insert(frame_name.clone(), frame_seq) { + if let Some(ref ipc) = data.state.electron_ipc { + let _ = ipc.send_frame_ack(&frame_name, previous_seq); + } + } + } else if let Some(ref ipc) = data.state.electron_ipc { + let _ = ipc.send_frame_ack(&frame_name, frame_seq); + } } // Evict textures for windows no longer in the DMABUF cache @@ -2099,6 +3140,20 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { ); data.state.metadata_dirty = true; } + ElectronMessage::SiteHashes { window_name, update } => { + use sha2::Digest; + if !update.index_bytes.is_empty() { + let hashes = crate::electron::SiteHashes { + page_url: update.page_url, + index_sha256: format!("{:x}", sha2::Sha256::digest(&update.index_bytes)), + styles_sha256: format!("{:x}", sha2::Sha256::digest(&update.styles_bytes)), + }; + info!(window = %window_name, page = %hashes.page_url, "computed and registered document and stylesheet hashes"); + data.state.site_hashes.insert(window_name, hashes); + } else { + warn!(window = %window_name, "rejected empty site assets"); + } + } ElectronMessage::Syscall(req) => { if let Some(ref handler) = data.state.syscall_handler { trace!("Syscall from {}: {}", req.window_name, req.syscall_type); @@ -2376,7 +3431,16 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { let dx = payload_json.as_ref().and_then(|p| p.get("dx")).and_then(|v| v.as_f64()).unwrap_or(0.0); let dy = payload_json.as_ref().and_then(|p| p.get("dy")).and_then(|v| v.as_f64()).unwrap_or(0.0); let tname = target_name.as_deref().unwrap_or(""); - data.state.handle_forwarded_pointer_scroll(tname, dx, dy); + let (x, y) = data.state.find_wayland_window(tname) + .and_then(|window| data.state.space.element_location(&window).map(|location| { + let geometry = window.geometry().loc; + ( + data.state.pointer_location.x - location.x as f64 - geometry.x as f64, + data.state.pointer_location.y - location.y as f64 - geometry.y as f64, + ) + })) + .unwrap_or((0.0, 0.0)); + data.state.handle_forwarded_pointer_scroll(tname, x, y, dx, dy, 0); } "pointer_leave" => { use smithay::input::pointer::MotionEvent; @@ -2427,31 +3491,95 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { } } else { // Electron window actions (existing logic) + let managed_name = target_name + .clone() + .unwrap_or_else(|| action_msg.window_name.clone()); match act_str.as_str() { + "move" => { + let x = payload_json.as_ref().and_then(|p| p.get("x")).and_then(|v| v.as_i64()).unwrap_or(0) as i32; + let y = payload_json.as_ref().and_then(|p| p.get("y")).and_then(|v| v.as_i64()).unwrap_or(0) as i32; + let current = data.state.window_positions.get(&managed_name).copied().or_else(|| { + data.state.config.windows.iter().find(|w| w.name == managed_name) + .map(|w| (w.x, w.y, w.width, w.height)) + }); + if let Some((_, _, width, height)) = current { + data.state.window_positions.insert(managed_name.clone(), (x, y, width, height)); + data.state.metadata_dirty = true; + } + } + "resize" => { + let width = payload_json.as_ref().and_then(|p| p.get("width")).and_then(|v| v.as_u64()).unwrap_or(1).clamp(1, 16384) as u32; + let outer_height = payload_json.as_ref().and_then(|p| p.get("height")).and_then(|v| v.as_u64()).unwrap_or(1).clamp(1, 16384) as u32; + let height = outer_height.saturating_sub(handlers::xdg_shell::TITLE_H as u32).max(1); + let current = data.state.window_positions.get(&managed_name).copied().or_else(|| { + data.state.config.windows.iter().find(|w| w.name == managed_name) + .map(|w| (w.x, w.y, w.width, w.height)) + }); + if let Some((x, y, _, _)) = current { + data.state.window_positions.insert(managed_name.clone(), (x, y, width, height)); + if let Some(ref ipc) = data.state.electron_ipc { + let _ = ipc.send_to_window(&managed_name, &crate::electron::ElectronInputEvent::WindowSize { width, height }); + } + data.state.metadata_dirty = true; + } + } + "maximize" => { + let width = data.state.output_size.0; + let height = data.state.output_size.1 + .saturating_sub(handlers::xdg_shell::PANEL_H as u32) + .saturating_sub(handlers::xdg_shell::TITLE_H as u32) + .max(1); + data.state.window_positions.insert(managed_name.clone(), (0, handlers::xdg_shell::PANEL_H, width, height)); + if let Some(ref ipc) = data.state.electron_ipc { + let _ = ipc.send_to_window(&managed_name, &crate::electron::ElectronInputEvent::WindowSize { width, height }); + } + data.state.metadata_dirty = true; + } + "fullscreen" => { + let restore = if data.state.fullscreen_windows.remove(&managed_name) { + data.state.fullscreen_restore.remove(&managed_name) + } else { + let current = data.state.window_positions.get(&managed_name).copied().or_else(|| { + data.state.config.windows.iter().find(|w| w.name == managed_name) + .map(|w| (w.x, w.y, w.width, w.height)) + }); + if let Some(rect) = current { + data.state.fullscreen_restore.insert(managed_name.clone(), rect); + } + data.state.fullscreen_windows.insert(managed_name.clone()); + None + }; + let rect = restore.unwrap_or((0, 0, data.state.output_size.0, data.state.output_size.1)); + data.state.window_positions.insert(managed_name.clone(), rect); + if let Some(ref ipc) = data.state.electron_ipc { + let _ = ipc.send_to_window(&managed_name, &crate::electron::ElectronInputEvent::WindowSize { width: rect.2, height: rect.3 }); + } + data.state.metadata_dirty = true; + } "focus" => { data.state .window_mapped - .insert(action_msg.window_name.clone(), true); + .insert(managed_name.clone(), true); // Give keyboard and pointer logical focus to this window - data.state.keyboard_window_focus = Some(action_msg.window_name.clone()); - data.state.pointer_window_focus = Some(action_msg.window_name.clone()); + data.state.keyboard_window_focus = Some(managed_name.clone()); + data.state.pointer_window_focus = Some(managed_name.clone()); data.state.metadata_dirty = true; } "map" => { data.state .window_mapped - .insert(action_msg.window_name.clone(), true); + .insert(managed_name.clone(), true); data.state.metadata_dirty = true; } "unmap" | "minimize" => { data.state .window_mapped - .insert(action_msg.window_name.clone(), false); + .insert(managed_name.clone(), false); // Clear any frontend keyboard/pointer focus targeting this window - if data.state.keyboard_window_focus.as_deref() == Some(&action_msg.window_name) { + if data.state.keyboard_window_focus.as_deref() == Some(&managed_name) { data.state.keyboard_window_focus = None; } - if data.state.pointer_window_focus.as_deref() == Some(&action_msg.window_name) { + if data.state.pointer_window_focus.as_deref() == Some(&managed_name) { data.state.pointer_window_focus = None; } data.state.metadata_dirty = true; @@ -2461,20 +3589,23 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { .state .electron_processes .iter() - .position(|p| p.name == action_msg.window_name) + .position(|p| p.name == managed_name) { let mut proc = data.state.electron_processes.remove(idx); proc.kill(); } - data.state.texture_cache.remove(&action_msg.window_name); - data.state.window_positions.remove(&action_msg.window_name); - data.state.window_mapped.remove(&action_msg.window_name); + data.state.texture_cache.remove(&managed_name); + data.state.current_frame_seq.remove(&managed_name); + data.state.window_positions.remove(&managed_name); + data.state.window_mapped.remove(&managed_name); + data.state.fullscreen_restore.remove(&managed_name); + data.state.fullscreen_windows.remove(&managed_name); // Clear any stored focus referring to this window - if data.state.keyboard_window_focus.as_deref() == Some(&action_msg.window_name) { + if data.state.keyboard_window_focus.as_deref() == Some(&managed_name) { data.state.keyboard_window_focus = None; } - if data.state.pointer_window_focus.as_deref() == Some(&action_msg.window_name) { + if data.state.pointer_window_focus.as_deref() == Some(&managed_name) { data.state.pointer_window_focus = None; } if let Some(ref ipc) = data.state.electron_ipc { @@ -2482,7 +3613,7 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { .clients .lock() .unwrap() - .remove(&action_msg.window_name); + .remove(&managed_name); } data.state.metadata_dirty = true; } @@ -2528,9 +3659,10 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { match ElectronProcess::spawn( win_cfg, &data.state.config.compositor.electron_path, - &Path::new(env!("CARGO_MANIFEST_DIR")).join("electron"), + &crate::electron::app_dir(), &data.state.config.compositor.ipc_socket, &data.electron_render_node, + "wayland", ) { Ok(proc) => data.state.electron_processes.push(proc), Err(e) => { @@ -2541,10 +3673,7 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { warn!("autostart: window config not found: {}", name); } } else if cmd == "wo://portal/start" { - let portal_bin = std::env::current_exe() - .ok() - .and_then(|p| p.parent().map(|d| d.join("wo-portal"))) - .unwrap_or_else(|| std::path::PathBuf::from("wo-portal")); + let portal_bin = companion_binary("wo-portal"); match std::process::Command::new(&portal_bin).spawn() { Ok(_) => info!("Spawned portal service: {:?}", portal_bin), Err(e) => error!("Failed to spawn portal: {e:#}"), @@ -2568,8 +3697,7 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { let _ = std::process::Command::new(&p).spawn(); } } else { - // Generic command: if contains whitespace, run via shell - if cmd.contains(' ') { + if command_uses_shell(&cmd) { if task.restart { let c = cmd.clone(); std::thread::spawn(move || loop { @@ -2631,6 +3759,7 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { Ok(status) => { let name = data.state.electron_processes[idx].name.clone(); info!(window = %name, pid = pid, "Electron process exited: {:?}", status); + data.state.current_frame_seq.remove(&name); let mut proc = data.state.electron_processes.remove(idx); let _ = proc.child.try_wait(); data.state.metadata_dirty = true; @@ -2670,11 +3799,14 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { "y": y, "width": w, "height": h, + "content_width": w, + "content_height": h, "z_order": win_cfg.z_order, "focused": is_focused, "mapped": mapped, "source": "electron", "ssd": true, + "fullscreen": data.state.fullscreen_windows.contains(&win_cfg.name), })); } @@ -2720,7 +3852,8 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { let bbox_w = geo.size.w + geo.loc.x.max(0) * 2; let bbox_h = geo.size.h + geo.loc.y.max(0) * 2; - let th = data.state.title_h_for_window(window); + let fullscreen = data.state.fullscreen_windows.contains(&window_name); + let th = if fullscreen { 0 } else { data.state.title_h_for_window(window) }; let is_ssd = th > 0; let (parent_surface, parent_x11_id) = if let Some(x11) = window.x11_surface() { @@ -2777,20 +3910,32 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> { "y": loc.y - th - geo.loc.y.max(0), "width": bbox_w, "height": bbox_h + th, + "content_width": geo.size.w, + "content_height": geo.size.h, "z_order": space_idx, "focused": is_focused, "source": source, "mapped": mapped, "ssd": is_ssd, + "fullscreen": fullscreen, "dialog": is_dialog, "parent_name": parent_name, })); } - if !metadata_list.is_empty() { + if !metadata_list.is_empty() || !data.state.monitors.is_empty() { let metadata_msg = serde_json::json!({ "type": "metadata", "windows": metadata_list, + "monitors": data.state.monitors.iter().map(|monitor| serde_json::json!({ + "id": monitor.id, + "x": monitor.location.x, + "y": monitor.location.y, + "width": monitor.size.0, + "height": monitor.size.1, + "primary": monitor.primary, + "scale": 1, + })).collect::>(), }); if let Some(ref ipc) = data.state.electron_ipc { diff --git a/src/nested.rs b/src/nested.rs index 23ab05c..bc3ef0b 100644 --- a/src/nested.rs +++ b/src/nested.rs @@ -22,14 +22,12 @@ use smithay::{ }, output::{Mode as OutputMode, Output, PhysicalProperties, Scale, Subpixel}, reexports::wayland_server::{Display, ListeningSocket}, - utils::{ - Logical, Physical, Point, Rectangle, - Scale as ScaleF, Size, Transform, - }, + utils::{Logical, Physical, Point, Rectangle, Scale as ScaleF, Size, Transform}, wayland::{seat::WaylandFocus, xdg_activation::XdgActivationHandler}, }; use std::{ collections::HashMap, + os::unix::fs::FileTypeExt, path::Path, time::{Duration, Instant}, }; @@ -67,6 +65,93 @@ fn find_render_node() -> DrmNode { .unwrap_or_else(|_| panic!("Could not find or create any render node")) } +fn refresh_millihz(backend: &winit::WinitGraphicsBackend) -> u32 { + backend + .window() + .current_monitor() + .and_then(|monitor| monitor.refresh_rate_millihertz()) + .filter(|rate| *rate > 0) + .unwrap_or(60_000) +} + +fn frame_duration(refresh_millihz: u32) -> Duration { + Duration::from_secs_f64(1_000.0 / f64::from(refresh_millihz.max(1))) +} + +fn host_wayland_display() -> Option { + let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR")?; + let runtime_path = Path::new(&runtime_dir); + + if let Some(display) = std::env::var_os("WAYLAND_DISPLAY") { + let socket = Path::new(&display); + let socket_path = if socket.is_absolute() { + socket.to_path_buf() + } else { + runtime_path.join(socket) + }; + if socket_path.metadata().is_ok_and(|metadata| metadata.file_type().is_socket()) { + return Some(display); + } + warn!(path = %socket_path.display(), "WAYLAND_DISPLAY does not name an accessible socket; searching runtime directory"); + } + + let mut sockets = std::fs::read_dir(runtime_path) + .ok()? + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry.file_name().to_string_lossy().starts_with("wayland-") + && entry.file_type().is_ok_and(|kind| kind.is_socket()) + }) + .collect::>(); + sockets.sort_by_key(|entry| entry.file_name()); + sockets.first().map(|entry| entry.file_name()) +} + +fn spawn_nested_electron_windows( + state: &mut WoState, + config: &Config, + app_dir: &Path, + render_node: &str, + width: u32, + height: u32, + ozone_platform: &str, +) { + for root_cfg in &config.root { + let mut full = root_cfg.clone(); + full.system_shell = true; + full.x = 0; + full.y = 0; + full.width = width; + full.height = height; + full.z_order = i32::MIN / 2; + match ElectronProcess::spawn( + &full, + &config.compositor.electron_path, + app_dir, + &config.compositor.ipc_socket, + render_node, + ozone_platform, + ) { + Ok(proc) => state.electron_processes.push(proc), + Err(e) => error!(window = %full.name, "failed to spawn Electron root: {e:#}"), + } + } + + for win_cfg in &config.windows { + match ElectronProcess::spawn( + win_cfg, + &config.compositor.electron_path, + app_dir, + &config.compositor.ipc_socket, + render_node, + ozone_platform, + ) { + Ok(proc) => state.electron_processes.push(proc), + Err(e) => error!(window = %win_cfg.name, "failed to spawn Electron: {e:#}"), + } + } +} + fn is_egl_context_or_surface_lost(err: &str) -> bool { let lowered = err.to_ascii_lowercase(); lowered.contains("context has been lost") @@ -77,15 +162,15 @@ fn is_egl_context_or_surface_lost(err: &str) -> bool { || lowered.contains("failed to allocate resources") } -pub fn run_nested(config: Config) -> Result<()> { +pub fn run_nested(mut config: Config) -> Result<()> { unsafe { libc::signal(libc::SIGPIPE, libc::SIG_IGN); } info!("Starting nested compositor mode"); - // Apply configured environment variables (e.g. DISPLAY=:0) early so that - // all children inherit them. XWayland will override DISPLAY later. + // Apply explicit environment overrides early. DISPLAY is set only after + // this compositor's XWayland server is ready. config.apply_environment(); if config.compositor.exit_on_escape { @@ -101,7 +186,28 @@ pub fn run_nested(config: Config) -> Result<()> { .with_context(|| format!("binding Wayland socket {}", config.compositor.socket_name))?; info!(socket = %config.compositor.socket_name, "Wayland socket created"); - let (mut backend, mut winit_evt_loop) = match winit::init::() { + // Some session launchers and old configs provide DISPLAY but omit + // WAYLAND_DISPLAY. Discover the host compositor socket in that case so + // nested Wo uses a native Wayland host window instead of failing XOpenDisplay + // and unnecessarily dropping into DRM/KMS root mode. + let host_display = std::env::var_os("DISPLAY"); + let host_wayland = host_wayland_display(); + if let Some(wayland_display) = host_wayland { + std::env::set_var("WAYLAND_DISPLAY", &wayland_display); + info!(socket = %wayland_display.to_string_lossy(), "Using host Wayland display for nested startup"); + if host_display.is_some() { + info!("Temporarily hiding DISPLAY so winit selects the host Wayland backend"); + } + std::env::remove_var("DISPLAY"); + } else { + info!("No host Wayland socket found; nested startup will use X11 if DISPLAY is available"); + } + let backend_result = winit::init::(); + if let Some(display) = host_display { + std::env::set_var("DISPLAY", display); + } + + let (mut backend, mut winit_evt_loop) = match backend_result { Ok(b) => b, Err(e) => { error!("Failed to create winit backend: {:?}", e); @@ -124,9 +230,10 @@ pub fn run_nested(config: Config) -> Result<()> { }, ); + let mut refresh_rate = refresh_millihz(&backend); let output_mode = OutputMode { size: (width as i32, height as i32).into(), - refresh: 60_000, + refresh: refresh_rate as i32, }; output.change_current_state( @@ -164,18 +271,34 @@ pub fn run_nested(config: Config) -> Result<()> { dmabuf_formats: Some(dmabuf_formats), }; + crate::spawn_immediate_autostarts(&mut config); let mut state = WoState::new(&mut display, config.clone(), render_node, backend_data); let (ipc, mut msg_rx) = ElectronIpc::listen(&config.compositor.ipc_socket).context("IPC socket")?; state.electron_ipc = Some(ipc); + let app_dir = crate::electron::app_dir(); + // Nested mode has no DRM card; use the first available render node. + let nested_render_node = std::fs::read_dir("/dev/dri") + .ok() + .into_iter() + .flat_map(|e| e.filter_map(Result::ok)) + .find(|e| { + e.file_name() + .to_str() + .map_or(false, |n| n.starts_with("renderD")) + }) + .map(|e| e.path().to_string_lossy().into_owned()) + .unwrap_or_else(|| "/dev/dri/renderD128".to_string()); + let mut calloop_loop = calloop::EventLoop::::try_new().context("calloop EventLoop creation")?; let calloop_handle = calloop_loop.handle(); state.xwayland_shell_state = Some(XWaylandShellState::new::(&dh)); + let mut xwayland_source_registered = false; match XWayland::spawn( &dh, None, // auto-detect display number @@ -189,6 +312,10 @@ pub fn run_nested(config: Config) -> Result<()> { let wm_handle = calloop_handle.clone(); let xw_client = std::rc::Rc::new(std::cell::RefCell::new(Some(xw_client))); let xw_client_cb = xw_client.clone(); + let startup_config = config.clone(); + let startup_app_dir = app_dir.clone(); + let startup_render_node = nested_render_node.clone(); + let mut electron_started = false; if let Err(e) = calloop_handle.insert_source(xwayland, move |event, _, state: &mut WoState| { @@ -216,15 +343,40 @@ pub fn run_nested(config: Config) -> Result<()> { Err(e) => error!("X11Wm::start_wm failed: {e:#}"), } } + if !electron_started { + spawn_nested_electron_windows( + state, + &startup_config, + &startup_app_dir, + &startup_render_node, + width as u32, + height as u32, + "wayland", + ); + electron_started = true; + } } XWaylandEvent::Error => { warn!("XWayland server encountered an error during startup"); + if !electron_started { + spawn_nested_electron_windows( + state, + &startup_config, + &startup_app_dir, + &startup_render_node, + width as u32, + height as u32, + "wayland", + ); + electron_started = true; + } } } }) { error!("Failed to register XWayland event source: {e:#}"); } else { + xwayland_source_registered = true; info!("XWayland server starting"); } } @@ -236,47 +388,16 @@ pub fn run_nested(config: Config) -> Result<()> { std::env::set_var("WAYLAND_DISPLAY", &config.compositor.socket_name); info!("Set WAYLAND_DISPLAY={}", config.compositor.socket_name); crate::state::propagate_environment(&["WAYLAND_DISPLAY"]); - - let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("electron"); - // Nested mode has no DRM card; use the first available render node. - let nested_render_node = std::fs::read_dir("/dev/dri") - .ok() - .into_iter() - .flat_map(|e| e.filter_map(Result::ok)) - .find(|e| e.file_name().to_str().map_or(false, |n| n.starts_with("renderD"))) - .map(|e| e.path().to_string_lossy().into_owned()) - .unwrap_or_else(|| "/dev/dri/renderD128".to_string()); - - for root_cfg in &config.root { - let mut full = root_cfg.clone(); - full.x = 0; - full.y = 0; - full.width = width as u32; - full.height = height as u32; - full.z_order = std::i32::MIN / 2; - match ElectronProcess::spawn( - &full, - &config.compositor.electron_path, + if !xwayland_source_registered { + spawn_nested_electron_windows( + &mut state, + &config, &app_dir, - &config.compositor.ipc_socket, &nested_render_node, - ) { - Ok(proc) => state.electron_processes.push(proc), - Err(e) => error!(window = %full.name, "failed to spawn Electron root: {e:#}"), - } - } - - for win_cfg in &config.windows { - match ElectronProcess::spawn( - win_cfg, - &config.compositor.electron_path, - &app_dir, - &config.compositor.ipc_socket, - &nested_render_node, - ) { - Ok(proc) => state.electron_processes.push(proc), - Err(e) => error!(window = %win_cfg.name, "failed to spawn Electron: {e:#}"), - } + width as u32, + height as u32, + "wayland", + ); } let mut autostart_tasks: Vec<(crate::config::AutostartConfig, std::time::Instant)> = Vec::new(); @@ -287,18 +408,20 @@ pub fn run_nested(config: Config) -> Result<()> { } let bg = Color32F::from(config.compositor.background); - let frame_time = Duration::from_millis(16); + let mut frame_time = frame_duration(refresh_rate); let mut last_render = std::time::Instant::now(); let event_start_time = std::time::Instant::now(); let mut gles_tex_cache: HashMap = HashMap::new(); let mut offscreen_tex_cache: HashMap = HashMap::new(); + let mut logged_first_frame_import = false; + let mut logged_first_texture_draw = false; + let mut logged_first_output_submit = false; use crate::electron::ElectronFrame; let mut latest_frames: HashMap = HashMap::new(); let mut last_wayland_elem_count: usize = 0; // Frame tracking for backpressure management let mut frame_timestamps: HashMap = HashMap::new(); - let _max_frame_age = Duration::from_millis(33); // Drop frames older than 2 frames @60Hz // Track previous cursor position to detect movement let mut last_cursor_pos: (f64, f64) = (0.0, 0.0); @@ -315,6 +438,22 @@ pub fn run_nested(config: Config) -> Result<()> { break; } + let current_refresh = refresh_millihz(&backend); + if current_refresh != refresh_rate { + refresh_rate = current_refresh; + frame_time = frame_duration(refresh_rate); + let mode = OutputMode { + size: (state.output_size.0 as i32, state.output_size.1 as i32).into(), + refresh: refresh_rate as i32, + }; + output.change_current_state(Some(mode), None, None, None); + output.set_preferred(mode); + info!( + refresh_hz = refresh_rate as f64 / 1000.0, + "nested display refresh changed" + ); + } + let winit_t0 = std::time::Instant::now(); let _dispatch_result = winit_evt_loop.dispatch_new_events(|event| { match event { @@ -322,7 +461,7 @@ pub fn run_nested(config: Config) -> Result<()> { info!("Window resized to {}x{}", size.w, size.h); let new_mode = OutputMode { size: (size.w as i32, size.h as i32).into(), - refresh: 60_000, + refresh: refresh_rate as i32, }; output.change_current_state(Some(new_mode), None, None, None); } @@ -409,6 +548,7 @@ pub fn run_nested(config: Config) -> Result<()> { } state.debug_new_toplevel = false; + let dispatch_elapsed = dispatch_t0.elapsed(); let calloop_t0 = std::time::Instant::now(); if let Err(e) = calloop_loop.dispatch(Duration::ZERO, &mut state) { warn!("calloop dispatch error: {e}"); @@ -426,7 +566,6 @@ pub fn run_nested(config: Config) -> Result<()> { ); last_wayland_elem_count = cur_wayland_count; } - let dispatch_elapsed = dispatch_t0.elapsed(); if dispatch_elapsed.as_millis() > 50 { warn!( "dispatch_clients+flush took {}ms!", @@ -437,11 +576,11 @@ pub fn run_nested(config: Config) -> Result<()> { let emsg_t0 = std::time::Instant::now(); let mut emsg_count = 0u32; let mut input_events_pending = false; - + // Process messages with priority: input events first, then frames let mut frames_batch: Vec = Vec::new(); let mut input_batch: Vec = Vec::new(); - + while let Ok(msg) = msg_rx.try_recv() { emsg_count += 1; match &msg { @@ -450,41 +589,48 @@ pub fn run_nested(config: Config) -> Result<()> { | ElectronMessage::ForwardedKeyboard { .. } | ElectronMessage::ForwardedPointerButton { .. } | ElectronMessage::ForwardedRelativePointer { .. } - | ElectronMessage::ForwardedPointerScroll { .. } => { + | ElectronMessage::ForwardedPointerScroll { .. } + | ElectronMessage::ForwardedPointerLeave { .. } => { input_batch.push(msg); input_events_pending = true; } _ => process_electron_message(msg, &mut state, event_start_time), } } - + // Process input events immediately for low latency for msg in input_batch { process_electron_message(msg, &mut state, event_start_time); } - + // Process frames with backpressure handling let now = Instant::now(); for msg in frames_batch { if let ElectronMessage::Frame(frame) = msg { let nm = frame.name.clone(); trace!("main-loop: dequeued frame seq={} window={}", frame.seq, nm); - + // Drop frames that are too old (backpressure) if let Some(last_ts) = frame_timestamps.get(&nm) { - if now.duration_since(*last_ts) < Duration::from_millis(8) { - // Skip frame if less than 8ms since last update (>120fps throttle) + if now.duration_since(*last_ts) < frame_time { trace!("Dropping frame {} due to backpressure", nm); + if let Some(ref ipc) = state.electron_ipc { + let _ = ipc.send_frame_ack(&nm, frame.seq); + } continue; } } - + frame_timestamps.insert(nm.clone(), now); // Only keep the latest frame per window - latest_frames.insert(nm, frame); + if let Some(stale) = latest_frames.insert(nm.clone(), frame) { + if let Some(ref ipc) = state.electron_ipc { + let _ = ipc.send_frame_ack(&nm, stale.seq); + } + } } } - + let emsg_ms = emsg_t0.elapsed().as_millis(); if emsg_ms > 20 { warn!( @@ -498,7 +644,7 @@ pub fn run_nested(config: Config) -> Result<()> { } else { frame_time }; - + if last_render.elapsed() < adaptive_frame_time { // Sleep for the remaining frame budget instead of busy-looping. // Cap the sleep to avoid missing events for too long. @@ -533,6 +679,7 @@ pub fn run_nested(config: Config) -> Result<()> { Ok(status) => { let name = state.electron_processes[idx].name.clone(); info!(window = %name, pid = pid, "Electron process exited: {:?}", status); + state.current_frame_seq.remove(&name); let mut proc = state.electron_processes.remove(idx); let _ = proc.child.try_wait(); state.metadata_dirty = true; @@ -609,19 +756,37 @@ pub fn run_nested(config: Config) -> Result<()> { for (_, frame) in latest_frames.drain() { let win_name = frame.name.clone(); + let frame_seq = frame.seq; let (width, height) = (frame.width, frame.height); - + let mut imported = false; + // Import each new frame - each has unique pixel data even if size unchanged match import_electron_frame(renderer, frame) { Ok((tex, cached)) => { trace!("Frame imported for {} ({}x{})", win_name, width, height); + if !logged_first_frame_import { + info!(window = %win_name, width, height, seq = frame_seq, "first Electron frame imported as a DMA-BUF texture"); + logged_first_frame_import = true; + } state.texture_cache.insert_dmabuf(cached); gles_tex_cache.insert(win_name.clone(), tex.texture); + imported = true; } Err(e) => { warn!("DMABUF import failed for {}: {e:#}", win_name); } } + if imported { + if let Some(previous_seq) = + state.current_frame_seq.insert(win_name.clone(), frame_seq) + { + if let Some(ref ipc) = state.electron_ipc { + let _ = ipc.send_frame_ack(&win_name, previous_seq); + } + } + } else if let Some(ref ipc) = state.electron_ipc { + let _ = ipc.send_frame_ack(&win_name, frame_seq); + } } // Evict textures for windows no longer in the DMABUF cache. @@ -632,16 +797,23 @@ pub fn run_nested(config: Config) -> Result<()> { // Only run the full compositing pass when the output needs updating. if frame_dirty { // Prepare cursor elements before starting the frame - let cursor_elements: Vec> = - if let smithay::input::pointer::CursorImageStatus::Surface(ref cursor_surface) = state.cursor_status { - use smithay::backend::renderer::element::{surface::render_elements_from_surface_tree, Kind}; + let cursor_elements: Vec> = + if let smithay::input::pointer::CursorImageStatus::Surface(ref cursor_surface) = + state.cursor_status + { + use smithay::backend::renderer::element::{ + surface::render_elements_from_surface_tree, Kind, + }; use smithay::utils::Scale as ScaleF; - + let scale = ScaleF::from(1.0); render_elements_from_surface_tree( renderer, cursor_surface, - (state.pointer_location.x as i32, state.pointer_location.y as i32), + ( + state.pointer_location.x as i32, + state.pointer_location.y as i32, + ), scale, 1.0, Kind::Cursor, @@ -651,17 +823,29 @@ pub fn run_nested(config: Config) -> Result<()> { }; // Load themed cursor texture for Named cursor status - let themed_cursor = if let smithay::input::pointer::CursorImageStatus::Named(ref icon) = state.cursor_status { - state.cursor_theme_manager.get_cursor(icon.name(), renderer).cloned() - } else if cursor_elements.is_empty() { - if !matches!(state.cursor_status, smithay::input::pointer::CursorImageStatus::Hidden) { - state.cursor_theme_manager.get_cursor("default", renderer).cloned() + let themed_cursor = + if let smithay::input::pointer::CursorImageStatus::Named(ref icon) = + state.cursor_status + { + state + .cursor_theme_manager + .get_cursor(icon.name(), renderer) + .cloned() + } else if cursor_elements.is_empty() { + if !matches!( + state.cursor_status, + smithay::input::pointer::CursorImageStatus::Hidden + ) { + state + .cursor_theme_manager + .get_cursor("default", renderer) + .cloned() + } else { + None + } } else { None - } - } else { - None - }; + }; // Wayland windows are exclusively rendered by comraw via off-screen buffers. // The native space.elements() rendering pass has been removed here. @@ -716,7 +900,12 @@ pub fn run_nested(config: Config) -> Result<()> { 1.0, ) { warn!("Failed to render texture for {}: {}", win.name, e); + } else if !logged_first_texture_draw { + info!(window = %win.name, x = win.x, y = win.y, output_width = size.w, output_height = size.h, "first Electron texture rendered into the output frame"); + logged_first_texture_draw = true; } + } else if win.name == "main" && !logged_first_frame_import { + trace!(window = %win.name, mapped, "desktop root has no imported texture yet"); } } @@ -829,29 +1018,32 @@ pub fn run_nested(config: Config) -> Result<()> { // Extract Wayland damage rects for partial-update optimization. // Empty damage = full frame (surfaces in dirty list always get exported). - let damage_rects: Vec = with_states(&surface, |states| { - let mut attrs = states.cached_state.get::(); - let current = attrs.current(); - current.damage.iter().map(|d| { - match d { - Damage::Buffer(rect) => crate::state::DamageRect { - x: rect.loc.x, - y: rect.loc.y, - width: rect.size.w, - height: rect.size.h, - }, - Damage::Surface(rect) => { - let scale = current.buffer_scale; - crate::state::DamageRect { - x: rect.loc.x * scale, - y: rect.loc.y * scale, - width: rect.size.w * scale, - height: rect.size.h * scale, + let damage_rects: Vec = + with_states(&surface, |states| { + let mut attrs = states.cached_state.get::(); + let current = attrs.current(); + current + .damage + .iter() + .map(|d| match d { + Damage::Buffer(rect) => crate::state::DamageRect { + x: rect.loc.x, + y: rect.loc.y, + width: rect.size.w, + height: rect.size.h, + }, + Damage::Surface(rect) => { + let scale = current.buffer_scale; + crate::state::DamageRect { + x: rect.loc.x * scale, + y: rect.loc.y * scale, + width: rect.size.w * scale, + height: rect.size.h * scale, + } } - } - } - }).collect() - }); + }) + .collect() + }); // Calculate bbox (bounding box) for CSD windows with shadows/decorations let bbox_w = (geo.size.w + geo.loc.x.max(0) * 2) as u32; @@ -914,8 +1106,7 @@ pub fn run_nested(config: Config) -> Result<()> { let src = unsafe { std::slice::from_raw_parts(ptr, pixel_bytes) }; // Determine whether to do a partial (damage-rect) or full copy. - let do_partial = !damage_rects.is_empty() - && damage_rects.len() < 64; + let do_partial = !damage_rects.is_empty() && damage_rects.len() < 64; if do_partial { // Partial update: only copy damaged rows with BGRA→RGBA swap @@ -931,9 +1122,9 @@ pub fn run_nested(config: Config) -> Result<()> { let si = src_row_off + col * 4; let di = dst_row_off + col * 4; // BGRA → RGBA: swap B↔R - dst[di] = src[si + 2]; // R + dst[di] = src[si + 2]; // R dst[di + 1] = src[si + 1]; // G - dst[di + 2] = src[si]; // B + dst[di + 2] = src[si]; // B dst[di + 3] = src[si + 3]; // A } } @@ -946,9 +1137,9 @@ pub fn run_nested(config: Config) -> Result<()> { for col in 0..bw as usize { let si = src_row_off + col * 4; let di = dst_row_off + col * 4; - dst[di] = src[si + 2]; // R + dst[di] = src[si + 2]; // R dst[di + 1] = src[si + 1]; // G - dst[di + 2] = src[si]; // B + dst[di + 2] = src[si]; // B dst[di + 3] = src[si + 3]; // A } } @@ -972,13 +1163,18 @@ pub fn run_nested(config: Config) -> Result<()> { let rss_data = states.data_map.get::(); rss_data .and_then(|d| d.lock().ok()) - .and_then(|rss| rss.buffer().map(|b| { - let mut r = (0u32, 0u32); - let _ = with_buffer_contents(&b, |_ptr, _len, spec| { - r = (spec.width.max(0) as u32, spec.height.max(0) as u32); - }); - r - })) + .and_then(|rss| { + rss.buffer().map(|b| { + let mut r = (0u32, 0u32); + let _ = with_buffer_contents(&b, |_ptr, _len, spec| { + r = ( + spec.width.max(0) as u32, + spec.height.max(0) as u32, + ); + }); + r + }) + }) .unwrap_or((w, h)) }); @@ -1107,9 +1303,8 @@ pub fn run_nested(config: Config) -> Result<()> { state.dirty_surfaces.clear(); } // inner bind scope ends here, backend borrow is released - let did_bind = render_ok; - + if did_bind { let submit_t0 = std::time::Instant::now(); if !render_ok { @@ -1124,6 +1319,10 @@ pub fn run_nested(config: Config) -> Result<()> { } } else { trace!("Frame submitted successfully"); + if !logged_first_output_submit { + info!("first nested output frame submitted successfully"); + logged_first_output_submit = true; + } } let submit_ms = submit_t0.elapsed().as_millis(); if submit_ms > 50 { @@ -1154,7 +1353,11 @@ pub fn run_nested(config: Config) -> Result<()> { // some of the budget; sleep the rest so the next iteration starts close // to the target frame boundary. let elapsed = loop_t0.elapsed(); - let target = if input_events_pending { Duration::from_millis(8) } else { frame_time }; + let target = if input_events_pending { + Duration::from_millis(8) + } else { + frame_time + }; if elapsed < target { let remaining = target - elapsed; // Don't sleep more than 4ms to stay responsive to new Wayland/IPC events @@ -1164,7 +1367,15 @@ pub fn run_nested(config: Config) -> Result<()> { // Log if the entire loop iteration was slow. let total_loop_ms = loop_t0.elapsed().as_millis(); if total_loop_ms > 50 { - warn!("SLOW LOOP iter={} total={}ms", loop_iter, total_loop_ms); + warn!( + "SLOW LOOP iter={} total={}ms wayland_dispatch={}ms calloop={}ms electron_messages={}ms render_callbacks={}ms", + loop_iter, + total_loop_ms, + dispatch_elapsed.as_millis(), + calloop_ms, + emsg_ms, + render_total_ms, + ); } } @@ -1189,7 +1400,11 @@ fn execute_autostart_task( .ok() .into_iter() .flat_map(|e| e.filter_map(Result::ok)) - .find(|e| e.file_name().to_str().map_or(false, |n| n.starts_with("renderD"))) + .find(|e| { + e.file_name() + .to_str() + .map_or(false, |n| n.starts_with("renderD")) + }) .map(|e| e.path().to_string_lossy().into_owned()) .unwrap_or_else(|| "/dev/dri/renderD128".to_string()); match ElectronProcess::spawn( @@ -1198,23 +1413,21 @@ fn execute_autostart_task( app_dir, &state.config.compositor.ipc_socket, &render_node, + "wayland", ) { Ok(proc) => state.electron_processes.push(proc), Err(e) => error!(window = %name, "autostart spawn failed: {e:#}"), } } } else if cmd == "wo://portal/start" { - let portal_bin = std::env::current_exe() - .ok() - .and_then(|p| p.parent().map(|d| d.join("wo-portal"))) - .unwrap_or_else(|| std::path::PathBuf::from("wo-portal")); + let portal_bin = crate::companion_binary("wo-portal"); match std::process::Command::new(&portal_bin).spawn() { Ok(_) => info!("Spawned portal service: {:?}", portal_bin), Err(e) => error!("Failed to spawn portal: {e:#}"), } } else if let Some(path) = cmd.strip_prefix("file://") { let _ = std::process::Command::new(path).spawn(); - } else if cmd.contains(' ') { + } else if crate::command_uses_shell(cmd) { let _ = std::process::Command::new("sh").arg("-c").arg(cmd).spawn(); } else { let _ = std::process::Command::new(cmd).spawn(); @@ -1241,6 +1454,23 @@ fn process_electron_message( ); state.metadata_dirty = true; } + ElectronMessage::SiteHashes { + window_name, + update, + } => { + use sha2::Digest; + if !update.index_bytes.is_empty() { + let hashes = crate::electron::SiteHashes { + page_url: update.page_url, + index_sha256: format!("{:x}", sha2::Sha256::digest(&update.index_bytes)), + styles_sha256: format!("{:x}", sha2::Sha256::digest(&update.styles_bytes)), + }; + info!(window = %window_name, page = %hashes.page_url, "computed and registered document and stylesheet hashes"); + state.site_hashes.insert(window_name, hashes); + } else { + warn!(window = %window_name, "rejected empty site assets"); + } + } ElectronMessage::Syscall(req) => { info!(window = %req.window_name, payload = %req.payload, "syscall received"); if let Some(ref handler) = state.syscall_handler { @@ -1310,7 +1540,8 @@ fn process_electron_message( .unwrap_or(0) as i32; // comraw reports the MacWindow origin (title bar top); // the Wayland surface sits th pixels below that. - let prev_loc = state.space.element_location(&window).unwrap_or_default(); + let prev_loc = + state.space.element_location(&window).unwrap_or_default(); state.space.map_element(window.clone(), (x, y + th), false); state.translate_dialog_children( &window, @@ -1436,11 +1667,20 @@ fn process_electron_message( smithay::utils::user_data::UserDataMap::new(), ), }; - state.request_activation(token, token_data, surface.clone()); + state.request_activation( + token, + token_data, + surface.clone(), + ); if let Some(keyboard) = state.seat.get_keyboard() { - let serial = smithay::utils::SERIAL_COUNTER.next_serial(); - keyboard.set_focus(state, Some(surface.clone()), serial); + let serial = + smithay::utils::SERIAL_COUNTER.next_serial(); + keyboard.set_focus( + state, + Some(surface.clone()), + serial, + ); } if let Some(name) = target_name.as_ref() { @@ -1489,25 +1729,27 @@ fn process_electron_message( if window.wl_surface().is_none() { // Surface not ready yet. } else if let Some(loc) = state.space.element_location(&window) { - let px = payload_json + let mut px = payload_json .as_ref() .and_then(|p| p.get("x")) .and_then(|v| v.as_f64()) .unwrap_or(0.0); - let py = payload_json + let mut py = payload_json .as_ref() .and_then(|p| p.get("y")) .and_then(|v| v.as_f64()) .unwrap_or(0.0); + let geometry_offset = window.geometry().loc; + px += geometry_offset.x as f64; + py += geometry_offset.y as f64; + // Client dictates event dispatch; compositor // owns pointer_location (hardware). Compute // a synthetic location for Smithay without // overwriting pointer_location. - let dispatch_location: Point = ( - loc.x as f64 + px, - loc.y as f64 + py, - ).into(); + let dispatch_location: Point = + (loc.x as f64 + px, loc.y as f64 + py).into(); let under = window.surface_under( (px, py), @@ -1552,22 +1794,24 @@ fn process_electron_message( .and_then(|p| p.get("pressed")) .and_then(|v| v.as_bool()) .unwrap_or(false); - let px = payload_json + let mut px = payload_json .as_ref() .and_then(|p| p.get("x")) .and_then(|v| v.as_f64()) .unwrap_or(0.0); - let py = payload_json + let mut py = payload_json .as_ref() .and_then(|p| p.get("y")) .and_then(|v| v.as_f64()) .unwrap_or(0.0); + let geometry_offset = window.geometry().loc; + px += geometry_offset.x as f64; + py += geometry_offset.y as f64; + // Update pointer_location consistently - state.pointer_location = ( - loc.x as f64 + px, - loc.y as f64 + py, - ).into(); + state.pointer_location = + (loc.x as f64 + px, loc.y as f64 + py).into(); // Send motion first so focus is correct for the button let under = window.surface_under( @@ -1578,7 +1822,8 @@ fn process_electron_message( let focus_pos: Point = ( loc.x as f64 + off.x as f64, loc.y as f64 + off.y as f64, - ).into(); + ) + .into(); (s, focus_pos) }); let time = event_start_time.elapsed().as_millis() as u32; @@ -1754,6 +1999,7 @@ fn process_electron_message( proc.kill(); } state.texture_cache.remove(&action_msg.window_name); + state.current_frame_seq.remove(&action_msg.window_name); state.window_positions.remove(&action_msg.window_name); state.window_mapped.remove(&action_msg.window_name); if state.keyboard_window_focus.as_deref() @@ -1804,10 +2050,16 @@ fn process_electron_message( } ElectronMessage::ForwardedPointerScroll { window_name, + x, + y, dx, dy, + time, } => { - state.handle_forwarded_pointer_scroll(&window_name, dx, dy); + state.handle_forwarded_pointer_scroll(&window_name, x, y, dx, dy, time); + } + ElectronMessage::ForwardedPointerLeave { window_name } => { + state.handle_forwarded_pointer_leave(&window_name); } } } @@ -1836,11 +2088,14 @@ fn send_window_metadata(state: &WoState) { "y": y, "width": w, "height": h, + "content_width": w, + "content_height": h, "z_order": win_cfg.z_order, "focused": is_focused, "mapped": mapped, "source": "electron", "ssd": true, + "fullscreen": state.fullscreen_windows.contains(&win_cfg.name), })); } @@ -1887,22 +2142,30 @@ fn send_window_metadata(state: &WoState) { let bbox_w = geo.size.w + geo.loc.x.max(0) * 2; let bbox_h = geo.size.h + geo.loc.y.max(0) * 2; - let th = state.title_h_for_window(window); + let fullscreen = state.fullscreen_windows.contains(&window_name); + let th = if fullscreen { + 0 + } else { + state.title_h_for_window(window) + }; let is_ssd = th > 0; let (parent_surface, parent_x11_id) = if let Some(x11) = window.x11_surface() { (None, x11.is_transient_for()) } else { - (window.toplevel().and_then(|t| { - smithay::wayland::compositor::with_states(t.wl_surface(), |states| { - states - .data_map - .get::() - .and_then(|d| d.lock().ok().and_then(|data| data.parent.clone())) - }) - }), None) + ( + window.toplevel().and_then(|t| { + smithay::wayland::compositor::with_states(t.wl_surface(), |states| { + states + .data_map + .get::() + .and_then(|d| d.lock().ok().and_then(|data| data.parent.clone())) + }) + }), + None, + ) }; - + let parent_name = if let Some(parent_wl) = parent_surface.as_ref() { use smithay::reexports::wayland_server::Resource; state.wayland_window_names.get(&parent_wl.id()).cloned() @@ -1911,7 +2174,7 @@ fn send_window_metadata(state: &WoState) { } else { None }; - + let is_dialog = parent_name.is_some(); let is_focused = if let Some(x11) = window.x11_surface() { @@ -1941,11 +2204,14 @@ fn send_window_metadata(state: &WoState) { "y": loc.y - th - geo.loc.y.max(0), "width": bbox_w, "height": bbox_h + th, + "content_width": geo.size.w, + "content_height": geo.size.h, "z_order": space_idx, "focused": is_focused, "mapped": mapped, "source": if window.x11_surface().is_some() { "x11" } else { "wayland" }, "ssd": is_ssd, + "fullscreen": fullscreen, "dialog": is_dialog, "parent_name": parent_name, })); diff --git a/src/state.rs b/src/state.rs index b42993f..96d5712 100644 --- a/src/state.rs +++ b/src/state.rs @@ -8,7 +8,7 @@ use smithay::{ }, utils::{Logical, Point, SERIAL_COUNTER}, wayland::{ - compositor::{CompositorClientState, CompositorState, with_states}, cursor_shape::CursorShapeManagerState, dmabuf::{DmabufFeedback, DmabufFeedbackBuilder, DmabufState}, fractional_scale::{ + compositor::{CompositorClientState, CompositorState, with_states}, cursor_shape::CursorShapeManagerState, dmabuf::{DmabufFeedback, DmabufFeedbackBuilder, DmabufGlobal, DmabufState}, fractional_scale::{ self as fractional_scale_mod, FractionalScaleHandler, FractionalScaleManagerState, }, output::OutputManagerState, selection::{ @@ -87,7 +87,11 @@ impl MappedShmSlot { anyhow::bail!("mmap failed for MappedShmSlot"); } - Ok(Self { fd, ptr: ptr as *mut u8, len: size }) + Ok(Self { + fd, + ptr: ptr as *mut u8, + len: size, + }) } /// Resize the slot, remapping if the new size differs. @@ -200,6 +204,72 @@ pub struct BackendData { pub dmabuf_formats: Option>, } +/// One compositor output in the global desktop coordinate space. +/// +/// DRM and nested backends can publish multiple entries. The primary output +/// remains available through `WoState::output` for code paths that have not +/// yet been made output-aware. +#[derive(Clone)] +pub struct MonitorOutput { + pub id: String, + pub output: Output, + pub location: Point, + pub size: (u32, u32), + pub primary: bool, +} + +impl MonitorOutput { + pub fn contains(&self, point: Point) -> bool { + let x = point.x - self.location.x as f64; + let y = point.y - self.location.y as f64; + x >= 0.0 && y >= 0.0 && x < self.size.0 as f64 && y < self.size.1 as f64 + } +} + +fn clamp_point_to_monitors( + monitors: &[MonitorOutput], + point: Point, +) -> Point { + if monitors.is_empty() || monitors.iter().any(|monitor| monitor.contains(point)) { + return point; + } + monitors + .iter() + .map(|monitor| { + let max_x = monitor.location.x as f64 + monitor.size.0.saturating_sub(1) as f64; + let max_y = monitor.location.y as f64 + monitor.size.1.saturating_sub(1) as f64; + let x = point.x.clamp(monitor.location.x as f64, max_x); + let y = point.y.clamp(monitor.location.y as f64, max_y); + let dx = point.x - x; + let dy = point.y - y; + (x, y, dx * dx + dy * dy) + }) + .min_by(|a, b| a.2.total_cmp(&b.2)) + .map(|(x, y, _)| (x, y).into()) + .unwrap_or(point) +} + +fn monitor_desktop_bounds(monitors: &[MonitorOutput]) -> smithay::utils::Rectangle { + let Some(first) = monitors.first() else { + return smithay::utils::Rectangle::new((0, 0).into(), (0, 0).into()); + }; + let extent = |pixels: u32| pixels.min(i32::MAX as u32) as i32; + let mut left = first.location.x; + let mut top = first.location.y; + let mut right = left.saturating_add(extent(first.size.0)); + let mut bottom = top.saturating_add(extent(first.size.1)); + for monitor in monitors.iter().skip(1) { + left = left.min(monitor.location.x); + top = top.min(monitor.location.y); + right = right.max(monitor.location.x.saturating_add(extent(monitor.size.0))); + bottom = bottom.max(monitor.location.y.saturating_add(extent(monitor.size.1))); + } + smithay::utils::Rectangle::new( + (left, top).into(), + (right.saturating_sub(left), bottom.saturating_sub(top)).into(), + ) +} + impl BackendData { pub fn renderer(&self) -> Option>> { self.renderer.clone() @@ -213,7 +283,7 @@ pub struct WoState { pub xdg_decoration_state: XdgDecorationState, pub shm_state: ShmState, pub dmabuf_state: DmabufState, - // pub dmabuf_global: DmabufGlobal, + pub dmabuf_global: DmabufGlobal, pub dmabuf_default_feedback: DmabufFeedback, pub dmabuf_scanout_feedback: DmabufFeedback, pub seat_state: SeatState, @@ -238,10 +308,19 @@ pub struct WoState { pub space: Space, pub output: Output, + /// Outputs ordered with the primary monitor first, in global desktop coordinates. + pub monitors: Vec, pub electron_processes: Vec, pub texture_cache: TextureCache, + /// Sequence of the Electron DMA-BUF currently retained for each window. + /// It is acknowledged only after a newer frame replaces it. + pub current_frame_seq: std::collections::HashMap, pub window_positions: std::collections::HashMap, + /// Hashes of the verified document and theme stylesheet bound to each Electron window. + pub site_hashes: std::collections::HashMap, + pub fullscreen_restore: std::collections::HashMap, + pub fullscreen_windows: std::collections::HashSet, pub electron_ipc: Option, pub syscall_handler: Option, pub portal: Option>, @@ -350,7 +429,7 @@ impl WoState { let screencopy_state = Some(ScreencopyManagerState::new::(&dh)); let data_device_state = DataDeviceState::new::(&dh); - let dmabuf_state = DmabufState::new(); + let mut dmabuf_state = DmabufState::new(); // Get DMABUF formats from renderer if available let (default_feedback, scanout_feedback) = if let Some(renderer_arc) = &backend.renderer { @@ -394,13 +473,8 @@ impl WoState { .expect("building empty scanout dmabuf feedback"); (default_fb, scanout_fb) }; - // Disable DMABUF advertising to force clients to use SHM for the pixel export pipeline. - /* - let dmabuf_global = dmabuf_state.create_global_with_default_feedback::( - &dh, - &default_feedback, - ); - */ + let dmabuf_global = + dmabuf_state.create_global_with_default_feedback::(&dh, &default_feedback); let popup_manager = PopupManager::default(); let space = Space::default(); @@ -416,7 +490,7 @@ impl WoState { xdg_decoration_state, shm_state, dmabuf_state, - // dmabuf_global, + dmabuf_global, dmabuf_default_feedback: default_feedback, dmabuf_scanout_feedback: scanout_feedback, seat_state, @@ -438,10 +512,21 @@ impl WoState { keyboard_window_focus: None, output_size, space, - output, + output: output.clone(), + monitors: vec![MonitorOutput { + id: "wo-output".to_string(), + output, + location: (0, 0).into(), + size: output_size, + primary: true, + }], electron_processes: vec![], texture_cache: TextureCache::default(), + current_frame_seq: std::collections::HashMap::new(), window_positions: std::collections::HashMap::new(), + site_hashes: std::collections::HashMap::new(), + fullscreen_restore: std::collections::HashMap::new(), + fullscreen_windows: std::collections::HashSet::new(), electron_ipc: None, syscall_handler, portal, @@ -473,14 +558,33 @@ impl WoState { // Map the output into the space at origin. state.space.map_output(&state.output, (0, 0)); - // Initialize mapping state for configured windows as mapped (visible). + // Initialize configured windows and desktop roots as mapped (visible). for win in &state.config.windows { state.window_mapped.insert(win.name.clone(), true); } + for root in &state.config.root { + state.window_mapped.insert(root.name.clone(), true); + } state } + /// Find the output under a global logical desktop coordinate. + pub fn monitor_at(&self, point: Point) -> Option<&MonitorOutput> { + self.monitors.iter().find(|monitor| monitor.contains(point)) + } + + /// Clamp a global pointer position to a real output, including layouts + /// with negative origins or gaps between monitors. + pub fn clamp_pointer_to_monitors(&self, point: Point) -> Point { + clamp_point_to_monitors(&self.monitors, point) + } + + /// Return the union of all output rectangles in global logical coordinates. + pub fn desktop_bounds(&self) -> smithay::utils::Rectangle { + monitor_desktop_bounds(&self.monitors) + } + /// Return the effective title-bar height for a Wayland window. /// SSD windows get TITLE_H (comraw draws the title bar); CSD windows get 0 /// (the client draws its own decorations). @@ -619,19 +723,28 @@ impl WoState { /// Find a Wayland or X11 Window element by its stable name (even if minimized). pub fn find_wayland_window(&self, name: &str) -> Option { - self.all_windows.iter().find(|w| { - if let Some(toplevel) = w.toplevel() { - if let Some(win_name) = self.wayland_window_names.get(&toplevel.wl_surface().id()) { - if win_name == name { return true; } + self.all_windows + .iter() + .find(|w| { + if let Some(toplevel) = w.toplevel() { + if let Some(win_name) = + self.wayland_window_names.get(&toplevel.wl_surface().id()) + { + if win_name == name { + return true; + } + } } - } - if let Some(x11) = w.x11_surface() { - if let Some(win_name) = self.x11_window_names.get(&x11.window_id()) { - if win_name == name { return true; } + if let Some(x11) = w.x11_surface() { + if let Some(win_name) = self.x11_window_names.get(&x11.window_id()) { + if win_name == name { + return true; + } + } } - } - false - }).cloned() + false + }) + .cloned() } /// Focus the topmost mapped native window in `space`. @@ -703,7 +816,8 @@ impl WoState { // Translate global space coordinate to surface-local coordinate // window.geometry().loc is the offset from surface origin to content area. let geo = window.geometry(); - let surface_local: Point = (local.x + geo.loc.x as f64, local.y + geo.loc.y as f64).into(); + let surface_local: Point = + (local.x + geo.loc.x as f64, local.y + geo.loc.y as f64).into(); window .surface_under(surface_local, smithay::desktop::WindowSurfaceType::ALL) .map(|(s, p)| (s, p.to_f64() + window_pos.to_f64())) @@ -711,6 +825,70 @@ impl WoState { } } +#[cfg(test)] +mod monitor_layout_tests { + use super::*; + use smithay::output::{PhysicalProperties, Subpixel}; + + fn monitor(id: &str, x: i32, y: i32, width: u32, height: u32) -> MonitorOutput { + MonitorOutput { + id: id.to_string(), + output: Output::new( + id.to_string(), + PhysicalProperties { + size: (0, 0).into(), + subpixel: Subpixel::Unknown, + make: "test".into(), + model: id.into(), + }, + ), + location: (x, y).into(), + size: (width, height), + primary: x == 0 && y == 0, + } + } + + #[test] + fn monitor_hit_test_uses_global_coordinates_and_excludes_far_edge() { + let displays = vec![monitor("left", -100, 20, 100, 80)]; + assert!(displays[0].contains((-100.0, 20.0).into())); + assert!(displays[0].contains((-0.5, 99.5).into())); + assert!(!displays[0].contains((0.0, 20.0).into())); + assert!(!displays[0].contains((-1.0, 100.0).into())); + } + + #[test] + fn pointer_clamps_to_nearest_output_across_layout_gaps() { + let displays = vec![ + monitor("left", -100, 0, 100, 100), + monitor("right", 20, 0, 100, 100), + ]; + assert_eq!( + clamp_point_to_monitors(&displays, (-110.0, 40.0).into()), + (-100.0, 40.0).into() + ); + assert_eq!( + clamp_point_to_monitors(&displays, (0.0, 40.0).into()), + (-1.0, 40.0).into() + ); + assert_eq!( + clamp_point_to_monitors(&displays, (300.0, 120.0).into()), + (119.0, 99.0).into() + ); + } + + #[test] + fn desktop_bounds_include_negative_origins_and_output_gaps() { + let displays = vec![ + monitor("left", -100, -20, 100, 80), + monitor("right", 20, 10, 120, 90), + ]; + let bounds = monitor_desktop_bounds(&displays); + assert_eq!(bounds.loc, (-100, -20).into()); + assert_eq!(bounds.size, (240, 120).into()); + } +} + impl SeatHandler for WoState { type KeyboardFocus = smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; type PointerFocus = smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; @@ -800,14 +978,34 @@ smithay::delegate_cursor_shape!(WoState); impl PointerConstraintsHandler for WoState { fn cursor_position_hint( &mut self, - _surface: &WlSurface, - _pointer: &PointerHandle, - _location: Point, + surface: &WlSurface, + pointer: &PointerHandle, + location: Point, ) { + let window = self.space.elements().find(|window| { + window + .toplevel() + .map(|toplevel| toplevel.wl_surface() == surface) + .unwrap_or(false) + || window + .x11_surface() + .and_then(|x11| x11.wl_surface()) + .map(|wl_surface| wl_surface == *surface) + .unwrap_or(false) + }); + + if let Some(window) = window { + if let Some(origin) = self.space.element_location(window) { + let content_origin = origin + window.geometry().loc; + let hinted_location = content_origin.to_f64() + location; + self.pointer_location = hinted_location; + pointer.set_location(hinted_location); + } + } } fn new_constraint(&mut self, surface: &WlSurface, pointer: &PointerHandle) { - // When a new constraint is created, check if it's a lock and notify Electron. + // Notify Electron when a native client requests a pointer lock. pointer_constraints::with_pointer_constraint(surface, pointer, |constraint| { if let Some(constraint) = constraint { if let smithay::wayland::pointer_constraints::PointerConstraint::Locked(_) = @@ -868,9 +1066,7 @@ pub fn propagate_environment(vars: &[&str]) { let var_args: Vec = vars .iter() - .filter_map(|&name| { - std::env::var(name).ok().map(|val| format!("{name}={val}")) - }) + .filter_map(|&name| std::env::var(name).ok().map(|val| format!("{name}={val}"))) .collect(); if var_args.is_empty() { @@ -889,7 +1085,11 @@ pub fn propagate_environment(vars: &[&str]) { for arg in &var_args { dbus_cmd.arg(arg); } - match dbus_cmd.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() { + match dbus_cmd + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { Ok(mut child) => { let _ = child.wait(); info!("Propagated {:?} to DBus activation environment", names); @@ -903,7 +1103,11 @@ pub fn propagate_environment(vars: &[&str]) { for name in &names { systemctl_cmd.arg(name); } - match systemctl_cmd.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() { + match systemctl_cmd + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { Ok(mut child) => { let _ = child.wait(); info!("Propagated {:?} to systemd user environment", names); diff --git a/src/state/portal.rs b/src/state/portal.rs index eabd1a7..2f7211b 100644 --- a/src/state/portal.rs +++ b/src/state/portal.rs @@ -1,5 +1,4 @@ /// xdg-desktop-portal implementation for Wo compositor - use anyhow::{Context, Result}; use pipewire as pw; use serde::{Deserialize, Serialize}; @@ -13,7 +12,7 @@ use tracing::{debug, info, warn}; pub struct PortalSession { pub session_id: String, pub source_type: SourceType, - pub target: Option, // "screen" or window name + pub target: Option, // "screen" or window name pub active: bool, } @@ -55,7 +54,6 @@ pub struct WoPortal { sessions: Arc>>, next_session_id: Arc>, streams: Arc>>>, - compositor_socket: Option, } impl WoPortal { @@ -64,38 +62,9 @@ impl WoPortal { sessions: Arc::new(Mutex::new(HashMap::new())), next_session_id: Arc::new(Mutex::new(1)), streams: Arc::new(Mutex::new(HashMap::new())), - compositor_socket: None, } } - pub fn with_compositor_socket(mut self, socket: String) -> Self { - self.compositor_socket = Some(socket); - self - } - - /// Request capture from compositor via IPC - fn request_compositor_capture(&self, window: &Option, source_type: SourceType) -> Result<()> { - if let Some(socket_path) = &self.compositor_socket { - use std::os::unix::net::UnixStream; - use std::io::Write; - - // Connect to compositor IPC - if let Ok(mut stream) = UnixStream::connect(socket_path) { - let request = serde_json::json!({ - "type": "portal_capture_request", - "source_type": source_type, - "window": window, - }); - let msg = serde_json::to_string(&request)?; - let _ = stream.write_all(msg.as_bytes()); - info!("Requested capture from compositor: {:?}", source_type); - } else { - warn!("Could not connect to compositor socket: {}", socket_path); - } - } - Ok(()) - } - /// Create a new portal session pub fn create_session(&self) -> String { let mut next_id = self.next_session_id.lock().unwrap(); @@ -109,21 +78,18 @@ impl WoPortal { active: false, }; - self.sessions.lock().unwrap().insert(session_id.clone(), session); + self.sessions + .lock() + .unwrap() + .insert(session_id.clone(), session); info!("Created portal session: {}", session_id); session_id } /// Select sources for a session - pub fn select_sources( - &self, - session_id: &str, - request: CaptureRequest, - ) -> Result<()> { + pub fn select_sources(&self, session_id: &str, request: CaptureRequest) -> Result<()> { let mut sessions = self.sessions.lock().unwrap(); - let session = sessions - .get_mut(session_id) - .context("session not found")?; + let session = sessions.get_mut(session_id).context("session not found")?; session.source_type = request.source_type; session.target = request.target.clone(); @@ -143,9 +109,7 @@ impl WoPortal { height: u32, ) -> Result { let mut sessions = self.sessions.lock().unwrap(); - let session = sessions - .get_mut(session_id) - .context("session not found")?; + let session = sessions.get_mut(session_id).context("session not found")?; session.active = true; @@ -155,7 +119,8 @@ impl WoPortal { .filter(|c| c.is_ascii_digit()) .collect::() .parse::() - .unwrap_or(1) + 100; // Offset to avoid conflicts + .unwrap_or(1) + + 100; // Offset to avoid conflicts // Create the actual PipeWire stream let stream = Arc::new(ScreenCaptureStream::new( @@ -172,9 +137,6 @@ impl WoPortal { let mut streams = self.streams.lock().unwrap(); streams.insert(real_node_id, stream); - // Request capture from compositor - self.request_compositor_capture(&session.target, session.source_type)?; - let stream_info = StreamInfo { node_id: real_node_id, width, @@ -238,7 +200,7 @@ impl WoPortal { modifier: u64, ) -> Result<()> { let streams = self.streams.lock().unwrap(); - + for stream in streams.values() { // Check if this stream is for this window let matches = match (&stream.window_name, window_name) { @@ -262,17 +224,13 @@ impl WoPortal { /// Push raw pixel data (ARGB8888/BGRx) to matching portal streams. /// Called from the compositor after offscreen capture or full-screen compositing. - pub fn push_pixels_to_streams( - &self, - window_name: Option<&str>, - pixels: &[u8], - ) { + pub fn push_pixels_to_streams(&self, window_name: Option<&str>, pixels: &[u8]) { let streams = self.streams.lock().unwrap(); for stream in streams.values() { let matches = match (&stream.window_name, window_name) { (Some(stream_win), Some(win)) => stream_win == win, - (None, None) => true, // Monitor capture gets full screen - (None, Some(_)) => true, // Monitor capture sees all windows + (None, None) => true, // Monitor capture gets full screen + (None, Some(_)) => true, // Monitor capture sees all windows _ => false, }; if matches && stream.is_active() { @@ -280,6 +238,47 @@ impl WoPortal { } } } + + /// Publish a window crop from the already-composited desktop frame. + /// The output is scaled to the negotiated PipeWire size so captures remain + /// valid when a window changes size after portal Start. + pub fn push_window_capture( + &self, + window_name: &str, + pixels: &[u8], + screen_width: u32, + screen_height: u32, + bounds: (i32, i32, u32, u32), + ) { + let streams = self.streams.lock().unwrap(); + let (x, y, width, height) = bounds; + if screen_width == 0 || screen_height == 0 || width == 0 || height == 0 { + return; + } + for stream in streams.values().filter(|stream| { + stream.window_name.as_deref() == Some(window_name) && stream.is_active() + }) { + let mut output = vec![0; stream.width as usize * stream.height as usize * 4]; + for dy in 0..stream.height { + let sy = y + (dy as u64 * height as u64 / stream.height.max(1) as u64) as i32; + if sy < 0 || sy >= screen_height as i32 { + continue; + } + for dx in 0..stream.width { + let sx = x + (dx as u64 * width as u64 / stream.width.max(1) as u64) as i32; + if sx < 0 || sx >= screen_width as i32 { + continue; + } + let src = (sy as usize * screen_width as usize + sx as usize) * 4; + let dst = (dy as usize * stream.width as usize + dx as usize) * 4; + if src + 4 <= pixels.len() { + output[dst..dst + 4].copy_from_slice(&pixels[src..src + 4]); + } + } + } + stream.push_pixels(&output); + } + } } impl Default for WoPortal { @@ -316,7 +315,10 @@ impl PortalDBusHandler { } ("org.freedesktop.portal.ScreenCast", "SelectSources") => { let request: CaptureRequest = serde_json::from_value(args)?; - let session_id = request.restore_token.clone().unwrap_or_else(|| "default".to_string()); + let session_id = request + .restore_token + .clone() + .unwrap_or_else(|| "default".to_string()); self.portal.select_sources(&session_id, request)?; Ok(serde_json::json!({ "success": true })) } @@ -358,7 +360,7 @@ pub struct ScreenCaptureStream { /// Frame data for PipeWire streaming #[derive(Clone, Debug)] pub struct PipeWireFrame { - pub pts: u64, // Presentation timestamp + pub pts: u64, // Presentation timestamp pub dmabuf_fd: i32, pub stride: u32, pub offset: u32, @@ -373,7 +375,7 @@ impl ScreenCaptureStream { "Created PipeWire stream {} for {}x{} capture (window: {:?})", node_id, width, height, window_name ); - + Ok(Self { node_id, width, @@ -387,7 +389,13 @@ impl ScreenCaptureStream { } /// Push a frame to the PipeWire stream with DMABUF - pub fn push_frame(&self, dmabuf_fd: i32, stride: u32, offset: u32, modifier: u64) -> Result<()> { + pub fn push_frame( + &self, + dmabuf_fd: i32, + stride: u32, + offset: u32, + modifier: u64, + ) -> Result<()> { if !*self.active.lock().unwrap() { return Ok(()); // Stream not active } @@ -409,9 +417,10 @@ impl ScreenCaptureStream { let mut queue = self.frame_queue.lock().unwrap(); queue.push(frame.clone()); - + // Keep queue bounded to prevent memory issues - if queue.len() > 30 { // 30 frame buffer + if queue.len() > 30 { + // 30 frame buffer queue.remove(0); } @@ -462,13 +471,9 @@ impl ScreenCaptureStream { std::thread::Builder::new() .name(format!("pw-capture-{}", self.node_id)) .spawn(move || { - if let Err(e) = run_pw_output_stream( - width, - height, - active.clone(), - latest_pixels, - node_tx, - ) { + if let Err(e) = + run_pw_output_stream(width, height, active.clone(), latest_pixels, node_tx) + { warn!("PipeWire stream thread failed: {e:#}"); // Mark inactive on failure so the stream reports itself as dead. *active.lock().unwrap() = false; @@ -516,7 +521,11 @@ impl ScreenCaptureStream { impl Drop for ScreenCaptureStream { fn drop(&mut self) { self.disconnect(); - debug!("Stream {}: dropped (pushed {} frames)", self.node_id, self.frame_count()); + debug!( + "Stream {}: dropped (pushed {} frames)", + self.node_id, + self.frame_count() + ); } } @@ -665,10 +674,7 @@ fn run_pw_output_stream( Fraction, pw::spa::utils::Fraction { num: 60, denom: 1 }, pw::spa::utils::Fraction { num: 1, denom: 1 }, - pw::spa::utils::Fraction { - num: 120, - denom: 1 - } + pw::spa::utils::Fraction { num: 120, denom: 1 } ), ); diff --git a/src/syscall.rs b/src/syscall.rs index 454cfb3..a1eee40 100644 --- a/src/syscall.rs +++ b/src/syscall.rs @@ -1,5 +1,4 @@ /// Dynamic syscall handling for IPC - use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; use std::os::unix::process::CommandExt; @@ -49,19 +48,13 @@ pub enum SyscallRequest { }, /// List directory contents #[serde(rename = "listdir")] - ListDir { - path: String, - }, + ListDir { path: String }, /// Get file/directory metadata #[serde(rename = "stat")] - Stat { - path: String, - }, + Stat { path: String }, /// Delete file #[serde(rename = "delete")] - Delete { - path: String, - }, + Delete { path: String }, /// Custom extension syscall #[serde(rename = "custom")] Custom { @@ -99,12 +92,8 @@ pub trait ExtensionHandler: Send + Sync { #[derive(Debug, Clone, Serialize)] #[serde(tag = "status")] pub enum SyscallResponse { - Success { - data: serde_json::Value, - }, - Error { - message: String, - }, + Success { data: serde_json::Value }, + Error { message: String }, } /// Syscall handler with security policies @@ -176,10 +165,10 @@ impl SyscallHandler { SyscallRequest::ListApplications => { Ok(serde_json::Value::Array(self.applications.clone())) } - SyscallRequest::BrowseApplications => { - browse_applications() + SyscallRequest::BrowseApplications => browse_applications(), + SyscallRequest::Launch { command, args } => { + self.handle_launch(&command, args.as_deref()) } - SyscallRequest::Launch { command, args } => self.handle_launch(&command, args.as_deref()), SyscallRequest::Exec { command, args, @@ -198,12 +187,8 @@ impl SyscallHandler { SyscallRequest::Quit => { std::process::exit(0); } - SyscallRequest::Shutdown => { - handle_power_command("systemctl", &["poweroff"]) - } - SyscallRequest::Restart => { - handle_power_command("systemctl", &["reboot"]) - } + SyscallRequest::Shutdown => handle_power_command("systemctl", &["poweroff"]), + SyscallRequest::Restart => handle_power_command("systemctl", &["reboot"]), SyscallRequest::Logout => { if let Ok(session_id) = std::env::var("XDG_SESSION_ID") { handle_power_command("loginctl", &["terminate-session", &session_id]) @@ -212,25 +197,21 @@ impl SyscallHandler { std::process::exit(0); } } - SyscallRequest::Lock => { - handle_power_command("loginctl", &["lock-session"]) - } - SyscallRequest::Sleep => { - handle_power_command("systemctl", &["suspend"]) - } + SyscallRequest::Lock => handle_power_command("loginctl", &["lock-session"]), + SyscallRequest::Sleep => handle_power_command("systemctl", &["suspend"]), } } - fn handle_launch( - &self, - command: &str, - args: Option<&[String]>, - ) -> Result { + fn handle_launch(&self, command: &str, args: Option<&[String]>) -> Result { if !self.allow_exec { bail!("exec syscalls are disabled"); } - tracing::info!("Launching command (fire-and-forget): {} {:?}", command, args); + tracing::info!( + "Launching command (fire-and-forget): {} {:?}", + command, + args + ); let mut cmd = Command::new("sh"); let shell_cmd = if let Some(args) = args { @@ -302,12 +283,10 @@ impl SyscallHandler { .stderr(Stdio::null()); match cmd.spawn() { - Ok(child) => { - Ok(serde_json::json!({ - "pid": child.id(), - "launched": true, - })) - } + Ok(child) => Ok(serde_json::json!({ + "pid": child.id(), + "launched": true, + })), Err(e) => { bail!("failed to execute '{}': {}", command, e); } @@ -436,10 +415,7 @@ impl SyscallHandler { .context("HOME not set")?; let canonical = path.canonicalize().context("canonicalizing path")?; if !canonical.starts_with(&home) && !canonical.starts_with("/proc") { - bail!( - "path outside home directory: {}", - canonical.display() - ); + bail!("path outside home directory: {}", canonical.display()); } } @@ -460,10 +436,7 @@ impl SyscallHandler { if parent.exists() { let canonical = parent.canonicalize().context("canonicalizing parent")?; if !canonical.starts_with(&home) { - bail!( - "write path outside home directory: {}", - canonical.display() - ); + bail!("write path outside home directory: {}", canonical.display()); } } @@ -489,7 +462,9 @@ fn handle_power_command(program: &str, args: &[&str]) -> Result Ok(serde_json::json!({ "ok": true, "command": format!("{} {}", program, args.join(" ")) })), + Ok(_child) => Ok( + serde_json::json!({ "ok": true, "command": format!("{} {}", program, args.join(" ")) }), + ), Err(e) => bail!("failed to execute '{}': {}", program, e), } } diff --git a/whatever.txt b/whatever.txt index 5602b99..9f2a732 100644 --- a/whatever.txt +++ b/whatever.txt @@ -1,222 +1,154 @@ - Compiling wo v0.1.0 (/home/hellings/Documents/wo) - Compiling wo v0.1.0 (/home/hellings/Documents/wo) - Finished `release` profile [optimized] target(s) in 2m 21s - Finished `release` profile [optimized] target(s) in 2m 21s - Running `target/release/wo` - Running `target/release/wo` -Xwayland glamor: GBM Wayland interfaces not available -Failed to initialize glamor, falling back to sw -Xwayland glamor: GBM Wayland interfaces not available -Failed to initialize glamor, falling back to sw -(II) IGLX: Loaded and initialized swrast -(II) GLX: Initialized DRISWRAST GL provider for screen 0 -(II) IGLX: Loaded and initialized swrast -(II) GLX: Initialized DRISWRAST GL provider for screen 0 +2026-10-26T03:36:18.813061Z  INFO wo: wo compositor starting +2026-10-26T03:36:18.813121Z  INFO wo::config: Loading config from "/home/hellings/.config/wo/config.toml" +2026-10-26T03:36:18.813191Z  INFO wo: Attempting nested mode (gles renderer) +2026-10-26T03:36:18.813218Z  INFO wo::nested: Starting nested compositor mode +2026-10-26T03:36:18.813231Z  INFO wo::nested: Press ESC to exit +2026-10-26T03:36:18.813449Z  INFO wo::nested: Wayland socket created socket=wo-0 +2026-10-26T03:36:18.813504Z  INFO wo::nested: Using host Wayland display for nested startup socket=wayland-0 +2026-10-26T03:36:18.856187Z ERROR smithay::backend::egl::ffi: [EGL] 0x3004 (BAD_ATTRIBUTE) eglCreateSyncKHR: EGL_BAD_ATTRIBUTE error: In eglCreateSyncKHR: EGL_SYNC_NATIVE_FENCE_FD_ANDROID specified valid fd butEGL_SYNC_STATUS is also being set + +2026-10-26T03:36:18.911103Z  INFO wo::nested: Nested window size width=1280 height=800 +2026-10-26T03:36:18.911169Z  INFO wo::nested: Using render node: /dev/dri/renderD128 +2026-10-26T03:36:18.911238Z  INFO wo::nested: Collected 672 dmabuf formats from renderer +2026-10-26T03:36:18.911502Z  INFO wo: Started configured service before root windows name=vite pid=1378486 +2026-10-26T03:36:18.913053Z  INFO wo::handlers::screencopy: Registered zwlr_screencopy_manager_v1 global (v3) +2026-10-26T03:36:18.913289Z  INFO wo::cursor: Loading cursor theme 'Adwaita' (size 24) +2026-10-26T03:36:18.913344Z  INFO wo::cursor: Loading cursor theme 'default' (size 24) +2026-10-26T03:36:18.913378Z  INFO wo::cursor: Loading cursor theme 'DMZ-White' (size 24) +2026-10-26T03:36:18.913400Z  INFO wo::cursor: Loading cursor theme 'breeze' (size 24) +2026-10-26T03:36:18.913478Z  INFO wo::electron: IPC socket listening socket="/run/user/1000/wo-ipc.sock" +2026-10-26T03:36:18.916655Z  INFO wo::nested: XWayland server starting +2026-10-26T03:36:18.916688Z  INFO wo::nested: Set WAYLAND_DISPLAY=wo-0 +2026-10-26T03:36:18.919374Z  INFO wo::state: Propagated ["WAYLAND_DISPLAY"] to DBus activation environment +2026-10-26T03:36:18.922988Z  INFO wo::state: Propagated ["WAYLAND_DISPLAY"] to systemd user environment +2026-10-26T03:36:18.923014Z  INFO wo::nested: Nested compositor running +2026-10-26T03:36:18.923039Z  INFO wo::nested: Window resized to 1280x800 +2026-10-26T03:36:18.923058Z  INFO wo::nested: Window resized to 1280x800 +2026-10-26T03:36:18.962626Z  INFO wo::handlers::compositor: Client missing ClientData (likely XWayland), using fallback compositor state +2026-10-26T03:36:19.023297Z  INFO wo::nested: Spawned portal service: "/home/hellings/.local/share/wo/bin/wo-portal" +2026-10-26T03:36:19.025666Z  INFO wo_portal: xdg-desktop-portal-wo starting +2026-10-26T03:36:19.026881Z  INFO wo_portal: Portal service registered as org.freedesktop.impl.portal.desktop.wo +2026-10-26T03:36:19.026938Z  INFO wo_portal: Listening for screen capture requests... +(II) GLX: Initialized glamor GL provider for screen 0 The XKEYBOARD keymap compiler (xkbcomp) reports: -> Warning: Multiple symbols for level 1/group 1 on key -> Using F23, ignoring XF86TouchpadOff -> Warning: Symbol map for key redefined -> Using last definition for conflicting fields -> Warning: Symbol map for key redefined -> Using last definition for conflicting fields -The XKEYBOARD keymap compiler (xkbcomp) reports: -> Warning: Multiple symbols for level 1/group 1 on key -> Using F23, ignoring XF86TouchpadOff -> Warning: Symbol map for key redefined -> Using last definition for conflicting fields -> Warning: Symbol map for key redefined -> Using last definition for conflicting fields -Errors from xkbcomp are not fatal to the X server +> Warning: Could not resolve keysym XF86CameraAccessEnable +> Warning: Could not resolve keysym XF86CameraAccessDisable +> Warning: Could not resolve keysym XF86CameraAccessToggle +> Warning: Could not resolve keysym XF86NextElement +> Warning: Could not resolve keysym XF86PreviousElement +> Warning: Could not resolve keysym XF86AutopilotEngageToggle +> Warning: Could not resolve keysym XF86MarkWaypoint +> Warning: Could not resolve keysym XF86Sos +> Warning: Could not resolve keysym XF86NavChart +> Warning: Could not resolve keysym XF86FishingChart +> Warning: Could not resolve keysym XF86SingleRangeRadar +> Warning: Could not resolve keysym XF86DualRangeRadar +> Warning: Could not resolve keysym XF86RadarOverlay +> Warning: Could not resolve keysym XF86TraditionalSonar +> Warning: Could not resolve keysym XF86ClearvuSonar +> Warning: Could not resolve keysym XF86SidevuSonar +> Warning: Could not resolve keysym XF86NavInfo Errors from xkbcomp are not fatal to the X server +2026-10-26T03:36:19.106508Z  INFO wo::nested: XWayland ready on DISPLAY :1 +2026-10-26T03:36:19.108904Z  INFO wo::state: Propagated ["DISPLAY", "WAYLAND_DISPLAY"] to DBus activation environment +2026-10-26T03:36:19.112684Z  INFO wo::state: Propagated ["DISPLAY", "WAYLAND_DISPLAY"] to systemd user environment The XKEYBOARD keymap compiler (xkbcomp) reports: -> Warning: Unsupported maximum keycode 709, clipping. +> Warning: Unsupported maximum keycode 708, clipping. > X11 cannot support keycodes above 255. -The XKEYBOARD keymap compiler (xkbcomp) reports: -> Warning: Unsupported maximum keycode 709, clipping. -> X11 cannot support keycodes above 255. -> Warning: Virtual modifier Hyper multiply defined -> Using 0, ignoring 0 -> Warning: Virtual modifier ScrollLock multiply defined -> Using 0, ignoring 0 -> Warning: Virtual modifier Hyper multiply defined -> Using 0, ignoring 0 > Warning: Virtual modifier ScrollLock multiply defined > Using 0, ignoring 0 Errors from xkbcomp are not fatal to the X server -Errors from xkbcomp are not fatal to the X server -Authorization required, but no authorization protocol specified +2026-10-26T03:36:19.116806Z  INFO wo::nested: X11 window manager attached +2026-10-26T03:36:19.116829Z  INFO wo::electron: Spawning Electron for window 'main' with config: WindowConfig { name: "main", system_shell: true, url: Some("http://127.0.0.1:5173"), html: None, css: None, js: None, width: 1280, height: 800, x: 0, y: 0, z_order: -1073741824, fps: 0, format: "ARGB8888", focusable: false, interactive: false, floating: false } +2026-10-26T03:36:19.116868Z  INFO wo::electron: Serialized config: {"name":"main","url":"http://127.0.0.1:5173","html":null,"css":null,"js":null,"width":1280,"height":800,"x":0,"y":0,"z_order":-1073741824,"fps":0,"format":"ARGB8888","focusable":false,"interactive":false,"floating":false} +2026-10-26T03:36:19.117076Z  INFO wo::electron: spawned Electron process window=main pid=1378527 render_node="/dev/dri/renderD128" ozone=wayland +[Wo] Electron starting, NODE_VERSION= v24.22.0 +[Wo] GPU backend preference: auto; Ozone platform: wayland +[Wo] Graphics process context: {"uid":1000,"groups":[4,24,27,30,44,46,100,117,125,995,1000],"env":{"WAYLAND_DISPLAY":"wo-0","DISPLAY":":1","XDG_SESSION_TYPE":"tty","XDG_RUNTIME_DIR":"/run/user/1000","EGL_PLATFORM":"wayland"},"renderNodeAccess":{"path":"/dev/dri/renderD128","mode":"660","readable":true,"writable":true},"switches":[{"name":"ozone-platform","present":true,"value":"wayland"},{"name":"use-gl","present":false,"value":""},{"name":"use-angle","present":false,"value":""},{"name":"disable-gpu","present":false,"value":""},{"name":"disable-vulkan","present":true,"value":""},{"name":"disable-features","present":true,"value":"Vulkan"}]} +[Wo] WO_WINDOW_CONFIG env = {"name":"main","url":"http://127.0.0.1:5173","html":null,"css":null,"js":null,"width":1280,"height":800,"x":0,"y":0,"z_order":-1073741824,"fps":0,"format":"ARGB8888","focusable":false,"interactive":false,"floating":false} +[Wo] WINDOW_CONFIG parsed = {"name":"main","url":"http://127.0.0.1:5173","html":null,"css":null,"js":null,"width":1280,"height":800,"x":0,"y":0,"z_order":-1073741824,"fps":0,"format":"ARGB8888","focusable":false,"interactive":false,"floating":false} +[Wo] App ready, CLIENT_MODE= false WO_STANDALONE= undefined +[Wo] GPU feature status: {"2d_canvas":"disabled_software","direct_rendering_display_compositor":"disabled_off_ok","gpu_compositing":"disabled_software","multiple_raster_threads":"enabled_force_on","opengl":"disabled_off","rasterization":"disabled_software","raw_draw":"enabled_on","skia_graphite":"disabled_off","trees_in_viz":"disabled_off","video_decode":"disabled_software","video_encode":"disabled_software","vulkan":"disabled_off","webgl":"disabled_off","webgpu":"disabled_off","webgpu_on_vk_via_gl_interop":"disabled_off","webnn":"disabled_off"} +[Wo] GPU info: {"auxAttributes":{"amdSwitchable":false,"canSupportThreadedTextureMailbox":false,"glImplementationParts":"(gl=none,angle=none)","glResetNotificationStrategy":0,"hardwareSupportsVulkan":false,"inProcessGpu":true,"initializationTime":0,"isAsan":false,"isClangCoverage":false,"jpegDecodeAcceleratorSupported":false,"optimus":false,"passthroughCmdDecoder":false,"sandboxed":false,"skiaBackendType":"None","subpixelFontRendering":true,"targetCpuBits":64,"visibilityCallbackCallCount":0},"gpuDevice":[{"active":true,"deviceId":9352,"gpuPreference":0,"vendorId":4318}]} +[Wo] Offscreen shared texture: disabled (GPU compositor=disabled_software, threaded texture mailbox=false, WebGL=disabled_off) +[Wo] Will connect to compositor +[Wo] Connecting to compositor IPC: /run/user/1000/wo-ipc.sock +[Wo] Portal UI bridge listening at /run/user/1000/wo-portal-ui.sock +2026-10-26T03:36:19.251134Z  INFO wo::electron: Electron window connected window=main +[Wo] IPC connected, sent HELLO message +[Wo] Initializing GBM on render node: /dev/dri/renderD128 +[Wo] DMABUF sender initialized +[Wo] Creating window +[Wo] createWindow starting +127.0.0.1 - - [25/Oct/2026 23:36:19] "GET / HTTP/1.1" 200 - +127.0.0.1 - - [25/Oct/2026 23:36:19] "GET /_wo.capabilities.json HTTP/1.1" 200 - +127.0.0.1 - - [25/Oct/2026 23:36:19] "GET /_wo.components.css HTTP/1.1" 200 - +127.0.0.1 - - [25/Oct/2026 23:36:19] "GET /assets/index-HBQlw-Wm.js HTTP/1.1" 200 - +127.0.0.1 - - [25/Oct/2026 23:36:19] "GET /assets/index-D7GQWtqC.css HTTP/1.1" 200 - +[Wo] Verified site rip.crit.comraw; requested capabilities: window-management, overlay-rendering, desktop-applications, xdg, dbus, portals, process-launch, system-control, notifications +[Wo] Granted verified comraw system-shell capabilities +[Wo] preloadPath=/home/hellings/.local/share/wo/electron/dist/preload.js +2026-10-26T03:36:19.290019Z  INFO wo::state: new_fractional_scale: setting preferred_scale(1.0) +2026-10-26T03:36:19.290063Z  INFO wo::state: new_fractional_scale: done +2026-10-26T03:36:19.290115Z  INFO wo::nested: computed and registered document and stylesheet hashes window=main page=http://127.0.0.1:5173/ +[Wo] BrowserWindow created, CLIENT_MODE=false offscreen=true painting=true +[Wo] setupIpcHandlers completed +[Wo] Loading content URL: http://127.0.0.1:5173 +127.0.0.1 - - [25/Oct/2026 23:36:19] "GET / HTTP/1.1" 304 - +[Wo] Locking verified script http://127.0.0.1:5173/_wo.components.css -> 8337fca251ea +[Wo] Locking verified script http://127.0.0.1:5173/assets/index-HBQlw-Wm.js -> d1647de85605 +[Wo] Locking verified script http://127.0.0.1:5173/assets/index-D7GQWtqC.css -> c0e2f97ed4d1 +[1378559:1025/233619.349346:ERROR:ui/ozone/platform/wayland/gpu/wayland_surface_factory.cc:249] '--ozone-platform=wayland' is not compatible with Vulkan. Consider switching to '--ozone-platform=x11' or disabling Vulkan +[Wo] Resource request failed: net::ERR_ABORTED stylesheet http://127.0.0.1:5173/_wo.components.css +[Wo] Resource request failed: net::ERR_ABORTED stylesheet http://127.0.0.1:5173/assets/index-D7GQWtqC.css +[Wo] Serving verified script 8337fca251ea (2190 bytes) +[Wo] Resource request failed: net::ERR_ABORTED script http://127.0.0.1:5173/assets/index-HBQlw-Wm.js +[Wo] Serving verified script c0e2f97ed4d1 (36664 bytes) +[Wo] Serving verified script d1647de85605 (268639 bytes) +[Wo] Content loaded from: http://127.0.0.1:5173 +127.0.0.1 - - [25/Oct/2026 23:36:19] "GET /elementary.jpg HTTP/1.1" 304 - +[Wo] Page render state: {"readyState":"complete","title":"comraw","bodyText":"1\n2\n3\n4\nSun, Oct 25\n11:36 PM","bodyBackground":"rgba(0, 0, 0, 0)","bodyBackgroundImage":"none","bodyDisplay":"block","portalCardBackground":"","portalCardBounds":null,"buttons":12,"resourceConsentBridge":false,"rootChildren":1,"shellPresent":true,"wallpaperImage":"url(\"http://127.0.0.1:5173/elementary.jpg\")","compositorApi":true,"woApi":true} +[Wo] Page initialized, setting up rendering +[Wo] Offscreen frame pacing: 60 Hz (window cap auto) +[Wo] createWindow completed successfully +[Wo] Window created successfully +[Wo] Ignoring empty offscreen paint 0x0; content=1280x800 dirty=0,0,1280,800 +[1378559:1025/233619.594500:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:438] Unable to initialize SkSurface +[1378559:1025/233619.594551:ERROR:gpu/command_buffer/service/raster_decoder.cc:2939] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glBeginRasterCHROMIUM: failed to create surface +[1378559:1025/233619.594579:ERROR:gpu/command_buffer/service/raster_decoder.cc:3001] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glRasterCHROMIUM: RasterCHROMIUM without BeginRasterCHROMIUM +[1378559:1025/233619.594599:ERROR:gpu/command_buffer/service/raster_decoder.cc:3090] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glEndRasterCHROMIUM: EndRasterCHROMIUM without BeginRasterCHROMIUM +[1378559:1025/233619.594683:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:438] Unable to initialize SkSurface +[1378559:1025/233619.594712:ERROR:gpu/command_buffer/service/raster_decoder.cc:2939] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glBeginRasterCHROMIUM: failed to create surface +[1378559:1025/233619.594738:ERROR:gpu/command_buffer/service/raster_decoder.cc:3001] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glRasterCHROMIUM: RasterCHROMIUM without BeginRasterCHROMIUM +[1378559:1025/233619.594758:ERROR:gpu/command_buffer/service/raster_decoder.cc:3090] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glEndRasterCHROMIUM: EndRasterCHROMIUM without BeginRasterCHROMIUM +[1378559:1025/233619.594801:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:438] Unable to initialize SkSurface +[1378559:1025/233619.594816:ERROR:gpu/command_buffer/service/raster_decoder.cc:2939] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glBeginRasterCHROMIUM: failed to create surface +[1378559:1025/233619.594828:ERROR:gpu/command_buffer/service/raster_decoder.cc:3001] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glRasterCHROMIUM: RasterCHROMIUM without BeginRasterCHROMIUM +[1378559:1025/233619.594848:ERROR:gpu/command_buffer/service/raster_decoder.cc:3090] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glEndRasterCHROMIUM: EndRasterCHROMIUM without BeginRasterCHROMIUM +[1378559:1025/233619.604006:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:438] Unable to initialize SkSurface +[1378559:1025/233619.604035:ERROR:gpu/command_buffer/service/raster_decoder.cc:2939] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glBeginRasterCHROMIUM: failed to create surface +[1378559:1025/233619.604047:ERROR:gpu/command_buffer/service/raster_decoder.cc:3001] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glRasterCHROMIUM: RasterCHROMIUM without BeginRasterCHROMIUM +[1378559:1025/233619.604061:ERROR:gpu/command_buffer/service/raster_decoder.cc:3090] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glEndRasterCHROMIUM: EndRasterCHROMIUM without BeginRasterCHROMIUM +[1378559:1025/233619.604410:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:593] Attempt to read from an uninitialized SharedImage. Initialized region: (0, 0, 0, 0) Size: (1280, 832) +[1378559:1025/233619.607032:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:593] Attempt to read from an uninitialized SharedImage. Initialized region: (0, 0, 0, 0) Size: (1280, 64) +[1378559:1025/233619.609323:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:593] Attempt to read from an uninitialized SharedImage. Initialized region: (0, 0, 0, 0) Size: (192, 256) +[1378559:1025/233619.609467:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:593] Attempt to read from an uninitialized SharedImage. Initialized region: (0, 0, 0, 0) Size: (64, 64) +[Wo] First bitmap pixels (BGRA top-left/mid-left/center/bottom-right): [[255,255,255,255],[255,255,255,255],[255,255,255,255],[255,255,255,255]] +[Wo] First bitmap frame sent as DMA-BUF: seq=1 size=1280x800 stride=5120 +[Wo] Second bitmap pixels (BGRA top-left/mid-left/center/bottom-right): [[255,255,255,255],[255,255,255,255],[255,255,255,255],[255,255,255,255]] +[Wo] SKIPPED paint due to frame backpressure inFlight=2 +2026-10-26T03:36:19.634388Z  INFO wo::nested: first Electron frame imported as a DMA-BUF texture window=main width=1280 height=800 seq=1 +2026-10-26T03:36:19.634880Z  INFO wo::cursor: Loaded cursor 'default' (24x24, hotspot 3,1) from /usr/share/icons/Adwaita/cursors/default +2026-10-26T03:36:19.635561Z  INFO wo::nested: first Electron texture rendered into the output frame window=main x=0 y=0 output_width=1280 output_height=800 +2026-10-26T03:36:19.663496Z  INFO wo::nested: first nested output frame submitted successfully +[1378559:1025/233619.899568:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:438] Unable to initialize SkSurface +[1378559:1025/233619.899611:ERROR:gpu/command_buffer/service/raster_decoder.cc:2939] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glBeginRasterCHROMIUM: failed to create surface +[1378559:1025/233619.899629:ERROR:gpu/command_buffer/service/raster_decoder.cc:3001] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glRasterCHROMIUM: RasterCHROMIUM without BeginRasterCHROMIUM +[1378559:1025/233619.899638:ERROR:gpu/command_buffer/service/raster_decoder.cc:3090] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glEndRasterCHROMIUM: EndRasterCHROMIUM without BeginRasterCHROMIUM +[1378559:1025/233619.900098:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:593] Attempt to read from an uninitialized SharedImage. Initialized region: (0, 0, 0, 0) Size: (1280, 832) +[Wo] Load timeout, continuing +Serving HTTP on 127.0.0.1 port 5173 (http://127.0.0.1:5173/) ... -Authorization required, but no authorization protocol specified - -Authorization required, but no authorization protocol specified - -Authorization required, but no authorization protocol specified - -Gtk-Message: 13:58:16.908: Failed to load module "appmenu-gtk-module" -Gtk-Message: 13:58:16.908: Failed to load module "appmenu-gtk-module" -Authorization required, but no authorization protocol specified - -Authorization required, but no authorization protocol specified - -[24415:0311/135817.250839:ERROR:ui/ozone/platform/wayland/gpu/wayland_surface_factory.cc:250] '--ozone-platform=wayland' is not compatible with Vulkan. Consider switching to '--ozone-platform=x11' or disabling Vulkan -[24415:0311/135817.250839:ERROR:ui/ozone/platform/wayland/gpu/wayland_surface_factory.cc:250] '--ozone-platform=wayland' is not compatible with Vulkan. Consider switching to '--ozone-platform=x11' or disabling Vulkan -[24415:0311/135817.264878:ERROR:components/viz/service/display_embedder/skia_output_device_vulkan.cc:301] Failed to create vulkan surface. -[24415:0311/135817.264878:ERROR:components/viz/service/display_embedder/skia_output_device_vulkan.cc:301] Failed to create vulkan surface. -[24330:0311/135939.036070:ERROR:content/browser/network_service_instance_impl.cc:613] Network service crashed, restarting service. -[24330:0311/135939.036070:ERROR:content/browser/network_service_instance_impl.cc:613] Network service crashed, restarting service. -[24330:0311/135939.185963:ERROR:content/browser/gpu/gpu_process_host.cc:999] GPU process exited unexpectedly: exit_code=15 -[24330:0311/135939.185963:ERROR:content/browser/gpu/gpu_process_host.cc:999] GPU process exited unexpectedly: exit_code=15 -Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed - at s.send (node:electron/js2c/browser_init:2:100266) - at b.send (node:electron/js2c/browser_init:2:84687) - at Socket. (file:///home/hellings/Documents/wo/electron/dist/main.js:1213:36) - at Socket.emit (node:events:519:28) - at addChunk (node:internal/streams/readable:561:12) - at readableAddChunkPushByteMode (node:internal/streams/readable:512:3) - at Readable.push (node:internal/streams/readable:392:5) - at Pipe.onStreamRead (node:internal/stream_base_commons:189:23) -Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed - at s.send (node:electron/js2c/browser_init:2:100266) - at b.send (node:electron/js2c/browser_init:2:84687) - at Socket. (file:///home/hellings/Documents/wo/electron/dist/main.js:1213:36) - at Socket.emit (node:events:519:28) - at addChunk (node:internal/streams/readable:561:12) - at readableAddChunkPushByteMode (node:internal/streams/readable:512:3) - at Readable.push (node:internal/streams/readable:392:5) - at Pipe.onStreamRead (node:internal/stream_base_commons:189:23) -Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed - at s.send (node:electron/js2c/browser_init:2:100266) - at b.send (node:electron/js2c/browser_init:2:84687) - at Socket. (file:///home/hellings/Documents/wo/electron/dist/main.js:1235:40) - at Socket.emit (node:events:519:28) - at addChunk (node:internal/streams/readable:561:12) - at readableAddChunkPushByteMode (node:internal/streams/readable:512:3) - at Readable.push (node:internal/streams/readable:392:5) - at Pipe.onStreamRead (node:internal/stream_base_commons:189:23) -Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed - at s.send (node:electron/js2c/browser_init:2:100266) - at b.send (node:electron/js2c/browser_init:2:84687) - at Socket. (file:///home/hellings/Documents/wo/electron/dist/main.js:1235:40) - at Socket.emit (node:events:519:28) - at addChunk (node:internal/streams/readable:561:12) - at readableAddChunkPushByteMode (node:internal/streams/readable:512:3) - at Readable.push (node:internal/streams/readable:392:5) - at Pipe.onStreamRead (node:internal/stream_base_commons:189:23) -Authorization required, but no authorization protocol specified - -Authorization required, but no authorization protocol specified - -Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed - at s.send (node:electron/js2c/browser_init:2:100266) - at b.send (node:electron/js2c/browser_init:2:84687) - at Socket. (file:///home/hellings/Documents/wo/electron/dist/main.js:1213:36) - at Socket.emit (node:events:519:28) - at addChunk (node:internal/streams/readable:561:12) - at readableAddChunkPushByteMode (node:internal/streams/readable:512:3) - at Readable.push (node:internal/streams/readable:392:5) - at Pipe.onStreamRead (node:internal/stream_base_commons:189:23) -Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed - at s.send (node:electron/js2c/browser_init:2:100266) - at b.send (node:electron/js2c/browser_init:2:84687) - at Socket. (file:///home/hellings/Documents/wo/electron/dist/main.js:1213:36) - at Socket.emit (node:events:519:28) - at addChunk (node:internal/streams/readable:561:12) - at readableAddChunkPushByteMode (node:internal/streams/readable:512:3) - at Readable.push (node:internal/streams/readable:392:5) - at Pipe.onStreamRead (node:internal/stream_base_commons:189:23) -Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed - at s.send (node:electron/js2c/browser_init:2:100266) - at b.send (node:electron/js2c/browser_init:2:84687) - at Socket. (file:///home/hellings/Documents/wo/electron/dist/main.js:1235:40) - at Socket.emit (node:events:519:28) - at addChunk (node:internal/streams/readable:561:12) - at readableAddChunkPushByteMode (node:internal/streams/readable:512:3) - at Readable.push (node:internal/streams/readable:392:5) - at Pipe.onStreamRead (node:internal/stream_base_commons:189:23) -Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed - at s.send (node:electron/js2c/browser_init:2:100266) - at b.send (node:electron/js2c/browser_init:2:84687) - at Socket. (file:///home/hellings/Documents/wo/electron/dist/main.js:1235:40) - at Socket.emit (node:events:519:28) - at addChunk (node:internal/streams/readable:561:12) - at readableAddChunkPushByteMode (node:internal/streams/readable:512:3) - at Readable.push (node:internal/streams/readable:392:5) - at Pipe.onStreamRead (node:internal/stream_base_commons:189:23) -Authorization required, but no authorization protocol specified - -Authorization required, but no authorization protocol specified - -Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed - at s.send (node:electron/js2c/browser_init:2:100266) - at b.send (node:electron/js2c/browser_init:2:84687) - at Socket. (file:///home/hellings/Documents/wo/electron/dist/main.js:1213:36) - at Socket.emit (node:events:519:28) - at addChunk (node:internal/streams/readable:561:12) - at readableAddChunkPushByteMode (node:internal/streams/readable:512:3) - at Readable.push (node:internal/streams/readable:392:5) - at Pipe.onStreamRead (node:internal/stream_base_commons:189:23) -Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed - at s.send (node:electron/js2c/browser_init:2:100266) - at b.send (node:electron/js2c/browser_init:2:84687) - at Socket. (file:///home/hellings/Documents/wo/electron/dist/main.js:1213:36) - at Socket.emit (node:events:519:28) - at addChunk (node:internal/streams/readable:561:12) - at readableAddChunkPushByteMode (node:internal/streams/readable:512:3) - at Readable.push (node:internal/streams/readable:392:5) - at Pipe.onStreamRead (node:internal/stream_base_commons:189:23) -Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed - at s.send (node:electron/js2c/browser_init:2:100266) - at b.send (node:electron/js2c/browser_init:2:84687) - at Socket. (file:///home/hellings/Documents/wo/electron/dist/main.js:1235:40) - at Socket.emit (node:events:519:28) - at addChunk (node:internal/streams/readable:561:12) - at readableAddChunkPushByteMode (node:internal/streams/readable:512:3) - at Readable.push (node:internal/streams/readable:392:5) - at Pipe.onStreamRead (node:internal/stream_base_commons:189:23) -Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed - at s.send (node:electron/js2c/browser_init:2:100266) - at b.send (node:electron/js2c/browser_init:2:84687) - at Socket. (file:///home/hellings/Documents/wo/electron/dist/main.js:1235:40) - at Socket.emit (node:events:519:28) - at addChunk (node:internal/streams/readable:561:12) - at readableAddChunkPushByteMode (node:internal/streams/readable:512:3) - at Readable.push (node:internal/streams/readable:392:5) - at Pipe.onStreamRead (node:internal/stream_base_commons:189:23) -Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed - at s.send (node:electron/js2c/browser_init:2:100266) - at b.send (node:electron/js2c/browser_init:2:84687) - at Socket. (file:///home/hellings/Documents/wo/electron/dist/main.js:1213:36) - at Socket.emit (node:events:519:28) - at addChunk (node:internal/streams/readable:561:12) - at readableAddChunkPushByteMode (node:internal/streams/readable:512:3) - at Readable.push (node:internal/streams/readable:392:5) - at Pipe.onStreamRead (node:internal/stream_base_commons:189:23) -Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed - at s.send (node:electron/js2c/browser_init:2:100266) - at b.send (node:electron/js2c/browser_init:2:84687) - at Socket. (file:///home/hellings/Documents/wo/electron/dist/main.js:1213:36) - at Socket.emit (node:events:519:28) - at addChunk (node:internal/streams/readable:561:12) - at readableAddChunkPushByteMode (node:internal/streams/readable:512:3) - at Readable.push (node:internal/streams/readable:392:5) - at Pipe.onStreamRead (node:internal/stream_base_commons:189:23) -Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed - at s.send (node:electron/js2c/browser_init:2:100266) - at b.send (node:electron/js2c/browser_init:2:84687) - at Socket. (file:///home/hellings/Documents/wo/electron/dist/main.js:1235:40) - at Socket.emit (node:events:519:28) - at addChunk (node:internal/streams/readable:561:12) - at readableAddChunkPushByteMode (node:internal/streams/readable:512:3) - at Readable.push (node:internal/streams/readable:392:5) - at Pipe.onStreamRead (node:internal/stream_base_commons:189:23) -Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed - at s.send (node:electron/js2c/browser_init:2:100266) - at b.send (node:electron/js2c/browser_init:2:84687) - at Socket. (file:///home/hellings/Documents/wo/electron/dist/main.js:1235:40) - at Socket.emit (node:events:519:28) - at addChunk (node:internal/streams/readable:561:12) - at readableAddChunkPushByteMode (node:internal/streams/readable:512:3) - at Readable.push (node:internal/streams/readable:392:5) - at Pipe.onStreamRead (node:internal/stream_base_commons:189:23) -(EE) failed to read Wayland events: Broken pipe -(EE) failed to read Wayland events: Broken pipe +Keyboard interrupt received, exiting. +[Wo] IPC connection closed +Gdk-Message: 23:36:25.499: Error reading events from display: Broken pipe