diff --git a/Cargo.lock b/Cargo.lock index 0c26c49ee20..40c9e40fba1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,9 +298,9 @@ dependencies = [ [[package]] name = "annotate-snippets" -version = "0.12.15" +version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92570a3f9c98e7e84df84b71d0965ac99b1871fcd75a3773a3bd1bad13f64cf7" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" dependencies = [ "anstyle", "memchr", @@ -785,6 +785,16 @@ dependencies = [ "v_frame", ] +[[package]] +name = "avahi-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e00c83b3887835fd326daae1b2c4e7a435405033331a876ae1dd03f77b8274" +dependencies = [ + "bindgen 0.69.5", + "libc", +] + [[package]] name = "avif-serialize" version = "0.8.8" @@ -870,6 +880,29 @@ dependencies = [ "unty", ] +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags 2.11.1", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.117", + "which 4.4.2", +] + [[package]] name = "bindgen" version = "0.72.1" @@ -1038,6 +1071,16 @@ dependencies = [ "slint-build", ] +[[package]] +name = "bonjour-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73af71be57caf454918b16e5f4ac6bfcbe2dcd68c381f080a4cd1df598a24627" +dependencies = [ + "bindgen 0.72.1", + "libc", +] + [[package]] name = "borsh" version = "1.6.1" @@ -1087,6 +1130,29 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" +[[package]] +name = "bytecheck" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0caa33a2c0edca0419d15ac723dff03f1956f7978329b1e3b5fdaaaed9d3ca8b" +dependencies = [ + "bytecheck_derive", + "ptr_meta 0.3.1", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "bytemuck" version = "1.25.0" @@ -1310,7 +1376,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", "terminal_size", ] @@ -1350,6 +1416,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -1605,7 +1680,7 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" dependencies = [ - "bindgen", + "bindgen 0.72.1", ] [[package]] @@ -1866,6 +1941,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "darling" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d706e75d87e35569db781a9b5e2416cff1236a47ed380831f959382ccd5f858" +dependencies = [ + "darling_core 0.10.2", + "darling_macro 0.10.2", +] + [[package]] name = "darling" version = "0.20.11" @@ -1896,6 +1981,20 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling_core" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c960ae2da4de88a91b2d920c2a7233b400bc33cb28453a2987822d8392519b" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.9.3", + "syn 1.0.109", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -1906,7 +2005,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.117", ] @@ -1932,10 +2031,21 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b5a2f4ac4969822c62224815d069952656cadc7084fdca9751e6d959189b72" +dependencies = [ + "darling_core 0.10.2", + "quote", + "syn 1.0.109", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -1989,6 +2099,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "data-url" version = "0.3.2" @@ -2084,6 +2200,53 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "derive-getters" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2c35ab6e03642397cdda1dd58abbc05d418aef8e36297f336d5aba060fe8df" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive-new" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3418329ca0ad70234b9735dc4ceed10af4df60eff9c8e7b06cb5e520d92c3535" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2658621297f2cf68762a6f7dc0bb7e1ff2cfd6583daef8ee0fed6f7ec468ec0" +dependencies = [ + "darling 0.10.2", + "derive_builder_core", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2791ea3e372c8495c0bc2033991d76b512cd799d07491fbd6890124db9458bef" +dependencies = [ + "darling 0.10.2", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -2527,7 +2690,7 @@ dependencies = [ "embedded-io-async", "futures-sink", "futures-util", - "heapless", + "heapless 0.8.0", ] [[package]] @@ -2541,7 +2704,7 @@ dependencies = [ "embedded-io-async", "futures-core", "futures-sink", - "heapless", + "heapless 0.8.0", ] [[package]] @@ -2578,7 +2741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc55c748d16908a65b166d09ce976575fb8852cf60ccd06174092b41064d8f83" dependencies = [ "embassy-executor", - "heapless", + "heapless 0.8.0", ] [[package]] @@ -2717,6 +2880,12 @@ dependencies = [ "nb 1.1.0", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + [[package]] name = "embedded-io" version = "0.6.1" @@ -2829,18 +2998,18 @@ dependencies = [ [[package]] name = "enumset" -version = "1.1.10" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +checksum = "7f96a4a12fe60ac746ae295a1a4ecb5bb02debc20856506c8635288065f142de" dependencies = [ "enumset_derive", ] [[package]] name = "enumset_derive" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" dependencies = [ "darling 0.21.3", "proc-macro2", @@ -2952,7 +3121,7 @@ dependencies = [ "cfg-if", "esp-config", "esp-println", - "heapless", + "heapless 0.8.0", "semihosting", ] @@ -3387,7 +3556,7 @@ version = "8.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a314bc0e022a33a99567ed4bd2576bd58ffd8fcff7891c29194cfecc26a62547" dependencies = [ - "bindgen", + "bindgen 0.72.1", "cc", "libc", "num_cpus", @@ -3489,6 +3658,17 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -3683,7 +3863,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0bac594f5ab9765057e989fc04f816583a24fac44381c73c7da7821414a4551" dependencies = [ "embedded-hal 1.0.0", - "heapless", + "heapless 0.8.0", ] [[package]] @@ -3867,6 +4047,30 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getifs" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7143bdeb50c2871c11628ebd913d22c4182b41d1793912e6f89ecbcb6b48d596" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "either", + "hardware-address", + "ipnet", + "iprfc", + "iprobe", + "libc", + "linux-raw-sys 0.12.1", + "paste", + "rustix 1.1.4", + "smallvec-wrapper", + "smol_str 0.3.2", + "triomphe", + "widestring", + "windows-sys 0.61.2", +] + [[package]] name = "getopts" version = "0.2.24" @@ -4542,7 +4746,7 @@ dependencies = [ "defmt 0.3.100", "embedded-hal 1.0.0", "embedded-hal-async", - "heapless", + "heapless 0.8.0", ] [[package]] @@ -4596,6 +4800,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hardware-address" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fca164ea873ea6d164c3b9e6bc59a70e172d28a57b8513528ed4f5ec127c9f" +dependencies = [ + "paste", + "thiserror 2.0.18", +] + [[package]] name = "harfrust" version = "0.6.0" @@ -4609,6 +4823,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hash32" version = "0.3.1" @@ -4653,6 +4876,20 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version 0.4.1", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heapless" version = "0.8.0" @@ -4660,7 +4897,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" dependencies = [ "defmt 0.3.100", - "hash32", + "hash32 0.3.1", "stable_deref_trait", ] @@ -4700,6 +4937,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "home-automation" version = "1.17.0" @@ -4711,6 +4957,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "htmlparser" version = "0.2.1" @@ -5181,6 +5438,7 @@ dependencies = [ name = "i-slint-preview-protocol" version = "1.17.0" dependencies = [ + "derive_more", "lsp-types", "serde", "serde_json", @@ -5454,6 +5712,26 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf39cc0423ee66021dc5eccface85580e4a001e0c5288bae8bea7ecb69225e90" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "image" version = "0.25.10" @@ -5648,13 +5926,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] -name = "iri-string" -version = "0.7.12" +name = "iprfc" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +checksum = "0bc9535b4a1d67bfcd48132d801bedcba48364615bd718b1d63da985e93d5310" dependencies = [ - "memchr", - "serde", + "bitflags 2.11.1", + "ipnet", + "paste", +] + +[[package]] +name = "iprobe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff11c64cf994eda9584764f77c94dd1a7499bcbe1634f2c330e7ab9e0d7f5a6d" +dependencies = [ + "rustix 1.1.4", ] [[package]] @@ -5693,6 +5981,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -5827,9 +6124,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -5973,6 +6270,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -6036,7 +6339,7 @@ dependencies = [ "bitflags 2.11.1", "libc", "plain", - "redox_syscall 0.7.4", + "redox_syscall 0.7.5", ] [[package]] @@ -6461,7 +6764,7 @@ dependencies = [ "embedded-hal 1.0.0", "env_logger", "gt911", - "heapless", + "heapless 0.8.0", "log", "object-pool", "panic-probe 1.0.0", @@ -6472,6 +6775,35 @@ dependencies = [ "tinybmp", ] +[[package]] +name = "mdns-sd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183d7ebd9fe3815c1635cd039d1ed95f1d315731cc13e6d504e4ae039d527b8d" +dependencies = [ + "fastrand", + "flume", + "if-addrs 0.14.0", + "mio", + "socket-pktinfo", + "socket2 0.6.3", +] + +[[package]] +name = "mdns-sd" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d797ab3274a16f4940f9650a29838e940223aeff31773df5c2827ad82150182f" +dependencies = [ + "fastrand", + "flume", + "if-addrs 0.15.0", + "log", + "mio", + "socket-pktinfo", + "socket2 0.6.3", +] + [[package]] name = "memchr" version = "2.8.0" @@ -6626,7 +6958,7 @@ checksum = "9ba34dcbf61182ffa6992b5a4d9b566d5a99df127fd93f6d314213347329e92f" dependencies = [ "embedded-graphics-core", "embedded-hal 1.0.0", - "heapless", + "heapless 0.8.0", "nb 1.1.0", ] @@ -6671,6 +7003,26 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "naga" version = "27.0.3" @@ -6946,9 +7298,9 @@ dependencies = [ [[package]] name = "no_std_io2" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" dependencies = [ "memchr", ] @@ -8040,18 +8392,18 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" dependencies = [ "proc-macro2", "quote", @@ -8257,6 +8609,19 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless 0.7.17", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -8476,7 +8841,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcada80daa06c42ed5f48c9a043865edea5dc44cbf9ac009fda3b89526e28607" dependencies = [ - "ptr_meta_derive", + "ptr_meta_derive 0.2.0", +] + +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive 0.3.1", ] [[package]] @@ -8490,6 +8864,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pulldown-cmark" version = "0.13.3" @@ -8678,6 +9063,15 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46b71a9d9206e8b46714c74255adcaea8b11e0350c1d8456165073c3f75fc81a" +[[package]] +name = "rancor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +dependencies = [ + "ptr_meta 0.3.1", +] + [[package]] name = "rand" version = "0.8.6" @@ -8892,9 +9286,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ "bitflags 2.11.1", ] @@ -8945,6 +9339,42 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "remote-viewer" +version = "1.17.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "dashmap", + "futures-util", + "getifs", + "hostname", + "i-slint-backend-selector", + "i-slint-compiler", + "i-slint-core", + "i-slint-preview-protocol", + "lsp-types", + "mdns-sd 0.17.2", + "postcard", + "serde", + "slint", + "slint-interpreter", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "zeroconf-tokio", +] + +[[package]] +name = "rend" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" +dependencies = [ + "bytecheck", +] + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -9155,6 +9585,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "rkyv" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73389e0c99e664f919275ab5b5b0471391fe9a8de61e1dff9b1eaf56a90f16e3" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown 0.17.0", + "indexmap", + "munge", + "ptr_meta 0.3.1", + "rancor", + "rend", + "rkyv_derive", + "smallvec", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2ed0b54125315fb36bd021e82d314d1c126548f871634b483f46b31d13cac6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "rlsf" version = "0.2.2" @@ -9834,6 +10295,17 @@ dependencies = [ "esp-println", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2-const-stable" version = "0.1.0" @@ -9971,7 +10443,7 @@ version = "0.90.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f6f96e00735f14a781aac8a6870c862b8cc831df6d8e4ad77ab78e11411b9af" dependencies = [ - "bindgen", + "bindgen 0.72.1", "cc", "flate2", "heck 0.5.0", @@ -10194,7 +10666,9 @@ dependencies = [ "js-sys", "lsp-server", "lsp-types", + "mdns-sd 0.18.2", "nucleo-matcher", + "postcard", "send_wrapper", "serde", "serde-wasm-bindgen", @@ -10206,6 +10680,7 @@ dependencies = [ "spin_on", "tikv-jemallocator", "tokio", + "tokio-tungstenite-wasm", "tracing", "tracing-subscriber", "wasm-bindgen", @@ -10335,6 +10810,19 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smallvec-wrapper" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71a120c9f2941389140711334f4aec8f54544cffd18b86bc20eb1230cd73191b" +dependencies = [ + "either", + "paste", + "rkyv", + "serde", + "smallvec", +] + [[package]] name = "smart-default" version = "0.7.1" @@ -10484,6 +10972,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "socket-pktinfo" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f" +dependencies = [ + "libc", + "socket2 0.6.3", + "windows-sys 0.60.2", +] + [[package]] name = "socket2" version = "0.5.10" @@ -10556,6 +11055,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "spin_on" version = "0.1.1" @@ -10667,6 +11175,12 @@ dependencies = [ "precomputed-hash", ] +[[package]] +name = "strsim" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c" + [[package]] name = "strsim" version = "0.11.1" @@ -10996,7 +11510,7 @@ version = "1.17.0" dependencies = [ "tempfile", "test_driver_lib", - "which", + "which 8.0.2", ] [[package]] @@ -11319,6 +11833,38 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-tungstenite-wasm" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccecee909c02b8863f9bda87253127eb4da0e7e1342330b2583fbc4d1795c2f8" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "httparse", + "js-sys", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -11431,20 +11977,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "bitflags 2.11.1", "bytes", "futures-util", "http 1.4.0", "http-body 1.0.1", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -11527,6 +12073,16 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" +dependencies = [ + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -11548,6 +12104,23 @@ dependencies = [ "core_maths", ] +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typed-index-collections" version = "3.3.0" @@ -11605,7 +12178,7 @@ dependencies = [ "bitflags 2.11.1", "cfg-if", "log", - "ptr_meta", + "ptr_meta 0.2.0", "ucs2", "uefi-macros", "uefi-raw", @@ -11641,7 +12214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f6d465de2c918779dafb769a5a4fe8d6e4fb7cc4cc6cb1a735f2f6ec68beea4" dependencies = [ "bitflags 2.11.1", - "ptr_meta", + "ptr_meta 0.2.0", "uguid", ] @@ -11865,7 +12438,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98816b1accafbb09085168b90f27e93d790b4bfa19d883466b5e53315b5f06a6" dependencies = [ - "heapless", + "heapless 0.8.0", "portable-atomic", ] @@ -11909,6 +12482,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -12111,9 +12690,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -12124,9 +12703,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.70" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -12134,9 +12713,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -12144,9 +12723,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -12157,9 +12736,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -12412,9 +12991,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -12768,6 +13347,18 @@ dependencies = [ "wgpu 28.0.0", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "which" version = "8.0.2" @@ -12787,6 +13378,12 @@ dependencies = [ "safe_arch", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -13763,7 +14360,7 @@ dependencies = [ "regex", "serde_json", "toml_edit", - "which", + "which 8.0.2", "xshell", ] @@ -13969,6 +14566,44 @@ dependencies = [ "libm", ] +[[package]] +name = "zeroconf" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d598a65ad4a0bf645fae8441b5d4eb8db7bfd1c2f3889069320bd13859204a6" +dependencies = [ + "avahi-sys", + "bonjour-sys", + "derive-getters", + "derive-new", + "derive_builder", + "libc", + "log", + "zeroconf-macros", +] + +[[package]] +name = "zeroconf-macros" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b52318880bad5d5a081f7ac4251b5144e73dfa859cbcd10562d9433eeed6b72" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zeroconf-tokio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b7eebfa4a31245de0d62ec17bdbd767575560743fd7c8237cac776dd420587f" +dependencies = [ + "log", + "tokio", + "tokio-util", + "zeroconf", +] + [[package]] name = "zerocopy" version = "0.8.48" diff --git a/Cargo.toml b/Cargo.toml index 50efa05760a..ebec98d2c42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,7 @@ members = [ 'tools/tr-extractor', "ui-libraries/material/examples/gallery", 'xtask', + 'tools/remote-viewer', ] default-members = [ @@ -135,6 +136,7 @@ i-slint-common = { version = "=1.17.0", path = "internal/common", default-featur i-slint-compiler = { version = "=1.17.0", path = "internal/compiler", default-features = false } i-slint-core = { version = "=1.17.0", path = "internal/core", default-features = false } i-slint-core-macros = { version = "=1.17.0", path = "internal/core-macros", default-features = false } +i-slint-preview-protocol = { version = "=1.17.0", path = "internal/preview-protocol" } i-slint-renderer-femtovg = { version = "=1.17.0", path = "internal/renderers/femtovg", default-features = false } i-slint-renderer-skia = { version = "=1.17.0", path = "internal/renderers/skia", default-features = false } i-slint-renderer-software = { version = "=1.17.0", path = "internal/renderers/software", default-features = false } diff --git a/docs/development/lsp-architecture.md b/docs/development/lsp-architecture.md index 8e14b1dd23c..3105abe6225 100644 --- a/docs/development/lsp-architecture.md +++ b/docs/development/lsp-architecture.md @@ -352,7 +352,7 @@ pub enum LspToPreviewMessage { // Preview to LSP pub enum PreviewToLspMessage { - RequestState { unused: bool }, + RequestState { paths: Vec }, UpdateElement { ... }, SendWorkspaceEdit { ... }, ShowDocument { ... }, diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 1d8994fe5a3..440ec237cc3 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -89,6 +89,18 @@ "category": "Slint", "icon": "$(preview)" }, + { + "command": "slint.selectRemotePreview", + "title": "Connect to Remote Preview", + "category": "Slint", + "icon": "$(preview)" + }, + { + "command": "slint.disconnectRemotePreview", + "title": "Disconnect Remote Preview", + "category": "Slint", + "icon": "$(debug-disconnect)" + }, { "command": "slint.reload", "title": "Restart server", @@ -107,6 +119,10 @@ "command": "slint.showPreview", "when": "editorLangId == slint" }, + { + "command": "slint.selectRemotePreview", + "when": "editorLangId == slint" + }, { "command": "slint.reload" }, @@ -253,4 +269,4 @@ "typescript": "catalog:", "vscode-tmgrammar-test": "0.1.3" } -} +} \ No newline at end of file diff --git a/editors/vscode/src/common.ts b/editors/vscode/src/common.ts index b94b438eb9b..1f8442b4362 100644 --- a/editors/vscode/src/common.ts +++ b/editors/vscode/src/common.ts @@ -55,6 +55,55 @@ export class ClientHandle { const client = new ClientHandle(); +export type RemoteViewerInfo = { + id: string; + + label: string; + detail: string; + + value: { + addresses: string[]; + port: number; + }; + + timer?: NodeJS.Timeout; +}; +export const remote_viewers = new Map(); + +let remoteViewerStatusBarItem: vscode.StatusBarItem | undefined; +export function updateRemoteViewerStatusBarItem(newItem: vscode.StatusBarItem) { + remoteViewerStatusBarItem = newItem; +} +export enum RemoteViewerStatusBarItemState { + disconnected = 0, + connecting = 1, + connected = 2, +} +export function setRemoteViewerStatusBarItemState( + state: RemoteViewerStatusBarItemState, +) { + if (remoteViewerStatusBarItem) { + switch (state) { + case RemoteViewerStatusBarItemState.disconnected: + remoteViewerStatusBarItem.text = "$(vm) Slint Remote Preview"; + remoteViewerStatusBarItem.command = "slint.selectRemotePreview"; + break; + case RemoteViewerStatusBarItemState.connecting: + remoteViewerStatusBarItem.text = + "$(vm-connect) Slint Remote Preview"; + remoteViewerStatusBarItem.command = + "slint.disconnectRemotePreview"; + break; + case RemoteViewerStatusBarItemState.connected: + remoteViewerStatusBarItem.text = + "$(vm-active) Slint Remote Preview"; + remoteViewerStatusBarItem.command = + "slint.disconnectRemotePreview"; + break; + } + } +} + // LSP related: // Set up our middleware. It is used to redirect/forward to the WASM preview @@ -149,6 +198,57 @@ export function activate( client.add_updater((cl) => { wasm_preview.initClientForPreview(context, cl); + cl?.onNotification("slint/remote_viewer_discovered", (params) => { + vscode.window.showInformationMessage( + `Remote viewer discovered: ${params.host}`, + ); + cl.outputChannel.appendLine( + `Remote viewer discovered: ${params.host} (${params.addresses.join(", ")}:${params.port})`, + ); + const old_entry = remote_viewers.get(params.host); + if (old_entry) { + clearTimeout(old_entry.timer); + } + const remote_viewer_entry = { + id: params.host, + + label: params.host, + detail: params.addresses.join(", "), + + value: params, + timer: setTimeout(() => { + remote_viewers.delete(params.host); + }, 60000), + }; + remote_viewers.set(params.host, remote_viewer_entry); + }); + cl?.onNotification("slint/remote_viewer_connection_state", (params) => { + switch (params.state) { + case "connected": + vscode.window.showInformationMessage( + `Remote viewer connected: ${params.address}:${params.port}`, + ); + cl.outputChannel.appendLine( + `Remote viewer connected: ${params.address}:${params.port}`, + ); + setRemoteViewerStatusBarItemState( + RemoteViewerStatusBarItemState.connected, + ); + break; + case "disconnected": + vscode.window.showInformationMessage( + `Remote viewer disconnected: ${params.address}:${params.port}`, + ); + cl.outputChannel.appendLine( + `Remote viewer disconnected: ${params.address}:${params.port}`, + ); + setRemoteViewerStatusBarItemState( + RemoteViewerStatusBarItemState.disconnected, + ); + break; + } + // TODO + }); }); vscode.workspace.onDidChangeConfiguration(async (ev) => { @@ -236,6 +336,10 @@ export function deactivate(): Thenable | undefined { if (!client.client) { return undefined; } + for (const viewer of remote_viewers.values()) { + clearTimeout(viewer.timer); + } + remote_viewers.clear(); return client.stop(); } diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 499afc6fc0b..796124384be 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -11,6 +11,7 @@ import * as vscode from "vscode"; import { SlintTelemetrySender } from "./telemetry"; import * as common from "./common"; import { NotificationType } from "vscode-languageclient"; +import * as lsp_commands from "./lsp_commands"; import { LanguageClient, @@ -189,6 +190,7 @@ function startClient( const devBuild = serverModule.includes("/target/debug/"); if (devBuild) { options.env["RUST_BACKTRACE"] = "1"; + options.env["RUST_LOG"] = "debug"; } options.env["SLINT_LSP_PANIC_LOG_DIR"] = lsp_panic_log_dir(context).fsPath; @@ -234,6 +236,10 @@ function startClient( handleTelemetryEvent(params.type, context.globalState); }, ); + + common.setRemoteViewerStatusBarItemState( + common.RemoteViewerStatusBarItemState.disconnected, + ); }); const cl = new LanguageClient( @@ -319,6 +325,8 @@ export function activate(context: vscode.ExtensionContext) { return; } + setupRemotePreview(context); + const custom_lsp = !serverModule.startsWith( path.join(context.extensionPath, "bin"), ); @@ -406,3 +414,102 @@ function startTelemetryTimer( } } } + +const remotePreviewConnectionStringMatcher = + /^(?:\[(?[0-9A-Fa-f:.]+)\]|(?[^:\s\[\]]+)):(?\d{1,5})$/; + +function setupRemotePreview(context: vscode.ExtensionContext) { + const remoteViewerStatusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 100, + ); + + common.updateRemoteViewerStatusBarItem(remoteViewerStatusBarItem); + common.setRemoteViewerStatusBarItemState( + common.RemoteViewerStatusBarItemState.disconnected, + ); + remoteViewerStatusBarItem.show(); + + context.subscriptions.push( + vscode.commands.registerCommand("slint.selectRemotePreview", () => { + const picker = vscode.window.createQuickPick< + vscode.QuickPickItem & Partial + >(); + picker.title = "Select a remote preview"; + picker.placeholder = "Manual entry (e.g. 127.0.1:1234)"; + picker.ignoreFocusOut = true; + + const updateItems = () => { + const typed = picker.value.trim(); + const items: (vscode.QuickPickItem & + Partial)[] = []; + + remotePreviewConnectionStringMatcher.lastIndex = 0; + const match = remotePreviewConnectionStringMatcher.exec(typed); + + if (match) { + items.push({ + id: "ENTER", + label: `Use typed value: ${typed}`, + detail: "", + alwaysShow: true, + value: { + addresses: [ + match.groups?.ipv6 ?? match.groups?.host ?? "", + ], + port: Number.parseInt(match.groups?.port ?? "1234"), + }, + }); + items.push({ + id: "sep", + label: "", + kind: vscode.QuickPickItemKind.Separator, + }); + } + + items.push(...common.remote_viewers.values()); + + picker.items = items; + }; + + const connect = (item: common.RemoteViewerInfo) => { + lsp_commands.connectRemotePreview( + item.value.addresses, + item.value.port, + ); + common.setRemoteViewerStatusBarItemState( + common.RemoteViewerStatusBarItemState.connecting, + ); + + picker.hide(); + }; + + // picker.onDidAccept(() => { + // const picked = picker.activeItems[0] ?? picker.selectedItems[0]; + // if (picked) { + // connect(picked as common.RemoteViewerInfo); + // } + // }); + + picker.onDidChangeSelection((items) => { + const picked = items[0]; + if (picked) { + connect(picked as common.RemoteViewerInfo); + } + }); + + picker.onDidHide(() => { + picker.dispose(); + }); + + updateItems(); + picker.onDidChangeValue(updateItems); + + picker.show(); + }), + remoteViewerStatusBarItem, + vscode.commands.registerCommand("slint.disconnectRemotePreview", () => { + lsp_commands.disconnectRemotePreview(); + }), + ); +} diff --git a/editors/vscode/src/lsp_commands.ts b/editors/vscode/src/lsp_commands.ts index 36f11d5c5ea..a2987811d21 100644 --- a/editors/vscode/src/lsp_commands.ts +++ b/editors/vscode/src/lsp_commands.ts @@ -21,3 +21,18 @@ import * as vscode from "vscode"; export function showPreview(url: LspURI, component: string): Thenable { return vscode.commands.executeCommand("slint/showPreview", url, component); } + +export function connectRemotePreview( + addresses: string[], + port: number, +): Thenable { + return vscode.commands.executeCommand( + "slint/connectRemotePreview", + addresses, + port, + ); +} + +export function disconnectRemotePreview(): Thenable { + return vscode.commands.executeCommand("slint/disconnectRemotePreview"); +} diff --git a/editors/vscode/src/tsconfig.json b/editors/vscode/src/tsconfig.json index e9d687c8376..738c8a458e4 100644 --- a/editors/vscode/src/tsconfig.json +++ b/editors/vscode/src/tsconfig.json @@ -1,8 +1,16 @@ { "extends": "../tsconfig.default.json", "compilerOptions": { - "lib": ["es2021", "webworker"], - "types": ["vscode"] + "target": "ES2021", + "lib": [ + "es2021", + "webworker" + ], + "types": [ + "vscode" + ] }, - "include": ["./*.ts", "./*.d.ts"] -} + "include": [ + "./*.ts" + ] +} \ No newline at end of file diff --git a/editors/vscode/src/wasm_preview.ts b/editors/vscode/src/wasm_preview.ts index c6351dac446..b2f182382b5 100644 --- a/editors/vscode/src/wasm_preview.ts +++ b/editors/vscode/src/wasm_preview.ts @@ -25,7 +25,10 @@ export function update_configuration() { if (language_client) { send_to_lsp({ PreviewTypeChanged: { - is_external: previewPanel !== null || use_wasm_preview(), + target: + previewPanel !== null || use_wasm_preview() + ? "embedded-wasm" + : "child-process", }, }); } @@ -230,7 +233,7 @@ function initPreviewPanel( map_url(panel.webview, message.url); return; case "preview_ready": - send_to_lsp({ RequestState: { unused: true } }); + send_to_lsp({ RequestState: {} }); return; case "slint/preview_to_lsp": send_to_lsp(message.params); diff --git a/internal/preview-protocol/Cargo.toml b/internal/preview-protocol/Cargo.toml index 3cd79a2c167..8167a95c880 100644 --- a/internal/preview-protocol/Cargo.toml +++ b/internal/preview-protocol/Cargo.toml @@ -18,3 +18,4 @@ version.workspace = true lsp-types.workspace = true serde.workspace = true serde_json.workspace = true +derive_more = { version = "2.1.1", features = ["debug"] } diff --git a/internal/preview-protocol/src/lib.rs b/internal/preview-protocol/src/lib.rs index b86536fd75e..087e5e15ac7 100644 --- a/internal/preview-protocol/src/lib.rs +++ b/internal/preview-protocol/src/lib.rs @@ -8,9 +8,12 @@ mod preview_to_lsp; mod versioned_url; pub use lsp_to_preview::{LspToPreviewMessage, PreviewComponent, PreviewConfig}; -pub use preview_to_lsp::PreviewToLspMessage; +pub use preview_to_lsp::{PreviewTarget, PreviewToLspMessage}; pub use versioned_url::VersionedUrl; pub use lsp_types; pub type SourceFileVersion = Option; +pub const SERVICE_TYPE: &str = "_slint-preview._tcp.local."; +pub const SERVICE_TYPE_NAME: &str = "slint-preview"; +pub const SERVICE_TYPE_PROTOCOL: &str = "tcp"; diff --git a/internal/preview-protocol/src/lsp_to_preview.rs b/internal/preview-protocol/src/lsp_to_preview.rs index 2a2e8135e7a..3f35019b2ff 100644 --- a/internal/preview-protocol/src/lsp_to_preview.rs +++ b/internal/preview-protocol/src/lsp_to_preview.rs @@ -27,14 +27,27 @@ pub struct PreviewConfig { pub enable_experimental: bool, } -#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +#[derive(Clone, derive_more::Debug, serde::Deserialize, serde::Serialize)] pub enum LspToPreviewMessage { - InvalidateContents { url: lsp_types::Url }, - ForgetFile { url: lsp_types::Url }, - SetContents { url: VersionedUrl, contents: String }, - SetConfiguration { config: PreviewConfig }, + InvalidateContents { + url: lsp_types::Url, + }, + ForgetFile { + url: lsp_types::Url, + }, + SetContents { + url: VersionedUrl, + #[debug("Vec {{ len: {} }}", contents.len())] + contents: Vec, + }, + SetConfiguration { + config: PreviewConfig, + }, ShowPreview(PreviewComponent), - HighlightFromEditor { url: Option, offset: u32 }, + HighlightFromEditor { + url: Option, + offset: u32, + }, Quit, } diff --git a/internal/preview-protocol/src/preview_to_lsp.rs b/internal/preview-protocol/src/preview_to_lsp.rs index 4f10fe80d8a..bc88b5132f9 100644 --- a/internal/preview-protocol/src/preview_to_lsp.rs +++ b/internal/preview-protocol/src/preview_to_lsp.rs @@ -5,6 +5,19 @@ use lsp_types::Url; use crate::SourceFileVersion; +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PreviewTarget { + #[allow(dead_code)] + ChildProcess, + #[allow(dead_code)] + EmbeddedWasm, + #[allow(dead_code)] + Remote, + #[allow(dead_code)] + Dummy, +} + #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] pub enum PreviewToLspMessage { /// Report diagnostics to editor. @@ -12,10 +25,13 @@ pub enum PreviewToLspMessage { /// Show a document in the editor. ShowDocument { file: Url, selection: lsp_types::Range, take_focus: bool }, /// Switch between native and WASM preview (if supported) - PreviewTypeChanged { is_external: bool }, + PreviewTypeChanged { target: PreviewTarget }, /// Request all documents and configuration to be sent from the LSP to the /// Preview. - RequestState { unused: bool }, + RequestState { + #[serde(default)] + files: Vec, + }, /// Pass a `WorkspaceEdit` on to the editor SendWorkspaceEdit { label: Option, edit: lsp_types::WorkspaceEdit }, /// Pass a `ShowMessage` notification on to the editor diff --git a/tests/driver/python/python.rs b/tests/driver/python/python.rs index bccde50a4a9..14eae68bb5c 100644 --- a/tests/driver/python/python.rs +++ b/tests/driver/python/python.rs @@ -122,7 +122,7 @@ pub fn test(testcase: &test_driver_lib::TestCase) -> Result<(), Box> // Append the python code to the bottom of the generated file and run it let mut f = std::fs::File::options().append(true).open(&python_file).unwrap(); - f.write(python_script.as_bytes()).unwrap(); + f.write_all(python_script.as_bytes()).unwrap(); }; let o = std::process::Command::new("uvx") diff --git a/tools/figma_import/src/figmatypes.rs b/tools/figma_import/src/figmatypes.rs index 92b53c38115..265931dc30b 100644 --- a/tools/figma_import/src/figmatypes.rs +++ b/tools/figma_import/src/figmatypes.rs @@ -7,7 +7,7 @@ use float_cmp::ApproxEq; use std::collections::HashMap; -use derive_more::*; +use derive_more::{Add, AddAssign, Neg, Sub, SubAssign}; use serde::Deserialize; use smart_default::SmartDefault; diff --git a/tools/lsp/Cargo.toml b/tools/lsp/Cargo.toml index a755efff9fc..319d64c9405 100644 --- a/tools/lsp/Cargo.toml +++ b/tools/lsp/Cargo.toml @@ -65,7 +65,7 @@ renderer-winit-skia-vulkan = ["renderer-skia-vulkan"] renderer-winit-software = ["renderer-software"] ## Enable support for previewing .slint files -preview = ["preview-builtin", "preview-external", "preview-engine"] +preview = ["preview-builtin", "preview-external", "preview-remote", "preview-engine"] ## [deprecated] Used to enable the "Show Preview" lenses and action on components. preview-lense = [] ## [deprecated] Used to enable partial support for external previewers. @@ -84,6 +84,8 @@ preview-engine = [ preview-builtin = ["preview-engine"] ## Support the external preview optionally used by e.g. the VSCode plugin preview-external = [] +## Support the remote preview via a WebSocket connection +preview-remote = [] default = ["backend-default", "renderer-femtovg", "renderer-software", "preview"] @@ -110,6 +112,8 @@ slint = { workspace = true, features = ["compat-1-2"], optional = true } slint-interpreter = { workspace = true, features = ["compat-1-2", "internal", "internal-highlight", "internal-json", "image-default-formats"], optional = true } nucleo-matcher = "0.3.1" futures-util = "0.3.32" +tokio-tungstenite-wasm = "0.8.2" +postcard = { version = "1.1.3", features = ["alloc"] } [target.'cfg(not(any(target_os = "openbsd", target_os = "windows", target_arch = "wasm32", all(target_arch = "aarch64", target_os = "linux"))))'.dependencies] tikv-jemallocator = { workspace = true } @@ -120,6 +124,7 @@ crossbeam-channel = "0.5" lsp-server = "0.7" tokio = { version = "1.50.0", features = ["rt", "sync", "time", "macros", "io-std", "io-util", "net", "fs", "process"] } dashmap = "6.1.0" +mdns-sd = "0.18.2" [target.'cfg(target_arch = "wasm32")'.dependencies] console_error_panic_hook = "0.1.5" diff --git a/tools/lsp/common.rs b/tools/lsp/common.rs index bff41972a37..815e96f5cbe 100644 --- a/tools/lsp/common.rs +++ b/tools/lsp/common.rs @@ -6,7 +6,7 @@ use i_slint_compiler::object_tree::ElementRc; use i_slint_compiler::parser::{SyntaxKind, SyntaxNode, TextSize, syntax_nodes}; use i_slint_preview_protocol::{ - LspToPreviewMessage, PreviewToLspMessage, SourceFileVersion, VersionedUrl, + LspToPreviewMessage, PreviewTarget, PreviewToLspMessage, SourceFileVersion, VersionedUrl, }; use lsp_types::{TextEdit, Url, WorkspaceEdit}; @@ -19,6 +19,8 @@ pub use document_cache::DocumentCache; pub use i_slint_compiler::diagnostics::ByteFormat; pub mod rename_component; pub mod rename_element_id; +mod switchable; +pub use switchable::SwitchableLspToPreview; #[cfg(test)] pub mod test; #[cfg(any(test, feature = "preview-engine"))] @@ -48,20 +50,9 @@ where /// ignore a node for code analysis purposes. pub const NODE_IGNORE_COMMENT: &str = "@lsp:ignore-node"; -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -pub enum PreviewTarget { - #[allow(dead_code)] - ChildProcess, - #[allow(dead_code)] - EmbeddedWasm, - #[allow(dead_code)] - Dummy, -} - #[allow(dead_code)] -pub trait LspToPreview { +pub trait LspToPreview: std::any::Any { fn send(&self, message: &LspToPreviewMessage); - fn set_preview_target(&self, target: PreviewTarget) -> Result<()>; fn preview_target(&self) -> PreviewTarget; fn shutdown<'a>(&'a self) -> std::pin::Pin + 'a>> { Box::pin(async {}) @@ -78,10 +69,6 @@ impl LspToPreview for DummyLspToPreview { fn preview_target(&self) -> PreviewTarget { PreviewTarget::Dummy } - - fn set_preview_target(&self, _: PreviewTarget) -> Result<()> { - Err("Can not change the preview target".into()) - } } #[allow(dead_code)] diff --git a/tools/lsp/common/switchable.rs b/tools/lsp/common/switchable.rs new file mode 100644 index 00000000000..21bba8e27cc --- /dev/null +++ b/tools/lsp/common/switchable.rs @@ -0,0 +1,79 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +use i_slint_preview_protocol::PreviewTarget; +use std::{any::Any, cell::RefCell, collections::HashMap}; + +use super::{LspToPreview, Result}; + +pub struct SwitchableLspToPreview { + lsp_to_previews: HashMap>, + current_target: RefCell, +} + +#[allow(dead_code)] // Which methods are live depends on the enabled preview features. +impl SwitchableLspToPreview { + pub fn new( + lsp_to_previews: HashMap>, + current_target: PreviewTarget, + ) -> Result { + if lsp_to_previews.contains_key(¤t_target) { + Ok(Self { lsp_to_previews, current_target: RefCell::new(current_target) }) + } else { + Err("No such target".into()) + } + } + + pub fn with_one(lsp_to_preview: impl LspToPreview) -> Self { + let target = lsp_to_preview.preview_target(); + let lsp_to_previews = + std::iter::once((target, Box::new(lsp_to_preview) as Box)).collect(); + Self { lsp_to_previews, current_target: RefCell::new(target) } + } + + pub fn send(&self, message: &i_slint_preview_protocol::LspToPreviewMessage) { + self.lsp_to_previews.get(&self.current_target.borrow()).unwrap().send(message); + } + + pub async fn shutdown(&self) { + self.send(&i_slint_preview_protocol::LspToPreviewMessage::Quit); + futures_util::future::join_all( + self.lsp_to_previews.values().map(|to_preview| to_preview.shutdown()), + ) + .await; + } + + pub fn preview_target(&self) -> PreviewTarget { + *self.current_target.borrow() + } + + pub fn set_preview_target(&self, target: PreviewTarget) -> Result<()> { + if self.lsp_to_previews.contains_key(&target) { + *self.current_target.borrow_mut() = target; + Ok(()) + } else { + Err("Target not found".into()) + } + } + + pub fn with_preview_target(&self, f: impl FnOnce(&T) -> R) -> Option { + for target in self.lsp_to_previews.values() { + if let Some(target) = ::downcast_ref(target.as_ref()) { + return Some(f(target)); + } + } + None + } + + pub async fn with_preview_target_async( + &self, + f: impl AsyncFnOnce(&T) -> R, + ) -> Option { + for target in self.lsp_to_previews.values() { + if let Some(target) = ::downcast_ref(target.as_ref()) { + return Some(f(target).await); + } + } + None + } +} diff --git a/tools/lsp/editor.rs b/tools/lsp/editor.rs index a3393e6a41b..805ae69142f 100644 --- a/tools/lsp/editor.rs +++ b/tools/lsp/editor.rs @@ -15,9 +15,9 @@ use lsp_server::{Message, RequestId}; use lsp_types::{MessageType, Url, notification::Notification}; use crate::{ - common::{self, LspToPreview, Result, document_cache::OpenImportCallback}, + common::{self, Result, document_cache::OpenImportCallback}, language, preview, - preview::connector::EmbeddedLspToPreview, + preview::connector::{EmbeddedLspToPreview, SwitchableLspToPreview}, }; pub fn editor_main() { @@ -197,18 +197,22 @@ fn start_lsp_thread( fn bridge_crossbeam_to_tokio( from_preview: crossbeam_channel::Receiver, -) -> tokio::sync::mpsc::UnboundedReceiver { +) -> ( + tokio::sync::mpsc::UnboundedSender, + tokio::sync::mpsc::UnboundedReceiver, +) { let (from_preview_tx, from_preview_rx) = tokio::sync::mpsc::unbounded_channel::(); + let inner_from_preview_tx = from_preview_tx.clone(); std::thread::spawn(move || { while let Ok(msg) = from_preview.recv() { - if from_preview_tx.send(msg).is_err() { + if inner_from_preview_tx.send(msg).is_err() { break; } } tracing::debug!("Preview->LSP crossbeam adapter thread exited"); }); - from_preview_rx + (from_preview_tx, from_preview_rx) } async fn lsp_main( @@ -219,10 +223,10 @@ async fn lsp_main( ) -> Result<()> { use crate::common::document_cache::CompilerConfiguration; - let mut from_preview_rx = bridge_crossbeam_to_tokio(from_preview); + let (from_preview_tx, mut from_preview_rx) = bridge_crossbeam_to_tokio(from_preview); // Wrap to_preview in Rc for sharing with the import callback and Context - let to_preview: Rc = Rc::new(to_preview); + let to_preview = Rc::new(SwitchableLspToPreview::with_one(to_preview)); let open_import_callback = { let to_preview = Rc::clone(&to_preview); @@ -230,7 +234,7 @@ async fn lsp_main( let to_preview = Rc::clone(&to_preview); Box::pin(async move { tracing::trace!("Importing file: {}", path); - let contents = std::fs::read_to_string(&path); + let contents = std::fs::read(&path); if let Ok(url) = Url::from_file_path(&path) { if let Ok(contents) = &contents { to_preview.send(&LspToPreviewMessage::SetContents { @@ -241,7 +245,11 @@ async fn lsp_main( to_preview.send(&LspToPreviewMessage::ForgetFile { url }); } } - Some(contents.map(|c| (None, c))) + Some( + contents + .and_then(|c| String::from_utf8(c).map_err(std::io::Error::other)) + .map(|c| (None, c)), + ) }) as Pin< Box>>>, @@ -265,6 +273,7 @@ async fn lsp_main( open_urls: Default::default(), to_preview, pending_recompile: Default::default(), + preview_to_lsp_sender: from_preview_tx, }; // Load the initial document through the compiler. This triggers the import @@ -315,9 +324,13 @@ async fn lsp_main( fn handle_preview_message(msg: PreviewToLspMessage, ctx: &language::Context) { use PreviewToLspMessage::*; match &msg { - RequestState { .. } => { - tracing::debug!("Preview requested state, re-sending all documents"); - language::send_state_to_preview(ctx); + RequestState { files } => { + if files.is_empty() { + tracing::debug!("Preview requested state, re-sending all documents"); + language::send_state_to_preview(ctx); + } else { + language::send_files_to_preview(ctx, files); + } } SendShowMessage { message } => { match message.typ { diff --git a/tools/lsp/language.rs b/tools/lsp/language.rs index ea903ecfb22..305f820d621 100644 --- a/tools/lsp/language.rs +++ b/tools/lsp/language.rs @@ -12,6 +12,7 @@ mod signature_help; #[cfg(test)] pub mod test; +use crate::common::SwitchableLspToPreview; use crate::common::uri_to_file; use crate::{common, util}; @@ -22,6 +23,7 @@ use i_slint_compiler::parser::{ NodeOrToken, SyntaxKind, SyntaxNode, SyntaxToken, TextRange, TextSize, syntax_nodes, }; use i_slint_compiler::{diagnostics::BuildDiagnostics, langtype::Type}; +use i_slint_preview_protocol::PreviewToLspMessage; use itertools::Itertools; use lsp_types::{ ClientCapabilities, CodeActionOrCommand, CodeActionProviderCapability, CodeLens, @@ -46,12 +48,20 @@ use std::rc::Rc; const POPULATE_COMMAND: &str = "slint/populate"; pub const SHOW_PREVIEW_COMMAND: &str = "slint/showPreview"; +#[cfg(feature = "preview-remote")] +pub const CONNECT_REMOTE_PREVIEW_COMMAND: &str = "slint/connectRemotePreview"; +#[cfg(feature = "preview-remote")] +pub const DISCONNECT_REMOTE_PREVIEW_COMMAND: &str = "slint/disconnectRemotePreview"; fn command_list() -> Vec { vec![ POPULATE_COMMAND.into(), #[cfg(any(feature = "preview-builtin", feature = "preview-external"))] SHOW_PREVIEW_COMMAND.into(), + #[cfg(feature = "preview-remote")] + CONNECT_REMOTE_PREVIEW_COMMAND.into(), + #[cfg(feature = "preview-remote")] + DISCONNECT_REMOTE_PREVIEW_COMMAND.into(), ] } @@ -93,7 +103,7 @@ pub fn send_state_to_preview(ctx: &Context) { ctx.to_preview.send(&i_slint_preview_protocol::LspToPreviewMessage::SetContents { url: i_slint_preview_protocol::VersionedUrl::new(url, version), - contents: node.text().to_string(), + contents: node.text().to_string().into(), }); doc_count += 1; } @@ -113,6 +123,31 @@ pub fn send_state_to_preview(ctx: &Context) { } } +#[cfg(any(feature = "preview-external", feature = "preview-engine", feature = "preview-remote"))] +pub fn send_files_to_preview(ctx: &Context, files: &[lsp_types::Url]) { + for url in files { + let Some(path) = url.to_file_path().ok() else { + tracing::warn!("Cannot convert URL to file path: {url}"); + continue; + }; + match std::fs::read(&path) { + Ok(contents) => { + tracing::debug!("Sending file {} ({} bytes) to preview", url, contents.len()); + ctx.to_preview.send(&i_slint_preview_protocol::LspToPreviewMessage::SetContents { + url: i_slint_preview_protocol::VersionedUrl::new(url.clone(), None), + contents, + }); + } + Err(err) => { + tracing::warn!("Failed to read file {}: {err}", path.display()); + ctx.to_preview.send(&i_slint_preview_protocol::LspToPreviewMessage::ForgetFile { + url: url.clone(), + }); + } + } + } +} + async fn register_file_watcher(ctx: &Context) -> common::Result<()> { use lsp_types::notification::Notification; @@ -162,19 +197,31 @@ pub struct Context { pub to_show: Option, /// File currently open in the editor pub open_urls: HashSet, - pub to_preview: Rc, + pub to_preview: Rc, /// Files to recompile after all other operations are done /// (i.e. recompilations triggered by updates to unopened files) pub pending_recompile: HashSet, + #[cfg_attr(not(feature = "preview-remote"), allow(dead_code))] + pub preview_to_lsp_sender: tokio::sync::mpsc::UnboundedSender, } /// An error from a LSP request +#[derive(Debug, Clone)] pub struct LspError { pub code: LspErrorCode, pub message: String, } +impl std::fmt::Display for LspError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({})", self.message, self.code) + } +} + +impl std::error::Error for LspError {} + /// The code of a LspError. Correspond to the lsp_server::ErrorCode +#[derive(Debug, Clone, Copy)] pub enum LspErrorCode { /// Invalid method parameter(s). InvalidParameter, @@ -200,6 +247,17 @@ pub enum LspErrorCode { ContentModified = -32801, } +impl std::fmt::Display for LspErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LspErrorCode::InvalidParameter => write!(f, "Invalid Parameter"), + LspErrorCode::InternalError => write!(f, "Internal Error"), + LspErrorCode::RequestFailed => write!(f, "Request Failed"), + LspErrorCode::ContentModified => write!(f, "Content Modified"), + } + } +} + #[derive(Default)] pub struct RequestHandler( pub HashMap< @@ -362,16 +420,34 @@ pub fn register_request_handlers(rh: &mut RequestHandler) { Ok(result) }); rh.register::(|params, ctx| { - if params.command.as_str() == SHOW_PREVIEW_COMMAND { - #[cfg(any(feature = "preview-builtin", feature = "preview-external"))] - { - show_preview_command(¶ms.arguments, ctx)?; + match params.command.as_str() { + SHOW_PREVIEW_COMMAND => { + #[cfg(any(feature = "preview-builtin", feature = "preview-external"))] + { + show_preview_command(¶ms.arguments, ctx)?; + } + return Ok(None::); + } + POPULATE_COMMAND => { + let future = populate_command(¶ms.arguments, ctx)?; + crate::common::spawn_local(async move { + if let Err(err) = future.await { + tracing::error!("Error executing populate command: {err}"); + } + }); + return Ok(None::); + } + #[cfg(feature = "preview-remote")] + CONNECT_REMOTE_PREVIEW_COMMAND => { + return connect_remote_preview_command(¶ms.arguments, ctx); + } + #[cfg(feature = "preview-remote")] + DISCONNECT_REMOTE_PREVIEW_COMMAND => { + disconnect_remote_preview_command(ctx); + } + _ => { + tracing::error!("Received unknown command {}", params.command.as_str()); } - return Ok(None::); - } - if params.command.as_str() == POPULATE_COMMAND { - common::spawn_local(populate_command(¶ms.arguments, ctx)?); - return Ok(None::); } Ok(None::) }); @@ -597,6 +673,61 @@ pub fn show_preview(component: i_slint_preview_protocol::PreviewComponent, ctx: ctx.to_preview.send(&i_slint_preview_protocol::LspToPreviewMessage::ShowPreview(component)); } +#[cfg(feature = "preview-remote")] +pub fn connect_remote_preview_command( + params: &[serde_json::Value], + ctx: &Context, +) -> Result, LspError> { + let addresses = params.first().and_then(serde_json::Value::as_array).map(|addresses| { + addresses.iter().filter_map(serde_json::Value::as_str).map(String::from).collect::>() + }); + let port = params.get(1).and_then(serde_json::Value::as_u64); + + if let Some(addresses) = addresses { + if let Some(port) = port { + use crate::preview::connector::remote::RemoteLspToPreview; + + let _ = + ctx.to_preview.set_preview_target(i_slint_preview_protocol::PreviewTarget::Remote); + ctx.to_preview.with_preview_target::, LspError>>( + |remote| { + let preview_to_lsp_sender = ctx.preview_to_lsp_sender.clone(); + let future = remote.connect(addresses, port as u16); + crate::common::spawn_local(async move { + if let Err(err) = future.await { + LspError { + code: LspErrorCode::RequestFailed, + message: format!("Failed to connect to remote preview: {err}"), + }; + } else { + let _ = preview_to_lsp_sender.send(PreviewToLspMessage::RequestState { files: Vec::new() }); + } + }); + Ok(None) + }).unwrap() + } else { + Err(LspError { + code: LspErrorCode::InvalidParameter, + message: "Need number as the second parameter".to_owned(), + }) + } + } else { + Err(LspError { + code: LspErrorCode::InvalidParameter, + message: "Need array of string as the first parameter".to_owned(), + }) + } +} + +#[cfg(feature = "preview-remote")] +pub fn disconnect_remote_preview_command(ctx: &Context) { + let to_preview = ctx.to_preview.clone(); + tracing::debug!("disconnect_remote_preview_command"); + to_preview.with_preview_target::(|remote| { + crate::common::spawn_local(remote.disconnect()); + }); +} + fn populate_command_range( node: &SyntaxNode, format: common::ByteFormat, @@ -766,7 +897,7 @@ pub(crate) async fn load_document_impl( FileAction::ProcessContent(content) => { ctx.to_preview.send(&i_slint_preview_protocol::LspToPreviewMessage::SetContents { url: i_slint_preview_protocol::VersionedUrl::new(url.clone(), version), - contents: content.clone(), + contents: content.clone().into(), }); let dependencies: HashSet = ctx.document_cache.invalidate_url(&url); let _ = ctx.document_cache.load_url(&url, version, content, &mut diag).await; diff --git a/tools/lsp/language/goto.rs b/tools/lsp/language/goto.rs index 6187aad61c7..12dcf161aca 100644 --- a/tools/lsp/language/goto.rs +++ b/tools/lsp/language/goto.rs @@ -199,8 +199,11 @@ fn test_goto_definition_multi_files() { init_param: Default::default(), to_show: None, open_urls: Default::default(), - to_preview: std::rc::Rc::new(common::DummyLspToPreview::default()), + to_preview: std::rc::Rc::new(crate::common::SwitchableLspToPreview::with_one( + common::DummyLspToPreview::default(), + )), pending_recompile: Default::default(), + preview_to_lsp_sender: tokio::sync::mpsc::unbounded_channel().0, }; let (extra_files, diag) = spin_on::spin_on(crate::language::load_document_impl( &mut ctx, diff --git a/tools/lsp/language/test.rs b/tools/lsp/language/test.rs index 7b20d26f6f9..2b9a37dafd2 100644 --- a/tools/lsp/language/test.rs +++ b/tools/lsp/language/test.rs @@ -9,9 +9,11 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; use std::rc::Rc; -use crate::common; -use crate::language::convert_diagnostics; -use crate::language::load_document_impl; +use crate::{ + common, + common::SwitchableLspToPreview, + language::{convert_diagnostics, load_document_impl}, +}; use super::Context; @@ -31,8 +33,9 @@ pub fn mock_context() -> Context { #[cfg(any(feature = "preview-external", feature = "preview-engine"))] to_show: None, open_urls: HashSet::new(), - to_preview: Rc::new(common::DummyLspToPreview::default()), + to_preview: Rc::new(SwitchableLspToPreview::with_one(common::DummyLspToPreview::default())), pending_recompile: Default::default(), + preview_to_lsp_sender: tokio::sync::mpsc::unbounded_channel().0, } } @@ -74,8 +77,11 @@ pub fn loaded_document_cache_with_file_name( init_param: Default::default(), to_show: None, open_urls: Default::default(), - to_preview: std::rc::Rc::new(common::DummyLspToPreview::default()), + to_preview: std::rc::Rc::new(SwitchableLspToPreview::with_one( + common::DummyLspToPreview::default(), + )), pending_recompile: Default::default(), + preview_to_lsp_sender: tokio::sync::mpsc::unbounded_channel().0, }; let (extra_files, diag) = spin_on::spin_on(load_document_impl(&mut ctx, content, url.clone(), Some(42))); diff --git a/tools/lsp/main.rs b/tools/lsp/main.rs index fd3d73fdb67..5e688dd3dfe 100644 --- a/tools/lsp/main.rs +++ b/tools/lsp/main.rs @@ -17,7 +17,7 @@ mod language; mod preview; pub mod util; -use common::{LspToPreview, Result}; +use common::Result; use language::*; use lsp_types::{ @@ -40,7 +40,9 @@ use std::sync::{Arc, atomic}; use std::task::{Poll, Waker}; use std::time::Duration; -use crate::common::document_cache::CompilerConfiguration; +use crate::common::{SwitchableLspToPreview, document_cache::CompilerConfiguration}; +#[cfg(feature = "preview-remote")] +use crate::preview::connector::RemoteLspToPreview; #[cfg(not(any( target_os = "openbsd", @@ -342,22 +344,31 @@ async fn main_loop( ServerNotifier { sender: connection.sender.clone(), queue: request_queue.clone() }; #[cfg(not(feature = "preview-engine"))] - let to_preview: Rc = Rc::new(common::DummyLspToPreview::default()); + let to_preview = + Rc::new(SwitchableLspToPreview::with_one(common::DummyLspToPreview::default())); #[cfg(feature = "preview-engine")] - let to_preview: Rc = { + let to_preview = { + use i_slint_preview_protocol::PreviewTarget; + let sn = server_notifier.clone(); - let child_preview: Box = - Box::new(preview::connector::ChildProcessLspToPreview::new(preview_to_lsp_sender)); + let child_preview: Box = Box::new( + preview::connector::ChildProcessLspToPreview::new(preview_to_lsp_sender.clone()), + ); let embedded_preview: Box = - Box::new(preview::connector::EmbeddedLspToPreview::new(sn)); + Box::new(preview::connector::EmbeddedLspToPreview::new(sn.clone())); + #[cfg(feature = "preview-remote")] + let remote_preview: Box = Box::new( + preview::connector::RemoteLspToPreview::new(sn, preview_to_lsp_sender.clone()), + ); Rc::new( preview::connector::SwitchableLspToPreview::new( std::collections::HashMap::from([ - (common::PreviewTarget::ChildProcess, child_preview), - (common::PreviewTarget::EmbeddedWasm, embedded_preview), + (PreviewTarget::ChildProcess, child_preview), + (PreviewTarget::EmbeddedWasm, embedded_preview), + (PreviewTarget::Remote, remote_preview), ]), - common::PreviewTarget::ChildProcess, + PreviewTarget::ChildProcess, ) .unwrap(), ) @@ -369,6 +380,7 @@ async fn main_loop( cli_args, request_queue, server_notifier, + preview_to_lsp_sender, preview_to_lsp_receiver, to_preview.clone(), ) @@ -385,11 +397,12 @@ async fn run_main_loop( cli_args: Cli, request_queue: OutgoingRequestQueue, server_notifier: ServerNotifier, + preview_to_lsp_sender: mpsc::UnboundedSender, #[cfg_attr(not(feature = "preview-engine"), allow(unused_mut))] mut preview_to_lsp_receiver: mpsc::UnboundedReceiver< i_slint_preview_protocol::PreviewToLspMessage, >, - to_preview: Rc, + to_preview: Rc, ) -> Result<()> { let mut rh = RequestHandler::default(); register_request_handlers(&mut rh); @@ -408,7 +421,7 @@ async fn run_main_loop( // let server_notifier = server_notifier_.clone(); Box::pin(async move { tracing::trace!("Importing file: {}", path); - let contents = std::fs::read_to_string(&path); + let contents = std::fs::read(&path); if let Ok(url) = Url::from_file_path(&path) { if let Ok(contents) = &contents { to_preview.send( @@ -423,7 +436,9 @@ async fn run_main_loop( ); } } - Some(contents.map(|c| (None, c))) + Some(contents.and_then(|c| { + String::from_utf8(c).map(|s| (None, s)).map_err(std::io::Error::other) + })) }) })), format: if init_param @@ -442,6 +457,7 @@ async fn run_main_loop( enable_experimental: false, }; + let (from_lsp_sender, mut from_lsp_receiver) = mpsc::unbounded_channel(); let mut ctx = Context { document_cache: crate::common::DocumentCache::new(compiler_config), preview_config: Default::default(), @@ -452,10 +468,10 @@ async fn run_main_loop( open_urls: Default::default(), to_preview, pending_recompile: Default::default(), + preview_to_lsp_sender, }; let connection = Arc::new(connection); - let (from_lsp_sender, mut from_lsp_receiver) = mpsc::unbounded_channel(); let inner_connection = connection.clone(); let adapter_thread = std::thread::spawn(move || { crossbeam_tokio_adapter(inner_connection, from_lsp_sender, request_queue); @@ -464,6 +480,13 @@ async fn run_main_loop( startup_lsp(&mut ctx).await?; + #[cfg(feature = "preview-remote")] + ctx.to_preview + .with_preview_target_async::(async |preview| { + preview.start_browsing().await; + }) + .await; + loop { let recompile_idle_timeout = if ctx.pending_recompile.is_empty() { Duration::MAX } else { RECOMPILE_IDLE_TIMEOUT }; @@ -660,7 +683,7 @@ async fn send_workspace_edit( Ok(()) } -#[cfg(any(feature = "preview-external", feature = "preview-engine"))] +#[cfg(any(feature = "preview-external", feature = "preview-engine", feature = "preview-remote"))] async fn handle_preview_to_lsp_message( message: i_slint_preview_protocol::PreviewToLspMessage, ctx: &Context, @@ -688,17 +711,17 @@ async fn handle_preview_to_lsp_message( ) .await; } - M::PreviewTypeChanged { is_external } => { - tracing::debug!("Preview type changed: is_external={}", is_external); - if is_external { - ctx.to_preview.set_preview_target(common::PreviewTarget::EmbeddedWasm)?; - } else { - ctx.to_preview.set_preview_target(common::PreviewTarget::ChildProcess)?; - } + M::PreviewTypeChanged { target } => { + tracing::debug!("Preview type changed: {target:?}"); + ctx.to_preview.set_preview_target(target)?; } - M::RequestState { .. } => { + M::RequestState { files } => { tracing::debug!("Preview requested state"); - crate::language::send_state_to_preview(ctx); + if files.is_empty() { + crate::language::send_state_to_preview(ctx); + } else { + crate::language::send_files_to_preview(ctx, &files); + } } M::SendWorkspaceEdit { label, edit } => { let sn = ctx.server_notifier.clone(); diff --git a/tools/lsp/preview.rs b/tools/lsp/preview.rs index baa5fcf2694..8ef31a0ad98 100644 --- a/tools/lsp/preview.rs +++ b/tools/lsp/preview.rs @@ -65,7 +65,7 @@ pub fn run( tracing::debug!("Preview: requesting state from LSP"); to_lsp - .send(&i_slint_preview_protocol::PreviewToLspMessage::RequestState { unused: true }) + .send(&i_slint_preview_protocol::PreviewToLspMessage::RequestState { files: Vec::new() }) .unwrap(); let app_window_clone = PREVIEW_STATE.with(move |preview_state| { diff --git a/tools/lsp/preview/connector.rs b/tools/lsp/preview/connector.rs index e45bb5c6eba..b715614ec64 100644 --- a/tools/lsp/preview/connector.rs +++ b/tools/lsp/preview/connector.rs @@ -11,6 +11,12 @@ pub mod native; #[cfg(all(not(target_arch = "wasm32"), feature = "preview-builtin"))] pub use native::*; +pub use crate::common::SwitchableLspToPreview; +#[cfg(feature = "preview-remote")] +pub mod remote; +#[cfg(feature = "preview-remote")] +pub use remote::*; + use crate::preview; pub fn lsp_to_preview(message: i_slint_preview_protocol::LspToPreviewMessage) { @@ -19,7 +25,9 @@ pub fn lsp_to_preview(message: i_slint_preview_protocol::LspToPreviewMessage) { M::InvalidateContents { url } => preview::invalidate_contents(&url), M::ForgetFile { url } => preview::delete_document(&url), M::SetContents { url, contents } => { - preview::set_contents(&url, contents); + if let Ok(contents) = String::from_utf8(contents) { + preview::set_contents(&url, contents); + } } M::SetConfiguration { config } => { preview::config_changed(config); diff --git a/tools/lsp/preview/connector/native.rs b/tools/lsp/preview/connector/native.rs index 30891ec1f2b..af52f095127 100644 --- a/tools/lsp/preview/connector/native.rs +++ b/tools/lsp/preview/connector/native.rs @@ -3,9 +3,9 @@ use crate::{common, preview}; -use std::collections::HashMap; use std::{cell::RefCell, io::BufRead}; +use i_slint_preview_protocol::PreviewTarget; use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt}, sync::mpsc, @@ -137,31 +137,8 @@ impl common::LspToPreview for ChildProcessLspToPreview { } } - fn preview_target(&self) -> common::PreviewTarget { - common::PreviewTarget::ChildProcess - } - - fn set_preview_target(&self, _: common::PreviewTarget) -> common::Result<()> { - Err("Can not change the preview target".into()) - } - - fn shutdown<'a>(&'a self) -> std::pin::Pin + 'a>> { - Box::pin(async move { - let Some(inner) = self.inner.borrow_mut().take() else { - return; - }; - let message = - serde_json::to_string(&i_slint_preview_protocol::LspToPreviewMessage::Quit) - .unwrap(); - let _ = inner.to_child_sender.send(message); - drop(inner.to_child_sender); - if tokio::time::timeout(std::time::Duration::from_secs(5), inner.communication_handle) - .await - .is_err() - { - tracing::warn!("Timed out waiting for preview child process to exit"); - } - }) + fn preview_target(&self) -> PreviewTarget { + PreviewTarget::ChildProcess } } @@ -182,57 +159,8 @@ impl common::LspToPreview for EmbeddedLspToPreview { .send_notification::(message.clone()); } - fn preview_target(&self) -> common::PreviewTarget { - common::PreviewTarget::EmbeddedWasm - } - - fn set_preview_target(&self, _: common::PreviewTarget) -> common::Result<()> { - Err("Can not change the preview target".into()) - } -} - -pub struct SwitchableLspToPreview { - lsp_to_previews: HashMap>, - current_target: RefCell, -} - -impl SwitchableLspToPreview { - pub fn new( - lsp_to_previews: HashMap>, - current_target: common::PreviewTarget, - ) -> common::Result { - if lsp_to_previews.contains_key(¤t_target) { - Ok(Self { lsp_to_previews, current_target: RefCell::new(current_target) }) - } else { - Err("No such target".into()) - } - } -} - -impl common::LspToPreview for SwitchableLspToPreview { - fn send(&self, message: &i_slint_preview_protocol::LspToPreviewMessage) { - self.lsp_to_previews.get(&self.current_target.borrow()).unwrap().send(message); - } - - fn preview_target(&self) -> common::PreviewTarget { - self.current_target.borrow().clone() - } - - fn set_preview_target(&self, target: common::PreviewTarget) -> common::Result<()> { - if self.lsp_to_previews.contains_key(&target) { - *self.current_target.borrow_mut() = target; - Ok(()) - } else { - Err("Target not found".into()) - } - } - - fn shutdown<'a>(&'a self) -> std::pin::Pin + 'a>> { - Box::pin(async move { - for child in self.lsp_to_previews.values() { - child.shutdown().await; - } - }) + fn preview_target(&self) -> PreviewTarget { + PreviewTarget::EmbeddedWasm } } diff --git a/tools/lsp/preview/connector/remote.rs b/tools/lsp/preview/connector/remote.rs new file mode 100644 index 00000000000..318acdece0d --- /dev/null +++ b/tools/lsp/preview/connector/remote.rs @@ -0,0 +1,350 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +use std::sync::Arc; + +use futures_util::{ + SinkExt as _, + lock::Mutex, + stream::{SplitSink, SplitStream, StreamExt as _}, +}; +use i_slint_preview_protocol::{PreviewTarget, PreviewToLspMessage}; +use tokio::sync::mpsc; +#[cfg(not(target_arch = "wasm32"))] +use tokio::{sync::RwLock, task::JoinHandle}; +use tokio_tungstenite_wasm::{Message, WebSocketStream}; + +use crate::preview::connector::remote::remote_notifications::{ + ConnectionState, RemoteViewerConnectionState, +}; + +mod remote_notifications; + +struct RemoteLspConnection { + sender: SplitSink, + task: tokio::task::JoinHandle<()>, +} + +pub struct RemoteLspToPreview { + #[cfg(not(target_arch = "wasm32"))] + browse_task: RwLock>>, + #[cfg(not(target_arch = "wasm32"))] + mdns: Option, + connection: Arc>>, + server_notifier: crate::ServerNotifier, + preview_to_lsp_sender: mpsc::UnboundedSender, +} + +impl RemoteLspToPreview { + pub fn new( + server_notifier: crate::ServerNotifier, + preview_to_lsp_sender: mpsc::UnboundedSender, + ) -> Self { + #[cfg(not(target_arch = "wasm32"))] + let mdns = mdns_sd::ServiceDaemon::new() + .inspect_err(|err| tracing::error!("Failed creating MDNS service daemon: {err}")) + .ok(); + + Self { + #[cfg(not(target_arch = "wasm32"))] + browse_task: RwLock::new(None), + #[cfg(not(target_arch = "wasm32"))] + mdns, + connection: Arc::default(), + server_notifier, + preview_to_lsp_sender, + } + } + + #[cfg(not(target_arch = "wasm32"))] + pub async fn start_browsing(&self) { + if let Some(mdns) = &self.mdns { + let server_notifier = self.server_notifier.clone(); + *self.browse_task.write().await = Self::browse_task(mdns, server_notifier); + } + } + + #[cfg(not(target_arch = "wasm32"))] + fn browse_task( + mdns: &mdns_sd::ServiceDaemon, + server_notifier: crate::ServerNotifier, + ) -> Option> { + let receiver = mdns + .browse(i_slint_preview_protocol::SERVICE_TYPE) + .inspect_err(|err| tracing::error!("Failed to start mDNS browsing: {err}")) + .ok()?; + + #[allow(clippy::disallowed_methods)] + Some(tokio::task::spawn_local(async move { + while let Ok(event) = receiver.recv_async().await { + match event { + mdns_sd::ServiceEvent::SearchStarted(_) => { + // tracing::debug!("mDNS browsing started"); + } + mdns_sd::ServiceEvent::ServiceFound(_, fullname) => { + tracing::debug!("mDNS service found: {fullname}"); + } + mdns_sd::ServiceEvent::ServiceResolved(resolved_service) => { + use crate::preview::connector::remote::remote_notifications::RemoteViewerDiscoveredMessage; + + tracing::debug!("mDNS service resolved: {resolved_service:?}"); + if let Err(err) = server_notifier + .send_notification::( + RemoteViewerDiscoveredMessage { + host: resolved_service.host, + port: resolved_service.port, + addresses: resolved_service + .addresses + .into_iter() + .map(|addr| match addr { + mdns_sd::ScopedIp::V4(scoped_ip_v4) => { + scoped_ip_v4.addr().to_string() + } + mdns_sd::ScopedIp::V6(scoped_ip_v6) => { + format!("[{}]", scoped_ip_v6.addr()) + } + _ => unimplemented!(), + }) + .collect(), + }, + ) + { + tracing::error!( + "Failed sending remote viewer discovered notification: {err}" + ); + } + } + mdns_sd::ServiceEvent::ServiceRemoved(_, fullname) => { + tracing::debug!("mDNS service removed: {fullname}"); + } + mdns_sd::ServiceEvent::SearchStopped(_) => { + tracing::debug!("mDNS browsing stopped"); + } + _ => { + tracing::warn!("Received unexpected mDNS event: {event:?}"); + } + } + } + })) + } + + pub fn connect>( + &self, + addresses: impl IntoIterator, + port: u16, + ) -> impl Future> + 'static { + tracing::debug!("RemoteLspToPreview::connect"); + let connection = self.connection.clone(); + let addresses = addresses.into_iter().map(Into::into).collect::>(); + let preview_to_lsp_sender = self.preview_to_lsp_sender.clone(); + let server_notifier = self.server_notifier.clone(); + async move { + let addresses = &mut addresses.into_iter(); + let (stream, address, port) = loop { + let Some(address) = addresses.next() else { + return Err("Unable to connect to remote viewer".into()); + }; + tracing::info!( + "Attempting to connect to remote preview server at {address}:{port}" + ); + // The host parameter is not sanitized here, but since it's provided by the user, it should be fine. + let connect_future = + tokio_tungstenite_wasm::connect(format!("ws://{address}:{port}")); + match connect_future.await { + Ok(stream) => { + tracing::info!("Connected to remote preview server at {address}:{port}"); + break (stream, address, port); + } + Err(err) => { + tracing::debug!( + "Failed connecting to remote viewer, trying next address: {err}" + ); + } + } + }; + + let (socket_sender, socket_receiver) = stream.split(); + + #[allow(clippy::disallowed_methods)] + let Some(old) = connection.lock().await.replace(RemoteLspConnection { + sender: socket_sender, + task: tokio::task::spawn_local(Self::receive_task( + socket_receiver, + preview_to_lsp_sender, + server_notifier, + address, + port, + )), + }) else { + return Ok(()); + }; + + tracing::info!("Closing previous connection to remote preview server"); + old.task.abort(); + + Ok(()) + } + } + + async fn receive_task( + mut socket_receiver: SplitStream, + preview_to_lsp_sender: mpsc::UnboundedSender, + server_notifier: crate::ServerNotifier, + address: String, + port: u16, + ) { + let mut connection_state_handle = + ConnectionStateHandle::new(server_notifier, address, port); + // TODO: implement a timer to send a ping every once in a while, and close the connection if we don't receive a pong in time + while let Some(msg) = socket_receiver.next().await { + match msg { + Ok(msg) => { + tracing::debug!("Received WebSocket message: {msg:?}"); + match msg { + Message::Text(utf8_bytes) => { + tracing::warn!( + "Received unexpected text message from remote preview server: {utf8_bytes}" + ); + } + Message::Binary(bytes) => { + match postcard::from_bytes::< + i_slint_preview_protocol::PreviewToLspMessage, + >(&bytes) + { + Ok(msg) => { + preview_to_lsp_sender.send(msg).unwrap_or_else(|err| { + tracing::error!( + "Failed sending message from remote preview server to LSP server: {err}" + ); + }); + } + Err(e) => { + tracing::error!( + "Failed decoding message from remote preview server: {e}" + ); + } + } + } + Message::Close(_) => { + connection_state_handle.error = + Some("Remote server closed the connection".into()); + return; + } + } + } + Err(tokio_tungstenite_wasm::Error::ConnectionClosed) + | Err(tokio_tungstenite_wasm::Error::AlreadyClosed) => { + connection_state_handle.error = + Some("Remote server closed the connection".into()); + return; + } + Err(tokio_tungstenite_wasm::Error::Io(err)) + if err.kind() != std::io::ErrorKind::WouldBlock => + { + tracing::error!("I/O error in WebSocket connection: {err}"); + connection_state_handle.error = Some(format!("I/O error: {err}")); + return; + } + Err(err) => { + tracing::error!("WebSocket error: {err}"); + } + } + } + } + + pub fn disconnect(&self) -> impl Future + 'static { + let connection = self.connection.clone(); + async move { + if let Some(connection) = connection.lock().await.take() { + connection.task.abort(); + } + } + } +} + +struct ConnectionStateHandle { + server_notifier: crate::ServerNotifier, + error: Option, + address: String, + port: u16, +} + +impl ConnectionStateHandle { + fn new(server_notifier: crate::ServerNotifier, address: String, port: u16) -> Self { + let _ = server_notifier.send_notification::( + RemoteViewerConnectionState { + address: address.clone(), + port, + state: ConnectionState::Connected, + error: None, + }, + ); + Self { server_notifier, error: None, address, port } + } +} + +impl Drop for ConnectionStateHandle { + fn drop(&mut self) { + let _ = self.server_notifier.send_notification::( + RemoteViewerConnectionState { + address: self.address.clone(), + port: self.port, + state: ConnectionState::Disconnected, + error: self.error.take(), + }, + ); + } +} + +impl Drop for RemoteLspToPreview { + fn drop(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(mdns) = self.mdns.take() { + let _ = mdns.shutdown().inspect_err(|err| { + tracing::error!("Failed shutting down mDNS service daemon: {err}"); + }); + } + let browse_task = std::mem::take(&mut self.browse_task); + crate::common::spawn_local(async move { + if let Some(join_handle) = browse_task.write().await.take() + && let Err(err) = join_handle.await + { + tracing::error!("Failed joining mDNS thread: {err:?}"); + } + }); + } + if let Some(connection) = self.connection.try_lock().unwrap().take() { + tracing::info!("Closing connection to remote preview server"); + connection.task.abort(); + } + } +} + +impl crate::common::LspToPreview for RemoteLspToPreview { + fn send(&self, message: &i_slint_preview_protocol::LspToPreviewMessage) { + tracing::debug!("Sending websocket message {message:?}"); + let connection = Arc::downgrade(&self.connection); + let message = postcard::to_allocvec(message).unwrap(); + crate::common::spawn_local(async move { + let Some(connection) = connection.upgrade() else { + tracing::warn!("Not connected to remote preview server, dropping message"); + return; + }; + let mut connection = connection.lock().await; + let Some(connection) = connection.as_mut() else { + tracing::warn!("Not connected to remote preview server, dropping message"); + return; + }; + let sender_future = connection.sender.send(Message::binary(message)); + if let Err(err) = sender_future.await { + tracing::error!("Error sending message to remote preview server: {err}"); + } + tracing::debug!("Succeeded sending websocket message!"); + }); + } + + fn preview_target(&self) -> PreviewTarget { + PreviewTarget::Remote + } +} diff --git a/tools/lsp/preview/connector/remote/remote_notifications.rs b/tools/lsp/preview/connector/remote/remote_notifications.rs new file mode 100644 index 00000000000..1502044c570 --- /dev/null +++ b/tools/lsp/preview/connector/remote/remote_notifications.rs @@ -0,0 +1,37 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +use lsp_types::notification::Notification; + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +pub(crate) struct RemoteViewerDiscoveredMessage { + pub host: String, + pub port: u16, + pub addresses: Vec, +} + +impl Notification for RemoteViewerDiscoveredMessage { + type Params = Self; + const METHOD: &'static str = "slint/remote_viewer_discovered"; +} + +#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum ConnectionState { + Disconnected, + Connected, +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +pub(crate) struct RemoteViewerConnectionState { + pub address: String, + pub port: u16, + pub state: ConnectionState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl Notification for RemoteViewerConnectionState { + type Params = Self; + const METHOD: &'static str = "slint/remote_viewer_connection_state"; +} diff --git a/tools/lsp/preview/connector/wasm.rs b/tools/lsp/preview/connector/wasm.rs index 0f712543797..f8c62ae85ea 100644 --- a/tools/lsp/preview/connector/wasm.rs +++ b/tools/lsp/preview/connector/wasm.rs @@ -7,8 +7,6 @@ use crate::common; use crate::preview::{self, connector, ui}; -use slint_interpreter::ComponentHandle; - use std::cell::RefCell; use std::rc::Rc; @@ -258,12 +256,8 @@ impl common::LspToPreview for WasmLspToPreview { .send_notification::(message.clone()); } - fn preview_target(&self) -> common::PreviewTarget { - common::PreviewTarget::EmbeddedWasm - } - - fn set_preview_target(&self, _: common::PreviewTarget) -> common::Result<()> { - Err("Can not change the preview target".into()) + fn preview_target(&self) -> i_slint_preview_protocol::PreviewTarget { + i_slint_preview_protocol::PreviewTarget::EmbeddedWasm } } diff --git a/tools/lsp/preview/ui/palette.rs b/tools/lsp/preview/ui/palette.rs index d67639da6ff..30d925b1cf0 100644 --- a/tools/lsp/preview/ui/palette.rs +++ b/tools/lsp/preview/ui/palette.rs @@ -610,8 +610,11 @@ export component Main { } init_param: Default::default(), to_show: None, open_urls: Default::default(), - to_preview: Rc::new(crate::common::DummyLspToPreview::default()), + to_preview: Rc::new(crate::common::SwitchableLspToPreview::with_one( + crate::common::DummyLspToPreview::default(), + )), pending_recompile: Default::default(), + preview_to_lsp_sender: tokio::sync::mpsc::unbounded_channel().0, }; let (url, _) = crate::language::test::load( &mut ctx, diff --git a/tools/lsp/wasm_main.rs b/tools/lsp/wasm_main.rs index 53b56ed9056..bfac205623c 100644 --- a/tools/lsp/wasm_main.rs +++ b/tools/lsp/wasm_main.rs @@ -11,11 +11,13 @@ mod language; mod preview; pub mod util; -use common::{DocumentCache, LspToPreview, Result}; +use common::SwitchableLspToPreview; +use common::{DocumentCache, Result}; use i_slint_preview_protocol::{LspToPreviewMessage, VersionedUrl}; use js_sys::Function; pub use language::{Context, RequestHandler}; use lsp_types::Url; + use std::cell::RefCell; use std::future::Future; use std::io::ErrorKind; @@ -251,10 +253,12 @@ pub fn create( let mut compiler_config = crate::common::document_cache::CompilerConfiguration::default(); #[cfg(not(feature = "preview-engine"))] - let to_preview: Rc = Rc::new(common::DummyLspToPreview::default()); + let to_preview = + Rc::new(SwitchableLspToPreview::with_one(common::DummyLspToPreview::default())); #[cfg(feature = "preview-engine")] - let to_preview: Rc = - Rc::new(preview::connector::WasmLspToPreview::new(server_notifier.clone())); + let to_preview = Rc::new(SwitchableLspToPreview::with_one( + preview::connector::WasmLspToPreview::new(server_notifier.clone()), + )); let to_preview_clone = to_preview.clone(); compiler_config.open_import_callback = Some(Rc::new(move |path| { @@ -268,7 +272,7 @@ pub fn create( if let Ok(contents) = &contents { to_preview.send(&LspToPreviewMessage::SetContents { url: VersionedUrl::new(url, None), - contents: contents.clone(), + contents: contents.clone().into(), }); } Some(contents.map(|c| (None, c))) @@ -279,6 +283,8 @@ pub fn create( let mut rh = RequestHandler::default(); language::register_request_handlers(&mut rh); + let (preview_to_lsp_sender, _preview_to_lsp_receiver) = tokio::sync::mpsc::unbounded_channel(); + Ok(SlintServer { ctx: ReentryGuard::new(Context { document_cache, @@ -289,6 +295,7 @@ pub fn create( open_urls: Default::default(), to_preview, pending_recompile: Default::default(), + preview_to_lsp_sender, }), rh: Rc::new(rh), }) @@ -320,7 +327,11 @@ fn forward_workspace_edit( #[wasm_bindgen] impl SlintServer { - #[cfg(all(feature = "preview-engine", feature = "preview-external"))] + #[cfg(all( + feature = "preview-engine", + feature = "preview-external", + feature = "preview-remote" + ))] #[wasm_bindgen] pub async fn process_preview_to_lsp_message( &self, @@ -330,7 +341,7 @@ impl SlintServer { let ctx = self.ctx.lock().await; - let Ok(message) = serde_wasm_bindgen::from_value::(value) else { + let Ok(message) = serde_wasm_bindgen::from_value(value) else { return Err(JsValue::from("Failed to convert value to PreviewToLspMessage")); }; @@ -352,8 +363,10 @@ impl SlintServer { .await }); } - M::PreviewTypeChanged { is_external: _ } => { - // Nothing to do! + M::PreviewTypeChanged { target } => { + ctx.to_preview + .set_preview_target(target) + .map_err(|err| js_sys::Error::new(&format!("{err}")))?; } M::RequestState { .. } => { crate::language::send_state_to_preview(&ctx); @@ -459,6 +472,55 @@ impl SlintServer { } } +#[cfg(any(feature = "preview-external", feature = "preview-engine", feature = "preview-remote"))] +fn handle_preview_to_lsp_message( + message: i_slint_preview_protocol::PreviewToLspMessage, + ctx: &Context, +) -> Result<()> { + use i_slint_preview_protocol::PreviewToLspMessage as M; + + match message { + M::Diagnostics { diagnostics, version, uri } => { + crate::common::lsp_to_editor::notify_lsp_diagnostics( + &ctx.server_notifier, + uri, + version, + diagnostics, + ); + } + M::ShowDocument { file, selection, .. } => { + let sn = ctx.server_notifier.clone(); + wasm_bindgen_futures::spawn_local(async move { + crate::common::lsp_to_editor::send_show_document_to_editor( + sn, file, selection, true, + ) + .await + }); + } + M::PreviewTypeChanged { target } => { + ctx.to_preview.set_preview_target(target)?; + } + M::RequestState { .. } => { + crate::language::send_state_to_preview(&ctx); + } + M::SendWorkspaceEdit { label, edit } => { + forward_workspace_edit(ctx.server_notifier.clone(), label, Ok(edit)); + } + M::SendShowMessage { message } => { + let _ = ctx + .server_notifier + .send_notification::(message); + } + M::TelemetryEvent(object) => { + let _ = + ctx.server_notifier.send_notification::( + lsp_types::OneOf::Left(object), + ); + } + } + Ok(()) +} + async fn load_file(path: String, load_file: &Function) -> std::io::Result { let string_promise = load_file .call1(&JsValue::UNDEFINED, &path.into()) diff --git a/tools/remote-viewer/.gitignore b/tools/remote-viewer/.gitignore new file mode 100644 index 00000000000..c4365c32793 --- /dev/null +++ b/tools/remote-viewer/.gitignore @@ -0,0 +1,2 @@ +*.xcodeproj +Info.plist \ No newline at end of file diff --git a/tools/remote-viewer/Cargo.toml b/tools/remote-viewer/Cargo.toml new file mode 100644 index 00000000000..c507e556f47 --- /dev/null +++ b/tools/remote-viewer/Cargo.toml @@ -0,0 +1,67 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +[package] +name = "remote-viewer" +description = "The remote viewer binary for Slint" +authors.workspace = true +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true +categories = ["gui", "development-tools"] +keywords = ["viewer", "gui", "ui", "toolkit"] + +[lib] +crate-type = ["lib", "cdylib"] +path = "src/lib.rs" + +[[bin]] +name = "remote-viewer" +path = "src/main.rs" + +[dependencies] +i-slint-compiler = { workspace = true } +i-slint-core = { workspace = true } +i-slint-backend-selector = { workspace = true } +i-slint-preview-protocol = { workspace = true } +slint-interpreter = { workspace = true, features = ["display-diagnostics", "compat-1-2", "internal", "accessibility", "image-default-formats", "internal-json"] } +slint = { workspace = true, features = ["default", "serde", "backend-android-activity-06"] } +lsp-types = { version = "0.95.0" } +serde = { version = "1.0", features = ["derive"] } +dashmap = { version = "6.1.0" } +tracing.workspace = true +tracing-subscriber = { workspace = true, features = ["env-filter"] } +hostname = { version = "0.4.1" } +tokio-tungstenite = { version = "0.28.0", features = ["rustls"] } +tokio = { version = "1.48.0", features = ["rt", "rt-multi-thread", "net", "sync", "macros"] } +futures-util = { version = "0.3.31", features = ["sink"] } +anyhow = "1.0.100" +postcard = { version = "1.1.3", features = ["alloc"] } +base64 = "0.22.1" + +[target.'cfg(not(any(target_vendor = "apple", target_arch = "wasm32")))'.dependencies] +mdns-sd = { version = "0.17.0", default-features = false, features = ["async"] } + +[target.'cfg(target_vendor = "apple")'.dependencies] +zeroconf-tokio = "0.3.2" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +getifs = "0.4.0" + +[package.metadata.android] +package = "dev.slint.remoteviewer" +min_sdk_version = 24 +target_sdk_version = 33 + +[package.metadata.android.application] +label = "Slint Remote Viewer" + +[[package.metadata.android.uses_permission]] +name = "android.permission.INTERNET" + +[[package.metadata.android.uses_permission]] +name = "android.permission.CHANGE_WIFI_MULTICAST_STATE" diff --git a/tools/remote-viewer/LICENSES/GPL-3.0-only.txt b/tools/remote-viewer/LICENSES/GPL-3.0-only.txt new file mode 120000 index 00000000000..33fa0859510 --- /dev/null +++ b/tools/remote-viewer/LICENSES/GPL-3.0-only.txt @@ -0,0 +1 @@ +../../../LICENSES/GPL-3.0-only.txt \ No newline at end of file diff --git a/tools/remote-viewer/LICENSES/LicenseRef-Slint-Royalty-free-2.0.md b/tools/remote-viewer/LICENSES/LicenseRef-Slint-Royalty-free-2.0.md new file mode 120000 index 00000000000..8a9df1ca583 --- /dev/null +++ b/tools/remote-viewer/LICENSES/LicenseRef-Slint-Royalty-free-2.0.md @@ -0,0 +1 @@ +../../../LICENSES/LicenseRef-Slint-Royalty-free-2.0.md \ No newline at end of file diff --git a/tools/remote-viewer/LICENSES/LicenseRef-Slint-Software-3.0.md b/tools/remote-viewer/LICENSES/LicenseRef-Slint-Software-3.0.md new file mode 120000 index 00000000000..fda05e95c48 --- /dev/null +++ b/tools/remote-viewer/LICENSES/LicenseRef-Slint-Software-3.0.md @@ -0,0 +1 @@ +../../../LICENSES/LicenseRef-Slint-Software-3.0.md \ No newline at end of file diff --git a/tools/remote-viewer/README.md b/tools/remote-viewer/README.md new file mode 100644 index 00000000000..3c141a160a1 --- /dev/null +++ b/tools/remote-viewer/README.md @@ -0,0 +1,66 @@ + + +# Slint Remote Viewer + +> **Experimental.** +> This tool is under active development. +> It'll eventually be merged into the [Slint Viewer](../viewer/). + +The remote viewer runs on a target device (phone, embedded board, desktop, etc.) +and renders Slint components sent from the editor over WebSocket. +This lets you preview your UI on real hardware while you edit `.slint` files in VS Code. + +## How It Works + +1. Start the remote viewer on the target device. +2. It announces itself on the local network via mDNS. +3. The VS Code extension discovers it and connects automatically. +4. As you edit, the LSP server sends `.slint` source files to the viewer over WebSocket, + which compiles and renders them in real time. + +## Building + +```sh +cargo build -p remote-viewer +``` + +## Running + +```sh +cargo run -p remote-viewer +``` + +The viewer picks a random port and announces itself via mDNS by default. +Run `--help` to see available options: + +``` +--port Listen on a specific port. +--listen Listen on a specific address and port. +--disable-mdns Don't announce on the local network. +``` + +## Connecting from VS Code + +The [Slint VS Code extension](../../editors/vscode/) can discover and connect +to remote viewers automatically. + +1. Make sure the remote viewer and VS Code are on the same local network. +2. Start the remote viewer on the target device. + If mDNS is enabled (the default), VS Code discovers it automatically. +3. Click the **Slint Remote Preview** status bar item (bottom right) to open the + connection picker. +4. Select a discovered viewer from the list, + or type an address manually (e.g. `192.168.1.42:3000` or `[::1]:3000`). +5. Open a `.slint` file and trigger a preview — the UI renders on the remote device. + +Click the status bar item again to disconnect. + +If the target isn't on the same network or mDNS isn't available, +start the viewer with `--disable-mdns` and connect manually using the address. + +## Protocol + +Messages between the editor and the remote viewer are serialized with +[postcard](https://crates.io/crates/postcard) over WebSocket. +The protocol is defined in `internal/preview-protocol/src/`. diff --git a/tools/remote-viewer/ios-project.yml b/tools/remote-viewer/ios-project.yml new file mode 100644 index 00000000000..d104cedc45e --- /dev/null +++ b/tools/remote-viewer/ios-project.yml @@ -0,0 +1,32 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 +name: Remote Slint Viewer +options: + bundleIdPrefix: dev.slint +settings: + ENABLE_USER_SCRIPT_SANDBOXING: NO +targets: + slint-remote-viewer: + type: application + platform: iOS + deploymentTarget: "16.6" + info: + path: Info.plist + properties: + UILaunchScreen: + - ImageRespectSafeAreaInsets: false + NSLocalNetworkUsageDescription: "We need to talk to editors on the local network." + NSBonjourServices: + - "_slint-preview._tcp" + UISupportedInterfaceOrientations: + [ + UIInterfaceOrientationPortrait, + UIInterfaceOrientationPortraitUpsideDown, + UIInterfaceOrientationPortraitLandscapeLeft, + UIInterfaceOrientationLandscapeRight, + ] + sources: [] + postCompileScripts: + - script: | + ../../scripts/build_for_ios_with_cargo.bash remote-viewer + outputFileLists: $TARGET_BUILD_DIR/$EXECUTABLE_PATH diff --git a/tools/remote-viewer/src/compilation.rs b/tools/remote-viewer/src/compilation.rs new file mode 100644 index 00000000000..7ae5dd71309 --- /dev/null +++ b/tools/remote-viewer/src/compilation.rs @@ -0,0 +1,68 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +use std::{path::PathBuf, rc::Weak}; + +use i_slint_core::InternalToken; + +use crate::connection::Connection; + +pub fn init_compiler(connection: Weak) -> slint_interpreter::Compiler { + let mut compiler = slint_interpreter::Compiler::new(); + + let file_loader_connection = connection.clone(); + compiler.set_file_loader(move |path: &std::path::Path| { + // make path absolute in our virtual file system + let path = PathBuf::from("/").join(path); + let connection = file_loader_connection.clone(); + Box::pin(async move { + Some(if let Some(connection) = connection.upgrade() { + connection + .request_file(path.as_os_str().to_str().unwrap().to_owned()) + .await + .map(|file_content| String::from_utf8_lossy(&file_content.contents).to_string()) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "Connection is no longer available", + )) + }) + }) + }); + + let mapper_connection = connection.clone(); + compiler.compiler_configuration(InternalToken).resource_url_mapper = + Some(std::rc::Rc::new(move |url: &str| { + let connection = mapper_connection.clone(); + let path = url.to_owned(); + Box::pin(async move { + if path.starts_with("builtin:/") { + return None; + } + let connection = connection.upgrade()?; + let file_content = connection.request_file(path.clone()).await.ok()?; + + let extension = std::path::Path::new(&path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("png"); + + let mime_type = match extension { + "svg" | "svgz" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "bmp" => "image/bmp", + "webp" => "image/webp", + _ => "application/octet-stream", + }; + + use base64::Engine as _; + let encoded = + base64::engine::general_purpose::STANDARD.encode(&*file_content.contents); + Some(format!("data:{mime_type};base64,{encoded}")) + }) + })); + + compiler +} diff --git a/tools/remote-viewer/src/connection.rs b/tools/remote-viewer/src/connection.rs new file mode 100644 index 00000000000..d16543c0459 --- /dev/null +++ b/tools/remote-viewer/src/connection.rs @@ -0,0 +1,383 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +use std::{ + net::{IpAddr, Ipv6Addr, SocketAddr, SocketAddrV6}, + sync::Arc, +}; + +use dashmap::{DashMap, Entry}; +use futures_util::{SinkExt as _, StreamExt as _, stream::SplitStream}; +use i_slint_preview_protocol::{ + LspToPreviewMessage, PreviewComponent, PreviewConfig, SourceFileVersion, +}; +use lsp_types::Url; +use serde::Serialize; +use tokio::{ + net::TcpStream, + sync::{self, mpsc::UnboundedSender, oneshot}, +}; +use tokio_tungstenite::{WebSocketStream, tungstenite::Message}; + +#[cfg(not(target_vendor = "apple"))] +use mdns_sd::ServiceInfo; + +#[derive(Clone, Debug)] +pub struct VersionedFileContent { + #[allow(dead_code)] + pub version: SourceFileVersion, + pub contents: Arc<[u8]>, +} + +#[derive(Debug)] +pub enum CacheEntry { + Loading(Vec>>), + Ready(VersionedFileContent), +} + +#[derive(Debug)] +pub enum ConnectionMessage { + Connected { + remote_addr: SocketAddr, + }, + Disconnected { + remote_addr: SocketAddr, + }, + SetConfiguration { + config: PreviewConfig, + }, + ShowPreview { + preview_component: PreviewComponent, + file_cache: Arc>, + }, + #[allow(dead_code)] + HighlightFromEditor { + url: Option, + offset: u32, + }, +} + +pub struct Connection { + local_addr: SocketAddr, + thread_handle: Option<(std::thread::JoinHandle<()>, sync::oneshot::Sender<()>)>, + message_sender: sync::mpsc::UnboundedSender, + file_cache: Arc>, +} + +impl Connection { + pub async fn listen( + address: Option, + message_handler: impl Fn(ConnectionMessage) + 'static + Send + Sync, + ) -> anyhow::Result { + let file_cache = Arc::new(DashMap::::new()); + let (message_sender, mut message_receiver) = sync::mpsc::unbounded_channel(); + + let inner_file_cache = file_cache.clone(); + let inner_message_sender = message_sender.clone(); + + let (local_addr_sender, local_addr_receiver) = + sync::oneshot::channel::>(); + let (quit_sender, mut quit_receiver) = tokio::sync::oneshot::channel::<()>(); + + let thread_handle = std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async move { + let listener = match tokio::net::TcpListener::bind( + address.unwrap_or(SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, 0, 0, 0))), + ) + .await { + Ok(listener) => listener, + Err(err) => { + tracing::error!("Failed to bind to address: {err}"); + local_addr_sender.send(Err(err)).ok(); + return; + } + }; + local_addr_sender.send(listener.local_addr()).ok(); + + let message_handler = Arc::new(message_handler); + let mut current_sink = None; + loop { + tokio::select! { + accept = listener.accept() => { + match accept { + Err(err) => { + tracing::error!("Failed listening for Websocket connections: {err}"); + return; + } + Ok((stream, addr)) => { + tracing::info!("Connected to {addr:?}"); + match tokio_tungstenite::accept_async(stream).await { + Err(err) => { + tracing::error!("Failed to establish websocket connection: {err}") + } + Ok(stream) => { + tracing::info!("Websocket established with {addr:?}"); + let (sink, receiver) = stream.split(); + tokio::spawn(Self::handle_connection( + receiver, + message_handler.clone(), + inner_file_cache.clone(), + inner_message_sender.clone(), + addr, + )); + if let Some(_old_sink) = current_sink.replace(sink) { + tracing::error!( + "Second connection while we were already connected, dropping old connection" + ); + } + } + } + } + } + } + _ = &mut quit_receiver => { + tracing::info!("Quit signal received, shutting down connection thread."); + break; + } + message = message_receiver.recv() => { + if let (Some(message), Some(current_sink)) = (message, &mut current_sink) + && let Err(err) = current_sink.send(message).await { + tracing::error!("Failed sending message to Websocket: {err}"); + } + } + } + } + }); + }); + + let local_addr = local_addr_receiver.await??; + tracing::info!("Listening on {}", local_addr); + + Ok(Self { + local_addr, + thread_handle: Some((thread_handle, quit_sender)), + message_sender, + file_cache, + }) + } + + async fn handle_connection( + mut receiver: SplitStream>, + message_handler: Arc, + file_cache: Arc>, + message_sender: UnboundedSender, + remote_addr: SocketAddr, + ) { + message_handler(ConnectionMessage::Connected { remote_addr }); + while let Some(msg) = receiver.next().await { + match msg { + Ok(Message::Text(text)) => { + // Handle incoming text messages + tracing::warn!("Received text message: {text}"); + } + Ok(Message::Binary(bin)) => { + // Handle incoming binary messages + match postcard::from_bytes::(&bin) { + Ok(message) => { + tracing::debug!("Received message {message:?}"); + // Process the data + match message { + LspToPreviewMessage::InvalidateContents { url } => { + file_cache.remove( + url.to_file_path().unwrap().as_os_str().to_str().unwrap(), + ); + } + LspToPreviewMessage::ForgetFile { url } => { + if let Some((_, CacheEntry::Loading(senders))) = file_cache + .remove( + url.to_file_path() + .unwrap() + .as_os_str() + .to_str() + .unwrap(), + ) + { + for sender in senders { + let _ = sender.send(Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "File not found", + ))); + } + } + } + LspToPreviewMessage::SetContents { url, contents } => { + tracing::debug!( + "Inserting file {} with {} bytes.", + url.url(), + contents.len() + ); + let versioned_content = VersionedFileContent { + version: *url.version(), + contents: contents.into(), + }; + file_cache + .entry( + url.url() + .to_file_path() + .unwrap() + .to_str() + .unwrap() + .to_owned(), + ) + .and_modify(|entry| { + if let CacheEntry::Loading(senders) = entry { + for sender in senders.drain(..) { + let _ = + sender.send(Ok(versioned_content.clone())); + } + } + }) + .insert(CacheEntry::Ready(versioned_content)); + } + LspToPreviewMessage::SetConfiguration { config } => { + message_handler(ConnectionMessage::SetConfiguration { config }); + } + LspToPreviewMessage::ShowPreview(preview_component) => { + message_handler(ConnectionMessage::ShowPreview { + preview_component, + file_cache: file_cache.clone(), + }); + } + LspToPreviewMessage::HighlightFromEditor { url, offset } => { + message_handler(ConnectionMessage::HighlightFromEditor { + url, + offset, + }); + } + LspToPreviewMessage::Quit => { + break; + } + } + } + Err(err) => { + tracing::error!("Failed to deserialize message: {err}"); + } + } + } + Ok(Message::Ping(data)) => { + message_sender.send(Message::Pong(data)).ok(); + } + Ok(Message::Pong(_)) => {} + Ok(Message::Close(_)) => { + // Handle connection close + break; + } + Ok(Message::Frame(_)) => unreachable!(), + Err(err) => { + tracing::error!("WebSocket error: {err}"); + break; + } + } + } + message_handler(ConnectionMessage::Disconnected { remote_addr }); + } + + pub fn send(&self, data: impl Serialize) -> anyhow::Result<()> { + let data: Vec = postcard::to_allocvec(&data)?; + self.message_sender.send(Message::Binary(data.into()))?; + + Ok(()) + } + + pub async fn request_file(&self, file: String) -> std::io::Result { + if let Some(entry) = self.file_cache.get(&file) + && let CacheEntry::Ready(entry) = entry.value() + { + return Ok(entry.clone()); + } + let (sender, receiver) = oneshot::channel(); + let request_file; // do not hold the lock on requested_files across await + match self.file_cache.entry(file.clone()) { + Entry::Occupied(mut occupied) => match occupied.get_mut() { + CacheEntry::Ready(entry) => { + return Ok(entry.clone()); + } + CacheEntry::Loading(senders) => { + senders.push(sender); + request_file = false; + } + }, + Entry::Vacant(vacant) => { + vacant.insert(CacheEntry::Loading(vec![sender])); + request_file = true; + } + } + if request_file { + self.send(i_slint_preview_protocol::PreviewToLspMessage::RequestState { + files: vec![Url::from_file_path(&file).map_err(|()| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Invalid file path: {file}"), + ) + })?], + }) + .map_err(std::io::Error::other)?; + } + receiver.await.map_err(std::io::Error::other)? + } + + pub fn local_ips(&self) -> Vec { + let unspecified = match self.local_addr { + SocketAddr::V4(socket_addr_v4) => socket_addr_v4.ip().is_unspecified(), + SocketAddr::V6(socket_addr_v6) => socket_addr_v6.ip().is_unspecified(), + }; + if unspecified { + let mut ips: Vec = + getifs::interface_addrs_by_filter(|addr| !addr.is_loopback()) + .unwrap_or_default() + .into_iter() + .map(|net| net.addr()) + .collect(); + if ips.is_empty() { + // Fallback: open a UDP socket to a public address (nothing is + // sent) and read back the local IP the OS picked. + if let Ok(sock) = std::net::UdpSocket::bind("0.0.0.0:0") + && sock.connect("8.8.8.8:80").is_ok() + && let Ok(addr) = sock.local_addr() + { + ips.push(addr.ip()); + } + } + ips + } else { + vec![self.local_addr.ip()] + } + } + pub fn local_port(&self) -> u16 { + self.local_addr.port() + } + + #[cfg(not(target_vendor = "apple"))] + pub fn service(&self) -> anyhow::Result { + let local_ips = self.local_ips(); + let local_port = self.local_port(); + let host = hostname::get()?; + let host = host.to_str().unwrap_or("unknown"); + // "localhost" is useless for mDNS — derive a name from the first IP instead. + let mdns_host = if host == "localhost" || host.is_empty() { + let ip = local_ips.first().map(|ip| ip.to_string()).unwrap_or("unknown".into()); + format!("slint-viewer-{ip}.local.") + } else { + format!("{host}.local.") + }; + tracing::info!("Announcing service on {local_ips:?} as {mdns_host}"); + ServiceInfo::new( + i_slint_preview_protocol::SERVICE_TYPE, + "viewer", + &mdns_host, + local_ips.as_slice(), + local_port, + None, + ) + .map_err(Into::into) + } +} + +impl Drop for Connection { + fn drop(&mut self) { + if let Some((thread_handle, quit_sender)) = self.thread_handle.take() { + quit_sender.send(()).ok(); + thread_handle.join().ok(); + } + } +} diff --git a/tools/remote-viewer/src/lib.rs b/tools/remote-viewer/src/lib.rs new file mode 100644 index 00000000000..2a825aae8e4 --- /dev/null +++ b/tools/remote-viewer/src/lib.rs @@ -0,0 +1,19 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +mod compilation; +mod connection; +mod resources; +mod ui; +mod util; + +pub use ui::run; + +#[cfg(target_os = "android")] +#[unsafe(no_mangle)] +fn android_main(app: slint::android::AndroidApp) { + slint::android::init(app).unwrap(); + + let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); + rt.block_on(ui::run(None, true)).unwrap(); +} diff --git a/tools/remote-viewer/src/main.rs b/tools/remote-viewer/src/main.rs new file mode 100644 index 00000000000..97cac4d393e --- /dev/null +++ b/tools/remote-viewer/src/main.rs @@ -0,0 +1,17 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _}; + +mod compilation; +mod connection; +mod resources; +mod ui; +mod util; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::registry().with(fmt::layer()).with(EnvFilter::from_default_env()).init(); + + ui::run(None, true).await +} diff --git a/tools/remote-viewer/src/resources.rs b/tools/remote-viewer/src/resources.rs new file mode 100644 index 00000000000..8ae5f059e32 --- /dev/null +++ b/tools/remote-viewer/src/resources.rs @@ -0,0 +1,30 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +use std::collections::HashSet; + +use i_slint_compiler::typeloader::TypeLoader; +use lsp_types::Url; + +#[allow(dead_code)] +fn extract_resources(dependencies: &HashSet, type_loader: &TypeLoader) -> HashSet { + let mut result: HashSet = Default::default(); + + for dependency in dependencies { + let Ok(path) = dependency.to_file_path() else { + continue; + }; + let Some(doc) = type_loader.get_document(&path) else { + continue; + }; + + result.extend( + doc.embedded_file_resources + .borrow() + .iter() + .filter_map(|er| Url::from_file_path(er.path.as_deref()?).ok()), + ); + } + + result +} diff --git a/tools/remote-viewer/src/ui.rs b/tools/remote-viewer/src/ui.rs new file mode 100644 index 00000000000..db127ee8859 --- /dev/null +++ b/tools/remote-viewer/src/ui.rs @@ -0,0 +1,264 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +use std::{net::SocketAddr, rc::Rc}; + +use i_slint_compiler::diagnostics::BuildDiagnostics; +use i_slint_core::InternalToken; +use i_slint_preview_protocol::PreviewToLspMessage; +use slint::{ComponentHandle as _, SharedString}; +use tokio::sync; + +use crate::{ + compilation, + connection::{self, CacheEntry}, + util, +}; + +const MAIN_SLINT: &str = include_str!("../ui/main.slint"); + +pub async fn run(address: Option, enable_mdns: bool) -> anyhow::Result<()> { + let (message_sender, mut message_receiver) = sync::mpsc::unbounded_channel(); + + let connection = Rc::new( + connection::Connection::listen(address, move |msg| { + let _ = message_sender.send(msg); + }) + .await?, + ); + + let mut compiler = compilation::init_compiler(Rc::downgrade(&connection)); + let base_path = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_owned())) + .unwrap_or_else(std::env::temp_dir); + let compilation_result = compiler.build_from_source(MAIN_SLINT.to_owned(), base_path).await; + if compilation_result.has_errors() { + let mut build_diagnostics = BuildDiagnostics::default(); + for d in compilation_result.diagnostics() { + build_diagnostics.push_compiler_error(d); + } + let diagnostics = build_diagnostics.diagnostics_as_string(); + tracing::error!("Failed compiling main.slint: {diagnostics}"); + anyhow::bail!("Failed compiling main.slint: {diagnostics}"); + } + let main_ui = compilation_result.component("EmptyWindow").unwrap(); + let window = main_ui.create().unwrap(); + + let mut inner_window = window.clone_strong(); + #[cfg(not(target_vendor = "apple"))] + let mdns = enable_mdns.then(mdns_sd::ServiceDaemon::new).transpose()?; + + #[cfg(not(target_vendor = "apple"))] + { + let service = connection.service()?; + mdns.as_ref().map(|mdns| mdns.register(service)).transpose()?; + } + #[cfg(target_vendor = "apple")] + let mut mdns = enable_mdns + .then(|| { + use zeroconf_tokio::prelude::TMdnsService as _; + + let mut service = zeroconf_tokio::MdnsService::new( + zeroconf_tokio::ServiceType::new( + i_slint_preview_protocol::SERVICE_TYPE_NAME, + i_slint_preview_protocol::SERVICE_TYPE_PROTOCOL, + )?, + connection.local_port(), + ); + service.set_name("viewer"); + zeroconf_tokio::MdnsServiceAsync::new(service) + }) + .transpose() + .inspect_err(|err| tracing::error!("Failed to initialize mDNS: {err}")) + .ok() + .flatten(); + + let local_port = connection.local_port(); + let local_ip_str: Vec = connection + .local_ips() + .into_iter() + .map(|ip| match ip { + std::net::IpAddr::V4(ipv4_addr) => format!("{ipv4_addr}:{local_port}"), + std::net::IpAddr::V6(ipv6_addr) => { + format!("[{ipv6_addr}]:{local_port}") + } + }) + .collect(); + + #[cfg(target_vendor = "apple")] + if let Some(mdns) = &mut mdns + && let Err(err) = mdns.start().await + { + tracing::error!("Failed to announce service: {err}"); + } + + let inner_local_ip_str = local_ip_str.clone(); + slint::spawn_local(async move { + let mut last_connection = None; + let mut instance = inner_window.clone_strong(); + while let Some(msg) = message_receiver.recv().await { + match msg { + connection::ConnectionMessage::SetConfiguration { config } => { + compiler.set_style(config.style); + compiler.compiler_configuration(InternalToken).enable_experimental = + config.enable_experimental; + } + connection::ConnectionMessage::ShowPreview { preview_component, file_cache } => { + tracing::debug!( + "Cached files: {:#?}", + file_cache.iter().map(|entry| entry.key().to_string()).collect::>() + ); + // TODO: pass resources from the file cache to the compiler via + // a resource preloader in CompilerConfiguration + let compilation_result = if let Some(entry) = file_cache.get( + preview_component.url.to_file_path().unwrap().as_os_str().to_str().unwrap(), + ) && let CacheEntry::Ready(file) = &*entry + { + tracing::debug!("Fetched file {} from cache.", preview_component.url); + compiler + .build_from_source( + str::from_utf8(&file.contents).unwrap().to_owned(), + preview_component.url.path().into(), + ) + .await + } else { + tracing::debug!( + "Failed fetching file {} from cache.", + preview_component.url + ); + compiler.build_from_path(preview_component.url.path()).await + }; + if compilation_result.has_errors() { + let mut build_diagnostics = BuildDiagnostics::default(); + for d in compilation_result.diagnostics() { + tracing::warn!("Compiler error: {d}"); + build_diagnostics.push_compiler_error(d); + } + + if let Err(err) = inner_window.set_property( + "message", + SharedString::from(build_diagnostics.to_string_vec().join("\n")).into(), + ) { + tracing::error!("Failed setting property: {err}"); + } + + let message = PreviewToLspMessage::Diagnostics { + uri: preview_component.url, + version: None, + diagnostics: compilation_result + .diagnostics() + .map(|diagnostic| { + util::to_lsp_diag( + &diagnostic, + i_slint_compiler::diagnostics::ByteFormat::Utf8, + ) + }) + .collect(), + }; + + connection.send(message).ok(); + + continue; + } + if let Err(err) = + inner_window.set_property("message", SharedString::new().into()) + { + tracing::error!("Failed setting property: {err}"); + } + + let Some(component) = preview_component + .component + .as_deref() + .or_else(|| compilation_result.component_names().next()) + .and_then(|name| compilation_result.component(name)) + else { + if let Err(err) = inner_window.set_property( + "message", + SharedString::from("Component not found").into(), + ) { + tracing::error!("Failed setting property: {err}"); + } + tracing::error!("Component not found"); + continue; + }; + + let Ok(new_instance) = component + .create_with_existing_window(instance.window()) + .inspect_err(|err| { + if let Err(err) = inner_window.set_property( + "message", + SharedString::from(format!("{err}")).into(), + ) { + tracing::error!("Failed setting property: {err}"); + } + tracing::warn!("Platform error: {err}"); + }) + else { + return; + }; + + if let Err(err) = new_instance.show() { + if let Err(err) = inner_window + .set_property("message", SharedString::from(format!("{err}")).into()) + { + tracing::error!("Failed setting property: {err}"); + } + tracing::warn!("Platform error: {err}"); + } else { + instance = new_instance; + } + } + connection::ConnectionMessage::HighlightFromEditor { .. } => {} + connection::ConnectionMessage::Connected { remote_addr } => { + if let Err(err) = inner_window.set_property( + "message", + SharedString::from(format!("Connected to {remote_addr}")).into(), + ) { + tracing::error!("Failed setting property: {err}"); + } + last_connection = Some(remote_addr); + } + connection::ConnectionMessage::Disconnected { remote_addr } => { + if last_connection == Some(remote_addr) { + last_connection = None; + inner_window = main_ui + .create_with_existing_window(instance.window()) + .unwrap_or_else(|_| main_ui.create().unwrap()); + if let Err(err) = inner_window.set_property( + "address", + SharedString::from(inner_local_ip_str.join("\n")).into(), + ) { + tracing::error!("Failed setting property: {err}"); + } + inner_window.show().unwrap(); + instance = inner_window.clone_strong(); + } + } + } + } + })?; + + if let Err(err) = + window.set_property("address", SharedString::from(local_ip_str.join("\n")).into()) + { + tracing::error!("Failed setting property: {err}"); + } + + println!("{}", local_ip_str.join("\n")); + + window.show().inspect_err(|err| tracing::error!("window show: {err}"))?; + + slint::run_event_loop().inspect_err(|err| tracing::error!("slint event loop: {err}"))?; + + #[cfg(not(target_vendor = "apple"))] + mdns.map(|mdns| mdns.shutdown()) + .transpose() + .inspect_err(|err| tracing::error!("mdns shutdown: {err}"))?; + + #[cfg(target_vendor = "apple")] + if let Some(mut mdns) = mdns.take() { + mdns.shutdown().await?; + } + Ok(()) +} diff --git a/tools/remote-viewer/src/util.rs b/tools/remote-viewer/src/util.rs new file mode 100644 index 00000000000..a44ff785660 --- /dev/null +++ b/tools/remote-viewer/src/util.rs @@ -0,0 +1,46 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +use i_slint_compiler::diagnostics::ByteFormat; +use slint_interpreter::{Diagnostic, DiagnosticLevel}; + +pub fn to_lsp_diag(d: &Diagnostic, format: ByteFormat) -> lsp_types::Diagnostic { + use i_slint_compiler::diagnostics; + let start_line_column = diagnostics::diagnostic_line_column_with_format(d, format); + let end_line_column = diagnostics::diagnostic_end_line_column_with_format(d, format); + lsp_types::Diagnostic::new( + to_range(start_line_column, end_line_column), + Some(to_lsp_diag_level(d.level())), + None, + None, + d.message().to_owned(), + None, + None, + ) +} + +/// Convert line-column pairs to an LSP range. +/// +/// The start and end are tuples of 1-indexed line-column values. +/// The end must be exclusive. +fn to_range(start: (usize, usize), end: (usize, usize)) -> lsp_types::Range { + let start = lsp_types::Position::new( + (start.0 as u32).saturating_sub(1), + (start.1 as u32).saturating_sub(1), + ); + let end = lsp_types::Position::new( + (end.0 as u32).saturating_sub(1), + (end.1 as u32).saturating_sub(1), + ); + lsp_types::Range::new(start, end) +} + +fn to_lsp_diag_level(level: DiagnosticLevel) -> lsp_types::DiagnosticSeverity { + use lsp_types::DiagnosticSeverity; + match level { + DiagnosticLevel::Error => DiagnosticSeverity::ERROR, + DiagnosticLevel::Warning => DiagnosticSeverity::WARNING, + DiagnosticLevel::Note => DiagnosticSeverity::HINT, + _ => DiagnosticSeverity::INFORMATION, + } +} diff --git a/tools/remote-viewer/ui/main.slint b/tools/remote-viewer/ui/main.slint new file mode 100644 index 00000000000..cfc37a0cfbf --- /dev/null +++ b/tools/remote-viewer/ui/main.slint @@ -0,0 +1,21 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +export component EmptyWindow inherits Window { + in property address; + in property message; + + HorizontalLayout { + alignment: center; + VerticalLayout { + alignment: center; + + if message == "": Text { + text: address; + } + if message != "": Text { + text: message; + } + } + } +}