diff --git a/Cargo.lock b/Cargo.lock index 19c65089..90c225f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -17,6 +17,16 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -54,9 +64,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.93" +version = "1.0.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" +checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" [[package]] name = "arbitrary" @@ -67,6 +77,35 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "async-tungstenite" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cca750b12e02c389c1694d35c16539f88b8bbaa5945934fdc1b41a776688589" +dependencies = [ + "futures-io", + "futures-util", + "log", + "pin-project-lite", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.25.0", + "tungstenite", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", + "tokio", +] + [[package]] name = "atlatl" version = "0.1.2" @@ -99,12 +138,32 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bidiff" +version = "1.1.0" +source = "git+https://github.com/divvun/bidiff.git?rev=b05865403c196225c8fcffcc89b540472de9ca4c#b05865403c196225c8fcffcc89b540472de9ca4c" +dependencies = [ + "byteorder", + "divsufsort", + "integer-encoding", + "log", + "rayon", + "sacabase", + "sacapart", +] + [[package]] name = "bincode" version = "1.3.3" @@ -114,6 +173,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bipatch" +version = "1.1.0" +source = "git+https://github.com/divvun/bidiff.git?rev=b05865403c196225c8fcffcc89b540472de9ca4c#b05865403c196225c8fcffcc89b540472de9ca4c" +dependencies = [ + "byteorder", + "integer-encoding", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -151,17 +219,29 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +[[package]] +name = "bytemuck" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "bzip2" @@ -186,9 +266,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" +checksum = "27f657647bcff5394bf56c7317665bbf790a137a50eaaa5c6bfbb9e27a518f2d" dependencies = [ "jobserver", "libc", @@ -207,11 +287,35 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" -version = "0.4.38" +version = "0.4.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" dependencies = [ "android-tzdata", "iana-time-zone", @@ -220,6 +324,33 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -228,6 +359,17 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", ] [[package]] @@ -236,12 +378,32 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "coset" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8cc80f631f8307b887faca24dcc3abc427cd0367f6eb6188f6e8f5b7ad8fb" +dependencies = [ + "ciborium", + "ciborium-io", +] + [[package]] name = "cpufeatures" version = "0.2.16" @@ -251,6 +413,22 @@ dependencies = [ "libc", ] +[[package]] +name = "crates-remote-display-video" +version = "0.1.0" +dependencies = [ + "bidiff", + "ciborium", + "console_error_panic_hook", + "image", + "lazy_static", + "serde", + "wasm-bindgen", + "wasm-bindgen-test", + "web-sys", + "zstd", +] + [[package]] name = "crc" version = "3.2.1" @@ -275,12 +453,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + [[package]] name = "crypto-common" version = "0.1.6" @@ -288,9 +491,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core", "typenum", ] +[[package]] +name = "data-encoding" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" + [[package]] name = "deflate64" version = "0.1.9" @@ -339,12 +549,27 @@ dependencies = [ "syn", ] +[[package]] +name = "divsufsort" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e57e36c45fe0b4ef9bc690e53cce2f46dc193f7be3bc93bb7d23ca6dc1be820" +dependencies = [ + "sacabase", +] + [[package]] name = "downcast-rs" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + [[package]] name = "emulator" version = "0.9.44" @@ -367,9 +592,9 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "fdeflate" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07c6f4c64c1d33a3111c4466f7365ebdcc37c5bd1ea0d62aae2e3d722aacbedb" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" dependencies = [ "simd-adler32", ] @@ -393,6 +618,17 @@ dependencies = [ "miniz_oxide", ] +[[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" @@ -408,6 +644,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -424,12 +675,34 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -448,8 +721,10 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -518,6 +793,16 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "half" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" +dependencies = [ + "cfg-if", + "crunchy", +] + [[package]] name = "hashbrown" version = "0.15.2" @@ -525,10 +810,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" [[package]] -name = "hermit-abi" -version = "0.3.9" +name = "hex" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hmac" @@ -541,9 +826,9 @@ dependencies = [ [[package]] name = "http" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" dependencies = [ "bytes", "fnv", @@ -608,10 +893,10 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls", + "rustls 0.23.19", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.1", "tower-service", "webpki-roots", ] @@ -797,6 +1082,21 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "num-traits", + "png", + "qoi", + "zune-core", + "zune-jpeg", +] + [[package]] name = "importer" version = "0.9.44" @@ -807,9 +1107,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", "hashbrown", @@ -825,6 +1125,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "integer-encoding" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" + [[package]] name = "ipnet" version = "2.10.1" @@ -833,9 +1139,9 @@ checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" [[package]] name = "itoa" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "540654e97a3f4470a492cd30ff187bc95d89557a903a2bbf112e2fae98104ef2" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] name = "jobserver" @@ -848,10 +1154,11 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.72" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ + "once_cell", "wasm-bindgen", ] @@ -891,9 +1198,9 @@ checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" [[package]] name = "libc" -version = "0.2.164" +version = "0.2.168" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" +checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" [[package]] name = "litemap" @@ -901,6 +1208,16 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "lockfree-object-pool" version = "0.1.6" @@ -935,6 +1252,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minicov" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27fe9f1cc3c22e1687f9446c2083c4c5fc7f0bcf1c7a86bdbded14985895b4b" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "miniz_oxide" version = "0.8.0" @@ -947,11 +1274,10 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ - "hermit-abi", "libc", "wasi", "windows-sys 0.52.0", @@ -999,6 +1325,18 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + [[package]] name = "paragraph-breaker" version = "0.4.4" @@ -1021,6 +1359,16 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + [[package]] name = "pin-project-lite" version = "0.2.15" @@ -1051,14 +1399,21 @@ name = "plato-core" version = "0.9.44" dependencies = [ "anyhow", + "base64 0.21.7", + "bipatch", "bitflags 2.6.0", "byteorder", + "bytes", + "chacha20poly1305", "chrono", + "coset", "downcast-rs", "entities", "flate2", "fxhash", "globset", + "hex", + "image", "indexmap", "kl-hyphenate", "lazy_static", @@ -1068,26 +1423,32 @@ dependencies = [ "paragraph-breaker", "percent-encoding", "png", + "rand", "rand_core", "rand_xoshiro", "regex", + "rumqttc", "septem", "serde", "serde_json", - "thiserror", + "thiserror 2.0.6", "titlecase", + "tokio", "toml", "unicode-normalization", + "url", "walkdir", + "webpki-roots", "xi-unicode", "zip", + "zstd", ] [[package]] name = "png" -version = "0.17.14" +version = "0.17.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f9d46a34a05a6a57566bc2bfae066ef07585a6e3fa30fbbdff5936380623f0" +checksum = "b67582bd5b65bdff614270e2ea89a1cf15bef71245cc1e5f7ea126977144211d" dependencies = [ "bitflags 1.3.2", "crc32fast", @@ -1096,6 +1457,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1120,6 +1492,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quinn" version = "0.11.6" @@ -1131,9 +1512,9 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", + "rustls 0.23.19", "socket2", - "thiserror", + "thiserror 2.0.6", "tokio", "tracing", ] @@ -1149,10 +1530,10 @@ dependencies = [ "rand", "ring", "rustc-hash", - "rustls", + "rustls 0.23.19", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.6", "tinyvec", "tracing", "web-time", @@ -1160,9 +1541,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5a626c6807713b15cac82a6acaccd6043c9a5408c24baae07611fec3f243da" +checksum = "52cd4b1eff68bf27940dd39811292c49e007f4d0b4c357358dc9b0197be6b527" dependencies = [ "cfg_aliases", "libc", @@ -1220,6 +1601,26 @@ dependencies = [ "rand_core", ] +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "regex" version = "1.11.1" @@ -1255,7 +1656,7 @@ version = "0.12.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -1274,7 +1675,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.19", "rustls-pemfile", "rustls-pki-types", "serde", @@ -1282,7 +1683,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.1", "tower-service", "url", "wasm-bindgen", @@ -1307,6 +1708,28 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rumqttc" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1568e15fab2d546f940ed3a21f48bbbd1c494c90c99c4481339364a497f94a9" +dependencies = [ + "async-tungstenite", + "bytes", + "flume", + "futures-util", + "http", + "log", + "rustls-native-certs", + "rustls-pemfile", + "rustls-webpki", + "thiserror 1.0.69", + "tokio", + "tokio-rustls 0.25.0", + "url", + "ws_stream_tungstenite", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -1315,15 +1738,38 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustc-hash" -version = "2.0.0" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" + +[[package]] +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] [[package]] name = "rustls" -version = "0.23.18" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9cc1d47e243d655ace55ed38201c19ae02c148ae56412ab8750e8f0166ab7f" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "934b404430bb06b3fae2cba809eb45a1ab1aecd64491213d7c3301b88393f8d1" dependencies = [ "once_cell", "ring", @@ -1333,6 +1779,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -1362,12 +1821,38 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + [[package]] name = "ryu" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "sacabase" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9883fc3d6ce3d78bb54d908602f8bc1f7b5f983afe601dabe083009d86267a84" +dependencies = [ + "num-traits", +] + +[[package]] +name = "sacapart" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35d1b03f7f300330cd30da84fea80f64c1d20558c39dc04efbcba7ce52023971" +dependencies = [ + "num-traits", + "rayon", + "sacabase", +] + [[package]] name = "same-file" version = "1.0.6" @@ -1377,6 +1862,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "sdl2" version = "0.37.0" @@ -1400,6 +1900,35 @@ dependencies = [ "version-compare", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.6.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa39c7303dc58b5543c94d22c1766b0d31f2ee58306363ea622b10bbc075eaa2" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + [[package]] name = "septem" version = "1.1.0" @@ -1408,18 +1937,18 @@ checksum = "c3bdcf1faee4966686e4545ccb1441ccf81f27a97fe16ad280a405a2f371aebc" [[package]] name = "serde" -version = "1.0.215" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.215" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", @@ -1518,9 +2047,9 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "socket2" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" dependencies = [ "libc", "windows-sys 0.52.0", @@ -1531,6 +2060,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "stable_deref_trait" @@ -1546,9 +2078,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.89" +version = "2.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" dependencies = [ "proc-macro2", "quote", @@ -1577,18 +2109,38 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.3" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c006c85c7651b3cf2ada4584faa36773bd07bac24acfb39f3c431b36d7e667aa" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec2a1820ebd077e2b90c4df007bebf344cd394098a13c563957d0afc83ea47" +dependencies = [ + "thiserror-impl 2.0.6", ] [[package]] name = "thiserror-impl" -version = "2.0.3" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f077553d607adc1caf65430528a576c757a71ed73944b66ebb58ef2bbd243568" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65750cab40f4ff1929fb1ba509e9914eb756131cef4210da8d5d700d26f6312" dependencies = [ "proc-macro2", "quote", @@ -1597,9 +2149,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.36" +version = "0.3.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" dependencies = [ "deranged", "num-conv", @@ -1650,9 +2202,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.41.1" +version = "1.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" +checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" dependencies = [ "backtrace", "bytes", @@ -1660,20 +2212,42 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.52.0", ] +[[package]] +name = "tokio-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-rustls" -version = "0.26.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" dependencies = [ - "rustls", + "rustls 0.22.4", "rustls-pki-types", "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" +dependencies = [ + "rustls 0.23.19", + "tokio", +] + [[package]] name = "toml" version = "0.8.19" @@ -1716,19 +2290,31 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", ] @@ -1739,6 +2325,27 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls 0.22.4", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + [[package]] name = "typenum" version = "1.17.0" @@ -1766,6 +2373,16 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -1783,6 +2400,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf16_iter" version = "1.0.5" @@ -1834,24 +2457,24 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.95" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", + "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.95" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", - "once_cell", "proc-macro2", "quote", "syn", @@ -1860,21 +2483,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.45" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" dependencies = [ "cfg-if", "js-sys", + "once_cell", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.95" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1882,9 +2506,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.95" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", @@ -1895,15 +2519,42 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.95" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66c8d5e33ca3b6d9fa3b4676d774c5778031d27a578c2b007f905acf816152c3" +dependencies = [ + "js-sys", + "minicov", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" +checksum = "17d5042cc5fa009658f9a7333ef24291b1291a25b6382dd68862a7f3b969f69b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "web-sys" -version = "0.3.72" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" dependencies = [ "js-sys", "wasm-bindgen", @@ -2079,6 +2730,26 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "ws_stream_tungstenite" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a198f414f083fb19fcc1bffcb0fa0cf46d33ccfa229adf248cac12c180e91609" +dependencies = [ + "async-tungstenite", + "async_io_stream", + "bitflags 2.6.0", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "pharos", + "rustc_version", + "tokio", + "tracing", + "tungstenite", +] + [[package]] name = "xi-unicode" version = "0.3.0" @@ -2215,7 +2886,7 @@ dependencies = [ "pbkdf2", "rand", "sha1", - "thiserror", + "thiserror 2.0.6", "time", "zeroize", "zopfli", @@ -2238,9 +2909,9 @@ dependencies = [ [[package]] name = "zstd" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" dependencies = [ "zstd-safe", ] @@ -2263,3 +2934,18 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99a5bab8d7dedf81405c4bb1f2b83ea057643d9cb28778cea9eecddeedd2e028" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 49fa308a..e9108091 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/emulator", "crates/importer", "crates/fetcher", + "crates/remote-display-video" ] [profile.release-minsized] diff --git a/contrib/remote-display-webext/.gitignore b/contrib/remote-display-webext/.gitignore new file mode 100644 index 00000000..0a86a937 --- /dev/null +++ b/contrib/remote-display-webext/.gitignore @@ -0,0 +1,2 @@ +webext.zip +*.bundle.mjs \ No newline at end of file diff --git a/contrib/remote-display-webext/Makefile b/contrib/remote-display-webext/Makefile new file mode 100644 index 00000000..937dfa0a --- /dev/null +++ b/contrib/remote-display-webext/Makefile @@ -0,0 +1,12 @@ +.PHONY: all build clean + +all: build + +fragment-generation-utils.bundle.mjs: + wget https://esm.sh/text-fragments-polyfill@6.4.1/es2022/dist/fragment-generation-utils.bundle.mjs -O fragment-generation-utils.bundle.mjs + +build: fragment-generation-utils.bundle.mjs + zip -r -FS ./webext.zip * --exclude webext.zip + +clean: + rm -f webext.zip fragment-generation-utils.bundle.mjs diff --git a/contrib/remote-display-webext/background.js b/contrib/remote-display-webext/background.js new file mode 100644 index 00000000..5b6c6a76 --- /dev/null +++ b/contrib/remote-display-webext/background.js @@ -0,0 +1,991 @@ +// #region browser interactivity + +let windowId; +let selectionInProgress = {}; +let awaitingFirstSelectionTap = {}; + +browser.tabs.query({ active: true, currentWindow: true }) + .then((tabs) => { + windowId = tabs[0].windowId; + }); + +async function tabOffset(offset) { + const tabs = await browser.tabs.query({ windowId }); + const currentTab = tabs.find((tab) => tab.active); + const currentIndex = tabs.indexOf(currentTab); + const newIndex = (currentIndex + offset + tabs.length) % tabs.length; + await browser.tabs.update(tabs[newIndex].id, { active: true }); +} + +async function windowOffset(offset) { + const windows = await browser.windows.getAll({ populate: true }); + const currentWindow = windows.find((window) => window.id === windowId); + const currentIndex = windows.indexOf(currentWindow); + const newIndex = (currentIndex + offset + windows.length) % windows.length; + windowId = windows[newIndex].id; + await new Promise((resolve) => setTimeout(resolve, 100)); +} + +async function moveTabToFirst() { + const tabs = await browser.tabs.query({ windowId }); + const currentTab = tabs.find((tab) => tab.active); + if (!currentTab) return; + await browser.tabs.move(currentTab.id, { index: 0 }); +} + +async function moveTabToLast() { + const tabs = await browser.tabs.query({ windowId }); + const currentTab = tabs.find((tab) => tab.active); + if (!currentTab) return; + await browser.tabs.move(currentTab.id, { index: -1 }); +} + +async function currentTab() { + const [tab] = await browser.tabs.query({ windowId, active: true }); + return tab; +} + +async function currentTabInfo() { + const tabs = await browser.tabs.query({ windowId }); + const currentTab = tabs.find((tab) => tab.active); + const currentTabIndex = tabs.indexOf(currentTab); + const windows = await browser.windows.getAll({ populate: true }); + const currentWindow = windows.find((window) => window.id === windowId); + const currentWindowIndex = windows.indexOf(currentWindow); + const url = new URL(currentTab.url); + return `W${currentWindowIndex + 1} T${currentTabIndex + 1 + }/${tabs.length} ${url.host}`; +} + +async function scroll(pctX, pctY, verticalPct, horizontalPct = 0) { + const { id } = await currentTab(); + const result = await browser.tabs.executeScript(id, { + code: `(() => { + // Combined scrollability check function + function isScrollable(element, direction) { + if (!element) return false; + + const isVertical = direction === 'vertical'; + const hasOverflow = isVertical + ? Math.abs(element.scrollHeight - element.clientHeight) > 10 + : Math.abs(element.scrollWidth - element.clientWidth) > 10; + + if (!hasOverflow) return false; + + const style = window.getComputedStyle(element); + const overflow = style.getPropertyValue(isVertical ? 'overflow-y' : 'overflow-x'); + const isScrollableStyle = ['auto', 'scroll', 'overlay'].includes(overflow); + + const isDefaultScrollable = element.tagName === 'BODY' || + element.tagName === 'HTML' || + element.tagName === 'DIV' && hasOverflow; + + return isScrollableStyle || isDefaultScrollable; + } + + const elements = [...document.elementsFromPoint( + window.innerWidth * ${pctX}, window.innerHeight * ${pctY} + )]; + + const addScrollendListener = (element) => { + const scrollStartTime = Date.now(); + element.addEventListener('scrollend', () => { + if (Date.now() - scrollStartTime > 50) { + browser.runtime.sendMessage({ type: 'CAPTURE_SCREENSHOT' }); + } + }, { once: true }); + }; + + // Handle iframe elements + if (elements[0]?.tagName === "IFRAME") { + const iframeElements = elements[0].contentDocument?.elementsFromPoint( + window.innerWidth * ${pctX}, window.innerHeight * ${pctY} + ); + elements.unshift(...(iframeElements ?? [])); + } + + // Try scrolling elements separately for each direction + let verticalScrolled = false; + let horizontalScrolled = false; + + // Handle vertical scrolling + if (${verticalPct} !== 0) { + for (const el of elements) { + if (isScrollable(el, 'vertical')) { + const prevTop = el.scrollTop; + el.scrollBy(0, window.innerHeight * ${verticalPct}); + if (el.scrollTop !== prevTop) { + verticalScrolled = true; + addScrollendListener(el); + break; // Found and scrolled vertically, move on + } + } + } + } + + // Handle horizontal scrolling independently + if (${horizontalPct} !== 0) { + for (const el of elements) { + if (isScrollable(el, 'horizontal')) { + const prevLeft = el.scrollLeft; + el.scrollBy(window.innerWidth * ${horizontalPct}, 0); + if (el.scrollLeft !== prevLeft) { + horizontalScrolled = true; + addScrollendListener(el); + break; // Found and scrolled horizontally, move on + } + } + } + } + + // Only return if both requested directions were handled + if ((${verticalPct} === 0 || verticalScrolled) && (${horizontalPct} === 0 || horizontalScrolled)) { + return horizontalScrolled; + } + + // Fallback to window scrolling + const prevWindowLeft = window.scrollX || window.pageXOffset; + window.scrollBy( + window.innerWidth * ${horizontalPct}, + window.innerHeight * ${verticalPct} + ); + const scrollStartTime = Date.now(); + addScrollendListener(window); + const newWindowLeft = window.scrollX || window.pageXOffset; + if (${horizontalPct} !== 0 && newWindowLeft !== prevWindowLeft) { + horizontalScrolled = true; + } + return horizontalScrolled; + })()`, + }); + browser.tabs.sendMessage(id, { type: 'WAIT_FOR_ANIMATIONS' }); + return result[0]; +} + +async function zoomPage(addFactor) { + const { id } = await currentTab(); + const factor = await browser.tabs.getZoom(id); + let newFactor = factor + addFactor; + if (newFactor < 0.3) newFactor = 0.3; + if (newFactor > 5) newFactor = 5; + await browser.tabs.setZoom(id, newFactor); + return newFactor; +} + +async function goForward() { + const { id } = await currentTab(); + await browser.tabs.goForward(id); +} + +async function goBack() { + const { id } = await currentTab(); + await browser.tabs.goBack(id); +} + +async function closeCurrentTab() { + const { id } = await currentTab(); + await browser.tabs.remove(id); +} + +async function reopenClosedTab() { + const sessions = await browser.sessions.getRecentlyClosed(); + const lastSession = sessions + .find((session) => session.tab && session.tab.windowId === windowId); + if (!lastSession) return; + await browser.sessions.restore(lastSession.tab.sessionId); + await browser.tabs.update(lastSession.tab.id, { active: true }); + return new URL(lastSession.tab.url).host; +} + +async function reloadCurrentTab() { + const { id } = await currentTab(); + await browser.tabs.reload(id); +} + +async function resizeViewport(width, height) { + width = Math.round(width); + height = Math.round(height); + const window = await browser.windows.get(windowId); + const tab = await currentTab(); + if (tab.width === width && tab.height === height) return; + if (tab.width > width || tab.height > height) { + await browser.windows.update(window.id, { + width: width, + height: height, + }); + } + const offsetWidth = window.width - tab.width; + const offsetHeight = window.height - tab.height; + await browser.windows.update(window.id, { + width: width + offsetWidth, + height: height + offsetHeight, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); +} + +async function openLinkUnderTap(pctX, pctY) { + const { id, windowId } = await currentTab(); + const [url] = await browser.tabs.executeScript(id, { + code: + `[...document.elementsFromPoint(window.innerWidth * ${pctX}, window.innerHeight * ${pctY})] + .find((e) => !!e.href) + ?.href`, + }); + if (!url) return; + await browser.tabs.create({ url, windowId, openerTabId: id, active: false }); + return new URL(url).host; +} + +async function clearSelection() { + const { id } = await currentTab(); + delete awaitingFirstSelectionTap[id]; + delete selectionInProgress[id]; + await browser.tabs.executeScript(id, { + code: `(() => { + const selection = window.getSelection(); + selection.removeAllRanges(); + })()`, + }); +} + +async function clickUnderTap(pctX, pctY) { + const { id } = await currentTab(); + await clearSelection(); + await browser.tabs.executeScript(id, { + code: `(() => { + const coordX = window.innerWidth * ${pctX}; + const coordY = window.innerHeight * ${pctY}; + const elements = [...document.elementsFromPoint(coordX, coordY)]; + if (elements[0]?.tagName === "IFRAME") { + try { + const iframeElements = elements[0].contentDocument?.elementsFromPoint(coordX, coordY); + if (iframeElements?.length) { + elements.unshift(...iframeElements); + } + } catch (e) { + console.error("Error accessing iframe content:", e); + } + } + function simulateMouseEvent(element, eventName) { + if (!element) return false; + const mouseEvent = new MouseEvent(eventName, { + view: window, + bubbles: true, + cancelable: true, + clientX: coordX, + clientY: coordY, + button: 0 + }); + + const dispatched = element.dispatchEvent(mouseEvent); + return dispatched; + } + function simulatePointerEvent(element, eventName) { + if (!element) return false; + + // Create and dispatch the pointer event with coordinates + const pointerEvent = new PointerEvent(eventName, { + view: window, + bubbles: true, + cancelable: true, + clientX: coordX, + clientY: coordY, + pointerId: 1, + pointerType: 'mouse', + isPrimary: true, + pressure: 1, + button: 0 + }); + + const dispatched = element.dispatchEvent(pointerEvent); + return dispatched; + } + for (const el of elements) { + const isClickable = el.onclick || + el.tagName === "A" || + el.tagName === "BUTTON" || + el.tagName === "INPUT" || + el.tagName === "SELECT" || + el.tagName === "TEXTAREA" || + el.getAttribute("role") === "button" || + window.getComputedStyle(el).cursor === "pointer"; + + if (isClickable) { + console.log("Clicking element:", el.tagName, el.className); + simulatePointerEvent(el, "pointerdown"); + simulateMouseEvent(el, "mousedown"); + simulatePointerEvent(el, "pointerup"); + simulateMouseEvent(el, "mouseup"); + simulateMouseEvent(el, "click"); + return; + } + } + + function findNearestHash(element, maxDepth = 4) { + const semanticTags = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SECTION', 'ARTICLE']; + if (!element || maxDepth <= 0) return null; + if (semanticTags.includes(element.tagName) && element.id) { + return element.id; + } + return findNearestHash(element.parentElement, maxDepth - 1); + } + + for (const el of elements) { + if (['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(el.tagName)) { + const targetId = findNearestHash(el); + if (targetId) { + console.log("Navigating to semantic element from header:", targetId); + window.location.hash = targetId; + break; + } + } + } + + if (elements.length > 0) { + console.log("Fallback click on:", elements[0].tagName); + simulatePointerEvent(elements[0], "pointerdown"); + simulateMouseEvent(elements[0], "mousedown"); + simulatePointerEvent(elements[0], "pointerup"); + simulateMouseEvent(elements[0], "mouseup"); + simulateMouseEvent(elements[0], "click"); + } + })()`, + }); + browser.tabs.sendMessage(id, { type: 'WAIT_FOR_ANIMATIONS' }); +} + +async function handleWordSelection(pctX, pctY) { + const { id } = await currentTab(); + await browser.tabs.executeScript(id, { + code: `(() => { + const p = document.caretPositionFromPoint(window.innerWidth * ${pctX}, window.innerHeight * ${pctY}); + if (p) { + const selection = window.getSelection(); + selection.setPosition(p.offsetNode, p.offset); + selection.modify("move", "backward", "word"); + selection.modify("extend", "forward", "word"); + } + })()`, + }); + selectionInProgress[id] = true; +} + +async function getSelection() { + const { id } = await currentTab(); + const code = `(() => { + const selection = window.getSelection(); + if (selection.rangeCount === 0) return ""; + return selection.toString().trim(); + })()`; + try { + const [selectedText] = await browser.tabs.executeScript(id, { code }); + return selectedText; + } catch (error) { + console.error("Error getting selection:", error); + return ""; + } +} + +async function openSearchTab(query) { + if (!query) { + await sendNotice("no query provided for search"); + return; + } + const tab = await currentTab(); + const newTab = await browser.tabs.create({ + windowId: tab.windowId, + index: tab.index + 1, + active: false, + }); + await browser.search.query({ + tabId: newTab.id, + text: query, + }); + await clearSelection(); + await sendNotice("search opened"); + await sendImage(); +} + +import * as pako from "https://esm.sh/pako@2.1.0"; +import * as base64 from "https://esm.sh/js-base64@3.7.7"; +async function noteSelection() { + const text = await getSelection(); + if (!text) { + await sendNotice("no text selected"); + return; + } + const { url: sourceUrl, index: currentIndex, windowId } = await currentTab(); + const encoder = new TextEncoder(); + const tabs = await browser.tabs.query({ windowId }); + const nextTab = tabs.find(tab => tab.index === currentIndex + 1); + const nextTabUrl = nextTab ? new URL(nextTab.url) : null; + + if (nextTab && nextTabUrl && nextTabUrl.host === "notepadtab.com") { + const existingBase64 = nextTabUrl.hash.slice(1); + let existingText = ''; + if (existingBase64) { + try { + const compressedExisting = base64.toUint8Array(existingBase64); + existingText = pako.inflate(compressedExisting, { to: 'string' }); + } catch (e) { + console.error('Failed to decode existing note:', e); + await sendNotice("error reading existing note"); + } + } + const newText = `${existingText}\n${text}\nSource: ${sourceUrl || "unknown"}\n`; + const encodedText = encoder.encode(newText); + const compressedText = pako.deflate(encodedText, { level: 9 }); + const base64Text = base64.fromUint8Array(compressedText); + const newUrl = `https://notepadtab.com/#${base64Text}`; + await browser.tabs.update(nextTab.id, { url: newUrl }); + await sendNotice("text appended to existing note"); + } else { + const encodedText = encoder.encode(`${text}\nSource: ${sourceUrl || "unknown"}\n\n`); + const compressedText = pako.deflate(encodedText, { level: 9 }); + const base64Text = base64.fromUint8Array(compressedText); + const url = `https://notepadtab.com/#${base64Text}`; + await browser.tabs.create({ + windowId, + index: currentIndex + 1, + active: false, + url, + }); + await sendNotice("text opened in new tab"); + } + await clearSelection(); + await sendImage(); +} + + +async function offsetContrastFilter(offset) { + const { id } = await currentTab(); + const [newContrast] = await browser.tabs.executeScript(id, { + code: `(() => { + const el = document.documentElement; + const match = el.style.filter?.match(/contrast\\((\\d+)%\\)/); + const contrast = match ? parseInt(match[1]) : 100; + const invert = el.style.filter?.includes("invert"); + const offsetContrast = contrast + ${offset}; + if (offsetContrast < 100) { + el.style.filter = \`\${invert ? "" : "grayscale() invert() "}contrast(100%)\`; + } else { + el.style.filter = \`\${ + (offsetContrast === 100) && !invert ? "" : "grayscale() "}\${ + invert ? "invert() " : "" + }contrast(\${offsetContrast}%)\`; + } + return el.style.filter.match(/contrast\\((\\d+)%\\)/)[1]; + })()`, + }); + return newContrast; +} +// #endregion + +// #region main loop + +// Message listener for content script communication +browser.runtime.onMessage.addListener(async (message, sender) => { + if (message.type === 'CAPTURE_SCREENSHOT') { + console.log('Received screenshot request from content script'); + await sendImage(); + return true; + } +}); + +let scaleFactor = 1; +let deviceWidth = 0; +let deviceHeight = 0; +let mq; + +import mqtt from "https://esm.sh/mqtt@5.10.1"; +import { encode, decode } from "https://esm.sh/cborg@4.2.4"; +import { Encrypt0Message } from "https://esm.sh/@ldclabs/cose-ts@1.3.2/encrypt0"; +import { hexToBytes } from "https://esm.sh/@ldclabs/cose-ts@1.3.2/utils"; +import { Header } from "https://esm.sh/@ldclabs/cose-ts@1.3.2/header"; +import { ChaCha20Poly1305Key } from "https://esm.sh/@ldclabs/cose-ts@1.3.2/chacha20poly1305"; +import * as iana from "https://esm.sh/@ldclabs/cose-ts@1.3.2/iana"; + +import init, { png_to_display_update } from "./enc/crates_remote_display_video.js"; +await init(); + +let topic = "remote-display-webext"; +let key; + +async function encodeCipher(msg) { + if (!key) { + throw new Error("no key"); + } + const nonce = new Uint8Array(12); + crypto.getRandomValues(nonce); + return await new Encrypt0Message( + msg instanceof Uint8Array ? msg : encode(msg), + new Header().setParam(iana.HeaderParameterAlg, iana.AlgorithmChaCha20Poly1305), + new Header().setParam(iana.HeaderParameterIV, nonce), + ).toBytes(key); +} + +async function send(msg) { + if (mq?.connected) { + await mq.publishAsync(`${topic}/device`, await encodeCipher(msg)); + } +} + +async function decodeCipher(msg) { + if (!key) { + throw new Error("no key"); + } + const emsg = await Encrypt0Message.fromBytes(key, new Uint8Array(msg)); + return decode(emsg.payload); +} + +async function sendNotice(notice) { + await send({ type: "notify", value: notice }); +} + +const sendImage = async (keyframe = false) => { + if (!mq?.connected) return; + if (!deviceWidth || !deviceHeight) { + await send({ type: "updateSize" }); + console.log("cannot update image without size"); + return; + } + window.performance.mark("remote_display_capture"); + const { id } = await currentTab(); + const dataUrl = await browser.tabs.captureTab(id, { + scale: scaleFactor, + }); + const buf = await fetch(dataUrl) + .then(a => a.arrayBuffer()) + .then(a => new Uint8Array(a)); + window.performance.mark("remote_display_capture_end"); + const captureMeasure = window.performance.measure("remote_display_capture", "remote_display_capture", "remote_display_capture_end"); + console.log(`captured PNG in ${captureMeasure.duration} ms`); + + try { + window.performance.mark("remote_display_encode"); + const qoi = png_to_display_update(buf, deviceWidth, deviceHeight, keyframe); + window.performance.mark("remote_display_encode_end"); + const encodeMeasure = window.performance.measure("remote_display_encode", "remote_display_encode", "remote_display_encode_end"); + if (qoi) { + const size = qoi.length / 1024; + console.log(`encoded display update of ${size.toFixed(2)} KB in ${encodeMeasure.duration} ms`); + window.performance.mark("remote_display_send_image"); + await send(qoi); + } else { + console.error("Failed to encode display updates"); + } + + await new Promise((resolve) => { + const updated = async (_, m) => { + const msg = await decodeCipher(m); + if (msg.type === "displayUpdated") { + mq.off("message", updated); + window.performance.mark("remote_display_send_image_end"); + const measure = window.performance.measure("remote_display_send_image", "remote_display_send_image", "remote_display_send_image_end"); + console.log(`resolved display update roundtrip in ${measure.duration} ms`); + resolve(); + } + } + mq.on("message", updated); + }); + } catch (e) { + console.error(e); + } +}; + +let timeout; +browser.tabs.onUpdated.addListener(async (_id, changeInfo, tab) => { + if (!tab.active) return; + if (changeInfo.status !== "complete") return; + const myTab = await currentTab(); + if (tab.id !== myTab.id) return; + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(async () => { + console.log("updating from tab load", changeInfo, tab); + const info = await currentTabInfo(); + await sendNotice(info); + await sendImage(true); + }, 1000); +}); + +async function onMessage(msg) { + console.log("message", msg); + switch (msg.type) { + case "deviceDisconnected": { + deviceWidth = 0; + deviceHeight = 0; + await sendNotice("Device disconnected, image sending disabled"); + break; + } + case "size": { + const { width, height } = msg.value; + if (deviceWidth === 0 && deviceHeight === 0) { + await sendNotice("Device reconnected, image sending enabled"); + } + deviceWidth = width; + deviceHeight = height; + await sendImage(true); + break; + } + case "swipe": { + const { dir, start, end } = msg.value; + const dx = end.x - start.x; + const dy = end.y - start.y; + + const { id } = await currentTab(); + if (awaitingFirstSelectionTap[id] && ["east", "west"].includes(dir)) { + const startPctX = start.x / deviceWidth; + const startPctY = start.y / deviceHeight; + const endPctX = end.x / deviceWidth; + const endPctY = end.y / deviceHeight; + await browser.tabs.executeScript(id, { + code: `(() => { + const selection = window.getSelection(); + const startCaret = document.caretPositionFromPoint( + window.innerWidth * ${startPctX}, window.innerHeight * ${startPctY} + ); + const endCaret = document.caretPositionFromPoint( + window.innerWidth * ${endPctX}, window.innerHeight * ${endPctY} + ); + if (startCaret && endCaret) { + selection.setPosition(startCaret.offsetNode, startCaret.offset); + selection.extend(endCaret.offsetNode, endCaret.offset); + } + })()`, + }); + await sendNotice("swipe left/right to expand, multiSwipe to contract"); + await sendImage(); + delete awaitingFirstSelectionTap[id]; + selectionInProgress[id] = true; + break; + } else if (selectionInProgress[id] && ["east", "west"].includes(dir)) { + await browser.tabs.executeScript(id, { + code: `(() => { + const selection = window.getSelection(); + if (${dir === "east"}) { + selection.modify("extend", "forward", "character"); + } else { + const fn = selection.focusNode; + const fo = selection.focusOffset; + selection.collapseToStart(); + selection.modify("move", "backward", "character"); + selection.extend(fn, fo); + } + })()`, + }); + await sendImage(); + break; + } + + const scrolledHorizontally = await scroll( + start.x / deviceWidth, + start.y / deviceHeight, + ["north", "south"].includes(dir) ? -(dy / deviceHeight) : 0, + ["east", "west"].includes(dir) ? -(dx / deviceWidth) : 0 + ); + + if (["east", "west"].includes(dir) && !scrolledHorizontally) { + await tabOffset(dir === "east" ? -1 : 1); + const info = await currentTabInfo(); + await sendNotice(info); + } + + await sendImage(); + break; + } + case "slantedSwipe": { + const { start, end } = msg.value; + const dx = end.x - start.x; + const dy = end.y - start.y; + + const horizontalPct = -(dx / deviceWidth); + const verticalPct = -(dy / deviceHeight); + + await scroll( + start.x / deviceWidth, + start.y / deviceHeight, + verticalPct, + horizontalPct + ); + await sendImage(); + break; + } + case "button": { + const { button, status } = msg.value; + if (!["released", "repeated"].includes(status)) break; + // scroll half pages + switch (button) { + case "forward": { + await scroll(0.5, 0.5, 0.5, 0); + break; + } + case "backward": { + await scroll(0.5, 0.5, -0.5, 0); + break; + } + } + if (status === "released") await sendImage(); + break; + } + case "arrow": { + const { dir } = msg.value; + switch (dir) { + case "east": { + const sel = await getSelection(); + if (sel) { + await openSearchTab(sel); + await clearSelection(); + break; + } + await goForward(); + break; + } + case "west": { + if (await getSelection()) { + await noteSelection(); + await clearSelection(); + break; + } + await goBack(); + break; + } + case "north": { + const tabToClose = await currentTab(); + if (await getSelection()) { + await browser.tabs.sendMessage(tabToClose.id, { type: 'GENERATE_TEXT_FRAGMENT' }); + delete awaitingFirstSelectionTap[tabToClose.id]; + delete selectionInProgress[tabToClose.id]; + break; + } + const tabs = await browser.tabs.query({ windowId }); + const currentTabIndex = tabs.findIndex(tab => tab.id === tabToClose.id); + const isLastTab = currentTabIndex === tabs.length - 1; + const info = await currentTabInfo(); + await sendNotice(`closing ${info}`); + await tabOffset(isLastTab ? -1 : 1); + await browser.tabs.remove(tabToClose.id); + const newInfo = await currentTabInfo(); + await sendNotice(newInfo); + await sendImage(); + break; + } + } + break; + } + case "multiSwipe": { + const { dir, starts, ends } = msg.value; + const { id } = await currentTab(); + if (selectionInProgress[id] && ["east", "west"].includes(dir)) { + await browser.tabs.executeScript(id, { + code: `(() => { + const selection = window.getSelection(); + if (${dir === "east"}) { + const fn = selection.focusNode; + const fo = selection.focusOffset; + selection.collapseToStart(); + selection.modify("move", "forward", "character"); + selection.extend(fn, fo); + } else { + selection.modify("extend", "backward", "character"); + } + })()`, + }); + await sendImage(); + break; + } + + switch (dir) { + case "east": + case "west": { + const offset = dir === "east" ? -1 : 1; + const avgStartX = starts.reduce((sum, point) => sum + point.x, 0) / starts.length; + const avgEndX = ends.reduce((sum, point) => sum + point.x, 0) / ends.length; + const dx = avgEndX - avgStartX; + // change windows if swipe covers more than half the screen + await (Math.abs(dx) > deviceHeight / 2 + ? windowOffset(offset) + : tabOffset(offset)); + const info = await currentTabInfo(); + await sendNotice(info); + await sendImage(true); + break; + } + case "north": + case "south": { + const tabs = await browser.tabs.query({ windowId }); + const targetIndex = dir === "south" ? 0 : tabs.length - 1; + await browser.tabs.update(tabs[targetIndex].id, { active: true }); + const info = await currentTabInfo(); + await sendNotice(info); + await sendImage(true); + break; + } + } + break; + } + case "multiArrow": { + const { dir } = msg.value; + switch (dir) { + case "east": + await moveTabToLast(); + break; + case "west": + await moveTabToFirst(); + break; + } + const info = await currentTabInfo(); + await sendNotice(info); + await sendImage(true); + break; + } + case "pinch": + case "spread": { + const newFactor = await zoomPage(msg.type === "spread" ? 0.1 : -0.1); + await sendNotice(`zoom ${(100.0 * newFactor).toFixed(0)}%`); + await sendImage(); + break; + } + case "rotate": + if (Math.abs(msg.value.angle) < 20) break; + if (msg.value.angle > 0) { + const restoredTab = await reopenClosedTab(); + if (restoredTab) await sendNotice(`restored ${restoredTab}`); + else await sendNotice("no tab restored"); + await sendImage(); + } else { + await reloadCurrentTab(); + } + break; + case "holdFingerShort": + await resizeViewport(deviceWidth / scaleFactor, deviceHeight / scaleFactor); + await sendImage(true); + await send({ type: "refreshDisplay" }); + break; + case "holdFingerLong": { + const [{ x, y }] = msg.value; + const tab = await openLinkUnderTap(x / deviceWidth, y / deviceHeight); + if (tab) await sendNotice(`${tab} opened`); + else await sendNotice("no link under finger"); + break; + } + case "tap": { + const { x, y } = msg.value; + const { id } = await currentTab(); + if (awaitingFirstSelectionTap[id]) { + await handleWordSelection(x / deviceWidth, y / deviceHeight); + await sendNotice("swipe left/right to expand, multiSwipe to contract"); + delete awaitingFirstSelectionTap[id]; + } else if (selectionInProgress[id]) { + await clearSelection(); + await sendImage(); + } else { + await clickUnderTap(x / deviceWidth, y / deviceHeight); + } + await sendImage(); + break; + } + case "corner": { + const { dir } = msg.value; + switch (dir) { + case "southWest": + case "southEast": + { + const newContrast = await offsetContrastFilter( + dir === "southWest" ? -25 : 25, + ); + await sendNotice(`contrast ${newContrast}%`); + await sendImage(true); + } + break; + case "northWest": + case "northEast": { + const newScaleFactor = scaleFactor + (dir === "northWest" ? -0.1 : 0.1); + if (newScaleFactor < 0.1 || newScaleFactor > 2) break; + scaleFactor = newScaleFactor; + await sendNotice(`scale ${Math.round(scaleFactor * 100)}%`); + await resizeViewport(deviceWidth / scaleFactor, deviceHeight / scaleFactor); + await sendImage(true); + } + } + break; + } + case "multiCorner": { + const { dir } = msg.value; + switch (dir) { + case "northEast": + const { id } = await currentTab(); + if (selectionInProgress[id] || awaitingFirstSelectionTap[id]) { + await clearSelection(); + await sendNotice("selection cancelled"); + break; + } + awaitingFirstSelectionTap[id] = true; + await sendNotice("now selecting, tap to select word or swipe to select text"); + break; + } + } + } +} + +const defaultConfig = { + wsUrl: "wss://broker.hivemq.com:8884/mqtt", + topic: "remote-display-webext", + enabled: false, + key: "", +}; + +async function getConfig() { + const result = await browser.storage.local.get(["wsUrl", "topic", "enabled", "key"]); + topic = result.topic || defaultConfig.topic; + const hexKey = result.key || defaultConfig.key; + key = ChaCha20Poly1305Key.fromSecret(hexToBytes(hexKey)); + await browser.storage.local.set({ ...defaultConfig, ...result }); + return { ...defaultConfig, ...result }; +} + + +async function refreshConnection(config) { + if (config.enabled && !mq?.connected) { + mq = mqtt.connect(config.wsUrl, { + will: { + topic: `${config.topic}/device`, + payload: await encodeCipher({ type: "notify", value: "Browser disconnected" }), + qos: 0, + } + }); + mq.subscribe(`${config.topic}/browser`); + mq.on("message", (_topic, message) => decodeCipher(message).then(onMessage)); + mq.on("connect", () => { + sendNotice("Browser connected") + .then(() => send({ type: "updateSize" })) + .then(() => { console.log("connected"); }) + }); + mq.on("disconnect", () => { + console.log("disconnected"); + }); + } else if (!config.enabled && mq?.connected) { + mq.end(); + } +} + +getConfig().then(refreshConnection).catch(console.error); + +browser.storage.local.onChanged.addListener((changes) => { + if ( + ( + ("wsUrl" in changes && changes.wsUrl.newValue !== changes.wsUrl.oldValue) + || ("topic" in changes && changes.topic.newValue !== changes.topic.oldValue) + ) && + mq?.connected + ) { + mq.end(); + getConfig().then(refreshConnection).catch(console.error); + } + if ("enabled" in changes && changes.enabled.newValue !== changes.enabled.oldValue) { + getConfig().then(refreshConnection).catch(console.error); + } +}); + +// #endregion diff --git a/contrib/remote-display-webext/content.js b/contrib/remote-display-webext/content.js new file mode 100644 index 00000000..5ab41548 --- /dev/null +++ b/contrib/remote-display-webext/content.js @@ -0,0 +1,133 @@ +class AnimationTracker { + constructor(options = {}) { + const { checkInterval = 50, maxWaitTime = 5000 } = options; + this.checkInterval = checkInterval; + this.maxWaitTime = maxWaitTime; + this.timeoutId = null; + this.screenshotTimeout = null; + } + + getAnimationCount() { + try { + return document.getAnimations() + .filter(a => a.playState !== "finished") + .length; + } catch (error) { + console.error('Error getting animations:', error); + return 0; + } + } + + queueScreenshot() { + if (this.screenshotTimeout) clearTimeout(this.screenshotTimeout); + this.screenshotTimeout = setTimeout(() => { + this.screenshotTimeout = null; + console.log('Sending screenshot request'); + browser.runtime.sendMessage({ + type: 'CAPTURE_SCREENSHOT' + }); + }, 100); + } + + waitForAnimations() { + if (this.timeoutId) { + console.log("Stopped previously running animation tracking"); + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + const initialCount = this.getAnimationCount(); + console.log(`Starting animation tracking. Initial count: ${initialCount}`); + + let elapsedTime = 0; + let lastCount = initialCount; + let highestCount = initialCount; + + const checkAnimations = () => { + const currentCount = this.getAnimationCount(); + + if (currentCount !== lastCount) { + console.log(`Animation count changed: ${lastCount} → ${currentCount}`); + lastCount = currentCount; + highestCount = Math.max(highestCount, currentCount); + } + + if (highestCount > 0 && currentCount === 0) { + console.log(`There were ${highestCount} animations, but we now have zero. Taking screenshot.`); + this.queueScreenshot(); + return; + } + + if (currentCount < initialCount) { + console.log(`Animations decreased from ${initialCount} to ${currentCount}`); + this.queueScreenshot(); + return; + } + + elapsedTime += this.checkInterval; + + // Stop checking if we've reached the maximum wait time + if (elapsedTime >= this.maxWaitTime) { + console.log(`Animation tracking timed out after ${this.maxWaitTime}ms with ${currentCount} animations still running`); + return; + } + + // Continue checking + this.timeoutId = setTimeout(checkAnimations, this.checkInterval); + }; + + checkAnimations(); + } +} + +function isInIframe() { + try { + return window.self !== window.top; + } catch (e) { + return true; + } +} + +const generateTextFragment = async (selection) => { + const src = browser.runtime.getURL('fragment-generation-utils.bundle.mjs'); + const { generateFragment } = await import(src); + const result = generateFragment(selection); + + if (result.status !== 0) { + return null; + } + + let url = `${location.origin}${location.pathname}${location.search}`; + + const fragment = result.fragment; + const prefix = fragment.prefix + ? `${encodeURIComponent(fragment.prefix)}-,` + : ''; + const suffix = fragment.suffix + ? `,-${encodeURIComponent(fragment.suffix)}` + : ''; + const start = encodeURIComponent(fragment.textStart); + const end = fragment.textEnd ? `,${encodeURIComponent(fragment.textEnd)}` : ''; + + url += `#:~:text=${prefix}${start}${end}${suffix}`; + + return url; +}; + + +const tracker = new AnimationTracker(); +browser.runtime.onMessage.addListener(async (message) => { + if (isInIframe()) return; + if (message.type === 'GENERATE_TEXT_FRAGMENT') { + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const url = await generateTextFragment(selection); + if (url) window.location.href = url; + } else { + console.log('No text selected for text fragment generation'); + } + } else if (message.type === 'WAIT_FOR_ANIMATIONS') { + tracker.waitForAnimations(); + } + return; +}); +console.log('Animation tracking content script loaded'); \ No newline at end of file diff --git a/contrib/remote-display-webext/manifest.json b/contrib/remote-display-webext/manifest.json new file mode 100644 index 00000000..94b9ce6c --- /dev/null +++ b/contrib/remote-display-webext/manifest.json @@ -0,0 +1,29 @@ +{ + "manifest_version": 2, + "name": "Plato Remote Display", + "version": "0.1", + "description": "Plato Remote Display", + "background": { + "scripts": ["background.js"], + "type": "module" + }, + "browser_action": { + "default_popup": "popup.html" + }, + "content_security_policy": "script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' https://esm.sh; object-src 'self'; worker-src 'self' blob: https://esm.sh", + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_idle" + } + ], + "web_accessible_resources": [ "fragment-generation-utils.bundle.mjs" ], + "permissions": [ + "tabs", + "", + "storage", + "sessions", + "search" + ] +} diff --git a/contrib/remote-display-webext/popup.html b/contrib/remote-display-webext/popup.html new file mode 100644 index 00000000..921a30d1 --- /dev/null +++ b/contrib/remote-display-webext/popup.html @@ -0,0 +1,38 @@ + + + + + Settings + + + + +
+ + + + + + + + + +
+ + + + + + \ No newline at end of file diff --git a/contrib/remote-display-webext/popup.js b/contrib/remote-display-webext/popup.js new file mode 100644 index 00000000..a54be969 --- /dev/null +++ b/contrib/remote-display-webext/popup.js @@ -0,0 +1,29 @@ +const form = document.getElementById('form'); +const urlInput = document.getElementById('url'); +const topicInput = document.getElementById('topic'); +const enabledInput = document.getElementById('enabled'); +const keyInput = document.getElementById('key'); + +form.addEventListener('submit', async (e) => { + e.preventDefault(); + + await browser.storage.local.set({ + wsUrl: urlInput.value, + topic: topicInput.value, + enabled: enabledInput.checked, + key: keyInput.value, + }); + + // deno-lint-ignore no-window-prefix no-window + window.close(); +}); + +(async () => { + const { wsUrl, topic, enabled, key } = + await browser.storage.local.get(['wsUrl', 'topic', 'enabled', 'key']); + + urlInput.value = wsUrl || ''; + topicInput.value = topic || ''; + enabledInput.checked = enabled || false; + keyInput.value = key || ''; +})(); diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 25a48e61..b216dcb7 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -39,3 +39,16 @@ rand_core = "0.6.4" rand_xoshiro = "0.6.0" percent-encoding = "2.3.1" chrono = { version = "0.4.38", features = ["serde", "clock"], default-features = false } +url = { version = "2.1.0" } +base64 = "0.21.4" +tokio = { version = "1.32.0", features = ["macros", "rt", "net", "sync"] } +rumqttc = { version = "0.24.0", features = ["use-rustls", "websocket", "url"] } +rand = "=0.8.5" +webpki-roots = "0.26.1" +coset = "0.3.6" +bytes = "^1.5" +chacha20poly1305 = "0.10.1" +hex = "0.4.3" +image = { version = "0.25.6", features = ["qoi", "jpeg"], default-features = false } +zstd = "0.13.3" +bipatch = { git = "https://github.com/divvun/bidiff.git", rev = "b05865403c196225c8fcffcc89b540472de9ca4c" } \ No newline at end of file diff --git a/crates/core/src/framebuffer/mod.rs b/crates/core/src/framebuffer/mod.rs index 8e0de949..099fed6c 100644 --- a/crates/core/src/framebuffer/mod.rs +++ b/crates/core/src/framebuffer/mod.rs @@ -8,9 +8,11 @@ mod kobo1; mod kobo2; use anyhow::Error; +use crate::device::CURRENT_DEVICE; use crate::geom::{Point, Rectangle, surface_area, nearest_segment_point, lerp}; use crate::geom::{CornerSpec, BorderSpec, ColorSource, Vec2}; use crate::color::{Color, BLACK, WHITE}; +use crate::unit::mm_to_px; pub use self::kobo1::KoboFramebuffer1; pub use self::kobo2::KoboFramebuffer2; @@ -396,4 +398,22 @@ pub trait Framebuffer { } } } + + fn draw_crosshair(&mut self, pt: Point, radius_mm: f32) { + let radius = mm_to_px(radius_mm, CURRENT_DEVICE.dpi) as i32; + let center_x = pt.x as i32; + let center_y = pt.y as i32; + for i in -radius - 2..=radius + 2 { + for dx in -2..=2 { + self.set_pixel((center_x + dx) as u32, (center_y + i) as u32, WHITE); + } + for dy in -2..=2 { + self.set_pixel((center_x + i) as u32, (center_y + dy) as u32, WHITE); + } + } + for i in -radius..=radius { + self.set_pixel(center_x as u32, (center_y + i) as u32, BLACK); + self.set_pixel((center_x + i) as u32, center_y as u32, BLACK); + } + } } diff --git a/crates/core/src/geom.rs b/crates/core/src/geom.rs index 3b490703..0da0ad45 100644 --- a/crates/core/src/geom.rs +++ b/crates/core/src/geom.rs @@ -5,7 +5,8 @@ use std::f32::consts; use std::ops::{Add, AddAssign, Sub, SubAssign, Mul, MulAssign, Div, DivAssign}; use crate::color::Color; -#[derive(Debug, Copy, Clone, Eq, PartialEq)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] pub enum Dir { North, East, @@ -42,7 +43,8 @@ impl fmt::Display for Dir { } } -#[derive(Debug, Copy, Clone, Eq, PartialEq)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] pub enum DiagDir { NorthWest, NorthEast, @@ -72,7 +74,8 @@ impl fmt::Display for DiagDir { } } -#[derive(Debug, Copy, Clone, Eq, PartialEq)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] pub enum Axis { Horizontal, Vertical, diff --git a/crates/core/src/gesture.rs b/crates/core/src/gesture.rs index 07c6d1a9..39d45970 100644 --- a/crates/core/src/gesture.rs +++ b/crates/core/src/gesture.rs @@ -5,6 +5,7 @@ use fxhash::FxHashMap; use std::f64; use std::time::Duration; use std::thread; +use serde::Serialize; use crate::unit::mm_to_px; use crate::input::{DeviceEvent, FingerStatus, ButtonCode, ButtonStatus}; use crate::view::Event; @@ -16,7 +17,8 @@ pub const HOLD_JITTER_MM: f32 = 1.5; pub const HOLD_DELAY_SHORT: Duration = Duration::from_millis(666); pub const HOLD_DELAY_LONG: Duration = Duration::from_millis(1333); -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Copy, Clone, Serialize)] +#[serde(rename_all = "camelCase", tag = "type", content = "value")] pub enum GestureEvent { Tap(Point), MultiTap([Point; 2]), diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs index 34bc3437..aa1fd904 100644 --- a/crates/core/src/input.rs +++ b/crates/core/src/input.rs @@ -8,6 +8,7 @@ use std::sync::mpsc::{self, Sender, Receiver}; use std::os::unix::io::AsRawFd; use std::ffi::CString; use fxhash::FxHashMap; +use serde::Serialize; use crate::framebuffer::Display; use crate::settings::ButtonScheme; use crate::device::CURRENT_DEVICE; @@ -130,7 +131,8 @@ impl ButtonStatus { } } -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize)] +#[serde(rename_all = "camelCase")] pub enum ButtonCode { Power, Home, diff --git a/crates/core/src/settings/mod.rs b/crates/core/src/settings/mod.rs index 2cffab5e..b5cd658c 100644 --- a/crates/core/src/settings/mod.rs +++ b/crates/core/src/settings/mod.rs @@ -5,7 +5,13 @@ use std::ops::Index; use std::fmt::{self, Debug}; use std::path::PathBuf; use std::collections::{BTreeMap, HashMap}; +use chacha20poly1305::KeyInit; +use chacha20poly1305::{ + aead::OsRng, ChaCha20Poly1305, +}; use fxhash::FxHashSet; +use rand::distributions::Alphanumeric; +use rand::Rng; use serde::{Serialize, Deserialize}; use crate::metadata::{SortMethod, TextAlign}; use crate::frontlight::LightLevels; @@ -121,6 +127,7 @@ pub struct Settings { pub reader: ReaderSettings, pub import: ImportSettings, pub dictionary: DictionarySettings, + pub remote_display: RemoteDisplaySettings, pub sketch: SketchSettings, pub calculator: CalculatorSettings, pub battery: BatterySettings, @@ -193,6 +200,33 @@ impl Default for DictionarySettings { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct RemoteDisplaySettings { + pub address: String, + pub topic: String, + pub key: String, +} + +impl Default for RemoteDisplaySettings { + fn default() -> Self { + let topic: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(16) + .map(char::from) + .collect(); + let client_id: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(16) + .map(char::from) + .collect(); + let address = format!("mqtts://broker.hivemq.com?client_id={}", client_id); + let cha_key = ChaCha20Poly1305::generate_key(&mut OsRng); + let key = hex::encode(cha_key.as_slice()); + RemoteDisplaySettings { address, topic, key } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, rename_all = "kebab-case")] pub struct SketchSettings { @@ -546,6 +580,7 @@ impl Default for Settings { reader: ReaderSettings::default(), import: ImportSettings::default(), dictionary: DictionarySettings::default(), + remote_display: RemoteDisplaySettings::default(), sketch: SketchSettings::default(), calculator: CalculatorSettings::default(), battery: BatterySettings::default(), diff --git a/crates/core/src/view/common.rs b/crates/core/src/view/common.rs index e4a5a696..94480fee 100644 --- a/crates/core/src/view/common.rs +++ b/crates/core/src/view/common.rs @@ -85,6 +85,8 @@ pub fn toggle_main_menu(view: &mut dyn View, rect: Rectangle, enable: Option), + PatchDisplay(Vec), +} + +fn handle_server_message( + message: ServerMessage, + event_tx: &std::sync::mpsc::Sender, + prev_frame: &mut Option>, + shared_pixmap: &Arc>, +) -> anyhow::Result<()> { + let event = match message { + ServerMessage::Notify(message) => Event::Notify(message), + ServerMessage::RefreshDisplay => Event::Update(UpdateMode::Full), + ServerMessage::UpdateSize => Event::Validate, + ServerMessage::UpdateDisplay(data) => { + let qoi = zstd::decode_all(data.as_slice())?; + *prev_frame = Some(qoi.clone()); + let rgb = image::load_from_memory(&qoi)?.to_rgb8(); + let width = rgb.width() as u32; + let height = rgb.height() as u32; + let data = rgb.into_raw(); + if let Ok(mut updated) = shared_pixmap.lock() { + updated.pixmap = Pixmap { + width, + height, + samples: 3, + data, + }; + updated.full = true; + updated.notified = false; + } + Event::Update(UpdateMode::Gui) + } + ServerMessage::PatchDisplay(data) => { + let decompressed = zstd::decode_all(data.as_slice())?; + let patched_buf = { + let prev = prev_frame + .as_ref() + .ok_or(anyhow::anyhow!("Served interframe with no previous frame"))?; + let mut slice = decompressed.as_slice(); + let mut patched = bipatch::Reader::new(&mut slice, Cursor::new(prev))?; + let mut buf = Vec::new(); + patched.read_to_end(&mut buf)?; + buf + }; + *prev_frame = Some(patched_buf.clone()); + let img = image::load_from_memory(&patched_buf)?; + let rgb = img.to_rgb8(); + let width = rgb.width() as u32; + let height = rgb.height() as u32; + let data = rgb.into_raw(); + if let Ok(mut updated) = shared_pixmap.lock() { + updated.pixmap = Pixmap { + width, + height, + samples: 3, + data, + }; + updated.full = false; + updated.notified = false; + } + Event::Update(UpdateMode::Gui) + } + }; + event_tx.send(event)?; + Ok(()) +} + +fn build_encrypted_payload( + value: Value, + header: &coset::Header, + cipher: &ChaCha20Poly1305, +) -> Result, anyhow::Error> { + let mut writer = Vec::new(); + into_writer(&value, &mut writer)?; + let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); + let payload = CoseEncrypt0Builder::new() + .protected(header.clone()) + .unprotected(HeaderBuilder::new().iv(nonce.into_iter().collect()).build()) + .try_create_ciphertext(writer.as_slice(), &[], |pt, aad| { + let mut out = pt.to_vec(); + cipher + .encrypt_in_place(&nonce, aad, &mut out) + .map_err(|e| anyhow::anyhow!("Encryption error: {}", e.to_string())) + .map(|_| out) + })? + .build() + .to_vec() + .map_err(|e| anyhow::anyhow!("Encryption error: {}", e.to_string()))?; + Ok(payload) +} + +#[tokio::main(flavor = "current_thread")] +async fn display_connection( + event_tx: std_mpsc::Sender, + mut socket_rx: tokio_mpsc::Receiver, + shared_pixmap: Arc>, + address: String, + topic: String, + hex_key: String, +) -> Result<(), Box> { + let pub_topic = topic.clone() + "/browser"; + let sub_topic = topic.clone() + "/device"; + + let mut mqo = MqttOptions::parse_url(address)?; + mqo.set_keep_alive(Duration::from_secs(30)); + mqo.set_max_packet_size(1_048_576, 1_048_576); + + let header = HeaderBuilder::new() + .algorithm(coset::iana::Algorithm::ChaCha20Poly1305) + .build(); + let key = hex::decode(hex_key)?; + let cipher = ChaCha20Poly1305::new(GenericArray::from_slice(key.as_slice())); + + let last_will_payload = build_encrypted_payload( + cbor!({ "type" => "deviceDisconnected" }) + .map_err(|e| anyhow::anyhow!("Encoding last will: {}", e))?, + &header, + &cipher, + )?; + mqo.set_last_will(rumqttc::LastWill { + topic: pub_topic.clone(), + message: last_will_payload.into(), + qos: QoS::AtMostOnce, + retain: false, + }); + + let root_store = rumqttc::tokio_rustls::rustls::RootCertStore { + roots: webpki_roots::TLS_SERVER_ROOTS.iter().cloned().collect(), + }; + let cc = ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + let tlsc = TlsConfiguration::Rustls(Arc::new(cc)); + match mqo.transport() { + Transport::Tls(..) => { + mqo.set_transport(Transport::Tls(tlsc)); + } + Transport::Wss(..) => { + mqo.set_transport(Transport::Wss(tlsc)); + } + _ => {} + } + let (client, mut eventloop) = AsyncClient::new(mqo, 10); + + let mut prev_frame: Option> = None; + + loop { + tokio::select! { + Some(socket_event) = socket_rx.recv() => { + match socket_event { + SocketEvent::Finished => { + client.disconnect().await?; + break; + } + SocketEvent::SendMessage(value) => { + let payload = build_encrypted_payload(value, &header, &cipher)?; + client.publish(pub_topic.clone(), QoS::AtMostOnce, false, payload).await?; + } + } + } + event = eventloop.poll() => { + match event { + Ok(rumqttc::Event::Incoming(Incoming::Publish(p))) => { + let ct_obj = CoseEncrypt0::from_slice(p.payload.chunk()) + .map_err(|e| anyhow::anyhow!("Encrypted message format error: {}", e.to_string()))?; + let pt = ct_obj.decrypt(&[], |pt, aad| { + let mut out = pt.to_vec(); + cipher.decrypt_in_place( + GenericArray::from_slice(ct_obj.unprotected.iv.as_slice()), + aad, &mut out + ) + .map_err(|e| anyhow::anyhow!("Decryption error: {}", e.to_string())) + .map(|_| out) + })?; + let message: ServerMessage = from_reader(pt.as_slice())?; + if let Err(e) = handle_server_message(message, &event_tx, &mut prev_frame, &shared_pixmap) { + event_tx.send(Event::Notify(format!("Error handling message: {}", e))).ok(); + } + } + Ok(rumqttc::Event::Incoming( + Incoming::ConnAck(ConnAck { session_present: _, code: ConnectReturnCode::Success })) + ) => { + event_tx.send(Event::Notify("Connected".to_string()))?; + client.subscribe(sub_topic.clone(), QoS::AtMostOnce).await?; + event_tx.send(Event::Validate)?; + } + Ok(..) => {} + Err(e) => { + println!("{}", e); + event_tx.send(Event::Notify(e.to_string()))?; + sleep(Duration::from_millis(2000)).await; + } + } + } + } + } + event_tx.send(Event::Notify("Disconnected".to_string()))?; + + Ok(()) +} + +struct UpdatedPixmap { + pixmap: Pixmap, + full: bool, + notified: bool, +} + +pub struct RemoteDisplay { + id: Id, + rect: Rectangle, + children: Vec>, + pixmap: Arc>, + message_tx: tokio_mpsc::Sender, + connection_active: bool, +} + +impl RemoteDisplay { + pub fn new( + rect: Rectangle, + hub: &Hub, + rq: &mut RenderQueue, + context: &mut Context, + ) -> RemoteDisplay { + let id = ID_FEEDER.next(); + let children = Vec::new(); + rq.add(RenderData::new(id, rect, UpdateMode::Full)); + + let pixmap = Arc::new(Mutex::new(UpdatedPixmap { + pixmap: Pixmap::new(rect.width(), rect.height(), 1), + full: false, + notified: true, + })); + let message_tx = RemoteDisplay::spawn_connection( + hub.clone(), + pixmap.clone(), + &context.settings.remote_display, + ); + + RemoteDisplay { + id, + rect, + children, + pixmap, + message_tx, + connection_active: true, + } + } + + fn spawn_connection( + hub: Hub, + pixmap: Arc>, + remote_display_settings: &RemoteDisplaySettings, + ) -> tokio_mpsc::Sender { + let (message_tx, message_rx) = tokio_mpsc::channel(16); + + let address = remote_display_settings.address.clone(); + let topic = remote_display_settings.topic.clone(); + let key = remote_display_settings.key.clone(); + + spawn(move || { + match display_connection(hub.clone(), message_rx, pixmap, address, topic, key) { + Ok(..) => {} + Err(e) => { + hub.send(Event::Back).ok(); + hub.send(Event::Notify(e.to_string())).ok(); + } + } + }); + + message_tx + } +} + +impl View for RemoteDisplay { + fn handle_event( + &mut self, + evt: &Event, + hub: &Hub, + bus: &mut Bus, + rq: &mut RenderQueue, + context: &mut Context, + ) -> bool { + match *evt { + Event::Suspend => { + if self.connection_active { + self.message_tx.try_send(SocketEvent::Finished).ok(); + self.connection_active = false; + } + true + } + Event::Gesture(GestureEvent::Arrow { + dir: Dir::South, + start: _start, + end: _end, + }) => { + self.message_tx.try_send(SocketEvent::Finished).ok(); + bus.push_back(Event::Back); + true + } + Event::Gesture(ge) => { + self.message_tx + .try_send(SocketEvent::SendMessage(cbor!(ge).unwrap())) + .ok(); + match ge { + GestureEvent::HoldFingerShort(pt, _) | GestureEvent::Tap(pt) => { + if let Ok(mut locked_pixmap) = self.pixmap.lock() { + locked_pixmap.pixmap.draw_crosshair(pt, 5.0); + } + rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui)); + true + } + _ => true, + } + } + Event::ClockTick => { + if !self.connection_active && locate::(self).is_none() { + self.message_tx = RemoteDisplay::spawn_connection( + hub.clone(), + self.pixmap.clone(), + &context.settings.remote_display, + ); + self.connection_active = true; + } + false + } + Event::Device(DeviceEvent::Button { code, status, .. }) => { + let button = match code { + ButtonCode::Forward => "forward", + ButtonCode::Backward => "backward", + _ => return false, + }; + let status = match status { + ButtonStatus::Pressed => "pressed", + ButtonStatus::Released => "released", + ButtonStatus::Repeated => "repeated", + }; + self.message_tx + .try_send(SocketEvent::SendMessage( + cbor!({ + "type" => "button", + "value" => { + "button" => button, + "status" => status, + } + }) + .unwrap(), + )) + .ok(); + true + } + Event::Validate => { + self.message_tx + .try_send(SocketEvent::SendMessage( + cbor!({ + "type" => "size", + "value" => { + "width" => self.rect.width(), + "height" => self.rect.height(), + } + }) + .unwrap(), + )) + .ok(); + true + } + Event::Update(UpdateMode::Full) => { + rq.add(RenderData::new(self.id, self.rect, UpdateMode::Full)); + true + } + Event::Update(UpdateMode::Gui) => { + rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui)); + true + } + _ => false, + } + } + + fn render(&self, fb: &mut dyn Framebuffer, rect: Rectangle, _fonts: &mut Fonts) { + if let Ok(mut locked_pixmap) = self.pixmap.lock() { + let pixmap = &locked_pixmap.pixmap; + if fb.width() != pixmap.width || fb.height() != pixmap.height || pixmap.data.is_empty() + { + // Create a blank pixmap with correct dimensions to prevent panics + let blank = Pixmap::new(fb.width(), fb.height(), 1); + fb.draw_framed_pixmap(&blank, &rect, rect.min); + return; + } + fb.draw_framed_pixmap(pixmap, &rect, rect.min); + if !locked_pixmap.notified { + cbor!({ "type" => "displayUpdated", "full" => locked_pixmap.full }) + .map_err(|e| anyhow::anyhow!("Encoding displayUpdated response: {}", e)) + .and_then(|msg| { + self.message_tx + .try_send(SocketEvent::SendMessage(msg)) + .map_err(|e| anyhow::anyhow!("Sending displayUpdated response: {}", e)) + }) + .inspect_err(|e| println!("{}", e)) + .ok(); + locked_pixmap.notified = true; + } + } else { + // If mutex is poisoned or cannot be locked, draw blank + let pixmap = Pixmap::new(fb.width(), fb.height(), 1); + fb.draw_framed_pixmap(&pixmap, &rect, rect.min); + } + } + + fn render_rect(&self, rect: &Rectangle) -> Rectangle { + rect.intersection(&self.rect).unwrap_or(self.rect) + } + + fn is_background(&self) -> bool { + true + } + + fn resize(&mut self, rect: Rectangle, hub: &Hub, rq: &mut RenderQueue, context: &mut Context) { + // Floating windows. + for i in 0..self.children.len() { + self.children[i].resize(rect, hub, rq, context); + } + + self.rect = rect; + hub.send(Event::Validate).ok(); + rq.add(RenderData::new(self.id, self.rect, UpdateMode::Full)); + } + + fn rect(&self) -> &Rectangle { + &self.rect + } + + fn rect_mut(&mut self) -> &mut Rectangle { + &mut self.rect + } + + fn children(&self) -> &Vec> { + &self.children + } + + fn children_mut(&mut self) -> &mut Vec> { + &mut self.children + } + + fn id(&self) -> Id { + self.id + } +} diff --git a/crates/emulator/src/main.rs b/crates/emulator/src/main.rs index 82a1b9f7..63da1efc 100644 --- a/crates/emulator/src/main.rs +++ b/crates/emulator/src/main.rs @@ -31,6 +31,7 @@ use plato_core::view::calculator::Calculator; use plato_core::view::sketch::Sketch; use plato_core::view::touch_events::TouchEvents; use plato_core::view::rotation_values::RotationValues; +use plato_core::view::remote_display::RemoteDisplay; use plato_core::view::common::{locate, locate_by_id, transfer_notifications, overlapping_rectangle}; use plato_core::view::common::{toggle_input_history_menu, toggle_keyboard_layout_menu}; use plato_core::helpers::{load_toml, save_toml}; @@ -450,6 +451,9 @@ fn main() -> Result<(), Error> { AppCmd::RotationValues => { Box::new(RotationValues::new(context.fb.rect(), &mut rq, &mut context)) }, + AppCmd::RemoteDisplay => { + Box::new(RemoteDisplay::new(context.fb.rect(), &tx, &mut rq, &mut context)) + } }; transfer_notifications(view.as_mut(), next_view.as_mut(), &mut rq, &mut context); history.push(view as Box); diff --git a/crates/plato/src/app.rs b/crates/plato/src/app.rs index 980ba81e..5a3a39d2 100644 --- a/crates/plato/src/app.rs +++ b/crates/plato/src/app.rs @@ -20,6 +20,7 @@ use plato_core::view::calculator::Calculator; use plato_core::view::sketch::Sketch; use plato_core::view::touch_events::TouchEvents; use plato_core::view::rotation_values::RotationValues; +use plato_core::view::remote_display::RemoteDisplay; use plato_core::document::sys_info_as_html; use plato_core::input::{DeviceEvent, PowerSource, ButtonCode, ButtonStatus, VAL_RELEASE, VAL_PRESS}; use plato_core::input::{raw_events, device_events, usb_events, display_rotate_event, button_scheme_event}; @@ -807,6 +808,9 @@ pub fn run() -> Result<(), Error> { AppCmd::RotationValues => { Box::new(RotationValues::new(context.fb.rect(), &mut rq, &mut context)) }, + AppCmd::RemoteDisplay => { + Box::new(RemoteDisplay::new(context.fb.rect(), &tx, &mut rq, &mut context)) + } }; transfer_notifications(view.as_mut(), next_view.as_mut(), &mut rq, &mut context); history.push(HistoryItem { diff --git a/crates/remote-display-video/Cargo.toml b/crates/remote-display-video/Cargo.toml new file mode 100644 index 00000000..7b3be8fd --- /dev/null +++ b/crates/remote-display-video/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "crates-remote-display-video" +version = "0.1.0" +authors = ["ewired <37567272+ewired@users.noreply.github.com>"] +edition = "2021" + +[lib] +doctest = false +test = false +crate-type = ["cdylib", "rlib"] + +[features] +default = ["console_error_panic_hook"] + +[dependencies] +wasm-bindgen = "0.2.84" +lazy_static = "1.4.0" +ciborium = "0.2.2" +serde = "1.0.219" +image = { version = "0.25.6", features = [ "qoi", "jpeg", "png" ], default-features = false } +zstd = "0.13.3" +bidiff = { git = "https://github.com/divvun/bidiff.git", rev = "b05865403c196225c8fcffcc89b540472de9ca4c" } +web-sys = { version = "0.3.77", features = ["console"] } +console_error_panic_hook = { version = "0.1.7", optional = true } + +[dev-dependencies] +wasm-bindgen-test = "0.3.34" diff --git a/crates/remote-display-video/build.sh b/crates/remote-display-video/build.sh new file mode 100755 index 00000000..59b54262 --- /dev/null +++ b/crates/remote-display-video/build.sh @@ -0,0 +1,5 @@ +#! /bin/sh + +deno run -A npm:wasm-pack build \ + --target=web crates/remote-display-video \ + -d ../../contrib/remote-display-webext/enc \ No newline at end of file diff --git a/crates/remote-display-video/src/lib.rs b/crates/remote-display-video/src/lib.rs new file mode 100644 index 00000000..e9819146 --- /dev/null +++ b/crates/remote-display-video/src/lib.rs @@ -0,0 +1,157 @@ +use ciborium::{cbor, into_writer, value::Value}; +use image::imageops::{grayscale_with_type, FilterType}; +use image::{DynamicImage, ImageFormat, Rgb}; +use std::cell::RefCell; +use std::io::Cursor; +use wasm_bindgen::prelude::*; +use web_sys::console::log_1; + +// Store the previous frame for delta updates in the future +thread_local! { + static PREVIOUS_QOI: RefCell>> = RefCell::new(None); +} + +#[wasm_bindgen] +pub fn png_to_display_update( + png_data: &[u8], + width: u32, + height: u32, + keyframe: bool, +) -> Result, JsValue> { + #[cfg(feature = "console_error_panic_hook")] + console_error_panic_hook::set_once(); + + log_1(&JsValue::from_str(&format!( + "Input PNG size: {} bytes", + png_data.len() + ))); + + // Load and resize the PNG image + let img: DynamicImage = image::load_from_memory(png_data) + .map_err(|e| JsValue::from_str(&format!("Failed to load image: {:?}", e)))? + .resize_exact(width, height, FilterType::Nearest); + let img = grayscale_with_type::, DynamicImage>(&img); + + let mut img_writer = Cursor::new(Vec::new()); + img.write_to(&mut img_writer, image::ImageFormat::Qoi) + .map_err(|e| JsValue::from_str(&format!("Failed to write QOI image: {:?}", e)))?; + + log_1(&JsValue::from_str(&format!( + "QOI image size: {} bytes", + img_writer.get_ref().len() + ))); + + // Store previous QOI image for delta updates + let previous_qoi = PREVIOUS_QOI.with(|prev| { + let result = prev.borrow().clone(); + *prev.borrow_mut() = Some(img_writer.get_ref().clone()); + if keyframe { + None + } else { + result + } + }); + + let (update_type, value) = if let Some(prev_img) = previous_qoi { + let mut delta = Cursor::new(Vec::new()); + bidiff::simple_diff( + prev_img.as_slice(), + img_writer.get_ref().as_slice(), + &mut delta, + ) + .map_err(|e| JsValue::from_str(&format!("Failed to calculate delta: {:?}", e)))?; + + let delta_data = delta.into_inner(); + log_1(&JsValue::from_str(&format!( + "Delta size: {} bytes", + delta_data.len() + ))); + + ("patchDisplay", delta_data) + } else { + ("updateDisplay", img_writer.get_ref().to_vec()) + }; + + let compressed = zstd::stream::encode_all(value.as_slice(), 3) + .map_err(|e| JsValue::from_str(&format!("Failed to compress image: {:?}", e)))?; + + log_1(&JsValue::from_str(&format!( + "Compressed size: {} bytes", + compressed.len() + ))); + + const MAX_SIZE: usize = 400_000; // 500KB + const FALLBACK_FORMAT: ImageFormat = ImageFormat::Jpeg; + if compressed.len() > MAX_SIZE { + log_1(&JsValue::from_str(&format!( + "Falling back to {:?} due to payload size", + FALLBACK_FORMAT + ))); + // Fallback to WebP encoding for large updates + let mut fallback_writer = Cursor::new(Vec::new()); + img.write_to(&mut fallback_writer, FALLBACK_FORMAT) + .map_err(|e| { + JsValue::from_str(&format!( + "Failed to write {:?} image: {:?}", + FALLBACK_FORMAT, e + )) + })?; + + // Compress the fallback data + log_1(&JsValue::from_str(&format!( + "{:?} size: {} bytes", + FALLBACK_FORMAT, + fallback_writer.get_ref().len() + ))); + + let fallback_compressed = zstd::stream::encode_all(fallback_writer.get_ref().as_slice(), 3) + .map_err(|e| { + JsValue::from_str(&format!( + "Failed to compress {:?} image: {:?}", + FALLBACK_FORMAT, e + )) + })?; + + log_1(&JsValue::from_str(&format!( + "Compressed {:?} size: {} bytes", + FALLBACK_FORMAT, + fallback_compressed.len() + ))); + + // Reset the previous frame storage to prevent diffing + PREVIOUS_QOI.with(|prev| { + *prev.borrow_mut() = None; + }); + + // Create a special fallback update type for the fallback + let binary_value = Value::Bytes(fallback_compressed); + let response = cbor!({ "type" => "updateDisplay", "value" => binary_value }) + .map_err(|e| JsValue::from_str(&format!("Failed to encode response: {:?}", e)))?; + let mut writer = Cursor::new(Vec::new()); + into_writer(&response, &mut writer) + .map_err(|e| JsValue::from_str(&format!("Failed to write response: {:?}", e)))?; + + let final_data = writer.into_inner(); + log_1(&JsValue::from_str(&format!( + "Final response size: {} bytes", + final_data.len() + ))); + + return Ok(final_data); + } + + let binary_value = Value::Bytes(compressed); + let response = cbor!({ "type" => update_type, "value" => binary_value }) + .map_err(|e| JsValue::from_str(&format!("Failed to encode response: {:?}", e)))?; + let mut writer = Cursor::new(Vec::new()); + into_writer(&response, &mut writer) + .map_err(|e| JsValue::from_str(&format!("Failed to write response: {:?}", e)))?; + + let final_data = writer.into_inner(); + log_1(&JsValue::from_str(&format!( + "Final response size: {} bytes", + final_data.len() + ))); + + Ok(final_data) +}