diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..136d6e8 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,4 @@ +edition = "2024" +reorder_imports = true +# imports_granularity = "Crate" + diff --git a/Cargo.lock b/Cargo.lock index 2625b80..3d12733 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -540,7 +540,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "itoa 1.0.15", + "itoa", "matchit", "memchr", "mime", @@ -730,9 +730,9 @@ dependencies = [ [[package]] name = "brotli" -version = "7.0.0" +version = "8.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" +checksum = "9991eea70ea4f293524138648e41ee89b0b2b12ddef3b255effa43c8056e0e0d" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -741,9 +741,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "4.0.3" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -751,9 +751,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.18.1" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "byte-unit" @@ -1167,15 +1167,15 @@ dependencies = [ [[package]] name = "cssparser" -version = "0.27.2" +version = "0.29.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "754b69d351cdc2d8ee09ae203db831e005560fc6030da058f86ad60c92a9cb0a" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" dependencies = [ "cssparser-macros", "dtoa-short", - "itoa 0.4.8", + "itoa", "matches", - "phf 0.8.0", + "phf 0.10.1", "proc-macro2", "quote", "smallvec", @@ -2243,7 +2243,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.9.0", + "indexmap 2.10.0", "slab", "tokio", "tokio-util", @@ -2309,16 +2309,14 @@ dependencies = [ [[package]] name = "html5ever" -version = "0.26.0" +version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" dependencies = [ "log", "mac", "markup5ever", - "proc-macro2", - "quote", - "syn 1.0.109", + "match_token", ] [[package]] @@ -2329,7 +2327,7 @@ checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ "bytes", "fnv", - "itoa 1.0.15", + "itoa", ] [[package]] @@ -2381,7 +2379,7 @@ dependencies = [ "http-body", "httparse", "httpdate", - "itoa 1.0.15", + "itoa", "pin-project-lite", "smallvec", "tokio", @@ -2591,9 +2589,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", "hashbrown 0.15.4", @@ -2680,12 +2678,6 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" -[[package]] -name = "itoa" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" - [[package]] name = "itoa" version = "1.0.15" @@ -2806,14 +2798,13 @@ dependencies = [ [[package]] name = "kuchikiki" -version = "0.8.2" +version = "0.8.8-speedreader" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f29e4755b7b995046f510a7520c42b2fed58b77bd94d5a87a8eb43d2fd126da8" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" dependencies = [ "cssparser", "html5ever", - "indexmap 1.9.3", - "matches", + "indexmap 2.10.0", "selectors", ] @@ -2874,9 +2865,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638" dependencies = [ "bitflags 2.9.1", "libc", @@ -2974,6 +2965,7 @@ dependencies = [ "serde", "specta", "tokio", + "tokio-util", "uuid", ] @@ -3014,18 +3006,29 @@ dependencies = [ [[package]] name = "markup5ever" -version = "0.11.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" dependencies = [ "log", - "phf 0.10.1", - "phf_codegen 0.10.0", + "phf 0.11.3", + "phf_codegen 0.11.3", "string_cache", "string_cache_codegen", "tendril", ] +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "matchbox_protocol" version = "0.12.0" @@ -3173,9 +3176,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4de14a9b5d569ca68d7c891d613b390cf5ab4f851c77aaa2f9e435555d3d9492" +checksum = "58b89bf91c19bf036347f1ab85a81c560f08c0667c8601bece664d860a600988" dependencies = [ "crossbeam-channel", "dpi", @@ -3758,9 +3761,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros 0.8.0", "phf_shared 0.8.0", - "proc-macro-hack", ] [[package]] @@ -3769,7 +3770,9 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ + "phf_macros 0.10.0", "phf_shared 0.10.0", + "proc-macro-hack", ] [[package]] @@ -3794,12 +3797,12 @@ dependencies = [ [[package]] name = "phf_codegen" -version = "0.10.0" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", + "phf_generator 0.11.3", + "phf_shared 0.11.3", ] [[package]] @@ -3834,12 +3837,12 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", + "phf_generator 0.10.0", + "phf_shared 0.10.0", "proc-macro-hack", "proc-macro2", "quote", @@ -3932,7 +3935,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d77244ce2d584cd84f6a15f86195b8c9b2a0dfbfd817c09e0464244091a58ed" dependencies = [ "base64 0.22.1", - "indexmap 2.9.0", + "indexmap 2.10.0", "quick-xml", "serde", "time", @@ -4817,22 +4820,20 @@ dependencies = [ [[package]] name = "selectors" -version = "0.22.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" dependencies = [ "bitflags 1.3.2", "cssparser", "derive_more 0.99.20", "fxhash", "log", - "matches", "phf 0.8.0", "phf_codegen 0.8.0", "precomputed-hash", "servo_arc", "smallvec", - "thin-slice", ] [[package]] @@ -4915,7 +4916,7 @@ version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ - "itoa 1.0.15", + "itoa", "memchr", "ryu", "serde", @@ -4927,7 +4928,7 @@ version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" dependencies = [ - "itoa 1.0.15", + "itoa", "serde", ] @@ -4958,7 +4959,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ "form_urlencoded", - "itoa 1.0.15", + "itoa", "ryu", "serde", ] @@ -4973,7 +4974,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.9.0", + "indexmap 2.10.0", "schemars 0.9.0", "serde", "serde_derive", @@ -5018,9 +5019,9 @@ dependencies = [ [[package]] name = "servo_arc" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98238b800e0d1576d8b6e3de32827c2d74bee68bb97748dcf5071fb53965432" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" dependencies = [ "nodrop", "stable_deref_trait", @@ -5399,9 +5400,9 @@ dependencies = [ [[package]] name = "tao" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e59c1f38e657351a2e822eadf40d6a2ad4627b9c25557bc1180ec1b3295ef82" +checksum = "49c380ca75a231b87b6c9dd86948f035012e7171d1a7c40a9c2890489a7ffd8a" dependencies = [ "bitflags 2.9.1", "core-foundation 0.10.1", @@ -5461,17 +5462,16 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.5.1" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7b0bc1aec81bda6bc455ea98fcaed26b3c98c1648c627ad6ff1c704e8bf8cbc" +checksum = "773663ec28d911ac02f3a478c55f7f2fa581368d9e16ce9dff8d650b3666f91e" dependencies = [ "anyhow", "bytes", "dirs", "dunce", "embed_plist", - "futures-util", - "getrandom 0.2.16", + "getrandom 0.3.3", "glob", "gtk", "heck 0.5.0", @@ -5513,9 +5513,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a0350f0df1db385ca5c02888a83e0e66655c245b7443db8b78a70da7d7f8fc" +checksum = "12f025c389d3adb83114bec704da973142e82fc6ec799c7c750c5e21cefaec83" dependencies = [ "anyhow", "cargo_toml", @@ -5535,9 +5535,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93f035551bf7b11b3f51ad9bc231ebbe5e085565527991c16cf326aa38cdf47" +checksum = "f5df493a1075a241065bc865ed5ef8d0fbc1e76c7afdc0bf0eccfaa7d4f0e406" dependencies = [ "base64 0.22.1", "brotli", @@ -5562,9 +5562,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db4df25e2d9d45de0c4c910da61cd5500190da14ae4830749fee3466dddd112" +checksum = "f237fbea5866fa5f2a60a21bea807a2d6e0379db070d89c3a10ac0f2d4649bbc" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -5576,9 +5576,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37a5ebe6a610d1b78a94650896e6f7c9796323f408800cef436e0fa0539de601" +checksum = "1d9a0bd00bf1930ad1a604d08b0eb6b2a9c1822686d65d7f4731a7723b8901d3" dependencies = [ "anyhow", "glob", @@ -5593,9 +5593,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.2.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a33318fe222fc2a612961de8b0419e2982767f213f54a4d3a21b0d7b85c41df8" +checksum = "1aefb14219b492afb30b12647b5b1247cadd2c0603467310c36e0f7ae1698c28" dependencies = [ "log", "raw-window-handle", @@ -5611,9 +5611,9 @@ dependencies = [ [[package]] name = "tauri-plugin-fs" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ead0daec5d305adcefe05af9d970fc437bcc7996052d564e7393eb291252da" +checksum = "c341290d31991dbca38b31d412c73dfbdb070bb11536784f19dd2211d13b778f" dependencies = [ "anyhow", "dunce", @@ -5633,9 +5633,9 @@ dependencies = [ [[package]] name = "tauri-plugin-geolocation" -version = "2.2.5" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dc06e23cb12d17533aca6001252f7f0fdbd295868194748821d0f0e6fbfbb59" +checksum = "d3e5d020065f33a6590a5fbf7bdd508845399fbe644c2daeb51be319df99fdd7" dependencies = [ "log", "serde", @@ -5647,9 +5647,9 @@ dependencies = [ [[package]] name = "tauri-plugin-log" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1063890b541877c4367d0ee9b1758360d86398862fbaafdd99aac02a55e28bf8" +checksum = "a59139183e0907cec1499dddee4e085f5a801dc659efa0848ee224f461371426" dependencies = [ "android_logger", "byte-unit", @@ -5669,9 +5669,9 @@ dependencies = [ [[package]] name = "tauri-plugin-notification" -version = "2.2.3" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1c87f171cdb35c3aa8f17e8dfd84c1b9f68eb4086ec16a5a1b9f13b5541c574" +checksum = "cfe06ed89cff6d0ec06ff4f544fb961e4718348a33309f56ccb2086e77bc9116" dependencies = [ "log", "notify-rust", @@ -5688,9 +5688,9 @@ dependencies = [ [[package]] name = "tauri-plugin-opener" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8983f50326d34437142a6d560b5c3426e91324297519b6eeb32ed0a1d1e0f2" +checksum = "ecee219f11cdac713ab32959db5d0cceec4810ba4f4458da992292ecf9660321" dependencies = [ "dunce", "glob", @@ -5710,9 +5710,9 @@ dependencies = [ [[package]] name = "tauri-plugin-store" -version = "2.2.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ada7e7aeea472dec9b8d09d25301e59fe3e8330dc11dbcf903d6388126cb3722" +checksum = "5916c609664a56c82aeaefffca9851fd072d4d41f73d63f22ee3ee451508194f" dependencies = [ "dunce", "serde", @@ -5726,9 +5726,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00f004905d549854069e6774533d742b03cacfd6f03deb08940a8677586cbe39" +checksum = "9e7bb73d1bceac06c20b3f755b2c8a2cb13b20b50083084a8cf3700daf397ba4" dependencies = [ "cookie", "dpi", @@ -5748,9 +5748,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f85d056f4d4b014fe874814034f3416d57114b617a493a4fe552580851a3f3a2" +checksum = "fe52ed0ef40fd7ad51a620ecb3018e32eba3040bb95025216a962a37f6f050c5" dependencies = [ "gtk", "http", @@ -5803,9 +5803,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2900399c239a471bcff7f15c4399eb1a8c4fe511ba2853e07c996d771a5e0a4" +checksum = "41743bbbeb96c3a100d234e5a0b60a46d5aa068f266160862c7afdbf828ca02e" dependencies = [ "anyhow", "brotli", @@ -5846,7 +5846,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8d321dbc6f998d825ab3f0d62673e810c861aac2d0de2cc2c395328f1d113b4" dependencies = [ "embed-resource", - "indexmap 2.9.0", + "indexmap 2.10.0", "toml", ] @@ -5886,12 +5886,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "thin-slice" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" - [[package]] name = "thiserror" version = "1.0.69" @@ -5939,7 +5933,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" dependencies = [ "deranged", - "itoa 1.0.15", + "itoa", "libc", "num-conv", "num_threads", @@ -6094,7 +6088,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.9.0", + "indexmap 2.10.0", "toml_datetime", "winnow 0.5.40", ] @@ -6105,7 +6099,7 @@ version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" dependencies = [ - "indexmap 2.9.0", + "indexmap 2.10.0", "toml_datetime", "winnow 0.5.40", ] @@ -6116,7 +6110,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.9.0", + "indexmap 2.10.0", "serde", "serde_spanned", "toml_datetime", @@ -6211,9 +6205,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.20.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7eee98ec5c90daf179d55c20a49d8c0d043054ce7c26336c09a24d31f14fa0" +checksum = "2da75ec677957aa21f6e0b361df0daab972f13a5bee3606de0638fd4ee1c666a" dependencies = [ "crossbeam-channel", "dirs", @@ -6907,9 +6901,9 @@ dependencies = [ [[package]] name = "webview2-com" -version = "0.37.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b542b5cfbd9618c46c2784e4d41ba218c336ac70d44c55e47b251033e7d85601" +checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" dependencies = [ "webview2-com-macros", "webview2-com-sys", @@ -6932,9 +6926,9 @@ dependencies = [ [[package]] name = "webview2-com-sys" -version = "0.37.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae2d11c4a686e4409659d7891791254cf9286d3cfe0eef54df1523533d22295" +checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" dependencies = [ "thiserror 2.0.12", "windows", @@ -7073,9 +7067,9 @@ dependencies = [ [[package]] name = "windows-registry" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3bab093bdd303a1240bb99b8aba8ea8a69ee19d34c9e2ef9594e708a4878820" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ "windows-link", "windows-result", @@ -7384,9 +7378,9 @@ checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" [[package]] name = "wry" -version = "0.51.2" +version = "0.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c886a0a9d2a94fd90cfa1d929629b79cfefb1546e2c7430c63a47f0664c0e4e2" +checksum = "12a714d9ba7075aae04a6e50229d6109e3d584774b99a6a8c60de1698ca111b9" dependencies = [ "base64 0.22.1", "block2 0.6.1", diff --git a/TODO.md b/TODO.md index 5f5b01c..e7ccf1d 100644 --- a/TODO.md +++ b/TODO.md @@ -23,8 +23,8 @@ - [x] Transport : Handle transport cancellation better - [x] Backend : Add checks for when the `powerup_locations` field is an empty array in settings - [ ] Backend : More tests - - [ ] Lobby tests - - [ ] Game end test for actual return from loop + - [x] Lobby tests + - [x] Game end test for actual return from loop - [ ] Testing crate for integration testing from a DSL - [ ] NixOS VM tests wrapping the testing crate - [ ] Nix : Cheat the dependency nightmare and use crane diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 8957b31..b49144a 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -3,7 +3,7 @@ name = "manhunt-app" version = "0.1.0" description = "A mobile app for playing the game \"manhunt\"" authors = ["Ben C "] -edition = "2021" +edition = "2024" default-run = "manhunt-app" [lib] diff --git a/backend/src/export_types.rs b/backend/src/export_types.rs index 7e4b47d..0ab2fcb 100644 --- a/backend/src/export_types.rs +++ b/backend/src/export_types.rs @@ -8,7 +8,9 @@ pub fn main() { let path = args.get(1).expect("Usage: export-types path"); let specta = mk_specta(); let mut lang = Typescript::new(); - lang.header = Cow::Borrowed("/* eslint @typescript-eslint/no-unused-vars: 0 */\n/* eslint @typescript-eslint/no-explicit-any: 0 */"); + lang.header = Cow::Borrowed( + "/* eslint @typescript-eslint/no-unused-vars: 0 */\n/* eslint @typescript-eslint/no-explicit-any: 0 */", + ); specta.export(lang, path).expect("Failed to export types"); println!("Successfully exported types, events, and commands to {path}",); } diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 77175e8..676ef6e 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -6,16 +6,16 @@ use std::{collections::HashMap, marker::PhantomData, sync::Arc, time::Duration}; use anyhow::Context; use location::TauriLocation; -use log::{error, info, warn, LevelFilter}; +use log::{LevelFilter, error, info, warn}; use manhunt_logic::{ Game as BaseGame, GameSettings, GameUiState, Lobby as BaseLobby, LobbyState, PlayerProfile, StartGameInfo, StateUpdateSender, }; -use manhunt_transport::{generate_join_code, room_exists, MatchboxTransport}; +use manhunt_transport::{MatchboxTransport, generate_join_code, room_exists}; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Manager, State}; use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; -use tauri_specta::{collect_commands, collect_events, ErrorHandlingMode, Event}; +use tauri_specta::{ErrorHandlingMode, Event, collect_commands, collect_events}; use tokio::sync::RwLock; use uuid::Uuid; @@ -213,7 +213,7 @@ impl AppState { let mut state = state_handle.write().await; match res { Ok(Some(start)) => { - info!("Starting game as"); + info!("Starting Game"); state.start_game(app_game, start).await; } Ok(None) => { diff --git a/flake.lock b/flake.lock index 997580e..2a63d81 100644 --- a/flake.lock +++ b/flake.lock @@ -36,11 +36,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1750731501, - "narHash": "sha256-Ah4qq+SbwMaGkuXCibyg+Fwn00el4KmI3XFX6htfDuk=", + "lastModified": 1750811787, + "narHash": "sha256-rD/978c35JXz6JLAzciTIOCMenPumF6zrQOj4rVZeHE=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "69dfebb3d175bde602f612915c5576a41b18486b", + "rev": "992f916556fcfaa94451ebc7fc6e396134bbf5b1", "type": "github" }, "original": { @@ -62,11 +62,11 @@ "nixpkgs": ["nixpkgs"] }, "locked": { - "lastModified": 1750732748, - "narHash": "sha256-HR2b3RHsPeJm+Fb+1ui8nXibgniVj7hBNvUbXEyz0DU=", + "lastModified": 1750819193, + "narHash": "sha256-XvkupGPZqD54HuKhN/2WhbKjAHeTl1UEnWspzUzRFfA=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "4b4494b2ba7e8a8041b2e28320b2ee02c115c75f", + "rev": "1ba3b9c59b68a4b00156827ad46393127b51b808", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index f177532..3b14bb9 100644 --- a/flake.nix +++ b/flake.nix @@ -18,7 +18,7 @@ formatters = pkgs: let prettier = "${pkgs.prettier}/bin/prettier --write ."; alejandra = "${pkgs.alejandra}/bin/alejandra ."; - rustfmt = "${pkgs.rustfmt}/bin/rustfmt"; + rustfmt = "${pkgs.rustfmt}/bin/rustfmt fmt"; just = "${pkgs.just}/bin/just --fmt --unstable"; in { "justfile" = just; diff --git a/manhunt-logic/Cargo.toml b/manhunt-logic/Cargo.toml index 54ba696..f81b268 100644 --- a/manhunt-logic/Cargo.toml +++ b/manhunt-logic/Cargo.toml @@ -10,5 +10,6 @@ rand = { version = "0.9.1", features = ["thread_rng"] } rand_chacha = "0.9.0" serde = { version = "1.0.219", features = ["derive"] } specta = { version = "=2.0.0-rc.22", features = ["uuid", "chrono", "derive"] } -tokio = { version = "1.45.1", features = ["macros", "rt", "sync", "time"] } +tokio = { version = "1.45.1", features = ["macros", "rt", "sync", "time", "test-util"] } +tokio-util = "0.7.15" uuid = { version = "1.17.0", features = ["serde", "v4"] } diff --git a/manhunt-logic/src/game.rs b/manhunt-logic/src/game.rs index 8bcab77..2d32116 100644 --- a/manhunt-logic/src/game.rs +++ b/manhunt-logic/src/game.rs @@ -1,9 +1,10 @@ use anyhow::bail; use chrono::{DateTime, Utc}; use std::{sync::Arc, time::Duration}; +use tokio_util::sync::CancellationToken; use uuid::Uuid; -use tokio::{sync::RwLock, time::MissedTickBehavior}; +use tokio::sync::RwLock; use crate::StartGameInfo; use crate::{prelude::*, transport::TransportMessage}; @@ -35,6 +36,7 @@ pub struct Game { location: L, state_update_sender: S, interval: Duration, + cancel: CancellationToken, } impl Game { @@ -57,6 +59,7 @@ impl Game { interval, state: RwLock::new(state), state_update_sender, + cancel: CancellationToken::new(), } } @@ -71,8 +74,8 @@ impl Game { state.remove_ping(id); // TODO: Maybe reroll for new powerups (specifically seeker ones) instead of just erasing it state.use_powerup(); - - self.send_event(GameEvent::PlayerCaught(state.id)).await; + drop(state); + self.send_event(GameEvent::PlayerCaught(id)).await; } pub async fn clone_settings(&self) -> GameSettings { @@ -250,26 +253,36 @@ impl Game { false } + pub async fn quit_game(&self) { + self.cancel.cancel(); + } + #[cfg(test)] - pub async fn force_tick(&self, now: UtcDT) { - let mut state = self.state.write().await; - self.tick(&mut state, now).await; + fn get_now() -> UtcDT { + let fake = tokio::time::Instant::now(); + let real = std::time::Instant::now(); + Utc::now() + (fake.into_std().duration_since(real) + Duration::from_secs(1)) } - pub async fn quit_game(&self) { - self.transport.disconnect().await; + #[cfg(not(test))] + fn get_now() -> UtcDT { + Utc::now() } /// Main loop of the game, handles ticking and receiving messages from [Transport]. pub async fn main_loop(&self) -> Result> { let mut interval = tokio::time::interval(self.interval); - interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + // interval.set_missed_tick_behavior(MissedTickBehavior::Delay); let res = 'game: loop { tokio::select! { biased; + _ = self.cancel.cancelled() => { + break 'game Ok(None); + } + messages = self.transport.receive_messages() => { let mut state = self.state.write().await; for (id, msg) in messages { @@ -286,7 +299,7 @@ impl Game { _ = interval.tick() => { let mut state = self.state.write().await; - let should_break = self.tick(&mut state, Utc::now()).await; + let should_break = self.tick(&mut state, Self::get_now()).await; if should_break { let history = state.as_game_history(); @@ -313,21 +326,23 @@ mod tests { }; use super::*; - use tokio::{task::yield_now, test}; + use tokio::{sync::oneshot, task::yield_now, test}; type TestGame = Game; + type EndRecv = oneshot::Receiver>>; + struct MockMatch { uuids: Vec, - games: HashMap>, + games: Vec>, settings: GameSettings, - mock_now: UtcDT, } - const INTERVAL: Duration = Duration::from_secs(u64::MAX); + const INTERVAL: Duration = Duration::from_secs(600000); impl MockMatch { pub fn new(settings: GameSettings, players: u32, seekers: u32) -> Self { + tokio::time::pause(); let (uuids, transports) = MockTransport::create_mesh(players); let initial_caught_state = (0..players) @@ -336,8 +351,7 @@ mod tests { let games = transports .into_iter() - .enumerate() - .map(|(id, transport)| { + .map(|transport| { let location = MockLocation; let start_info = StartGameInfo { initial_caught_state: initial_caught_state.clone(), @@ -351,63 +365,71 @@ mod tests { DummySender, ); - (id as u32, Arc::new(game)) + Arc::new(game) }) - .collect::>(); + .collect(); Self { settings, games, uuids, - mock_now: Utc::now(), } } - pub async fn start(&self) { - for game in self.games.values() { + pub async fn start(&self) -> Vec { + let mut recvs = Vec::with_capacity(self.games.len()); + for game in self.games.iter() { let game = game.clone(); + let (send, recv) = oneshot::channel(); + recvs.push(recv); tokio::spawn(async move { - game.main_loop().await.expect("Game Start Fail"); + let res = game.main_loop().await; + send.send(res).expect("Failed to send"); }); yield_now().await; } + recvs } - pub async fn pass_time(&mut self, d: Duration) { - self.mock_now += d; - } - - pub async fn assert_all_states(&self, f: impl Fn(&GameState)) { - for game in self.games.values() { + pub async fn assert_all_states(&self, f: impl Fn(usize, &GameState)) { + for (i, game) in self.games.iter().enumerate() { let state = game.state.read().await; - f(&state); + f(i, &state); } } - pub fn game(&self, id: u32) -> &TestGame { - self.games.get(&id).as_ref().unwrap() + pub fn assert_all_transports_disconnected(&self) { + for game in self.games.iter() { + assert!( + game.transport.is_disconnected(), + "Game {} is still connected", + game.transport.self_id() + ); + } } pub async fn wait_for_seekers(&mut self) { let hiding_time = Duration::from_secs(self.settings.hiding_time_seconds as u64 + 1); - self.mock_now += hiding_time; + + tokio::time::sleep(hiding_time).await; self.tick().await; - self.assert_all_states(|s| { - assert!(s.seekers_released()); + self.assert_all_states(|i, s| { + assert!(s.seekers_released(), "Seekers not released on game {i}"); }) .await; } - async fn tick_all(&self, now: UtcDT) { - for game in self.games.values() { - game.force_tick(now).await; + pub async fn wait_for_transports(&self) { + for game in self.games.iter() { + game.transport.wait_for_queue_empty().await; } } pub async fn tick(&self) { - self.tick_all(self.mock_now).await; + tokio::time::sleep(INTERVAL + Duration::from_secs(1)).await; + self.wait_for_transports().await; yield_now().await; } } @@ -436,29 +458,51 @@ mod tests { // 2 players, one is a seeker let mut mat = MockMatch::new(settings, 2, 1); - mat.start().await; + let recvs = mat.start().await; mat.wait_for_seekers().await; - mat.game(1).mark_caught().await; + mat.games[1].mark_caught().await; - mat.tick().await; + mat.wait_for_transports().await; - mat.assert_all_states(|s| { + mat.assert_all_states(|i, s| { assert_eq!( s.get_caught(mat.uuids[1]), Some(true), - "Game {} sees player 1 as not caught", - s.id + "Game {i} sees player 1 as not caught", ); }) .await; - // Extra tick for post-game syncing + // Tick to process game end mat.tick().await; - mat.assert_all_states(|s| assert!(s.game_ended(), "Game {} has not ended", s.id)) - .await; + mat.assert_all_states(|i, s| { + assert!(s.game_ended(), "Game {} has not ended", i); + }) + .await; + + // Tick for post-game sync + mat.tick().await; + + mat.assert_all_transports_disconnected(); + + for (i, recv) in recvs.into_iter().enumerate() { + let res = recv.await.expect("Failed to recv"); + match res { + Ok(Some(hist)) => { + assert!(!hist.locations.is_empty(), "Game {i} has no locations"); + assert!(!hist.events.is_empty(), "Game {i} has no event"); + } + Ok(None) => { + panic!("Game {i} exited without a history (did not end via post game sync)"); + } + Err(why) => { + panic!("Game {i} encountered error: {why:?}"); + } + } + } } #[test] @@ -472,47 +516,40 @@ mod tests { mat.wait_for_seekers().await; - mat.assert_all_states(|s| { + mat.assert_all_states(|i, s| { for id in 0..4 { let ping = s.get_ping(mat.uuids[id]); if id == 0 { assert!( ping.is_none(), - "Game 0 is a seeker and shouldn't be pinged (in {})", - s.id + "Game {i} has a ping for 0, despite them being a seeker", ); } else { assert!( ping.is_some(), - "Game {} is a hider and should be pinged (in {})", - id, - s.id + "Game {i} doesn't have a ping for {id}, despite them being a hider", ); } } }) .await; - mat.game(1).mark_caught().await; + mat.games[1].mark_caught().await; mat.tick().await; - mat.assert_all_states(|s| { + mat.assert_all_states(|i, s| { for id in 0..4 { let ping = s.get_ping(mat.uuids[id]); if id <= 1 { assert!( ping.is_none(), - "Game {} is a seeker and shouldn't be pinged (in {})", - id, - s.id + "Game {i} has a ping for {id}, despite them being a seeker", ); } else { assert!( ping.is_some(), - "Game {} is a hider and should be pinged (in {})", - id, - s.id + "Game {i} doesn't have a ping for {id}, despite them being a hider", ); } } @@ -539,21 +576,20 @@ mod tests { mat.start().await; mat.tick().await; mat.wait_for_seekers().await; - mat.pass_time(Duration::from_secs(60)).await; + tokio::time::sleep(Duration::from_secs(60)).await; mat.tick().await; - let game = mat.game(0); + let game = mat.games[0].clone(); let state = game.state.read().await; let location = state.powerup_location().expect("Powerup didn't spawn"); drop(state); - mat.assert_all_states(|s| { + mat.assert_all_states(|i, s| { assert_eq!( s.powerup_location(), Some(location), - "Game {} has a different location than 0", - s.id + "Game {i} has a different location than 0", ); }) .await; @@ -562,28 +598,31 @@ mod tests { #[test] async fn test_powerup_ping_seeker_as_you() { let mut settings = mk_settings(); - settings.ping_minutes_interval = 0; + settings.ping_minutes_interval = 1; let mut mat = MockMatch::new(settings, 2, 1); mat.start().await; mat.wait_for_seekers().await; - let game = mat.game(1); + mat.tick().await; + + tokio::time::sleep(Duration::from_secs(60)).await; + + let game = mat.games[1].clone(); let mut state = game.state.write().await; state.force_set_powerup(PowerUpType::PingSeeker); drop(state); mat.tick().await; - mat.assert_all_states(|s| { + mat.assert_all_states(|i, s| { if let Some(ping) = s.get_ping(mat.uuids[1]) { assert_eq!( ping.real_player, mat.uuids[0], - "Ping for 1 is not truly 0 (in {})", - s.id + "Game {i} has a ping for 1, but it wasn't from 0" ); } else { - panic!("No ping for 1 (in {})", s.id); + panic!("Game {i} has no ping for 1"); } }) .await; @@ -591,14 +630,15 @@ mod tests { #[test] async fn test_powerup_ping_random_hider() { - let settings = mk_settings(); + let mut settings = mk_settings(); + settings.ping_minutes_interval = u32::MAX; let mut mat = MockMatch::new(settings, 3, 1); mat.start().await; mat.wait_for_seekers().await; - let game = mat.game(1); + let game = mat.games[1].clone(); let mut state = game.state.write().await; state.force_set_powerup(PowerUpType::ForcePingOther); drop(state); @@ -606,12 +646,21 @@ mod tests { game.use_powerup().await; mat.tick().await; - mat.assert_all_states(|s| { - // Player 0 is a seeker, player 1 user the powerup, so 2 is the only one that should - // could have pinged - assert!(s.get_ping(mat.uuids[2]).is_some()); - assert!(s.get_ping(mat.uuids[0]).is_none()); - assert!(s.get_ping(mat.uuids[1]).is_none()); + mat.assert_all_states(|i, s| { + // Player 0 is a seeker, player 1 used the powerup, so 2 is the only one that should + // have pinged + assert!( + s.get_ping(mat.uuids[2]).is_some(), + "Ping 2 is not present in game {i}" + ); + assert!( + s.get_ping(mat.uuids[0]).is_none(), + "Ping 0 is present in game {i}" + ); + assert!( + s.get_ping(mat.uuids[1]).is_none(), + "Ping 1 is present in game {i}" + ); }) .await; } @@ -624,21 +673,24 @@ mod tests { mat.start().await; - let game = mat.game(3); + mat.tick().await; + + let game = mat.games[3].clone(); let mut state = game.state.write().await; state.force_set_powerup(PowerUpType::PingAllSeekers); drop(state); game.use_powerup().await; + // One tick to send out the ForcePing + mat.tick().await; + // One tick to for the seekers to reply mat.tick().await; - mat.assert_all_states(|s| { + mat.assert_all_states(|i, s| { for id in 0..3 { assert!( - s.get_caught(mat.uuids[id]).is_some(), - "Player {} should be pinged due to the powerup (in {})", - id, - s.id + &s.get_ping(mat.uuids[id]).is_some(), + "Game {i} does not have a ping for {id}, despite the powerup being active", ); } }) @@ -650,25 +702,30 @@ mod tests { let settings = mk_settings(); let mat = MockMatch::new(settings, 4, 1); - mat.start().await; + let mut recvs = mat.start().await; - let game = mat.game(2); - game.quit_game().await; + let game = mat.games[2].clone(); let id = game.state.read().await.id; + game.quit_game().await; + let res = recvs.swap_remove(2).await.expect("Failed to recv"); + assert!(res.is_ok_and(|o| o.is_none()), "2 did not exit cleanly"); + assert!( + game.transport.is_disconnected(), + "2's transport is not disconnected" + ); + mat.tick().await; - mat.assert_all_states(|s| { + mat.assert_all_states(|i, s| { if s.id != id { assert!( s.get_ping(id).is_none(), - "Game {} has not removed 2 from pings", - s.id + "Game {i} has not removed 2 from pings", ); assert!( s.get_caught(id).is_none(), - "Game {} has not removed 2 from caught state", - s.id + "Game {i} has not removed 2 from caught state", ); } }) diff --git a/manhunt-logic/src/game_state.rs b/manhunt-logic/src/game_state.rs index 8f8d22f..3ce97ab 100644 --- a/manhunt-logic/src/game_state.rs +++ b/manhunt-logic/src/game_state.rs @@ -2,9 +2,9 @@ use std::collections::HashMap; use chrono::Utc; use rand::{ + Rng, SeedableRng, distr::{Bernoulli, Distribution}, seq::{IndexedRandom, IteratorRandom}, - Rng, SeedableRng, }; use rand_chacha::ChaCha20Rng; use serde::{Deserialize, Serialize}; @@ -416,8 +416,8 @@ pub struct GameHistory { my_id: Uuid, pub game_started: UtcDT, game_ended: UtcDT, - events: Vec<(UtcDT, GameEvent)>, - locations: Vec<(Uuid, Vec<(UtcDT, Location)>)>, + pub events: Vec<(UtcDT, GameEvent)>, + pub locations: Vec<(Uuid, Vec<(UtcDT, Location)>)>, } /// Subset of [GameState] that is meant to be sent to a UI frontend diff --git a/manhunt-logic/src/lobby.rs b/manhunt-logic/src/lobby.rs index f80cd5c..f12b84a 100644 --- a/manhunt-logic/src/lobby.rs +++ b/manhunt-logic/src/lobby.rs @@ -3,6 +3,7 @@ use std::{collections::HashMap, sync::Arc}; use anyhow::anyhow; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; use uuid::Uuid; use crate::{ @@ -31,7 +32,7 @@ pub enum LobbyMessage { PlayerSwitch(Uuid, bool), } -#[derive(Clone, Serialize, Deserialize, specta::Type)] +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] pub struct LobbyState { profiles: HashMap, join_code: String, @@ -48,38 +49,57 @@ pub struct Lobby { state: Mutex, transport: Arc, state_updates: U, + cancel: CancellationToken, } impl Lobby { pub async fn new( join_code: &str, - host: bool, + is_host: bool, profile: PlayerProfile, settings: GameSettings, state_updates: U, ) -> Result> { - let transport = T::initialize(join_code, host) + let transport = T::initialize(join_code, is_host) .await .context("Failed to connect to lobby")?; - let self_id = transport.self_id(); + let lobby = Arc::new(Self::new_with_transport( + join_code, + is_host, + profile, + settings, + state_updates, + transport, + )); - let lobby = Arc::new(Self { + Ok(lobby) + } + + pub fn new_with_transport( + join_code: &str, + is_host: bool, + profile: PlayerProfile, + settings: GameSettings, + state_updates: U, + transport: Arc, + ) -> Self { + let self_id = transport.self_id(); + Self { transport, state_updates, - is_host: host, + is_host, + cancel: CancellationToken::new(), join_code: join_code.to_string(), state: Mutex::new(LobbyState { teams: HashMap::from_iter([(self_id, false)]), join_code: join_code.to_string(), profiles: HashMap::from_iter([(self_id, profile)]), self_id, - is_host: host, + is_host, settings, }), - }); - - Ok(lobby) + } } fn emit_state_update(&self) { @@ -134,12 +154,13 @@ impl Lobby { .await .map(|start_game| Ok(Some(start_game))), TransportMessage::PeerConnect(peer) => { - let mut state = self.state.lock().await; - state.teams.insert(peer, false); + let state = self.state.lock().await; let id = state.self_id; let msg = LobbyMessage::PlayerSync(id, state.profiles[&id].clone()); + let msg2 = LobbyMessage::PlayerSwitch(id, state.teams[&id]); drop(state); self.send_transport_message(Some(peer), msg).await; + self.send_transport_message(Some(peer), msg2).await; if self.is_host { let state = self.state.lock().await; let msg = LobbyMessage::HostPush(state.settings.clone()); @@ -163,16 +184,24 @@ impl Lobby { let res = 'lobby: loop { self.emit_state_update(); - let msgs = self.transport.receive_messages().await; + tokio::select! { + biased; + + msgs = self.transport.receive_messages() => { + for (peer, msg) in msgs { + if let Some(res) = self.handle_message(peer, msg).await { + break 'lobby res; + } + } + } - for (peer, msg) in msgs { - if let Some(res) = self.handle_message(peer, msg).await { - break 'lobby res; + _ = self.cancel.cancelled() => { + break Ok(None); } } }; - if res.is_err() { + if let Ok(None) | Err(_) = res { self.transport.disconnect().await; } @@ -234,6 +263,327 @@ impl Lobby { } pub async fn quit_lobby(&self) { - self.transport.disconnect().await; + self.cancel.cancel(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use tokio::{sync::oneshot, task::yield_now, test}; + + use crate::tests::{DummySender, MockTransport}; + + type MockLobby = Lobby; + + type CompleteRecv = oneshot::Receiver>>; + + struct MockLobbyPool { + uuids: Vec, + lobbies: Vec>, + } + + impl MockLobbyPool { + pub fn new(num_players: u32) -> Self { + let settings = GameSettings::default(); + let (uuids, transports) = MockTransport::create_mesh(num_players); + + let lobbies = transports + .into_iter() + .enumerate() + .map(|(i, transport)| { + let profile = PlayerProfile { + display_name: format!("Lobby {i} ({})", uuids[i]), + pfp_base64: None, + }; + + Arc::new(MockLobby::new_with_transport( + "aaa", + i == 0, + profile, + settings.clone(), + DummySender, + Arc::new(transport), + )) + }) + .collect(); + + Self { uuids, lobbies } + } + + pub async fn wait(&self) { + for lobby in self.lobbies.iter() { + lobby.transport.wait_for_queue_empty().await; + } + yield_now().await; + } + + pub async fn start_all_loops(&self) -> Vec { + let mut recv_set = Vec::with_capacity(self.lobbies.len()); + for lobby in self.lobbies.iter() { + let lobby = lobby.clone(); + let (send, recv) = oneshot::channel(); + recv_set.push(recv); + tokio::spawn(async move { + let res = lobby.main_loop().await; + send.send(res).ok(); + }); + } + recv_set + } + + pub async fn player_join(&self, i: usize) { + self.lobbies[i].transport.fake_join().await; + } + + pub async fn assert_state(&self, i: usize, f: impl Fn(&LobbyState)) { + let state = self.lobbies[i].state.lock().await; + f(&state); + } + + pub async fn assert_all_states(&self, f: impl Fn(usize, &LobbyState)) { + for (i, lobby) in self.lobbies.iter().enumerate() { + let state = lobby.state.lock().await; + f(i, &state); + } + } + } + + #[test] + async fn test_joins() { + let mat = MockLobbyPool::new(3); + + mat.start_all_loops().await; + + mat.player_join(0).await; + mat.player_join(1).await; + + mat.wait().await; + + for i in 0..=1 { + for j in 0..=1 { + mat.assert_state(i, |s| { + assert!( + s.teams.contains_key(&mat.uuids[j]), + "{i} doesn't have {j}'s uuid in teams" + ); + assert!( + s.profiles.contains_key(&mat.uuids[j]), + "{i} doesn't have {j}'s uuid in profiles" + ); + }) + .await; + } + } + + mat.lobbies[0].switch_teams(true).await; + + mat.wait().await; + + mat.player_join(2).await; + + mat.wait().await; + + mat.assert_all_states(|i, s| { + for j in 0..=2 { + assert!( + s.teams.contains_key(&mat.uuids[j]), + "{i} doesn't have {j}'s uuid in teams" + ); + assert!( + s.profiles.contains_key(&mat.uuids[j]), + "{i} doesn't have {j}'s uuid in profiles" + ); + assert_eq!( + s.teams.get(&mat.uuids[0]).copied(), + Some(true), + "{i} doesn't see 0 as a seeker" + ) + } + }) + .await; + } + + #[test] + async fn test_team_switch() { + let mat = MockLobbyPool::new(3); + + mat.start_all_loops().await; + + mat.lobbies[2].switch_teams(true).await; + + mat.wait().await; + + mat.assert_all_states(|i, s| { + assert_eq!( + s.teams.get(&mat.uuids[2]).copied(), + Some(true), + "{i} ({}) does not see 2 as a seeker", + mat.uuids[i] + ); + }) + .await; + } + + #[test] + async fn test_update_settings() { + let mat = MockLobbyPool::new(2); + + mat.start_all_loops().await; + + let mut settings = GameSettings::default(); + const UPDATED_ID: u32 = 284829; + settings.hiding_time_seconds = UPDATED_ID; + + mat.lobbies[0].update_settings(settings).await; + + mat.wait().await; + + mat.assert_all_states(|i, s| { + assert_eq!( + s.settings.hiding_time_seconds, UPDATED_ID, + "{i} ({}) did not get updated settings", + mat.uuids[i] + ) + }) + .await; + } + + #[test] + async fn test_update_settings_not_host() { + let mat = MockLobbyPool::new(2); + + mat.start_all_loops().await; + + let mut settings = GameSettings::default(); + let target = settings.hiding_time_seconds; + const UPDATED_ID: u32 = 284829; + settings.hiding_time_seconds = UPDATED_ID; + + mat.lobbies[1].update_settings(settings).await; + + mat.wait().await; + + mat.assert_all_states(|i, s| { + assert_eq!( + s.settings.hiding_time_seconds, target, + "{i} ({}) updated settings despite 1 not being host", + mat.uuids[i] + ) + }) + .await; + } + + #[test] + async fn test_game_start() { + let mat = MockLobbyPool::new(4); + + let recvs = mat.start_all_loops().await; + + for i in 0..3 { + mat.player_join(i).await; + } + + mat.lobbies[2].switch_teams(true).await; + + let settings = GameSettings { + hiding_time_seconds: 45, + ..Default::default() + }; + + mat.lobbies[0].update_settings(settings).await; + + mat.lobbies[3].quit_lobby().await; + + mat.wait().await; + + mat.lobbies[0].start_game().await; + + mat.wait().await; + + for (i, recv) in recvs.into_iter().enumerate() { + let res = recv.await.expect("Failed to recv"); + match res { + Ok(Some(StartGameInfo { + settings, + initial_caught_state, + })) => { + assert_eq!( + settings.hiding_time_seconds, 45, + "Lobby {i} does not match pushed settings" + ); + assert_eq!( + initial_caught_state.len(), + 3, + "Lobby {i} does not have 3 entries in caught state" + ); + assert_eq!( + initial_caught_state.get(&mat.uuids[2]).copied(), + Some(true), + "Lobby {i} does not see 2 as a seeker" + ); + assert!( + initial_caught_state.keys().all(|id| *id != mat.uuids[3]), + "Lobby {i} still has a disconnected player saved in caught state" + ); + let profiles = mat.lobbies[i].clone_profiles().await; + assert_eq!( + profiles.len(), + 3, + "Lobby {i} does not have 3 entries in profiles" + ); + assert!( + profiles.keys().all(|id| *id != mat.uuids[3]), + "Lobby {i} still has a disconnected player saved in profiles" + ); + } + Ok(None) => { + if i != 3 { + panic!("Lobby {i} did not exit with start info"); + } + } + Err(why) => { + panic!("Lobby {i} had an error: {why:?}"); + } + } + } + } + + #[test] + async fn test_drop_player() { + let mat = MockLobbyPool::new(3); + + let mut recvs = mat.start_all_loops().await; + + mat.lobbies[1].quit_lobby().await; + + let res = recvs.swap_remove(1).await.expect("Failed to recv"); + assert!(res.is_ok_and(|o| o.is_none()), "1 did not quit gracefully"); + + mat.wait().await; + + assert!( + mat.lobbies[1].transport.is_disconnected(), + "1 is not disconnected" + ); + + let id = mat.uuids[1]; + + mat.assert_all_states(|i, s| { + if mat.uuids[i] != id { + assert!( + !s.teams.contains_key(&id), + "{} has not been removed 1 from teams", + i + ); + assert!( + !s.profiles.contains_key(&id), + "{} has not been removed 1 from profiles", + i + ); + } + }) + .await; } } diff --git a/manhunt-logic/src/profile.rs b/manhunt-logic/src/profile.rs index 25bd212..ee00db8 100644 --- a/manhunt-logic/src/profile.rs +++ b/manhunt-logic/src/profile.rs @@ -2,6 +2,6 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Default, Debug, Serialize, Deserialize, specta::Type)] pub struct PlayerProfile { - display_name: String, - pfp_base64: Option, + pub display_name: String, + pub pfp_base64: Option, } diff --git a/manhunt-logic/src/tests.rs b/manhunt-logic/src/tests.rs index 4c817fd..11ba867 100644 --- a/manhunt-logic/src/tests.rs +++ b/manhunt-logic/src/tests.rs @@ -1,6 +1,9 @@ use std::{collections::HashMap, sync::Arc}; -use tokio::sync::{Mutex, mpsc}; +use tokio::{ + sync::{Mutex, mpsc}, + task::yield_now, +}; use uuid::Uuid; use crate::{ @@ -24,7 +27,7 @@ impl MockTransport { .map(|_| uuid::Uuid::new_v4()) .collect::>(); let channels = (0..players) - .map(|_| tokio::sync::mpsc::channel(10)) + .map(|_| tokio::sync::mpsc::channel(20)) .collect::>(); let txs = channels .iter() @@ -41,6 +44,31 @@ impl MockTransport { (uuids, transports) } + pub async fn wait_for_queue_empty(&self) { + // println!("Waiting for {} queue to empty", self.id); + loop { + let all_empty = self + .txs + .values() + .all(|tx| tx.is_closed() || tx.capacity() == tx.max_capacity()); + + if all_empty { + break; + } else { + yield_now().await; + } + } + } + + pub async fn fake_join(&self) { + self.send_message(TransportMessage::PeerConnect(self.id)) + .await; + } + + pub fn is_disconnected(&self) -> bool { + self.txs[&self.id].is_closed() + } + fn new(id: Uuid, rx: GameEventRx, txs: HashMap) -> Self { Self { id, @@ -63,6 +91,8 @@ impl Transport for MockTransport { async fn disconnect(&self) { self.send_message(TransportMessage::PeerDisconnect(self.id)) .await; + let mut rx = self.rx.lock().await; + rx.close(); } async fn receive_messages(&self) -> impl Iterator { @@ -74,25 +104,18 @@ impl Transport for MockTransport { async fn send_message(&self, msg: TransportMessage) { for (_id, tx) in self.txs.iter().filter(|(id, _)| **id != self.id) { - tx.send((Some(self.id), msg.clone())) - .await - .expect("Failed to send msg"); + tx.send((Some(self.id), msg.clone())).await.ok(); } } async fn send_message_single(&self, peer: Uuid, msg: TransportMessage) { if let Some(tx) = self.txs.get(&peer) { - tx.send((Some(self.id), msg)) - .await - .expect("Failed to send msg"); + tx.send((Some(self.id), msg)).await.ok(); } } async fn send_self(&self, msg: TransportMessage) { - self.txs[&self.id] - .send((Some(self.id), msg)) - .await - .expect("Failed to send msg"); + self.txs[&self.id].send((Some(self.id), msg)).await.ok(); } fn self_id(&self) -> Uuid { diff --git a/manhunt-transport/src/matchbox.rs b/manhunt-transport/src/matchbox.rs index b19587f..3532d83 100644 --- a/manhunt-transport/src/matchbox.rs +++ b/manhunt-transport/src/matchbox.rs @@ -1,8 +1,11 @@ -use std::{pin::Pin, sync::Arc, time::Duration}; +use std::{collections::HashSet, pin::Pin, sync::Arc}; use anyhow::{Context, anyhow}; -use futures::FutureExt; -use log::error; +use futures::{ + SinkExt, StreamExt, + channel::mpsc::{UnboundedReceiver, UnboundedSender}, +}; +use log::{error, info}; use matchbox_socket::{Error as SocketError, PeerId, PeerState, WebRtcSocket}; use tokio::{ sync::{Mutex, mpsc}, @@ -22,12 +25,15 @@ type Queue = QueuePair; pub struct MatchboxTransport { my_id: Uuid, incoming: Queue, - outgoing: Queue, + all_peers: Mutex>, + msg_sender: UnboundedSender, cancel_token: CancellationToken, } type LoopFutRes = Result<(), SocketError>; +type MatchboxMsgPair = (PeerId, Box<[u8]>); + fn map_socket_error(err: SocketError) -> anyhow::Error { match err { SocketError::ConnectionFailed(e) => anyhow!("Connection to server failed: {e:?}"), @@ -38,17 +44,21 @@ fn map_socket_error(err: SocketError) -> anyhow::Error { impl MatchboxTransport { pub async fn new(join_code: &str, is_host: bool) -> Result> { let (itx, irx) = mpsc::channel(15); - let (otx, orx) = mpsc::channel(15); let ws_url = server::room_url(join_code, is_host); let (mut socket, mut loop_fut) = WebRtcSocket::new_reliable(&ws_url); + let (mtx, mrx) = socket + .take_channel(0) + .expect("Failed to get channel") + .split(); + let res = loop { tokio::select! { id = Self::wait_for_id(&mut socket) => { if let Some(id) = id { - break Ok(id); + break Ok(id); } }, res = &mut loop_fut => { @@ -66,22 +76,24 @@ impl MatchboxTransport { let transport = Arc::new(Self { my_id, incoming: (itx, Mutex::new(irx)), - outgoing: (otx, Mutex::new(orx)), + all_peers: Mutex::new(HashSet::with_capacity(5)), + msg_sender: mtx.clone(), cancel_token: CancellationToken::new(), }); tokio::spawn({ let transport = transport.clone(); async move { - transport.main_loop(socket, loop_fut).await; + transport.main_loop(socket, loop_fut, mrx).await; } }); Ok(transport) } Err(why) => { + drop(mrx); + mtx.close_channel(); drop(socket); - loop_fut.await.context("While disconnecting")?; Err(why) } } @@ -104,141 +116,132 @@ impl MatchboxTransport { .expect("Failed to push to incoming queue"); } - async fn push_many_incoming(&self, msgs: Vec) { - let senders = self - .incoming - .0 - .reserve_many(msgs.len()) - .await - .expect("Failed to reserve in incoming queue"); - - for (sender, msg) in senders.into_iter().zip(msgs.into_iter()) { - sender.send(msg); - } - } - async fn main_loop( &self, mut socket: WebRtcSocket, loop_fut: Pin + Send + 'static>>, + mut mrx: UnboundedReceiver, ) { - let loop_fut = async { - let msg = match loop_fut.await { - Ok(_) => TransportMessage::Disconnected, - Err(e) => { - let msg = map_socket_error(e).to_string(); - TransportMessage::Error(msg) - } - }; - self.push_incoming(None, msg).await; - } - .fuse(); - tokio::pin!(loop_fut); - - let mut interval = tokio::time::interval(Duration::from_secs(1)); - - let mut outgoing_rx = self.outgoing.1.lock().await; - const MAX_MSG_SEND: usize = 30; - let mut message_buffer = Vec::with_capacity(MAX_MSG_SEND); - let mut packet_handler = PacketHandler::default(); - loop { - self.handle_peers(&mut socket).await; - - self.handle_recv(&mut socket, &mut packet_handler).await; + info!("Starting transport loop"); + let (should_await, msg) = loop { tokio::select! { biased; - _ = self.cancel_token.cancelled() => { - break; + res = &mut loop_fut => { + info!("Transport-initiated disconnect"); + break (false, match res { + Ok(_) => TransportMessage::Disconnected, + Err(e) => { + let msg = map_socket_error(e).to_string(); + TransportMessage::Error(msg) + } + }); } - _ = &mut loop_fut => { - break; + _ = self.cancel_token.cancelled() => { + info!("Logic-initiated disconnect"); + break (true, TransportMessage::Disconnected); } - _ = outgoing_rx.recv_many(&mut message_buffer, MAX_MSG_SEND) => { - let peers = socket.connected_peers().collect::>(); - self.handle_send(&mut socket, &peers, &mut message_buffer).await; + Some((peer, state)) = socket.next() => { + info!("Handling peer {peer}: {state:?}"); + self.handle_peer(peer, state).await; } - _ = interval.tick() => { - continue; + Some(data) = mrx.next() => { + info!("Handling new packet from {}", data.0); + self.handle_recv(data, &mut packet_handler).await; } + + + } + }; + + self.push_incoming(Some(self.my_id), msg).await; + + self.msg_sender.close_channel(); + drop(mrx); + socket.try_update_peers().ok(); + drop(socket); + if should_await { + if let Err(why) = loop_fut.await { + error!("Failed to await after disconnect: {why:?}"); } } + info!("Transport disconnected"); } - async fn handle_peers(&self, socket: &mut WebRtcSocket) { - for (peer, state) in socket.update_peers() { - let msg = match state { - PeerState::Connected => TransportMessage::PeerConnect(peer.0), - PeerState::Disconnected => TransportMessage::PeerDisconnect(peer.0), - }; - self.push_incoming(Some(peer.0), msg).await; - } + async fn handle_peer(&self, peer: PeerId, state: PeerState) { + let mut all_peers = self.all_peers.lock().await; + let msg = match state { + PeerState::Connected => { + all_peers.insert(peer.0); + TransportMessage::PeerConnect(peer.0) + } + PeerState::Disconnected => { + all_peers.remove(&peer.0); + TransportMessage::PeerDisconnect(peer.0) + } + }; + drop(all_peers); + self.push_incoming(Some(peer.0), msg).await; } - async fn handle_send( + async fn handle_recv( &self, - socket: &mut WebRtcSocket, - all_peers: &[PeerId], - messages: &mut Vec, + (PeerId(peer), packet): MatchboxMsgPair, + handler: &mut PacketHandler, ) { - let encoded_messages = messages.drain(..).filter_map(|(id, msg)| { - match PacketHandler::message_to_packets(&msg) { - Ok(packets) => Some((id, packets)), - Err(why) => { - error!("Error encoding message to packets: {why:?}"); - None - } + match handler.consume_packet(peer, packet.into_vec()) { + Ok(Some(msg)) => { + self.push_incoming(Some(peer), msg).await; } - }); - - let channel = socket.channel_mut(0); - - for (peer, packets) in encoded_messages { - if let Some(peer) = peer { - for packet in packets { - channel.send(packet.into_boxed_slice(), PeerId(peer)); - } - } else { - for packet in packets { - let boxed = packet.into_boxed_slice(); - for peer in all_peers { - channel.send(boxed.clone(), *peer); - } - } + Ok(None) => { + // Non complete message + } + Err(why) => { + error!("Error receiving message: {why}"); } } } - async fn handle_recv(&self, socket: &mut WebRtcSocket, handler: &mut PacketHandler) { - let data = socket.channel_mut(0).receive(); - let messages = data - .into_iter() - .filter_map( - |(peer, bytes)| match handler.consume_packet(peer.0, bytes.into_vec()) { - Ok(msg) => msg.map(|msg| (Some(peer.0), msg)), - Err(why) => { - error!("Error receiving message: {why}"); - None - } - }, - ) - .collect(); - self.push_many_incoming(messages).await; - } - pub async fn send_transport_message(&self, peer: Option, msg: TransportMessage) { - self.outgoing - .0 - .send((peer, msg)) - .await - .expect("Failed to add to outgoing queue"); + let mut tx = self.msg_sender.clone(); + + match PacketHandler::message_to_packets(&msg) { + Ok(packets) => { + if let Some(peer) = peer { + let mut stream = futures::stream::iter( + packets + .into_iter() + .map(|p| Ok((PeerId(peer), p.into_boxed_slice()))), + ); + if let Err(why) = tx.send_all(&mut stream).await { + error!("Error sending packet: {why}"); + } + } else { + let all_peers = self.all_peers.lock().await; + for peer in all_peers.iter().copied() { + let packets = packets.clone(); + let mut stream = futures::stream::iter( + packets + .into_iter() + .map(|p| Ok((PeerId(peer), p.into_boxed_slice()))), + ); + if let Err(why) = tx.send_all(&mut stream).await { + error!("Error sending packet: {why}"); + } + } + } + } + Err(why) => { + error!("Error encoding message: {why}"); + } + } } pub async fn recv_transport_messages(&self) -> Vec { diff --git a/manhunt-transport/src/packets.rs b/manhunt-transport/src/packets.rs index 0fd0d82..0390dc2 100644 --- a/manhunt-transport/src/packets.rs +++ b/manhunt-transport/src/packets.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use anyhow::{anyhow, bail}; -use manhunt_logic::{prelude::*, TransportMessage}; +use manhunt_logic::{TransportMessage, prelude::*}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -175,8 +175,6 @@ mod tests { let data = data.into_iter().next().unwrap(); - println!("dat: {data:?}"); - let decoded = handler .consume_packet(Uuid::default(), data) .expect("Failed to load message from bytes")