Compare commits
19 Commits
main
..
4fa4f8f326
| Author | SHA1 | Date | |
|---|---|---|---|
|
4fa4f8f326
|
|||
|
44fee7ff2a
|
|||
|
a375471b94
|
|||
|
5cf70f35bd
|
|||
|
fff1185079
|
|||
|
d102f19dc1
|
|||
|
a7dc1ba0ff
|
|||
|
e688fd3016
|
|||
|
c567ff8afd
|
|||
|
db41793f3a
|
|||
|
c17e61d0df
|
|||
|
065e1586c0
|
|||
|
8402540050
|
|||
|
49ca5c04a2
|
|||
|
f9a709466b
|
|||
|
40e4176246
|
|||
|
e8e7d7a93e
|
|||
| 8c8079fe19 | |||
| 993ab25bbf |
Generated
+501
-252
@@ -8,6 +8,17 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -38,6 +49,23 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android_log-sys"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
|
||||
|
||||
[[package]]
|
||||
name = "android_logger"
|
||||
version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
|
||||
dependencies = [
|
||||
"android_log-sys",
|
||||
"env_filter",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -53,6 +81,12 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -93,9 +127,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
@@ -155,13 +189,25 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitvec"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
|
||||
dependencies = [
|
||||
"funty",
|
||||
"radium",
|
||||
"tap",
|
||||
"wyz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -181,10 +227,34 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.3"
|
||||
name = "borsh"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
|
||||
checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a"
|
||||
dependencies = [
|
||||
"borsh-derive",
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh-derive"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
@@ -193,9 +263,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.1"
|
||||
version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
|
||||
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
@@ -212,9 +282,43 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "byte-unit"
|
||||
version = "5.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d"
|
||||
dependencies = [
|
||||
"rust_decimal",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"utf8-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytecheck"
|
||||
version = "0.6.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
|
||||
dependencies = [
|
||||
"bytecheck_derive",
|
||||
"ptr_meta",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytecheck_derive"
|
||||
version = "0.6.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
@@ -243,7 +347,7 @@ version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"cairo-sys-rs",
|
||||
"glib",
|
||||
"libc",
|
||||
@@ -306,9 +410,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.63"
|
||||
version = "1.2.62"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -349,6 +453,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.0"
|
||||
@@ -362,9 +472,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
@@ -432,7 +542,7 @@ version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
@@ -445,7 +555,7 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation",
|
||||
"libc",
|
||||
]
|
||||
@@ -689,7 +799,7 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
@@ -697,9 +807,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.6"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -803,9 +913,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -839,6 +949,16 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -902,6 +1022,15 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fern"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@@ -939,17 +1068,6 @@ dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -1019,6 +1137,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
@@ -1323,7 +1447,7 @@ version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
@@ -1444,25 +1568,19 @@ name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1478,11 +1596,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.11.1"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
|
||||
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1521,9 +1639,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.2"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
@@ -1560,9 +1678,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1800,11 +1918,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.11.2"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1"
|
||||
checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
@@ -1937,12 +2055,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.100"
|
||||
version = "0.3.98"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
|
||||
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -1974,16 +2093,16 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"serde",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.2.0"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
|
||||
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
|
||||
dependencies = [
|
||||
"kqueue-sys",
|
||||
"libc",
|
||||
@@ -1995,7 +2114,7 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -2034,7 +2153,6 @@ name = "lanspread-peer"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"crc32fast",
|
||||
"eyre",
|
||||
"futures",
|
||||
"gethostname",
|
||||
@@ -2095,14 +2213,10 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-store",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-log",
|
||||
"tracing-subscriber",
|
||||
"walkdir",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
@@ -2180,27 +2294,27 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.49"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
|
||||
checksum = "2d1eacfa31c33ec25e873c136ba5669f00f9866d0688bea7be4d3f7e43067df6"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.17"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
|
||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.37.0"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
@@ -2230,9 +2344,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.32"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
dependencies = [
|
||||
"value-bag",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
@@ -2247,12 +2364,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mdns-sd"
|
||||
version = "0.20.0"
|
||||
version = "0.19.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "892f96f6d2ebe1ea641279f986ac52a2a6bac71e8f743bb258315cfe2bd7e88e"
|
||||
checksum = "18148fee27e99e76dbf6e137f27727113d31f766e578d1b93a93c3615fca7081"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"flume 0.11.1",
|
||||
"flume",
|
||||
"if-addrs",
|
||||
"log",
|
||||
"mio",
|
||||
@@ -2262,9 +2379,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.1"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
@@ -2277,9 +2394,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.52"
|
||||
version = "0.1.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
|
||||
checksum = "b3627c4272df786b9260cabaa46aec1d59c93ede723d4c3ef646c503816b0640"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
@@ -2302,9 +2419,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.1"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@@ -2314,9 +2431,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.19.2"
|
||||
version = "0.19.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c"
|
||||
checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
@@ -2339,7 +2456,7 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
@@ -2369,7 +2486,7 @@ version = "8.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
@@ -2387,23 +2504,14 @@ version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
@@ -2481,7 +2589,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
@@ -2494,7 +2602,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
@@ -2515,7 +2623,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
]
|
||||
@@ -2526,7 +2634,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
@@ -2559,7 +2667,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
@@ -2586,7 +2694,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
@@ -2599,7 +2707,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2610,7 +2718,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
@@ -2622,7 +2730,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
@@ -2653,7 +2761,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -2858,7 +2966,7 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
@@ -2931,7 +3039,7 @@ version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit 0.25.12+spec-1.1.0",
|
||||
"toml_edit 0.25.11+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2967,6 +3075,26 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptr_meta"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
|
||||
dependencies = [
|
||||
"ptr_meta_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptr_meta_derive"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
@@ -2997,6 +3125,12 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "radium"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.7.3"
|
||||
@@ -3005,11 +3139,22 @@ checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
|
||||
dependencies = [
|
||||
"getrandom 0.1.16",
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_chacha 0.2.2",
|
||||
"rand_core 0.5.1",
|
||||
"rand_hc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.1"
|
||||
@@ -3031,6 +3176,16 @@ dependencies = [
|
||||
"rand_core 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
@@ -3040,6 +3195,15 @@ dependencies = [
|
||||
"getrandom 0.1.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
@@ -3067,7 +3231,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3103,9 +3267,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.4"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -3126,15 +3290,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rend"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
|
||||
dependencies = [
|
||||
"bytecheck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.4"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -3202,6 +3375,52 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rkyv"
|
||||
version = "0.7.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
|
||||
dependencies = [
|
||||
"bitvec",
|
||||
"bytecheck",
|
||||
"bytes",
|
||||
"hashbrown 0.12.3",
|
||||
"ptr_meta",
|
||||
"rend",
|
||||
"rkyv_derive",
|
||||
"seahash",
|
||||
"tinyvec",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rkyv_derive"
|
||||
version = "0.7.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.42.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"borsh",
|
||||
"bytes",
|
||||
"num-traits",
|
||||
"rand 0.8.6",
|
||||
"rkyv",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -3223,7 +3442,7 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@@ -3273,10 +3492,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "s2n-codec"
|
||||
version = "0.82.0"
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a650d3f187901f3519ec8a1fe7da3faccc0b2fb40f350eda2c7851fdf2bda0f6"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "s2n-codec"
|
||||
version = "0.80.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80c081add6c80a35614d55ac1ecac3b0d609de48c7d17c643e9bab0e84e27539"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
@@ -3285,9 +3510,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-quic"
|
||||
version = "1.82.0"
|
||||
version = "1.80.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c27c34127facefcd3e5530c4de5739a62cd4a593710b1194dacbd8e884b6be92"
|
||||
checksum = "d1949f3735d6c7003b74c0519afcddf7800bdc14cd65b03930710511802b4032"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
@@ -3309,9 +3534,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-quic-core"
|
||||
version = "0.82.0"
|
||||
version = "0.80.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79fbc3f06797d985363f74de105d18554b5a272b924b166d73a6564943da1230"
|
||||
checksum = "bf79f1e410296ae7bf17a0e877d3ce30cb589c73faf6ea31e9ee61aad486197d"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"byteorder",
|
||||
@@ -3331,9 +3556,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-quic-crypto"
|
||||
version = "0.82.0"
|
||||
version = "0.80.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e58ea5aa39eecc29559d1e1bb4a5d55a747fa7b80cff5a3400c57489510644e3"
|
||||
checksum = "9a6c42fa0e4a5305121fd0d55662614287283b2d54aeae8adecefe72d8d36b55"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"cfg-if",
|
||||
@@ -3345,9 +3570,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-quic-platform"
|
||||
version = "0.82.0"
|
||||
version = "0.80.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4eebb6007139cfffdf3d473d39f01a214032c339432a6293b16b0f7b25343f40"
|
||||
checksum = "a161996e9c20557626704b883f8845d04133c0a27b176d1a1ee401932423d2f2"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures",
|
||||
@@ -3360,9 +3585,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-quic-rustls"
|
||||
version = "0.82.0"
|
||||
version = "0.80.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb0084afa65eefae2c37d9ab44118a14dfc5bb78dbf997c0f5176f7cf8d2e633"
|
||||
checksum = "68e0dffd40e31fb305e68ab9884a74b1c94949b3e258d02fb6e3a1f1c4305ba0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"rustls",
|
||||
@@ -3374,9 +3599,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-quic-tls"
|
||||
version = "0.82.0"
|
||||
version = "0.80.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91150b25ce824ffea581b449ad04acf9b4aef2fa68a46f667cdc9cc6f7b87823"
|
||||
checksum = "c085a4e5085a30f796270f383e6b9707e81c3abbdbbf9123ad7577628cf26d4a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"errno",
|
||||
@@ -3389,9 +3614,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-quic-tls-default"
|
||||
version = "0.82.0"
|
||||
version = "0.80.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1f5ae64863972facee778dc80a24317e613f035296631f267b71f225e569c22"
|
||||
checksum = "ac276a124e18b980cc8f8483a41a26f42564216f759f7f5822a8c4ffb8af8f04"
|
||||
dependencies = [
|
||||
"s2n-quic-rustls",
|
||||
"s2n-quic-tls",
|
||||
@@ -3399,9 +3624,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-quic-transport"
|
||||
version = "0.82.0"
|
||||
version = "0.80.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b82fca53ce1734cc1d1dca96cc9ceb65ed528f27cb43b7de865215b6cf17908"
|
||||
checksum = "2b6c77ed7cf87296886620917ba760ddb1e042446faa7be876887725c89dab63"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
@@ -3417,9 +3642,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-tls"
|
||||
version = "0.3.37"
|
||||
version = "0.3.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60ada8adc59c848686744ff0ca93f1d1edc565a69d96a58a26abd84a521a5a0"
|
||||
checksum = "5d22c36f207d0bb6a38272d5d1fa1cd99094335c70db1bfef54e0b8b672ae26f"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"hex",
|
||||
@@ -3430,9 +3655,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-tls-sys"
|
||||
version = "0.3.37"
|
||||
version = "0.3.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6960ccb00e47fe4b641f9c7b769515a4f89cb65459ca7afbed20fa39aac0b1b"
|
||||
checksum = "bcf9e3b136cdd2c99f0cee2811d80b9f6bc44b5fd48c172857de32babb506f5e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"cc",
|
||||
@@ -3505,13 +3730,19 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "seahash"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.36.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"cssparser",
|
||||
"derive_more",
|
||||
"log",
|
||||
@@ -3589,9 +3820,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -3630,10 +3861,22 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.21.0"
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bs58",
|
||||
@@ -3651,9 +3894,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.21.0"
|
||||
version = "3.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
|
||||
checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
@@ -3703,15 +3946,6 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_child"
|
||||
version = "1.1.1"
|
||||
@@ -3725,9 +3959,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "sigchld"
|
||||
@@ -3766,6 +4000,12 @@ version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
@@ -3797,9 +4037,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.4"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -3864,9 +4104,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.9.0"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
|
||||
checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
|
||||
dependencies = [
|
||||
"sqlx-core",
|
||||
"sqlx-macros",
|
||||
@@ -3875,13 +4115,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-core"
|
||||
version = "0.9.0"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
|
||||
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
"crc",
|
||||
"crossbeam-queue",
|
||||
"either",
|
||||
@@ -3890,11 +4129,12 @@ dependencies = [
|
||||
"futures-intrusive",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.15.5",
|
||||
"hashlink",
|
||||
"indexmap 2.14.0",
|
||||
"log",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"sha2",
|
||||
@@ -3908,9 +4148,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-macros"
|
||||
version = "0.9.0"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
|
||||
checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3921,15 +4161,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-macros-core"
|
||||
version = "0.9.0"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
|
||||
checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dotenvy",
|
||||
"either",
|
||||
"heck 0.5.0",
|
||||
"hex",
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
@@ -3944,13 +4184,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-sqlite"
|
||||
version = "0.9.0"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
|
||||
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"flume 0.12.0",
|
||||
"form_urlencoded",
|
||||
"flume",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
@@ -3960,6 +4199,7 @@ dependencies = [
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_urlencoded",
|
||||
"sqlx-core",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
@@ -4047,6 +4287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
@@ -4096,11 +4337,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tao"
|
||||
version = "0.35.3"
|
||||
version = "0.35.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||
checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-graphics",
|
||||
@@ -4145,6 +4386,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tap"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -4322,6 +4569,28 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-log"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"byte-unit",
|
||||
"fern",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"swift-rs",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-shell"
|
||||
version = "2.3.5"
|
||||
@@ -4509,15 +4778,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
@@ -4724,9 +4984,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.12+spec-1.1.0"
|
||||
version = "0.25.11+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
|
||||
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
@@ -4766,11 +5026,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.11"
|
||||
version = "0.6.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
@@ -4824,32 +5084,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"nu-ansi-term",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4888,9 +5122,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "unic-char-property"
|
||||
@@ -4941,9 +5175,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
@@ -4994,6 +5228,12 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-width"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -5002,9 +5242,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.3"
|
||||
version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -5013,10 +5253,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
name = "value-bag"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
@@ -5107,9 +5347,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.123"
|
||||
version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
|
||||
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -5120,9 +5360,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.73"
|
||||
version = "0.4.71"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf"
|
||||
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -5130,9 +5370,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.123"
|
||||
version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
|
||||
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -5140,9 +5380,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.123"
|
||||
version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
|
||||
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -5153,9 +5393,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.123"
|
||||
version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
|
||||
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -5201,7 +5441,7 @@ version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap 2.14.0",
|
||||
"semver",
|
||||
@@ -5209,9 +5449,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.100"
|
||||
version = "0.3.98"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
|
||||
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -5892,7 +6132,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"indexmap 2.14.0",
|
||||
"log",
|
||||
"serde",
|
||||
@@ -5972,6 +6212,15 @@ dependencies = [
|
||||
"x11-dl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wyz"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
|
||||
dependencies = [
|
||||
"tap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11"
|
||||
version = "2.21.0"
|
||||
@@ -5995,9 +6244,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
@@ -6018,18 +6267,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.52"
|
||||
version = "0.8.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
|
||||
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.52"
|
||||
version = "0.8.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
|
||||
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+13
-23
@@ -14,49 +14,39 @@ members = [
|
||||
[workspace.dependencies]
|
||||
base64 = "0.22"
|
||||
bytes = { version = "1", features = ["serde"] }
|
||||
crc32fast = "1"
|
||||
eyre = "0.6"
|
||||
futures = "0.3"
|
||||
gethostname = "1"
|
||||
if-addrs = "0.15"
|
||||
log = "0.4"
|
||||
mdns-sd = "0.20"
|
||||
mdns-sd = "0.19"
|
||||
mimalloc = { version = "0.1", features = ["secure"] }
|
||||
notify = "8"
|
||||
s2n-quic = { version = "1", features = ["provider-event-tracing"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sqlx = {
|
||||
version = "0.9",
|
||||
default-features = false,
|
||||
features = [
|
||||
"derive",
|
||||
"runtime-tokio",
|
||||
"sqlite",
|
||||
]
|
||||
}
|
||||
sqlx = { version = "0.8", default-features = false, features = [
|
||||
"derive",
|
||||
"runtime-tokio",
|
||||
"sqlite",
|
||||
] }
|
||||
strum = { version = "0.28", features = ["derive"] }
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-store = "2"
|
||||
time = { version = "0.3", features = ["local-offset"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["codec", "rt"] }
|
||||
tracing = "0.1"
|
||||
tracing-log = "0.2"
|
||||
tracing-subscriber = "0.3"
|
||||
uuid = { version = "1", features = ["v7"] }
|
||||
walkdir = "2"
|
||||
windows = {
|
||||
version = "0.62",
|
||||
features = [
|
||||
"Win32",
|
||||
"Win32_UI",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
]
|
||||
}
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32",
|
||||
"Win32_UI",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
debug = true
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# Streamed Install Next Steps
|
||||
|
||||
I’d treat the prototype as proof of the hard part: “can we stream
|
||||
archive-derived install bytes into `local/` without making the receiver a
|
||||
source?” Yes. Next I’d harden the pieces that decide whether this is
|
||||
product-ready.
|
||||
|
||||
1. **Done — Move from CLI-only to real app integration**
|
||||
|
||||
The GUI now has an explicit “Low disk install” action in the game detail
|
||||
modal for remote-only games. The Tauri backend queues that path through
|
||||
`stream_install_game`, injects the shared external `unrar` stream provider,
|
||||
and hands fetched file details to `StreamInstallGame` instead of the normal
|
||||
download command.
|
||||
|
||||
2. **Done — Replace per-file `unrar p` with a final archive provider**
|
||||
|
||||
The shared external `unrar` stream provider now runs `unrar lt` once for the
|
||||
archive metadata and one sequential `unrar p` pass per archive for payload
|
||||
bytes. It frames directories, file starts, file chunks, and file ends from
|
||||
the technical listing, so CLI and GUI callers use one purpose-built provider
|
||||
instead of a per-file extraction loop.
|
||||
|
||||
3. **Done — Handle solid archives deliberately**
|
||||
|
||||
The provider exposes the RAR `solid` flag in `ArchiveBegin` and always uses
|
||||
one sequential payload pass per archive, which is the safe path for solid
|
||||
archives. S41 now verifies a real solid RAR fixture through the Docker
|
||||
peer-cli flow, including local-only final state, absent root archive/sentinel,
|
||||
byte count, and extracted payload SHA-256 hashes.
|
||||
|
||||
4. **Done — Decide the integrity model**
|
||||
|
||||
Streamed installs intentionally verify against sender archive metadata for
|
||||
now: each file must match the RAR-advertised size and CRC32. That catches
|
||||
transport corruption, truncation, and provider bugs, but does not claim
|
||||
malicious-peer protection. Trusted content remains a separate catalog schema
|
||||
step: add catalog-owned archive or extracted-file SHA-256 hashes, then verify
|
||||
those at the receiver before commit.
|
||||
|
||||
5. **Done — Upgrade retry/resume semantics**
|
||||
|
||||
Streamed install attempts now use the same majority-validated peer set as
|
||||
normal downloads, and each failed attempt rolls back its staging transaction
|
||||
before trying the next peer. S42 pins the policy: retry the whole stream from
|
||||
another validated peer, keep no partial files across attempts, and do not add
|
||||
byte-offset resume until there is a strong reason.
|
||||
|
||||
6. **Done — Expand scenario coverage**
|
||||
|
||||
S43-S47 cover the remaining streamed-install edges: already-installed
|
||||
rejection, corrupt archive rollback, sender disconnect mid-stream, receiver
|
||||
cancel mid-stream, and multi-archive `.eti` roots streamed in sorted order.
|
||||
The peer-cli harness now exposes `cancel-download` so cancellation scenarios
|
||||
exercise the same runtime path as the GUI.
|
||||
|
||||
7. **Done — Clean product semantics**
|
||||
|
||||
The UI now keeps streamed installs in the installed visual state while making
|
||||
the sharing limitation explicit: cards show `Not shareable`, and the detail
|
||||
modal status shows `Installed, not shareable`. Downloaded-and-installed games
|
||||
keep the normal `Installed` label.
|
||||
|
||||
The remaining production-readiness step is additive: move from sender-owned RAR
|
||||
metadata to catalog-owned archive or extracted-file hashes, then verify those
|
||||
at the receiver before committing the streamed install.
|
||||
+24
-203
@@ -22,57 +22,48 @@ for deterministic local runs; mDNS/macvlan remains an environment smoke path.
|
||||
| S12 | Transfer serving gates | A peer has a non-catalog, missing-sentinel, active-operation, or `local/` path request. | The serving peer declines metadata/data; covered by unit tests where timing is too small for a stable CLI race test. |
|
||||
| S13 | Exact transferred-file equality | Repeat small and large downloads, then compare every transferred regular file against its source with SHA-256 manifests. | Source and receiver manifests match exactly for each transferred file; no extra or missing files appear in the downloaded game root. |
|
||||
| S14 | Large multi-peer chunked download | `fixture-alpha/alienswarm` contains a renamed RAR `.eti` larger than 100 MB. A second peer downloads it, then a third peer downloads `alienswarm` from both peers. | The third peer's downloaded files match the source by SHA-256; `download-chunk-finished` events show the large `.eti` chunks coming from both peers with byte counts balanced within one chunk. |
|
||||
| S15 | Catalog-version skew | Three peers advertise the same catalog game ID. Peers A and B have stale `version.ini` values; peer C has the catalog's expected version. An empty client connects to all three and downloads the game with `install=false`. | `list-games` shows one row for the game with `peer_count=1` and the catalog `eti_game_version`. The `got-game-files` descriptor set and transfer source are peer C only; no chunks come from A or B. The receiver's `version.ini` and SHA-256 manifest match C exactly. |
|
||||
| S16 | Catalog-version fanout with stale peers present | Peer A has a stale version of a game. Peers B and C both advertise the catalog version with matching file manifests; use a large file when proving chunk split. | The aggregated row counts only catalog-version ready peers. Large-file chunks may split between B and C; peer A is not listed as downloadable and contributes no manifest vote or file chunks. |
|
||||
| S17 | Catalog-version conflict rejection | Peer A has a stale version. Peers B and C both advertise the catalog version, but their file sizes conflict. | Validation considers only the catalog-version peers, so A cannot rescue the majority. The download fails with `download-failed`, and no committed target `version.ini` remains. |
|
||||
| S15 | Three-way version skew | Three peers advertise the same catalog game ID. Peer A has `version.ini=20250101`, peer B has `version.ini=20250201`, and peer C has `version.ini=20250301`; each version has distinguishable file contents. An empty client connects to all three and downloads the game with `install=false`. | `list-games` shows one row for the game with `peer_count=3` and `eti_game_version=20250301`. The `got-game-files` descriptor set and transfer source are peer C's newest version only; no chunks come from A or B. The receiver's `version.ini` and SHA-256 manifest match C exactly. |
|
||||
| S16 | Latest-version fanout with stale peers present | Peer A has an older version of a game. Peers B and C both advertise the same newest version with matching file manifests; use a large file when proving chunk split. | The aggregated row still counts all ready peers, but eligible transfer peers are only B and C. Large-file chunks may split between B and C; peer A contributes no manifest majority vote and no file chunks. |
|
||||
| S17 | Latest-version conflict rejection | Peer A has an older version. Peers B and C both advertise the newest version, but their latest-version file sizes conflict. | Validation considers only the latest-version peers, so A cannot rescue the majority. The download fails with `download-failed`, and no committed target `version.ini` remains. |
|
||||
| S18 | Mid-download source drop with redundancy | Client downloads a large shared game from two ready peers, then one source is killed after the download has begun. | Failed chunks are retried against the surviving source; the download finishes, no `download-failed` is emitted, and the receiver's files match the source by diff or SHA-256. |
|
||||
| S19 | Mid-download sole-source drop | Client downloads a large game from one source, then that source is killed after the download has begun. | The download emits `download-failed`; no committed target `version.ini` remains; any partial payload is not advertised as ready; active operation state clears so a retry is possible. |
|
||||
| S20 | Receiver write failure | Client downloads a large game into a constrained `/games` filesystem. | The download fails deterministically, no committed `version.ini` is advertised, and active operation state clears so the peer can retry later. |
|
||||
| S21 | Add-game propagation | Two connected peers are running; one peer gains a new catalog game root through a completed download or an external drop. | The other peer receives a library update without reconnecting, and `list-games` shows the new remote game under the existing peer. |
|
||||
| S22 | Remove-game propagation | Two connected peers are running; one peer loses a previously advertised game root. | The other peer receives a library update without dropping the peer, and `list-games` no longer shows that remote game. |
|
||||
| S23 | Version bump propagation | Two connected peers are running; one peer's ready game root starts with a stale `version.ini`, then changes to the catalog version. | The other peer receives a library update without reconnecting; the stale row is absent before the change, then the catalog-version game appears as downloadable. |
|
||||
| S23 | Version bump propagation | Two connected peers are running; one peer's ready game root gets a newer `version.ini`. | The other peer receives a library update without reconnecting, and the aggregated row reflects the newer `eti_game_version`. |
|
||||
| S24 | Two clients pull from one source | Two empty clients connect to the same source and download the same large game concurrently. | Both downloads finish, both receivers match the source by diff or SHA-256, and the source remains responsive. |
|
||||
| S25 | One client downloads two games concurrently | One client connected to a source issues two different `download` commands without waiting for the first to finish. | Both operations may run in parallel; both eventually finish, each game reaches the requested install state, and each transferred root matches its source. |
|
||||
| S26 | Same-game duplicate download rejection | A client starts downloading a game, then issues a second `download` command for the same game while the first operation is active. | The second request is rejected deterministically as an operation-in-progress condition; the first download is not corrupted and still reaches its documented final state. |
|
||||
| S27 | Self-connect rejection | A peer sends `connect` to its own advertised listener address. | The command fails cleanly, no self-peer entry is created, and the peer remains responsive. |
|
||||
| S28 | Address change without identity change | A known peer is rediscovered with the same peer ID and a different listener address while its library is still known. | The peer record updates in place to the new address, the existing library stays attached to that peer ID, and no duplicate peer entry appears. This is covered with a deterministic unit-level check until the CLI can rebind a live listener without restart. |
|
||||
| S29 | Empty-library peer participates | A peer with no games connects into the mesh. | Other peers list it as a peer with zero games; it can receive a download, advertise the new game without restart, and become a source. |
|
||||
| S30 | 5+ peer mesh aggregation | Five peers advertise partially overlapping catalog games with a mix of unique and shared catalog-version games; a sixth client connects to all five. | The client shows one row per game ID, correct catalog-version ready-source `peer_count`, catalog `eti_game_version`, no duplicates, and no self entries. |
|
||||
| S30 | 5+ peer mesh aggregation | Five peers advertise partially overlapping catalog games with a mix of unique games, shared games, and differing versions; a sixth client connects to all five. | The client shows one row per game ID, correct ready-source `peer_count`, latest `eti_game_version`, no duplicates, and no self entries. |
|
||||
| S31 | Bootstrapped peer becomes source in same session | An empty client downloads a game from a source, the original source shuts down, then a fresh third peer downloads the same game from the bootstrapped client. | The third peer's files match the original source by diff or SHA-256, proving downloaded files become servable without restart. |
|
||||
| S32 | Reinstall after uninstall | A downloaded game is installed, uninstalled, then installed again without another download. | `local/` is recreated from preserved root files, no transfer events occur during reinstall, and the game returns to `installed=true`. |
|
||||
| S33 | Install after external root mutation | A downloaded game root is externally mutated before `install` is issued. | The CLI fixture installer installs from the current root bytes. The resulting `local/fixture-payload.txt` must match the mutated archive bytes exactly. |
|
||||
| S34 | Many-small-files game without `.eti` | A catalog game root contains `version.ini` plus many small regular files and no archive. | Download with `install=false` transfers every file, chunk events are coherent for small files, and source/receiver manifests match exactly. |
|
||||
| S35 | Unknown game ID from remote peer | A remote peer advertises a game ID that is not in the receiver's catalog. | The receiver does not list the unknown game as downloadable, download attempts fail deterministically, and no local files are created. |
|
||||
| S36 | Catalog singleton beats stale majority | Five peers advertise one game; one peer has the catalog version and four peers have stale versions. | `list-games` reports `peer_count=1` and the catalog `eti_game_version`; all descriptors and chunks come from the singleton catalog-version peer, while stale peers remain hidden and contribute zero bytes. |
|
||||
| S36 | Latest singleton beats stale majority | Five peers advertise one game; one peer has `20260501`, four peers have `20250101`. | `list-games` reports `eti_game_version=20260501`; all descriptors and chunks come from the singleton latest peer; stale peers contribute zero bytes. |
|
||||
| S37 | Single-source download throughput | A source peer advertises a temporary catalog game with one sparse `2 GiB` `.eti`; an empty client downloads it with `install=false`. | The client emits `download-finished` with throughput measurements (`bytes`, `duration_ms`, `mib_per_s`, `mbit_per_s`), and the downloaded archive size matches the source. |
|
||||
| S38 | First-play launch-setting stamping | `fixture-persona/css` ships a real RAR `.eti` whose tree buries a CRLF `SmartSteamEmu.ini` with a stub `PersonaName` line under `engine/bin/win64/steam_settings/`, plus a stub `account_name.txt` and `language.txt` under `profiles/local/`. A peer installs `css` (with `--unrar`), then sends `play css` with a username and language, then `play css` again. | After install the marker `games/css/launch_settings_applied` is absent and the stub files are intact under `local/`. The first `play` returns `already_applied=false` with `account_name_written`, `language_written`, and `persona_name_written` all true; the deep `SmartSteamEmu.ini` `PersonaName` value becomes the username with its `\r\n` ending and sibling lines preserved, `account_name.txt` becomes the username, `language.txt` becomes the passed language, and the marker now exists. A second `play` returns `already_applied=true`, rewrites nothing, and leaves the files untouched even if their values were reset externally. |
|
||||
| S39 | Streamed install without keeping archive payload | Empty client connects to `fixture-bravo`, then sends `stream-install cnctw`. The source has real RAR `.eti` payload entries under `bin/` and `data/`; the receiver uses the container-bundled `unrar` stream provider. | Client emits `download-begin`, streamed `download-chunk-finished`, `download-finished`, `install-begin`, and `install-finished`. Local `cnctw` is `downloaded=false`, `installed=true`, `availability=LocalOnly`; root `version.ini` and `.eti` are absent; `local/bin/cnctw-payload.bin` and `local/data/cnctw-assets.dat` match `unrar p` output by SHA-256; the source reports no active outbound transfer for `cnctw` after completion. |
|
||||
| S40 | Streamed install receiver is not a peer source | After S39, a third peer connects only to the streamed-install receiver. | The third peer may see the receiver's local-only summary in peer snapshots, but `list-games` remote aggregation does not expose `cnctw` as downloadable, `peer_count` remains zero/absent, and attempting `download cnctw` fails with no local files created. |
|
||||
| S41 | Solid archive streamed install | Empty client connects to a peer serving `fixture-solid/cnctw`, whose `.eti` is a real solid RAR archive. The receiver uses the container-bundled `unrar` stream provider. | The fixture is verified as solid with `unrar lt`; streamed install finishes with `downloaded=false`, `installed=true`, `availability=LocalOnly`; root archive and `version.ini` are absent; streamed byte count equals the extracted solid entries; local payload SHA-256 hashes match `unrar p` output. |
|
||||
| S42 | Streamed install whole-stream retry | Empty client connects to two peers serving the same catalog-version `cnctw`: one broken source whose `--unrar` path is missing, followed by one good source. | The broken source sorts before the good source in retry order, contributes zero chunks, and the good source completes a fresh whole-stream attempt. The final state is local-only installed, no root archive/sentinel, no `.local.installing`, byte count matches the extracted entries, and payload hashes match the good source. |
|
||||
| S43 | Already-installed streamed install rejection | A client first stream-installs `cnctw`, then attempts `stream-install cnctw` again. | The second request emits `download-failed`, does not emit a new success event, leaves the existing local-only install intact, and clears active operations. |
|
||||
| S44 | Corrupt archive streamed install rollback | A source advertises catalog-version `cnctw`, but its root `.eti` is replaced with invalid bytes before the client requests `stream-install cnctw`. | The stream emits `download-failed`, does not emit download/install success, clears active operations, and leaves no `local/`, `.local.installing`, root archive, or root `version.ini` on the receiver. |
|
||||
| S45 | Sender disconnect during streamed install | A source serves large catalog-version `alienswarm`; after the client receives the first streamed chunk, the source container is killed. | The operation reaches a terminal failure/peers-gone event, emits no download/install success, clears active operations, and rolls back local/staging state. |
|
||||
| S46 | Receiver cancel during streamed install | A client starts streaming large catalog-version `alienswarm`, receives the first chunk, then sends `cancel-download alienswarm`. | The receiver cancels without emitting download/install success or a user-visible download failure, clears active operations, and rolls back local/staging state. |
|
||||
| S47 | Multi-archive streamed install order | A source serves `fixture-multi/cnctw` with two root `.eti` archives named to require sorted processing. | Streamed chunk paths arrive in root archive sort order, both payloads install under `local/`, the receiver is local-only installed, and no root archives or sentinel are committed. |
|
||||
|
||||
## Version-Skew Contract
|
||||
|
||||
Use S15-S17 to pin down what happens when several peers have the same game ID
|
||||
but only some match the local catalog version:
|
||||
Use S15-S17 to pin down what "newer" means when several peers have the same
|
||||
game ID:
|
||||
|
||||
- The receiver's catalog is authoritative. A remote root whose `version.ini`
|
||||
does not match the catalog's expected version for that game ID is not
|
||||
downloadable.
|
||||
- Version comparison uses the eight-digit `version.ini` string, so use sortable
|
||||
`YYYYMMDD` values in manual fixtures.
|
||||
- `list-games` aggregates by game ID. The game appears once; `peer_count`
|
||||
counts only ready peers with that ID and the catalog version.
|
||||
- The aggregated `eti_game_version` must be the catalog version.
|
||||
counts all ready peers with that ID, including peers that only have older
|
||||
versions.
|
||||
- The aggregated `eti_game_version` must be the newest ready version.
|
||||
- The descriptor set emitted to the download path, file-size validation, and
|
||||
transfer planning are catalog-version-only. Stale peers must not supply
|
||||
download descriptors, majority votes, or chunks.
|
||||
- If exactly one peer has the catalog version, that peer is the only transfer
|
||||
source. If several peers match the catalog version, validation and chunk
|
||||
fanout happen among that catalog-version set only.
|
||||
transfer planning are latest-only. Older-version peers may be queried by a
|
||||
generic detail request, but their descriptors must not supply download
|
||||
descriptors, majority votes, or chunks once a newer version exists.
|
||||
- If exactly one peer has the latest version, that peer is the only transfer
|
||||
source. If several peers tie on the latest version, validation and chunk
|
||||
fanout happen among that latest-version set only.
|
||||
- Capture proof with the `list-games` row, `got-game-files` descriptors,
|
||||
`download-chunk-finished` source addresses, and source/receiver SHA-256
|
||||
manifests.
|
||||
@@ -86,7 +77,7 @@ GUI:
|
||||
payload files may remain, but they must not be advertised as a ready local
|
||||
game and must not leave an active operation stuck.
|
||||
- Source failure during a redundant download should retry failed chunks against
|
||||
another validated source for the same catalog-version file.
|
||||
another validated source for the same latest-version file.
|
||||
- Live local library changes are observable by connected peers through library
|
||||
deltas; reconnect is not required for add, remove, or version-bump cases.
|
||||
- Same-game operations are single-flight. A duplicate download request while a
|
||||
@@ -95,183 +86,13 @@ GUI:
|
||||
are not downloadable.
|
||||
|
||||
For a manual run, prefer a catalog game ID already served by the fixture lab,
|
||||
such as `cnc4`, then create temporary `just peer-cli-run` game roots where some
|
||||
peers match the catalog version and others deliberately use stale
|
||||
`version.ini` contents. The existing alpha/bravo/charlie fixtures cover
|
||||
duplicate-source and shared-game cases; S15-S17 add the focused skew cases.
|
||||
|
||||
## First-Play Launch-Setting Contract
|
||||
|
||||
Use S38 to pin down how launcher settings are stamped into an installed game:
|
||||
|
||||
- Stamping happens on the first `play`, not during install/update. The install
|
||||
transaction only clears the `games/<id>/launch_settings_applied` marker so the
|
||||
next play reapplies settings to a freshly (re)created `local/`.
|
||||
- The first play stamps the username into the first `account_name.txt` and the
|
||||
first `SmartSteamEmu.ini` `PersonaName` line, and the language into the first
|
||||
`language.txt`, searching the whole `local/` tree. The matched `PersonaName`
|
||||
line keeps its existing line ending (`\n` or `\r\n`).
|
||||
- The marker records only that we *tried*: it is written unconditionally after
|
||||
the first play, so a game with none of these files is still marked done.
|
||||
- S38 needs a real archive expanded with `--unrar`; the Docker matrix image now
|
||||
carries the Linux sidecar for streamed-install coverage, while the peer
|
||||
crate's `launch_settings` unit tests cover the rewrite, line-ending, and
|
||||
marker logic deterministically.
|
||||
|
||||
## Streamed Install Archive Contract
|
||||
|
||||
Use S39-S41 to pin down low-disk streamed installs:
|
||||
|
||||
- The stream provider performs one archive metadata pass and one payload pass
|
||||
per `.eti`, then frames entry boundaries for the receiver.
|
||||
- Non-solid and solid archives both install into `local/` without committing a
|
||||
root archive or root `version.ini`, so the receiver is installed but not a
|
||||
downloadable source.
|
||||
- Streamed install integrity is currently sender archive integrity: size and
|
||||
RAR CRC32 must match the sender's archive metadata. The SHA-256 checks in the
|
||||
scenarios prove the Docker/provider path matches the source fixture; they are
|
||||
not catalog-owned trust anchors.
|
||||
- S41 verifies the fixture is actually solid inside the source container, so
|
||||
solid handling stays covered by the same Docker harness as the existing
|
||||
streamed-install scenarios.
|
||||
- S42 verifies retry/resume semantics: failed streamed attempts roll back their
|
||||
staging directory and retry the whole stream from another validated peer.
|
||||
There is no byte-offset resume contract.
|
||||
- S43-S47 cover the remaining streamed-install failure and archive-shape edges:
|
||||
already-installed rejection, corrupt archive rollback, sender disconnect,
|
||||
receiver cancel, and multi-archive root sorting.
|
||||
such as `cnc4`, then create temporary `just peer-cli-run` game roots with
|
||||
different `version.ini` contents. The existing alpha/bravo/charlie fixtures
|
||||
cover duplicate-source and shared-game cases, but not the three-version skew
|
||||
until a dedicated fixture or temporary games root is prepared.
|
||||
|
||||
## Run Log
|
||||
|
||||
### 2026-06-07 - Catalog-Version Matrix Alignment (S1-S47)
|
||||
|
||||
- Code under test aligned checked-in fixture `version.ini` sentinels with the
|
||||
catalog, made `run_extended_scenarios.py` stamp generated fixture games with
|
||||
catalog versions by default, updated S15-S17/S23/S30/S36/S37 to assert
|
||||
catalog-authoritative aggregation, and wired S38 into the executable matrix.
|
||||
- Gates before Docker: `python3 -m py_compile
|
||||
crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` passed.
|
||||
- Targeted rebuilt-image runner:
|
||||
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S3 S8 S14 S15 S16 S17 S21 S22 S23 S24 S29 S30 S31 S34 S36 S37 S39 S40 S41 S42 S43 S44 S45 S46 S47 --build-image`
|
||||
passed.
|
||||
- S38 standalone runner:
|
||||
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S38`
|
||||
passed, proving the real-RAR `css` fixture installs with the container
|
||||
`/usr/local/bin/unrar` sidecar and stamps launch settings only once.
|
||||
- Full matrix runner:
|
||||
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
||||
passed for S1-S47 against the rebuilt `lanspread-peer-cli:dev` image.
|
||||
- The final full-run highlights included S3 aggregation, S15-S17
|
||||
catalog-version skew/fanout/conflict, S23 stale-to-catalog propagation, S30
|
||||
mesh aggregation, S36 catalog singleton over stale majority, S37 throughput,
|
||||
S38 first-play stamping, and S39-S47 streamed-install coverage.
|
||||
|
||||
### 2026-06-07 - Streamed Install Edge Coverage (S43-S47)
|
||||
|
||||
- Code under test added `cancel-download` to `lanspread-peer-cli`, added the
|
||||
tiny `fixture-multi/cnctw` two-archive fixture, and added S43-S47 in
|
||||
`run_extended_scenarios.py`.
|
||||
- Gates before Docker: `just fmt` and `python3 -m py_compile
|
||||
crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` passed.
|
||||
- Runner:
|
||||
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S43 S44 S45 S46 S47 --build-image`
|
||||
passed against the rebuilt `lanspread-peer-cli:dev` image.
|
||||
- S43 stream-installed `cnctw`, retried `stream-install cnctw`, observed
|
||||
`download-failed`, and verified the existing local-only install stayed intact.
|
||||
- S44 replaced the source `cnctw.eti` with invalid bytes. The receiver emitted
|
||||
`download-failed`, cleared active operations, and left no `local/`,
|
||||
`.local.installing`, root archive, or root `version.ini`.
|
||||
- S45 killed the sole `alienswarm` source after the first streamed chunk. The
|
||||
receiver ended with `download-failed`, emitted no success, cleared active
|
||||
operations, and rolled back local/staging state.
|
||||
- S46 cancelled `alienswarm` on the receiver after the first streamed chunk.
|
||||
The receiver emitted no success and no user-visible `download-failed`, cleared
|
||||
active operations, and rolled back local/staging state.
|
||||
- S47 streamed `fixture-multi/cnctw` and observed chunk paths in sorted root
|
||||
archive order: `cnctw/.local.installing/order/first.txt`, then
|
||||
`cnctw/.local.installing/order/second.txt`.
|
||||
|
||||
### 2026-06-07 - Streamed Install Whole-Stream Retry (S42)
|
||||
|
||||
- Code under test added S42 in `run_extended_scenarios.py`.
|
||||
- Gates before Docker: `python3 -m py_compile
|
||||
crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` passed.
|
||||
- Runner:
|
||||
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S42`
|
||||
passed against the current `lanspread-peer-cli:dev` image.
|
||||
- S42 started a broken source with `--unrar /missing-unrar` and a good source
|
||||
with the same catalog-version `cnctw` metadata. The broken source sorted first
|
||||
(`10.66.0.2:32897`) and the good source second (`10.66.0.3:34092`).
|
||||
- The broken source contributed zero chunks; the good source completed the fresh
|
||||
whole-stream attempt with `3145728` streamed file bytes.
|
||||
- The final client state was `downloaded=false`, `installed=true`,
|
||||
`availability=LocalOnly`, with no root `version.ini`, no root `cnctw.eti`,
|
||||
and no `.local.installing` staging directory. Payload SHA-256 hashes matched
|
||||
the good source's `unrar p` output.
|
||||
|
||||
### 2026-06-07 - Solid Streamed Install Coverage (S41)
|
||||
|
||||
- Code under test added `fixture-solid/cnctw`, a real solid RAR `.eti`, plus
|
||||
S41 in `run_extended_scenarios.py`.
|
||||
- Gates before Docker: `just fmt`, `git diff --check`, and
|
||||
`python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
||||
passed.
|
||||
- Runner:
|
||||
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S41 --build-image`
|
||||
passed against the rebuilt `lanspread-peer-cli:dev` image.
|
||||
- S41 verified the source archive with `unrar lt -cfg-` inside the source
|
||||
container; the archive reported `Details: RAR 5, solid`.
|
||||
- The streamed install finished with `downloaded=false`, `installed=true`,
|
||||
`availability=LocalOnly`, no root `version.ini`, and no root `cnctw.eti`.
|
||||
- The client received `118` streamed file bytes, matching the extracted solid
|
||||
entries. Payload SHA-256 hashes matched `unrar p` output:
|
||||
`88764c9a6c9b5b846b4323cf7725cb7fd70766ddd7fba4168332804a839fa193`
|
||||
(`bin/cnctw-solid-payload.bin`) and
|
||||
`44afc308269b2381b7c707a056dd8d9d393274108ac4d880237fa6772c861d7a`
|
||||
(`data/cnctw-solid-assets.dat`).
|
||||
|
||||
### 2026-06-07 - Streamed Install Prototype (S39-S40)
|
||||
|
||||
- Code under test added `stream-install` to `lanspread-peer-cli`, a peer
|
||||
`StreamInstallGame` command, streamed install frames over QUIC, and an
|
||||
injected `unrar lt`/`unrar p` provider for archive-derived bytes.
|
||||
- Gates before Docker: `just fmt` and
|
||||
`RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test` passed for the
|
||||
workspace.
|
||||
- Runner:
|
||||
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S39 S40 --build-image`
|
||||
passed against the rebuilt `lanspread-peer-cli:dev` image.
|
||||
- S39 streamed a catalog-version-adjusted `cnctw` fixture from a real RAR
|
||||
`.eti` into the receiver's `local/` only. The receiver had
|
||||
`downloaded=false`, `installed=true`, `availability=LocalOnly`, no root
|
||||
`version.ini`, no root `.eti`, and payload SHA-256 hashes
|
||||
`82f4da22dc042166def2a5ee2eca19fc9e52785f99838e86c32167cb342e2588`
|
||||
(`bin/cnctw-payload.bin`) and
|
||||
`abf833a06c74ea9f17d505c2684186491898ce906405e0f098f0deac19476b06`
|
||||
(`data/cnctw-assets.dat`) matching `unrar p`.
|
||||
- S40 connected an observer only to that streamed-install receiver. The
|
||||
observer saw the receiver's `cnctw` summary as local-only, remote aggregation
|
||||
hid it as a downloadable source, and `download cnctw` failed with
|
||||
`no peers have game cnctw`.
|
||||
|
||||
### 2026-05-28 - First-Play Launch-Setting Stamping (S38)
|
||||
|
||||
- Code under test moved the `account_name.txt`/`language.txt` overwrite out of
|
||||
the install transaction and into a single first-play step (shared with the new
|
||||
`SmartSteamEmu.ini` `PersonaName` rewrite) gated by the
|
||||
`games/<id>/launch_settings_applied` marker.
|
||||
- `just test` passed the whole workspace, including the new
|
||||
`lanspread_peer::launch_settings` unit tests and
|
||||
`install::transaction::install_resets_launch_settings_marker`.
|
||||
- S38 host run: built `crates/lanspread-peer-cli/fixtures/fixture-persona/css`
|
||||
with a stored RAR `.eti` (verified by `unrar t`) burying a CRLF
|
||||
`SmartSteamEmu.ini` plus stub `account_name.txt`/`language.txt`. A host peer
|
||||
installed `css` with `--unrar /usr/bin/unrar`, then `play css` stamped the
|
||||
username into the deep `PersonaName` line (CRLF preserved, sibling lines
|
||||
intact) and `account_name.txt`, the language into `language.txt`, and created
|
||||
the marker. A second `play css` returned `already_applied=true` and rewrote
|
||||
nothing even after the value was reset externally.
|
||||
|
||||
### 2026-05-19 - Snapshot Status Fix Docker Matrix Pass
|
||||
|
||||
- Code under test included `5c4976d` (`fix(peer): settle local state before
|
||||
|
||||
@@ -1,48 +1,46 @@
|
||||
# lanspread
|
||||
|
||||
Peer-to-peer game library sharing for LAN parties. Peers discover each other on
|
||||
the local network via mDNS, exchange library metadata over QUIC, and let users
|
||||
browse and download games from each other. Ships as a Tauri desktop app.
|
||||
## Description
|
||||
|
||||
## Build / install
|
||||
Peer-to-peer game library sharing for LAN parties.
|
||||
|
||||
Install Rust, Deno, and `just` first, then bootstrap the project:
|
||||
- Peers let users browse and download games from each other
|
||||
- they discover each other on the local network via mDNS
|
||||
- they exchange library metadata over QUIC
|
||||
|
||||
Ships as a Tauri desktop app.
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
just setup
|
||||
# install Tauri CLI
|
||||
cargo install tauri-cli
|
||||
|
||||
# install Deno with a package manager or from https://deno.land/
|
||||
```
|
||||
|
||||
That installs the Tauri CLI with `cargo install tauri-cli` and installs the
|
||||
Deno/npm dependencies from `crates/lanspread-tauri-deno-ts`.
|
||||
|
||||
Run the desktop app in development mode:
|
||||
|
||||
```bash
|
||||
just run
|
||||
```
|
||||
|
||||
Build without bundling:
|
||||
### Build or Run
|
||||
|
||||
```bash
|
||||
# build
|
||||
just build
|
||||
|
||||
# run
|
||||
just run
|
||||
|
||||
# test
|
||||
just test
|
||||
```
|
||||
|
||||
Create production bundles:
|
||||
### Scripted peer harness
|
||||
|
||||
`crates/lanspread-peer-cli` runs the peer runtime without the GUI and speaks
|
||||
JSONL on stdin/stdout. It is intended for automated multi-peer smoke tests.
|
||||
|
||||
```bash
|
||||
just bundle
|
||||
just peer-cli-build
|
||||
just peer-cli-image
|
||||
just peer-cli-run alpha
|
||||
```
|
||||
|
||||
## Important just commands
|
||||
|
||||
- `just setup` - install the Tauri CLI and frontend dependencies.
|
||||
- `just run` - run the Tauri app in dev mode.
|
||||
- `just build` - build the app without bundling.
|
||||
- `just bundle` - create production bundles.
|
||||
- `just fmt` - format Rust, TOML, and the justfile.
|
||||
- `just clippy` - lint the Rust workspace.
|
||||
- `just test` - run workspace tests.
|
||||
- `just frontend-test` - run frontend tests.
|
||||
- `just peer-cli-build` - build the JSONL peer test harness.
|
||||
- `just peer-cli-image` - build the peer harness Docker image.
|
||||
- `just peer-cli-run NAME` - run one peer harness container.
|
||||
|
||||
@@ -3,23 +3,23 @@ name = "lanspread-compat"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
test = false
|
||||
|
||||
[dependencies]
|
||||
# local
|
||||
lanspread-db = { path = "../lanspread-db" }
|
||||
|
||||
eyre = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
[dependencies]
|
||||
# local
|
||||
lanspread-db = { path = "../lanspread-db" }
|
||||
|
||||
eyre = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[lib]
|
||||
test = false
|
||||
doctest = false
|
||||
|
||||
@@ -57,14 +57,14 @@ impl From<EtiGame> for Game {
|
||||
release_year: eti_game.game_release,
|
||||
publisher: eti_game.game_publisher,
|
||||
max_players: eti_game.game_maxplayers,
|
||||
version: eti_game.game_version.clone(),
|
||||
version: eti_game.game_version,
|
||||
genre: eti_game.genre_de,
|
||||
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
|
||||
size: (eti_game.game_size * 1024.0 * 1024.0 * 1024.0) as u64,
|
||||
downloaded: false,
|
||||
installed: false,
|
||||
availability: Availability::LocalOnly,
|
||||
eti_game_version: Some(eti_game.game_version),
|
||||
eti_game_version: None,
|
||||
local_version: None,
|
||||
peer_count: 0, // ETI games start with 0 peers until peer system discovers them
|
||||
}
|
||||
|
||||
@@ -3,8 +3,13 @@ name = "lanspread-db"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
|
||||
[dependencies]
|
||||
eyre = { workspace = true }
|
||||
@@ -12,10 +17,5 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
@@ -78,7 +78,7 @@ pub struct Game {
|
||||
/// Backend-reported availability state for this game's local or peer summary.
|
||||
#[serde(default)]
|
||||
pub availability: Availability,
|
||||
/// Authoritative ETI game version from the bundled game.db (YYYYMMDD format).
|
||||
/// ETI game version from version.ini (YYYYMMDD format) (server)
|
||||
pub eti_game_version: Option<String>,
|
||||
/// Local game version from version.ini (YYYYMMDD format)
|
||||
pub local_version: Option<String>,
|
||||
@@ -198,60 +198,6 @@ impl Default for GameDB {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct GameCatalog {
|
||||
expected_versions: HashMap<String, Option<String>>,
|
||||
}
|
||||
|
||||
impl GameCatalog {
|
||||
#[must_use]
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
expected_versions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_game_db(game_db: &GameDB) -> Self {
|
||||
Self {
|
||||
expected_versions: game_db
|
||||
.games
|
||||
.values()
|
||||
.map(|game| (game.id.clone(), game.eti_game_version.clone()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_ids(ids: impl IntoIterator<Item = String>) -> Self {
|
||||
Self {
|
||||
expected_versions: ids.into_iter().map(|id| (id, None)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, id: String, expected_version: Option<String>) {
|
||||
self.expected_versions.insert(id, expected_version);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn contains<S>(&self, id: S) -> bool
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
self.expected_versions.contains_key(id.as_ref())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn expected_version<S>(&self, id: S) -> Option<&str>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
self.expected_versions
|
||||
.get(id.as_ref())
|
||||
.and_then(Option::as_deref)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct GameFileDescription {
|
||||
pub game_id: String,
|
||||
|
||||
@@ -3,19 +3,19 @@ name = "lanspread-mdns"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
test = false
|
||||
|
||||
[dependencies]
|
||||
eyre = { workspace = true }
|
||||
log = { workspace = true }
|
||||
mdns-sd = { workspace = true }
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
[dependencies]
|
||||
eyre = { workspace = true }
|
||||
log = { workspace = true }
|
||||
mdns-sd = { workspace = true }
|
||||
|
||||
[lib]
|
||||
test = false
|
||||
doctest = false
|
||||
|
||||
@@ -3,12 +3,14 @@ name = "lanspread-peer-cli"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[[bin]]
|
||||
name = "lanspread-peer-cli"
|
||||
path = "src/main.rs"
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
needless_pass_by_value = "allow"
|
||||
|
||||
[dependencies]
|
||||
lanspread-compat = { path = "../lanspread-compat" }
|
||||
@@ -20,11 +22,9 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
[lints.clippy]
|
||||
needless_pass_by_value = "allow"
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
[[bin]]
|
||||
name = "lanspread-peer-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
@@ -4,16 +4,14 @@ WORKDIR /work
|
||||
COPY . .
|
||||
RUN cargo build --release -p lanspread-peer-cli
|
||||
|
||||
FROM debian:trixie-slim
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates libstdc++6 \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /work/target/release/lanspread-peer-cli /usr/local/bin/lanspread-peer-cli
|
||||
COPY crates/lanspread-tauri-deno-ts/src-tauri/game.db /app/game.db
|
||||
COPY crates/lanspread-tauri-deno-ts/src-tauri/binaries/unrar-x86_64-unknown-linux-gnu /usr/local/bin/unrar
|
||||
RUN chmod +x /usr/local/bin/unrar
|
||||
|
||||
ENTRYPOINT ["lanspread-peer-cli"]
|
||||
CMD ["--games-dir", "/games", "--state-dir", "/state", "--catalog-db", "/app/game.db"]
|
||||
|
||||
@@ -42,7 +42,3 @@ echoed back on the result or error line.
|
||||
{"id":"u1","cmd":"uninstall","game_id":"fixture-one"}
|
||||
{"id":"q1","cmd":"shutdown"}
|
||||
```
|
||||
|
||||
The `status` result includes receiver-side `active_operations` and
|
||||
sender-side `active_outbound_transfers` counts by game ID, which the scenario
|
||||
runner uses to verify transfer lifecycle cleanup.
|
||||
|
||||
@@ -1 +1 @@
|
||||
20190317
|
||||
20250101
|
||||
|
||||
@@ -1 +1 @@
|
||||
20160130
|
||||
20250103
|
||||
|
||||
@@ -1 +1 @@
|
||||
20200721
|
||||
20250102
|
||||
|
||||
@@ -1 +1 @@
|
||||
20210416
|
||||
20250201
|
||||
|
||||
@@ -1 +1 @@
|
||||
20170204
|
||||
20250202
|
||||
|
||||
@@ -1 +1 @@
|
||||
20160128
|
||||
20250203
|
||||
|
||||
@@ -1 +1 @@
|
||||
20200721
|
||||
20250102
|
||||
|
||||
@@ -1 +1 @@
|
||||
20170204
|
||||
20250202
|
||||
|
||||
@@ -1 +1 @@
|
||||
20160920
|
||||
20250301
|
||||
|
||||
@@ -1 +1 @@
|
||||
20200315
|
||||
20250302
|
||||
|
||||
@@ -1 +1 @@
|
||||
20200907
|
||||
20250303
|
||||
|
||||
Binary file not shown.
@@ -1 +0,0 @@
|
||||
20160128
|
||||
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
|
||||
20240623
|
||||
Binary file not shown.
@@ -1 +0,0 @@
|
||||
20160128
|
||||
@@ -1,15 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the peer-cli scenarios S1-S47 through Docker."""
|
||||
"""Run the peer-cli scenarios S1-S36 through Docker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -28,20 +26,7 @@ CONTAINER_PREFIX = "lanspread-peer-cli-ext"
|
||||
CATALOG_DB = "/app/game.db"
|
||||
FIXTURES = REPO / "crates" / "lanspread-peer-cli" / "fixtures"
|
||||
CHUNK_SIZE = 128 * 1024 * 1024
|
||||
CATALOG_VERSIONS = {
|
||||
"alienswarm": "20190317",
|
||||
"bf1942": "20160130",
|
||||
"bfbc2": "20210416",
|
||||
"cnc4": "20170204",
|
||||
"cnctw": "20160128",
|
||||
"cod5": "20160920",
|
||||
"cod6": "20200315",
|
||||
"coh": "20200907",
|
||||
"css": "20240623",
|
||||
"ggoo": "20200721",
|
||||
}
|
||||
PERF_GAME_ID = "bf1942"
|
||||
PERF_GAME_VERSION = CATALOG_VERSIONS[PERF_GAME_ID]
|
||||
PERF_GAME_SIZE = 2 * 1024 * 1024 * 1024
|
||||
IGNORED_DIFF_NAMES = {".lanspread", ".lanspread.json", "local"}
|
||||
|
||||
@@ -318,8 +303,8 @@ class Runner:
|
||||
("S13", self.s13_exact_transfer_equality),
|
||||
("S14", self.s14_large_multi_peer_chunking),
|
||||
("S15", self.s15_three_way_version_skew),
|
||||
("S16", self.s16_catalog_fanout_with_stale),
|
||||
("S17", self.s17_catalog_conflict_rejection),
|
||||
("S16", self.s16_latest_fanout_with_stale),
|
||||
("S17", self.s17_latest_conflict_rejection),
|
||||
("S18", self.s18_redundant_source_drop),
|
||||
("S19", self.s19_sole_source_drop),
|
||||
("S20", self.s20_receiver_write_failure),
|
||||
@@ -338,18 +323,8 @@ class Runner:
|
||||
("S33", self.s33_install_after_mutation),
|
||||
("S34", self.s34_many_small_files),
|
||||
("S35", self.s35_unknown_game_filtered),
|
||||
("S36", self.s36_catalog_singleton),
|
||||
("S36", self.s36_latest_singleton),
|
||||
("S37", self.s37_single_source_download_throughput),
|
||||
("S38", self.s38_first_play_launch_settings),
|
||||
("S39", self.s39_streamed_install_local_only),
|
||||
("S40", self.s40_streamed_receiver_not_source),
|
||||
("S41", self.s41_solid_archive_streamed_install),
|
||||
("S42", self.s42_streamed_install_retries_next_source),
|
||||
("S43", self.s43_streamed_install_rejects_installed_game),
|
||||
("S44", self.s44_corrupt_stream_rolls_back),
|
||||
("S45", self.s45_sender_disconnect_mid_stream),
|
||||
("S46", self.s46_receiver_cancel_mid_stream),
|
||||
("S47", self.s47_multi_archive_streams_in_sorted_order),
|
||||
]
|
||||
|
||||
for scenario_id, scenario in scenarios:
|
||||
@@ -534,20 +509,20 @@ class Runner:
|
||||
def s8_ambiguous_metadata_rejection(self) -> str:
|
||||
dir_a = self.fixture_root / "s8-a"
|
||||
dir_b = self.fixture_root / "s8-b"
|
||||
copy_game("ggoo", dir_a)
|
||||
copy_game("ggoo", dir_b)
|
||||
copy_game("ggoo", dir_a, version="20260101")
|
||||
copy_game("ggoo", dir_b, version="20260101")
|
||||
with (dir_b / "ggoo" / "ggoo.eti").open("ab") as handle:
|
||||
handle.write(b"conflict")
|
||||
peer_a = self.peer("s8-a", games_dir=dir_a)
|
||||
peer_b = self.peer("s8-b", games_dir=dir_b)
|
||||
client = self.peer("s8-client")
|
||||
connect_many(client, [peer_a, peer_b])
|
||||
wait_remote_game(client, "ggoo", peer_count=2, version=CATALOG_VERSIONS["ggoo"])
|
||||
wait_remote_game(client, "ggoo", peer_count=2, version="20260101")
|
||||
waiter = LineWaiter(len(client.output))
|
||||
client.send({"cmd": "download", "game_id": "ggoo", "install": False})
|
||||
client.wait_for(event_is("download-failed", "ggoo"), timeout=30, description="ggoo failed", waiter=waiter)
|
||||
assert_not_exists(client.host_games_dir / "ggoo" / "version.ini")
|
||||
return "conflicting catalog-version ggoo file sizes emitted download-failed and left no version.ini"
|
||||
return "conflicting latest ggoo file sizes emitted download-failed and left no version.ini"
|
||||
|
||||
def s9_missing_game(self) -> str:
|
||||
client = self.peer("s9-client")
|
||||
@@ -629,7 +604,7 @@ class Runner:
|
||||
diff_game_dirs(source_dir / game_id, stage.host_games_dir / game_id)
|
||||
client = self.peer("s14-client")
|
||||
connect_many(client, [alpha, stage])
|
||||
wait_remote_game(client, game_id, peer_count=2, version=PERF_GAME_VERSION)
|
||||
wait_remote_game(client, game_id, peer_count=2, version="20260520")
|
||||
waiter = LineWaiter(len(client.output))
|
||||
client.send({"cmd": "download", "game_id": game_id, "install": False})
|
||||
client.wait_for(event_is("download-finished", game_id), timeout=90, description="client finish", waiter=waiter)
|
||||
@@ -643,11 +618,7 @@ class Runner:
|
||||
return f"{game_id} downloaded from two sources, diff matched, chunk totals={totals}"
|
||||
|
||||
def s15_three_way_version_skew(self) -> str:
|
||||
specs = [
|
||||
("s15-a", "20150101"),
|
||||
("s15-b", "20160101"),
|
||||
("s15-c", CATALOG_VERSIONS["cnc4"]),
|
||||
]
|
||||
specs = [("s15-a", "20250101"), ("s15-b", "20250201"), ("s15-c", "20250301")]
|
||||
peers = []
|
||||
for name, version in specs:
|
||||
game_dir = self.fixture_root / name
|
||||
@@ -655,19 +626,19 @@ class Runner:
|
||||
peers.append(self.peer(name, games_dir=game_dir))
|
||||
client = self.peer("s15-client")
|
||||
connect_many(client, peers)
|
||||
wait_remote_game(client, "cnc4", peer_count=1, version=CATALOG_VERSIONS["cnc4"])
|
||||
wait_remote_game(client, "cnc4", peer_count=3, version="20250301")
|
||||
waiter = LineWaiter(len(client.output))
|
||||
client.send({"cmd": "download", "game_id": "cnc4", "install": False})
|
||||
client.wait_for(event_is("download-finished", "cnc4"), timeout=60, description="cnc4 finish", waiter=waiter)
|
||||
assert_only_chunk_sources(client, "cnc4", {peers[2].ready_addr})
|
||||
diff_game_dirs(peers[2].host_games_dir / "cnc4", client.host_games_dir / "cnc4")
|
||||
return "three-way skew exposed only the catalog-version peer and receiver diffed cleanly"
|
||||
return "three-way skew selected only 20250301 peer and receiver diffed cleanly"
|
||||
|
||||
def s16_catalog_fanout_with_stale(self) -> str:
|
||||
def s16_latest_fanout_with_stale(self) -> str:
|
||||
specs = [
|
||||
("s16-a", "20180101"),
|
||||
("s16-b", CATALOG_VERSIONS["alienswarm"]),
|
||||
("s16-c", CATALOG_VERSIONS["alienswarm"]),
|
||||
("s16-a", "20250101"),
|
||||
("s16-b", "20250301"),
|
||||
("s16-c", "20250301"),
|
||||
]
|
||||
peers = []
|
||||
for name, version in specs:
|
||||
@@ -676,7 +647,7 @@ class Runner:
|
||||
peers.append(self.peer(name, games_dir=game_dir))
|
||||
client = self.peer("s16-client")
|
||||
connect_many(client, peers)
|
||||
wait_remote_game(client, "alienswarm", peer_count=2, version=CATALOG_VERSIONS["alienswarm"])
|
||||
wait_remote_game(client, "alienswarm", peer_count=3, version="20250301")
|
||||
waiter = LineWaiter(len(client.output))
|
||||
client.send({"cmd": "download", "game_id": "alienswarm", "install": False})
|
||||
client.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="alienswarm finish", waiter=waiter)
|
||||
@@ -685,13 +656,13 @@ class Runner:
|
||||
if peers[0].ready_addr in totals:
|
||||
raise ScenarioError(f"stale peer contributed chunks: {totals}")
|
||||
diff_game_dirs(peers[1].host_games_dir / "alienswarm", client.host_games_dir / "alienswarm")
|
||||
return f"catalog-version B/C peers served alienswarm while stale A contributed zero; totals={totals}"
|
||||
return f"latest B/C peers served alienswarm while stale A contributed zero; totals={totals}"
|
||||
|
||||
def s17_catalog_conflict_rejection(self) -> str:
|
||||
def s17_latest_conflict_rejection(self) -> str:
|
||||
specs = [
|
||||
("s17-a", "20150101", False),
|
||||
("s17-b", CATALOG_VERSIONS["cnc4"], False),
|
||||
("s17-c", CATALOG_VERSIONS["cnc4"], True),
|
||||
("s17-a", "20250101", False),
|
||||
("s17-b", "20250301", False),
|
||||
("s17-c", "20250301", True),
|
||||
]
|
||||
peers = []
|
||||
for name, version, conflict in specs:
|
||||
@@ -703,12 +674,12 @@ class Runner:
|
||||
peers.append(self.peer(name, games_dir=game_dir))
|
||||
client = self.peer("s17-client")
|
||||
connect_many(client, peers)
|
||||
wait_remote_game(client, "cnc4", peer_count=2, version=CATALOG_VERSIONS["cnc4"])
|
||||
wait_remote_game(client, "cnc4", peer_count=3, version="20250301")
|
||||
waiter = LineWaiter(len(client.output))
|
||||
client.send({"cmd": "download", "game_id": "cnc4", "install": False})
|
||||
client.wait_for(event_is("download-failed", "cnc4"), timeout=30, description="cnc4 failed", waiter=waiter)
|
||||
assert_not_exists(client.host_games_dir / "cnc4" / "version.ini")
|
||||
return "catalog-version file conflict failed download and left no committed version.ini"
|
||||
return "latest-version file conflict failed download and left no committed version.ini"
|
||||
|
||||
def s18_redundant_source_drop(self) -> str:
|
||||
source_a_dir = self.fixture_root / "s18-a"
|
||||
@@ -791,13 +762,13 @@ class Runner:
|
||||
def s23_version_bump_propagation(self) -> str:
|
||||
alpha = self.peer("s23-alpha")
|
||||
bravo_dir = self.fixture_root / "s23-bravo"
|
||||
copy_game("cnc4", bravo_dir, version="20160101")
|
||||
copy_game("cnc4", bravo_dir, version="20250101")
|
||||
bravo = self.peer("s23-bravo", games_dir=bravo_dir)
|
||||
connect_many(alpha, [bravo])
|
||||
wait_remote_absent(alpha, "cnc4", timeout=5)
|
||||
(bravo_dir / "cnc4" / "version.ini").write_text(CATALOG_VERSIONS["cnc4"], encoding="utf-8")
|
||||
wait_remote_game(alpha, "cnc4", peer_count=1, version=CATALOG_VERSIONS["cnc4"])
|
||||
return "alpha observed stale cnc4 become catalog-version downloadable without reconnect"
|
||||
wait_remote_game(alpha, "cnc4", peer_count=1, version="20250101")
|
||||
(bravo_dir / "cnc4" / "version.ini").write_text("20260501", encoding="utf-8")
|
||||
wait_remote_game(alpha, "cnc4", peer_count=1, version="20260501")
|
||||
return "alpha observed cnc4 eti_game_version change 20250101 -> 20260501 without reconnect"
|
||||
|
||||
def s24_two_clients_one_source(self) -> str:
|
||||
source = self.peer("s24-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
|
||||
@@ -894,11 +865,11 @@ class Runner:
|
||||
def s30_mesh_aggregation(self) -> str:
|
||||
dirs = []
|
||||
specs = [
|
||||
("s30-a", [("ggoo", CATALOG_VERSIONS["ggoo"]), ("bf1942", CATALOG_VERSIONS["bf1942"])]),
|
||||
("s30-b", [("ggoo", CATALOG_VERSIONS["ggoo"]), ("cnc4", CATALOG_VERSIONS["cnc4"])]),
|
||||
("s30-c", [("cnc4", CATALOG_VERSIONS["cnc4"]), ("cod5", CATALOG_VERSIONS["cod5"])]),
|
||||
("s30-d", [("cnctw", CATALOG_VERSIONS["cnctw"]), ("coh", CATALOG_VERSIONS["coh"])]),
|
||||
("s30-e", [("cnctw", CATALOG_VERSIONS["cnctw"]), ("bf1942", CATALOG_VERSIONS["bf1942"])]),
|
||||
("s30-a", [("ggoo", "20250101"), ("bf1942", "20250101")]),
|
||||
("s30-b", [("ggoo", "20250101"), ("cnc4", "20250101")]),
|
||||
("s30-c", [("cnc4", "20250301"), ("cod5", "20250101")]),
|
||||
("s30-d", [("cnctw", "20250101"), ("coh", "20250101")]),
|
||||
("s30-e", [("cnctw", "20250201"), ("bf1942", "20250201")]),
|
||||
]
|
||||
peers = []
|
||||
for name, games in specs:
|
||||
@@ -910,12 +881,12 @@ class Runner:
|
||||
client = self.peer("s30-client")
|
||||
connect_many(client, peers)
|
||||
expected = {
|
||||
"ggoo": (2, CATALOG_VERSIONS["ggoo"]),
|
||||
"bf1942": (2, CATALOG_VERSIONS["bf1942"]),
|
||||
"cnc4": (2, CATALOG_VERSIONS["cnc4"]),
|
||||
"cod5": (1, CATALOG_VERSIONS["cod5"]),
|
||||
"cnctw": (2, CATALOG_VERSIONS["cnctw"]),
|
||||
"coh": (1, CATALOG_VERSIONS["coh"]),
|
||||
"ggoo": (2, "20250101"),
|
||||
"bf1942": (2, "20250201"),
|
||||
"cnc4": (2, "20250301"),
|
||||
"cod5": (1, "20250101"),
|
||||
"cnctw": (2, "20250201"),
|
||||
"coh": (1, "20250101"),
|
||||
}
|
||||
for game_id, (peer_count, version) in expected.items():
|
||||
wait_remote_game(client, game_id, peer_count=peer_count, version=version)
|
||||
@@ -925,7 +896,7 @@ class Runner:
|
||||
raise ScenarioError(f"duplicate game rows: {ids}")
|
||||
if any(peer["peer_id"] == client.peer_id for peer in client.list_peers()):
|
||||
raise ScenarioError("client listed itself as a peer")
|
||||
return f"client aggregated {len(expected)} IDs from 5 peers with expected peer_count/catalog versions"
|
||||
return f"client aggregated {len(expected)} IDs from 5 peers with expected peer_count/latest versions"
|
||||
|
||||
def s31_bootstrapped_peer_source(self) -> str:
|
||||
source = self.peer("s31-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
|
||||
@@ -1021,34 +992,34 @@ class Runner:
|
||||
assert_not_exists(client.host_games_dir / "mystery-game")
|
||||
return f"unknown game absent from list-games; download errored '{err['error']}'; no local files"
|
||||
|
||||
def s36_catalog_singleton(self) -> str:
|
||||
def s36_latest_singleton(self) -> str:
|
||||
peers = []
|
||||
for index in range(5):
|
||||
game_dir = self.fixture_root / f"s36-{index}"
|
||||
version = CATALOG_VERSIONS["cnc4"] if index == 0 else "20160101"
|
||||
version = "20260501" if index == 0 else "20250101"
|
||||
copy_game("cnc4", game_dir, version=version)
|
||||
peers.append(self.peer(f"s36-{index}", games_dir=game_dir))
|
||||
client = self.peer("s36-client")
|
||||
connect_many(client, peers)
|
||||
wait_remote_game(client, "cnc4", peer_count=1, version=CATALOG_VERSIONS["cnc4"])
|
||||
wait_remote_game(client, "cnc4", peer_count=5, version="20260501")
|
||||
waiter = LineWaiter(len(client.output))
|
||||
client.send({"cmd": "download", "game_id": "cnc4", "install": False})
|
||||
got = client.wait_for(event_is("got-game-files", "cnc4"), timeout=20, description="got game files", waiter=waiter)
|
||||
client.wait_for(event_is("download-finished", "cnc4"), timeout=60, description="download finish", waiter=waiter)
|
||||
catalog_addr = peers[0].ready_addr
|
||||
if catalog_addr is None:
|
||||
raise ScenarioError("catalog-version peer had no ready addr")
|
||||
latest_addr = peers[0].ready_addr
|
||||
if latest_addr is None:
|
||||
raise ScenarioError("latest peer had no ready addr")
|
||||
for item in client.output:
|
||||
if item.get("type") != "event" or item.get("event") != "download-chunk-finished":
|
||||
continue
|
||||
data = item["data"]
|
||||
if data.get("game_id") == "cnc4" and data.get("peer_addr") != catalog_addr:
|
||||
if data.get("game_id") == "cnc4" and data.get("peer_addr") != latest_addr:
|
||||
raise ScenarioError(f"stale peer contributed chunk: {data}")
|
||||
diff_game_dirs(peers[0].host_games_dir / "cnc4", client.host_games_dir / "cnc4")
|
||||
descs = got["data"]["file_descriptions"]
|
||||
if not descs:
|
||||
raise ScenarioError("got-game-files had no descriptors")
|
||||
return "client reported singleton catalog-version peer; stale peers stayed hidden and sent no chunks; diff matched"
|
||||
return "client reported latest 20260501 with peer_count=5; only singleton latest peer sent chunks; diff matched"
|
||||
|
||||
def s37_single_source_download_throughput(self) -> str:
|
||||
source_dir = self.fixture_root / "s37-source"
|
||||
@@ -1056,7 +1027,7 @@ class Runner:
|
||||
source = self.peer("s37-source", games_dir=source_dir)
|
||||
client = self.peer("s37-client")
|
||||
connect_many(client, [source])
|
||||
wait_remote_game(client, PERF_GAME_ID, peer_count=1, version=PERF_GAME_VERSION)
|
||||
wait_remote_game(client, PERF_GAME_ID, peer_count=1, version="20260520")
|
||||
|
||||
waiter = LineWaiter(len(client.output))
|
||||
client.send({"cmd": "download", "game_id": PERF_GAME_ID, "install": False})
|
||||
@@ -1075,7 +1046,7 @@ class Runner:
|
||||
throughput = finished.get("data", {}).get("throughput")
|
||||
if not throughput:
|
||||
raise ScenarioError(f"download-finished did not include throughput: {finished}")
|
||||
expected_bytes = PERF_GAME_SIZE + len(PERF_GAME_VERSION)
|
||||
expected_bytes = PERF_GAME_SIZE + len("20260520")
|
||||
if int(throughput["bytes"]) != expected_bytes:
|
||||
raise ScenarioError(
|
||||
f"throughput byte count mismatch: {throughput['bytes']} != {expected_bytes}"
|
||||
@@ -1089,535 +1060,6 @@ class Runner:
|
||||
f"{throughput['chunks']} chunks"
|
||||
)
|
||||
|
||||
def s38_first_play_launch_settings(self) -> str:
|
||||
client_dir = self.fixture_root / "s38-client"
|
||||
copy_game("css", client_dir)
|
||||
client = self.peer(
|
||||
"s38-client",
|
||||
games_dir=client_dir,
|
||||
extra_args=["--unrar", "/usr/local/bin/unrar"],
|
||||
)
|
||||
waiter = LineWaiter(len(client.output))
|
||||
client.send({"cmd": "install", "game_id": "css"})
|
||||
client.wait_for(
|
||||
event_is("install-finished", "css"),
|
||||
timeout=30,
|
||||
description="css install",
|
||||
waiter=waiter,
|
||||
)
|
||||
wait_local_game(client, "css", downloaded=True, installed=True)
|
||||
|
||||
marker = client.host_state_dir / "games" / "css" / "launch_settings_applied"
|
||||
if marker.exists():
|
||||
raise ScenarioError("launch settings marker existed before first play")
|
||||
|
||||
local_root = client.host_games_dir / "css" / "local"
|
||||
account_file = local_root / "profiles" / "local" / "account_name.txt"
|
||||
language_file = local_root / "profiles" / "local" / "language.txt"
|
||||
ini_file = (
|
||||
local_root
|
||||
/ "engine"
|
||||
/ "bin"
|
||||
/ "win64"
|
||||
/ "steam_settings"
|
||||
/ "SmartSteamEmu.ini"
|
||||
)
|
||||
for path in [account_file, language_file, ini_file]:
|
||||
if not path.is_file():
|
||||
raise ScenarioError(f"expected installed launch settings file: {path}")
|
||||
if b"PersonaName = stubplayer\r\n" not in ini_file.read_bytes():
|
||||
raise ScenarioError("installed SmartSteamEmu.ini did not preserve CRLF stub PersonaName")
|
||||
|
||||
first = client.send(
|
||||
{
|
||||
"cmd": "play",
|
||||
"game_id": "css",
|
||||
"username": "Lan Hero",
|
||||
"language": "german",
|
||||
}
|
||||
)["data"]["outcome"]
|
||||
expected_first = {
|
||||
"already_applied": False,
|
||||
"account_name_written": True,
|
||||
"language_written": True,
|
||||
"persona_name_written": True,
|
||||
}
|
||||
if first != expected_first:
|
||||
raise ScenarioError(f"unexpected first play outcome: {first}")
|
||||
if not marker.is_file():
|
||||
raise ScenarioError("launch settings marker was not written after first play")
|
||||
if account_file.read_text(encoding="utf-8") != "Lan Hero":
|
||||
raise ScenarioError("account_name.txt was not stamped with username")
|
||||
if language_file.read_text(encoding="utf-8") != "german":
|
||||
raise ScenarioError("language.txt was not stamped with language")
|
||||
stamped_ini = ini_file.read_bytes()
|
||||
if b"PersonaName = Lan Hero\r\n" not in stamped_ini:
|
||||
raise ScenarioError("PersonaName was not stamped with CRLF preserved")
|
||||
if b"AppId = 240\r\n" not in stamped_ini or b"Language = english\r\n" not in stamped_ini:
|
||||
raise ScenarioError("SmartSteamEmu.ini sibling lines were not preserved")
|
||||
|
||||
client.docker_exec(
|
||||
"sh",
|
||||
"-c",
|
||||
"printf resetaccount > /games/css/local/profiles/local/account_name.txt",
|
||||
)
|
||||
client.docker_exec(
|
||||
"sh",
|
||||
"-c",
|
||||
"printf resetlang > /games/css/local/profiles/local/language.txt",
|
||||
)
|
||||
client.docker_exec(
|
||||
"sh",
|
||||
"-c",
|
||||
"printf '[Settings]\\r\\nAppId = 240\\r\\n"
|
||||
"PersonaName = resetplayer\\r\\nLanguage = english\\r\\n' > "
|
||||
"/games/css/local/engine/bin/win64/steam_settings/SmartSteamEmu.ini",
|
||||
)
|
||||
|
||||
second = client.send(
|
||||
{
|
||||
"cmd": "play",
|
||||
"game_id": "css",
|
||||
"username": "Second User",
|
||||
"language": "french",
|
||||
}
|
||||
)["data"]["outcome"]
|
||||
expected_second = {
|
||||
"already_applied": True,
|
||||
"account_name_written": False,
|
||||
"language_written": False,
|
||||
"persona_name_written": False,
|
||||
}
|
||||
if second != expected_second:
|
||||
raise ScenarioError(f"unexpected second play outcome: {second}")
|
||||
if account_file.read_text(encoding="utf-8") != "resetaccount":
|
||||
raise ScenarioError("second play rewrote account_name.txt despite marker")
|
||||
if language_file.read_text(encoding="utf-8") != "resetlang":
|
||||
raise ScenarioError("second play rewrote language.txt despite marker")
|
||||
if b"PersonaName = resetplayer\r\n" not in ini_file.read_bytes():
|
||||
raise ScenarioError("second play rewrote PersonaName despite marker")
|
||||
|
||||
return "css first play stamped launch settings once; second play respected the marker"
|
||||
|
||||
def stream_install_cnctw(self, prefix: str) -> tuple[Peer, Peer]:
|
||||
source_dir = self.fixture_root / f"{prefix}-bravo"
|
||||
copy_game("cnctw", source_dir, version="20160128")
|
||||
source = self.peer(f"{prefix}-bravo", games_dir=source_dir)
|
||||
client = self.peer(f"{prefix}-client")
|
||||
connect_many(client, [source])
|
||||
wait_remote_game(client, "cnctw", peer_count=1)
|
||||
waiter = LineWaiter(len(client.output))
|
||||
client.send({"cmd": "stream-install", "game_id": "cnctw"})
|
||||
client.wait_for(
|
||||
event_is("download-begin", "cnctw"),
|
||||
timeout=20,
|
||||
description="stream begin cnctw",
|
||||
waiter=waiter,
|
||||
)
|
||||
client.wait_for(
|
||||
event_is("download-finished", "cnctw"),
|
||||
timeout=60,
|
||||
description="stream finish cnctw",
|
||||
waiter=waiter,
|
||||
)
|
||||
client.wait_for(
|
||||
event_is("install-finished", "cnctw"),
|
||||
timeout=30,
|
||||
description="stream install cnctw",
|
||||
waiter=waiter,
|
||||
)
|
||||
return source, client
|
||||
|
||||
def s39_streamed_install_local_only(self) -> str:
|
||||
source, client = self.stream_install_cnctw("s39")
|
||||
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
|
||||
assert_game_state(
|
||||
game,
|
||||
downloaded=False,
|
||||
installed=True,
|
||||
availability="LocalOnly",
|
||||
)
|
||||
|
||||
game_root = client.host_games_dir / "cnctw"
|
||||
assert_not_exists(game_root / "version.ini")
|
||||
assert_not_exists(game_root / "cnctw.eti")
|
||||
|
||||
expected = {
|
||||
"bin/cnctw-payload.bin": unrar_entry_sha256(
|
||||
source, "cnctw", "bin/cnctw-payload.bin"
|
||||
),
|
||||
"data/cnctw-assets.dat": unrar_entry_sha256(
|
||||
source, "cnctw", "data/cnctw-assets.dat"
|
||||
),
|
||||
}
|
||||
actual = {
|
||||
rel: sha256_file(game_root / "local" / rel)
|
||||
for rel in expected
|
||||
}
|
||||
if actual != expected:
|
||||
raise ScenarioError(f"streamed local payload hashes mismatched: {actual} != {expected}")
|
||||
|
||||
streamed_bytes = sum(
|
||||
int(item.get("data", {}).get("length", 0))
|
||||
for item in client.output
|
||||
if item.get("type") == "event"
|
||||
and item.get("event") == "download-chunk-finished"
|
||||
and item.get("data", {}).get("game_id") == "cnctw"
|
||||
)
|
||||
expected_bytes = 3 * 1024 * 1024
|
||||
if streamed_bytes != expected_bytes:
|
||||
raise ScenarioError(
|
||||
f"streamed byte count mismatch: {streamed_bytes} != {expected_bytes}"
|
||||
)
|
||||
|
||||
wait_no_outbound_transfer(source, "cnctw")
|
||||
|
||||
return (
|
||||
"cnctw streamed into local/ only; root archive and version.ini absent; "
|
||||
f"payload hashes={actual}; source outbound transfer drained"
|
||||
)
|
||||
|
||||
def s40_streamed_receiver_not_source(self) -> str:
|
||||
_source, receiver = self.stream_install_cnctw("s40")
|
||||
observer = self.peer("s40-observer")
|
||||
connect_many(observer, [receiver])
|
||||
receiver_snapshot = wait_peer_has_game(observer, receiver.peer_id, "cnctw")
|
||||
summary = next(
|
||||
game
|
||||
for game in receiver_snapshot.get("games", [])
|
||||
if game.get("id") == "cnctw"
|
||||
)
|
||||
if summary.get("availability") != "LocalOnly" or summary.get("downloaded"):
|
||||
raise ScenarioError(f"receiver did not advertise cnctw as local-only: {summary}")
|
||||
|
||||
wait_remote_absent(observer, "cnctw", timeout=5)
|
||||
err = observer.send(
|
||||
{"cmd": "download", "game_id": "cnctw", "install": False},
|
||||
expect_error=True,
|
||||
)
|
||||
if "no peers have game cnctw" not in err["error"]:
|
||||
raise ScenarioError(f"unexpected local-only download error: {err}")
|
||||
assert_not_exists(observer.host_games_dir / "cnctw")
|
||||
return (
|
||||
"observer saw receiver's local-only cnctw snapshot, but remote aggregation hid it "
|
||||
f"and download errored '{err['error']}'"
|
||||
)
|
||||
|
||||
def s41_solid_archive_streamed_install(self) -> str:
|
||||
source_dir = self.fixture_root / "s41-solid-source"
|
||||
source_game = source_dir / "cnctw"
|
||||
shutil.copytree(FIXTURES / "fixture-solid" / "cnctw", source_game)
|
||||
|
||||
source = self.peer("s41-solid-source", games_dir=source_dir)
|
||||
assert_peer_rar_archive_solid(source, "cnctw")
|
||||
client = self.peer("s41-solid-client")
|
||||
connect_many(client, [source])
|
||||
wait_remote_game(client, "cnctw", peer_count=1, version="20160128")
|
||||
|
||||
waiter = LineWaiter(len(client.output))
|
||||
client.send({"cmd": "stream-install", "game_id": "cnctw"})
|
||||
client.wait_for(
|
||||
event_is("download-finished", "cnctw"),
|
||||
timeout=60,
|
||||
description="solid stream finish cnctw",
|
||||
waiter=waiter,
|
||||
)
|
||||
client.wait_for(
|
||||
event_is("install-finished", "cnctw"),
|
||||
timeout=30,
|
||||
description="solid stream install cnctw",
|
||||
waiter=waiter,
|
||||
)
|
||||
|
||||
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
|
||||
assert_game_state(
|
||||
game,
|
||||
downloaded=False,
|
||||
installed=True,
|
||||
availability="LocalOnly",
|
||||
)
|
||||
game_root = client.host_games_dir / "cnctw"
|
||||
assert_not_exists(game_root / "version.ini")
|
||||
assert_not_exists(game_root / "cnctw.eti")
|
||||
|
||||
expected = {
|
||||
"bin/cnctw-solid-payload.bin": unrar_entry_sha256(
|
||||
source, "cnctw", "bin/cnctw-solid-payload.bin"
|
||||
),
|
||||
"data/cnctw-solid-assets.dat": unrar_entry_sha256(
|
||||
source, "cnctw", "data/cnctw-solid-assets.dat"
|
||||
),
|
||||
}
|
||||
actual = {
|
||||
rel: sha256_file(game_root / "local" / rel)
|
||||
for rel in expected
|
||||
}
|
||||
if actual != expected:
|
||||
raise ScenarioError(
|
||||
f"solid streamed payload hashes mismatched: {actual} != {expected}"
|
||||
)
|
||||
|
||||
streamed_bytes = sum(
|
||||
int(item.get("data", {}).get("length", 0))
|
||||
for item in client.output
|
||||
if item.get("type") == "event"
|
||||
and item.get("event") == "download-chunk-finished"
|
||||
and item.get("data", {}).get("game_id") == "cnctw"
|
||||
)
|
||||
expected_bytes = sum((game_root / "local" / rel).stat().st_size for rel in expected)
|
||||
if streamed_bytes != expected_bytes:
|
||||
raise ScenarioError(
|
||||
f"solid streamed byte count mismatch: {streamed_bytes} != {expected_bytes}"
|
||||
)
|
||||
|
||||
return (
|
||||
"solid cnctw archive streamed through one local-only install; "
|
||||
f"payload hashes={actual}, bytes={streamed_bytes}"
|
||||
)
|
||||
|
||||
def s42_streamed_install_retries_next_source(self) -> str:
|
||||
bad_dir = self.fixture_root / "s42-bad-source"
|
||||
good_dir = self.fixture_root / "s42-good-source"
|
||||
copy_game("cnctw", bad_dir, version="20160128")
|
||||
copy_game("cnctw", good_dir, version="20160128")
|
||||
|
||||
bad = self.peer(
|
||||
"s42-bad-source",
|
||||
games_dir=bad_dir,
|
||||
extra_args=["--unrar", "/missing-unrar"],
|
||||
)
|
||||
good = self.peer("s42-good-source", games_dir=good_dir)
|
||||
if socket_addr_sort_key(bad.ready_addr) > socket_addr_sort_key(good.ready_addr):
|
||||
raise ScenarioError(
|
||||
"S42 requires the broken source to sort before the good source; "
|
||||
f"bad={bad.ready_addr}, good={good.ready_addr}"
|
||||
)
|
||||
|
||||
client = self.peer("s42-client")
|
||||
connect_many(client, [bad, good])
|
||||
wait_remote_game(client, "cnctw", peer_count=2, version="20160128")
|
||||
|
||||
waiter = LineWaiter(len(client.output))
|
||||
client.send({"cmd": "stream-install", "game_id": "cnctw"})
|
||||
client.wait_for(
|
||||
event_is("download-finished", "cnctw"),
|
||||
timeout=60,
|
||||
description="retry stream finish cnctw",
|
||||
waiter=waiter,
|
||||
)
|
||||
client.wait_for(
|
||||
event_is("install-finished", "cnctw"),
|
||||
timeout=30,
|
||||
description="retry stream install cnctw",
|
||||
waiter=waiter,
|
||||
)
|
||||
|
||||
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
|
||||
assert_game_state(
|
||||
game,
|
||||
downloaded=False,
|
||||
installed=True,
|
||||
availability="LocalOnly",
|
||||
)
|
||||
game_root = client.host_games_dir / "cnctw"
|
||||
assert_not_exists(game_root / ".local.installing")
|
||||
assert_not_exists(game_root / "version.ini")
|
||||
assert_not_exists(game_root / "cnctw.eti")
|
||||
assert_only_chunk_sources(client, "cnctw", {good.ready_addr})
|
||||
|
||||
expected = {
|
||||
"bin/cnctw-payload.bin": unrar_entry_sha256(
|
||||
good, "cnctw", "bin/cnctw-payload.bin"
|
||||
),
|
||||
"data/cnctw-assets.dat": unrar_entry_sha256(
|
||||
good, "cnctw", "data/cnctw-assets.dat"
|
||||
),
|
||||
}
|
||||
actual = {
|
||||
rel: sha256_file(game_root / "local" / rel)
|
||||
for rel in expected
|
||||
}
|
||||
if actual != expected:
|
||||
raise ScenarioError(f"retry streamed payload hashes mismatched: {actual} != {expected}")
|
||||
|
||||
streamed_bytes = sum(
|
||||
int(item.get("data", {}).get("length", 0))
|
||||
for item in client.output
|
||||
if item.get("type") == "event"
|
||||
and item.get("event") == "download-chunk-finished"
|
||||
and item.get("data", {}).get("game_id") == "cnctw"
|
||||
)
|
||||
expected_bytes = 3 * 1024 * 1024
|
||||
if streamed_bytes != expected_bytes:
|
||||
raise ScenarioError(
|
||||
f"retry streamed byte count mismatch: {streamed_bytes} != {expected_bytes}"
|
||||
)
|
||||
|
||||
return (
|
||||
"broken first source failed without chunks, next source completed whole stream; "
|
||||
f"good={good.ready_addr}, bad={bad.ready_addr}, bytes={streamed_bytes}"
|
||||
)
|
||||
|
||||
def s43_streamed_install_rejects_installed_game(self) -> str:
|
||||
_source, client = self.stream_install_cnctw("s43")
|
||||
|
||||
start = len(client.output)
|
||||
waiter = LineWaiter(start)
|
||||
client.send({"cmd": "stream-install", "game_id": "cnctw"})
|
||||
client.wait_for(
|
||||
event_is("download-failed", "cnctw"),
|
||||
timeout=20,
|
||||
description="already-installed stream rejection",
|
||||
waiter=waiter,
|
||||
)
|
||||
assert_no_event_since(client, start, "install-finished", "cnctw")
|
||||
assert_no_event_since(client, start, "download-finished", "cnctw")
|
||||
wait_no_active(client, "cnctw")
|
||||
|
||||
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
|
||||
assert_game_state(
|
||||
game,
|
||||
downloaded=False,
|
||||
installed=True,
|
||||
availability="LocalOnly",
|
||||
)
|
||||
return "already-installed cnctw rejected a second streamed install without state drift"
|
||||
|
||||
def s44_corrupt_stream_rolls_back(self) -> str:
|
||||
source_dir = self.fixture_root / "s44-corrupt-source"
|
||||
copy_game("cnctw", source_dir, version="20160128")
|
||||
(source_dir / "cnctw" / "cnctw.eti").write_bytes(b"not a rar archive")
|
||||
|
||||
source = self.peer("s44-corrupt-source", games_dir=source_dir)
|
||||
client = self.peer("s44-client")
|
||||
connect_many(client, [source])
|
||||
wait_remote_game(client, "cnctw", peer_count=1, version="20160128")
|
||||
|
||||
start = len(client.output)
|
||||
waiter = LineWaiter(start)
|
||||
client.send({"cmd": "stream-install", "game_id": "cnctw"})
|
||||
client.wait_for(
|
||||
event_is("download-failed", "cnctw"),
|
||||
timeout=30,
|
||||
description="corrupt stream failed",
|
||||
waiter=waiter,
|
||||
)
|
||||
assert_no_event_since(client, start, "download-finished", "cnctw")
|
||||
assert_no_event_since(client, start, "install-finished", "cnctw")
|
||||
wait_no_active(client, "cnctw")
|
||||
assert_failed_stream_left_no_local(client, "cnctw")
|
||||
return "corrupt cnctw archive emitted download-failed and left no local install"
|
||||
|
||||
def s45_sender_disconnect_mid_stream(self) -> str:
|
||||
source_dir = self.fixture_root / "s45-source"
|
||||
copy_game("alienswarm", source_dir, version="20190317")
|
||||
source = self.peer("s45-source", games_dir=source_dir)
|
||||
client = self.peer("s45-client")
|
||||
connect_many(client, [source])
|
||||
wait_remote_game(client, "alienswarm", peer_count=1, version="20190317")
|
||||
|
||||
start = len(client.output)
|
||||
waiter = LineWaiter(start)
|
||||
client.send({"cmd": "stream-install", "game_id": "alienswarm"})
|
||||
client.wait_for(
|
||||
event_is("download-chunk-finished", "alienswarm"),
|
||||
timeout=30,
|
||||
description="first alienswarm stream chunk before source drop",
|
||||
waiter=waiter,
|
||||
)
|
||||
source.kill()
|
||||
terminal = client.wait_for(
|
||||
event_name_in({"download-failed", "download-peers-gone"}, "alienswarm"),
|
||||
timeout=60,
|
||||
description="sender disconnect terminal event",
|
||||
waiter=waiter,
|
||||
)
|
||||
assert_no_event_since(client, start, "download-finished", "alienswarm")
|
||||
assert_no_event_since(client, start, "install-finished", "alienswarm")
|
||||
wait_no_active(client, "alienswarm")
|
||||
assert_failed_stream_left_no_local(client, "alienswarm")
|
||||
return (
|
||||
"sender disconnect after first alienswarm chunk rolled back stream; "
|
||||
f"terminal={terminal['event']}"
|
||||
)
|
||||
|
||||
def s46_receiver_cancel_mid_stream(self) -> str:
|
||||
source_dir = self.fixture_root / "s46-source"
|
||||
copy_game("alienswarm", source_dir, version="20190317")
|
||||
source = self.peer("s46-source", games_dir=source_dir)
|
||||
client = self.peer("s46-client")
|
||||
connect_many(client, [source])
|
||||
wait_remote_game(client, "alienswarm", peer_count=1, version="20190317")
|
||||
|
||||
start = len(client.output)
|
||||
waiter = LineWaiter(start)
|
||||
client.send({"cmd": "stream-install", "game_id": "alienswarm"})
|
||||
client.wait_for(
|
||||
event_is("download-chunk-finished", "alienswarm"),
|
||||
timeout=30,
|
||||
description="first alienswarm stream chunk before receiver cancel",
|
||||
waiter=waiter,
|
||||
)
|
||||
client.send({"cmd": "cancel-download", "game_id": "alienswarm"})
|
||||
wait_no_active(client, "alienswarm", timeout=60)
|
||||
assert_no_event_since(client, start, "download-finished", "alienswarm")
|
||||
assert_no_event_since(client, start, "download-failed", "alienswarm")
|
||||
assert_no_event_since(client, start, "install-finished", "alienswarm")
|
||||
assert_failed_stream_left_no_local(client, "alienswarm")
|
||||
return "receiver cancel after first alienswarm chunk rolled back without failed event"
|
||||
|
||||
def s47_multi_archive_streams_in_sorted_order(self) -> str:
|
||||
source_dir = self.fixture_root / "s47-source"
|
||||
source_game = source_dir / "cnctw"
|
||||
shutil.copytree(FIXTURES / "fixture-multi" / "cnctw", source_game)
|
||||
|
||||
source = self.peer("s47-source", games_dir=source_dir)
|
||||
client = self.peer("s47-client")
|
||||
connect_many(client, [source])
|
||||
wait_remote_game(client, "cnctw", peer_count=1, version="20160128")
|
||||
|
||||
waiter = LineWaiter(len(client.output))
|
||||
client.send({"cmd": "stream-install", "game_id": "cnctw"})
|
||||
client.wait_for(
|
||||
event_is("download-finished", "cnctw"),
|
||||
timeout=30,
|
||||
description="multi-archive stream finish",
|
||||
waiter=waiter,
|
||||
)
|
||||
client.wait_for(
|
||||
event_is("install-finished", "cnctw"),
|
||||
timeout=30,
|
||||
description="multi-archive stream install",
|
||||
waiter=waiter,
|
||||
)
|
||||
|
||||
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
|
||||
assert_game_state(
|
||||
game,
|
||||
downloaded=False,
|
||||
installed=True,
|
||||
availability="LocalOnly",
|
||||
)
|
||||
game_root = client.host_games_dir / "cnctw"
|
||||
assert_not_exists(game_root / "version.ini")
|
||||
assert_not_exists(game_root / "a-first.eti")
|
||||
assert_not_exists(game_root / "z-second.eti")
|
||||
|
||||
chunk_paths = streamed_chunk_paths(client, "cnctw")
|
||||
expected_paths = [
|
||||
"cnctw/.local.installing/order/first.txt",
|
||||
"cnctw/.local.installing/order/second.txt",
|
||||
]
|
||||
if chunk_paths != expected_paths:
|
||||
raise ScenarioError(f"multi-archive stream order mismatch: {chunk_paths}")
|
||||
|
||||
first = (game_root / "local" / "order" / "first.txt").read_text(encoding="utf-8")
|
||||
second = (game_root / "local" / "order" / "second.txt").read_text(encoding="utf-8")
|
||||
if first != "first archive payload\n" or second != "second archive payload\n":
|
||||
raise ScenarioError(f"multi-archive payload mismatch: {first!r}, {second!r}")
|
||||
|
||||
return f"multi-archive cnctw streamed in sorted order: {chunk_paths}"
|
||||
|
||||
|
||||
def run(command: list[str], description: str) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(
|
||||
@@ -1686,7 +1128,6 @@ def copy_game(game_id: str, destination_games_dir: Path, *, version: str | None
|
||||
shutil.rmtree(destination)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(source, destination)
|
||||
version = version if version is not None else CATALOG_VERSIONS.get(game_id)
|
||||
if version is not None:
|
||||
(destination / "version.ini").write_text(version, encoding="utf-8")
|
||||
|
||||
@@ -1723,62 +1164,19 @@ def create_many_small_game(root: Path) -> None:
|
||||
for index in range(20):
|
||||
child = root / f"file-{index:02}.bin"
|
||||
child.write_bytes(hashlib.sha256(f"small-{index}".encode()).digest() * 8)
|
||||
(root / "version.ini").write_text(CATALOG_VERSIONS.get(root.name, "20250101"), encoding="utf-8")
|
||||
(root / "version.ini").write_text("20250101", encoding="utf-8")
|
||||
|
||||
|
||||
def create_large_sparse_game(root: Path, *, size: int) -> None:
|
||||
if root.exists():
|
||||
shutil.rmtree(root)
|
||||
root.mkdir(parents=True)
|
||||
(root / "version.ini").write_text(PERF_GAME_VERSION, encoding="utf-8")
|
||||
(root / "version.ini").write_text("20260520", encoding="utf-8")
|
||||
archive = root / f"{root.name}.eti"
|
||||
with archive.open("wb") as handle:
|
||||
handle.truncate(size)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
hasher = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
|
||||
|
||||
def unrar_entry_sha256(peer: Peer, game_id: str, relative_path: str) -> str:
|
||||
command = (
|
||||
f"unrar p -inul /games/{shlex.quote(game_id)}/{shlex.quote(game_id)}.eti "
|
||||
f"{shlex.quote(relative_path)} | sha256sum"
|
||||
)
|
||||
output = peer.docker_exec("sh", "-c", command).stdout.strip()
|
||||
if not output:
|
||||
raise ScenarioError(f"empty sha256 output for {game_id}:{relative_path}")
|
||||
return output.split()[0]
|
||||
|
||||
|
||||
def assert_peer_rar_archive_solid(peer: Peer, game_id: str) -> None:
|
||||
output = peer.docker_exec(
|
||||
"unrar",
|
||||
"lt",
|
||||
"-cfg-",
|
||||
f"/games/{game_id}/{game_id}.eti",
|
||||
).stdout
|
||||
for line in output.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("Details:"):
|
||||
if "solid" in stripped.lower():
|
||||
return
|
||||
raise ScenarioError(f"RAR archive is not solid: {game_id}")
|
||||
raise ScenarioError(f"RAR archive details were not reported: {game_id}")
|
||||
|
||||
|
||||
def socket_addr_sort_key(addr: str | None) -> tuple[int, int]:
|
||||
if addr is None:
|
||||
raise ScenarioError("cannot sort missing peer address")
|
||||
host, port = addr.rsplit(":", 1)
|
||||
host = host.removeprefix("[").removesuffix("]")
|
||||
return (int(ipaddress.ip_address(host)), int(port))
|
||||
|
||||
|
||||
def format_bytes(size: int) -> str:
|
||||
return f"{size / 1024 / 1024 / 1024:.2f} GiB"
|
||||
|
||||
@@ -1855,32 +1253,6 @@ def wait_local_game(
|
||||
)
|
||||
|
||||
|
||||
def wait_no_active(peer: Peer, game_id: str, timeout: float = 20) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
last_active: list[dict[str, Any]] = []
|
||||
while time.monotonic() < deadline:
|
||||
active = peer.status()["active_operations"]
|
||||
last_active = active
|
||||
if all(item["game_id"] != game_id for item in active):
|
||||
return
|
||||
time.sleep(0.4)
|
||||
raise ScenarioError(f"{peer.name} still has active operation for {game_id}: {last_active}")
|
||||
|
||||
|
||||
def wait_no_outbound_transfer(peer: Peer, game_id: str, timeout: float = 20) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
last_active: dict[str, int] = {}
|
||||
while time.monotonic() < deadline:
|
||||
active = peer.status()["active_outbound_transfers"]
|
||||
last_active = active
|
||||
if active.get(game_id, 0) == 0:
|
||||
return
|
||||
time.sleep(0.4)
|
||||
raise ScenarioError(
|
||||
f"{peer.name} still has outbound transfer for {game_id}: {last_active}"
|
||||
)
|
||||
|
||||
|
||||
def assert_game_state(
|
||||
game: dict[str, Any],
|
||||
*,
|
||||
@@ -1927,10 +1299,7 @@ def wait_peer_has_game(
|
||||
|
||||
def assert_local_absent(peer: Peer, game_id: str) -> None:
|
||||
rows = peer.list_games()["local"]
|
||||
if any(
|
||||
row["id"] == game_id and (row.get("downloaded") or row.get("installed"))
|
||||
for row in rows
|
||||
):
|
||||
if any(row["id"] == game_id and row.get("downloaded") for row in rows):
|
||||
raise ScenarioError(f"{peer.name} advertises failed local {game_id}: {rows}")
|
||||
|
||||
|
||||
@@ -1946,15 +1315,6 @@ def assert_not_exists(path: Path) -> None:
|
||||
raise ScenarioError(f"expected path to be absent: {path}")
|
||||
|
||||
|
||||
def assert_failed_stream_left_no_local(peer: Peer, game_id: str) -> None:
|
||||
game_root = peer.host_games_dir / game_id
|
||||
assert_local_absent(peer, game_id)
|
||||
assert_not_exists(game_root / "local")
|
||||
assert_not_exists(game_root / ".local.installing")
|
||||
assert_not_exists(game_root / "version.ini")
|
||||
assert_not_exists(game_root / f"{game_id}.eti")
|
||||
|
||||
|
||||
def event_is(event: str, game_id: str | None = None) -> Callable[[dict[str, Any]], bool]:
|
||||
def predicate(item: dict[str, Any]) -> bool:
|
||||
if item.get("type") != "event" or item.get("event") != event:
|
||||
@@ -1966,17 +1326,6 @@ def event_is(event: str, game_id: str | None = None) -> Callable[[dict[str, Any]
|
||||
return predicate
|
||||
|
||||
|
||||
def event_name_in(events: set[str], game_id: str | None = None) -> Callable[[dict[str, Any]], bool]:
|
||||
def predicate(item: dict[str, Any]) -> bool:
|
||||
if item.get("type") != "event" or item.get("event") not in events:
|
||||
return False
|
||||
if game_id is None:
|
||||
return True
|
||||
return item.get("data", {}).get("game_id") == game_id
|
||||
|
||||
return predicate
|
||||
|
||||
|
||||
def assert_no_event(peer: Peer, waiter: LineWaiter, event: str, game_id: str) -> None:
|
||||
for item in peer.output[waiter.seen :]:
|
||||
if item.get("type") == "event" and item.get("event") == event:
|
||||
@@ -1984,13 +1333,6 @@ def assert_no_event(peer: Peer, waiter: LineWaiter, event: str, game_id: str) ->
|
||||
raise ScenarioError(f"unexpected {event} for {game_id}: {item}")
|
||||
|
||||
|
||||
def assert_no_event_since(peer: Peer, start: int, event: str, game_id: str) -> None:
|
||||
for item in peer.output[start:]:
|
||||
if item.get("type") == "event" and item.get("event") == event:
|
||||
if item.get("data", {}).get("game_id") == game_id:
|
||||
raise ScenarioError(f"unexpected {event} for {game_id}: {item}")
|
||||
|
||||
|
||||
def assert_only_chunk_sources(
|
||||
peer: Peer,
|
||||
game_id: str,
|
||||
@@ -2016,16 +1358,6 @@ def assert_only_chunk_sources(
|
||||
raise ScenarioError(f"no chunk events recorded for {game_id}")
|
||||
|
||||
|
||||
def streamed_chunk_paths(peer: Peer, game_id: str) -> list[str]:
|
||||
return [
|
||||
item["data"]["relative_path"]
|
||||
for item in peer.output
|
||||
if item.get("type") == "event"
|
||||
and item.get("event") == "download-chunk-finished"
|
||||
and item.get("data", {}).get("game_id") == game_id
|
||||
]
|
||||
|
||||
|
||||
def chunk_totals(peer: Peer, game_id: str, relative_path: str) -> dict[str, int]:
|
||||
totals: dict[str, int] = {}
|
||||
for item in peer.output:
|
||||
|
||||
@@ -33,23 +33,12 @@ pub enum CliCommand {
|
||||
game_id: String,
|
||||
install_after_download: bool,
|
||||
},
|
||||
StreamInstall {
|
||||
game_id: String,
|
||||
},
|
||||
CancelDownload {
|
||||
game_id: String,
|
||||
},
|
||||
Install {
|
||||
game_id: String,
|
||||
},
|
||||
Uninstall {
|
||||
game_id: String,
|
||||
},
|
||||
Play {
|
||||
game_id: String,
|
||||
username: String,
|
||||
language: Option<String>,
|
||||
},
|
||||
WaitPeers {
|
||||
count: usize,
|
||||
timeout: Duration,
|
||||
@@ -69,11 +58,8 @@ impl CliCommand {
|
||||
Self::ListGames => "list-games",
|
||||
Self::SetGameDir { .. } => "set-game-dir",
|
||||
Self::Download { .. } => "download",
|
||||
Self::StreamInstall { .. } => "stream-install",
|
||||
Self::CancelDownload { .. } => "cancel-download",
|
||||
Self::Install { .. } => "install",
|
||||
Self::Uninstall { .. } => "uninstall",
|
||||
Self::Play { .. } => "play",
|
||||
Self::WaitPeers { .. } => "wait-peers",
|
||||
Self::Connect { .. } => "connect",
|
||||
Self::Shutdown => "shutdown",
|
||||
@@ -109,26 +95,12 @@ pub fn parse_command_value(value: &Value) -> eyre::Result<CommandEnvelope> {
|
||||
game_id: game_id(object)?,
|
||||
install_after_download: install_after_download(object)?,
|
||||
},
|
||||
"stream-install" => CliCommand::StreamInstall {
|
||||
game_id: game_id(object)?,
|
||||
},
|
||||
"cancel-download" => CliCommand::CancelDownload {
|
||||
game_id: game_id(object)?,
|
||||
},
|
||||
"install" => CliCommand::Install {
|
||||
game_id: game_id(object)?,
|
||||
},
|
||||
"uninstall" => CliCommand::Uninstall {
|
||||
game_id: game_id(object)?,
|
||||
},
|
||||
"play" => CliCommand::Play {
|
||||
game_id: game_id(object)?,
|
||||
username: required_str(object, "username")?,
|
||||
language: object
|
||||
.get("language")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
},
|
||||
"wait-peers" => CliCommand::WaitPeers {
|
||||
count: required_u64(object, "count")?
|
||||
.try_into()
|
||||
@@ -358,32 +330,6 @@ mod tests {
|
||||
assert_eq!(parsed["data"]["peer_count"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_stream_install_command() {
|
||||
let parsed = parse_command_line(r#"{"cmd":"stream-install","game_id":"cnctw"}"#)
|
||||
.expect("command should parse");
|
||||
|
||||
assert_eq!(
|
||||
parsed.command,
|
||||
CliCommand::StreamInstall {
|
||||
game_id: "cnctw".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_cancel_download_command() {
|
||||
let parsed = parse_command_line(r#"{"cmd":"cancel-download","game_id":"cnctw"}"#)
|
||||
.expect("command should parse");
|
||||
|
||||
assert_eq!(
|
||||
parsed.command,
|
||||
CliCommand::CancelDownload {
|
||||
game_id: "cnctw".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fixture_unpacker_creates_install_payload() {
|
||||
let temp = TempDir::new("lanspread-peer-cli-fixture");
|
||||
|
||||
@@ -12,14 +12,11 @@ use std::{
|
||||
|
||||
use eyre::Context;
|
||||
use lanspread_compat::eti::get_games;
|
||||
use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
|
||||
use lanspread_db::db::{Game, GameFileDescription};
|
||||
use lanspread_peer::{
|
||||
ActiveOperation,
|
||||
ActiveOperationKind,
|
||||
ExternalUnrarStreamProvider,
|
||||
InstallOperation,
|
||||
NoopStreamInstallProvider,
|
||||
OutboundTransfers,
|
||||
PeerCommand,
|
||||
PeerEvent,
|
||||
PeerGameDB,
|
||||
@@ -27,14 +24,12 @@ use lanspread_peer::{
|
||||
PeerRuntimeHandle,
|
||||
PeerSnapshot,
|
||||
PeerStartOptions,
|
||||
StreamInstallProvider,
|
||||
migrate_legacy_state,
|
||||
start_peer_with_options,
|
||||
};
|
||||
use lanspread_peer_cli::{
|
||||
CliCommand,
|
||||
CommandEnvelope,
|
||||
DEFAULT_FIXTURE_VERSION,
|
||||
ExternalUnrarUnpacker,
|
||||
FixtureSeed,
|
||||
FixtureUnpacker,
|
||||
@@ -119,11 +114,8 @@ struct DownloadMeasurement {
|
||||
struct SharedState {
|
||||
state: RwLock<CliState>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
active_outbound_transfers: OutboundTransfers,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
notify: Notify,
|
||||
games_dir: PathBuf,
|
||||
state_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -139,16 +131,10 @@ async fn main() -> eyre::Result<()> {
|
||||
let (tx_events, rx_events) = mpsc::unbounded_channel();
|
||||
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
||||
let catalog = Arc::new(RwLock::new(catalog));
|
||||
let active_outbound_transfers: OutboundTransfers = Arc::new(RwLock::new(HashMap::new()));
|
||||
let unrar_for_streaming = args.unrar.clone().or_else(default_unrar_program);
|
||||
let unpacker: Arc<dyn lanspread_peer::Unpacker> = match args.unrar.clone() {
|
||||
let unpacker: Arc<dyn lanspread_peer::Unpacker> = match args.unrar {
|
||||
Some(path) => Arc::new(ExternalUnrarUnpacker::new(path)),
|
||||
None => Arc::new(FixtureUnpacker),
|
||||
};
|
||||
let stream_install_provider: Arc<dyn StreamInstallProvider> = match unrar_for_streaming {
|
||||
Some(path) => Arc::new(ExternalUnrarStreamProvider::new(path)),
|
||||
None => Arc::new(NoopStreamInstallProvider),
|
||||
};
|
||||
|
||||
let mut handle = start_peer_with_options(
|
||||
args.games_dir.clone(),
|
||||
@@ -158,8 +144,6 @@ async fn main() -> eyre::Result<()> {
|
||||
catalog.clone(),
|
||||
PeerStartOptions {
|
||||
state_dir: Some(args.state_dir.clone()),
|
||||
active_outbound_transfers: Some(active_outbound_transfers.clone()),
|
||||
stream_install_provider: Some(stream_install_provider),
|
||||
},
|
||||
)?;
|
||||
let sender = handle.sender();
|
||||
@@ -168,10 +152,7 @@ async fn main() -> eyre::Result<()> {
|
||||
state: RwLock::new(CliState::default()),
|
||||
peer_game_db,
|
||||
catalog: catalog.clone(),
|
||||
active_outbound_transfers,
|
||||
notify: Notify::new(),
|
||||
games_dir: args.games_dir.clone(),
|
||||
state_dir: args.state_dir.clone(),
|
||||
});
|
||||
let writer = JsonlWriter::new();
|
||||
|
||||
@@ -259,37 +240,19 @@ async fn handle_command(
|
||||
id: game_id.clone(),
|
||||
file_descriptions: files,
|
||||
install_after_download: *install_after_download,
|
||||
account_name: None,
|
||||
})?;
|
||||
Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download}))
|
||||
}
|
||||
CliCommand::StreamInstall { game_id } => {
|
||||
ensure_catalog_game(shared, game_id).await?;
|
||||
ensure_no_active_operation(shared, game_id).await?;
|
||||
sender.send(PeerCommand::StreamInstallGame {
|
||||
id: game_id.clone(),
|
||||
})?;
|
||||
Ok(json!({"queued": true, "game_id": game_id}))
|
||||
}
|
||||
CliCommand::CancelDownload { game_id } => {
|
||||
ensure_catalog_game(shared, game_id).await?;
|
||||
sender.send(PeerCommand::CancelDownload {
|
||||
id: game_id.clone(),
|
||||
})?;
|
||||
Ok(json!({"queued": true, "game_id": game_id}))
|
||||
}
|
||||
CliCommand::Install { game_id } => {
|
||||
ensure_catalog_game(shared, game_id).await?;
|
||||
ensure_no_active_operation(shared, game_id).await?;
|
||||
sender.send(PeerCommand::InstallGame {
|
||||
id: game_id.clone(),
|
||||
account_name: None,
|
||||
})?;
|
||||
Ok(json!({"queued": true, "game_id": game_id}))
|
||||
}
|
||||
CliCommand::Play {
|
||||
game_id,
|
||||
username,
|
||||
language,
|
||||
} => play(shared, game_id, username, language.as_deref()).await,
|
||||
CliCommand::Uninstall { game_id } => {
|
||||
ensure_catalog_game(shared, game_id).await?;
|
||||
ensure_no_active_operation(shared, game_id).await?;
|
||||
@@ -317,20 +280,12 @@ async fn handle_command(
|
||||
async fn status(shared: &SharedState) -> eyre::Result<Value> {
|
||||
let state = shared.state.read().await;
|
||||
let peer_count = shared.peer_game_db.read().await.peer_snapshots().len();
|
||||
let active_outbound_transfers = {
|
||||
let active = shared.active_outbound_transfers.read().await;
|
||||
active
|
||||
.iter()
|
||||
.map(|(game_id, transfers)| (game_id.clone(), transfers.len()))
|
||||
.collect::<HashMap<_, _>>()
|
||||
};
|
||||
Ok(json!({
|
||||
"local_peer": state.local_peer.clone(),
|
||||
"peer_count": peer_count,
|
||||
"local_games": state.local_games.len(),
|
||||
"remote_games": state.remote_games.len(),
|
||||
"active_operations": active_operations_json(&state.active_operations),
|
||||
"active_outbound_transfers": active_outbound_transfers,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -341,8 +296,15 @@ async fn list_peers(shared: &SharedState) -> eyre::Result<Value> {
|
||||
|
||||
async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
|
||||
let state = shared.state.read().await;
|
||||
let catalog = shared.catalog.read().await;
|
||||
let remote = shared.peer_game_db.read().await.get_catalog_games(&catalog);
|
||||
let catalog = shared.catalog.read().await.clone();
|
||||
let remote = shared
|
||||
.peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.get_all_games()
|
||||
.into_iter()
|
||||
.filter(|game| catalog.contains(&game.id))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({
|
||||
"local": state.local_games.clone(),
|
||||
"remote": remote,
|
||||
@@ -350,25 +312,6 @@ async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn play(
|
||||
shared: &SharedState,
|
||||
game_id: &str,
|
||||
username: &str,
|
||||
language: Option<&str>,
|
||||
) -> eyre::Result<Value> {
|
||||
ensure_catalog_game(shared, game_id).await?;
|
||||
let game_root = shared.games_dir.join(game_id);
|
||||
let outcome = lanspread_peer::apply_launch_settings_once(
|
||||
&shared.state_dir,
|
||||
&game_root,
|
||||
game_id,
|
||||
Some(username),
|
||||
language,
|
||||
)
|
||||
.await?;
|
||||
Ok(json!({ "game_id": game_id, "outcome": outcome }))
|
||||
}
|
||||
|
||||
async fn ensure_catalog_game(shared: &SharedState, game_id: &str) -> eyre::Result<()> {
|
||||
if shared.catalog.read().await.contains(game_id) {
|
||||
return Ok(());
|
||||
@@ -465,7 +408,6 @@ async fn event_loop(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'static str, Value) {
|
||||
match event {
|
||||
PeerEvent::LocalPeerReady { peer_id, addr } => {
|
||||
@@ -490,7 +432,6 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
|
||||
state.local_games.clone_from(&games);
|
||||
("local-library-changed", json!({ "games": games }))
|
||||
}
|
||||
PeerEvent::OutboundTransferCountChanged => ("outbound-transfer-count-changed", json!({})),
|
||||
PeerEvent::ActiveOperationsChanged { active_operations } => {
|
||||
let mut state = shared.state.write().await;
|
||||
state.active_operations.clone_from(&active_operations);
|
||||
@@ -701,27 +642,18 @@ fn seed_fixtures(game_dir: &Path, fixtures: &[String]) -> eyre::Result<Vec<Fixtu
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn load_catalog(catalog_db: Option<&Path>, fixtures: &[FixtureSeed]) -> GameCatalog {
|
||||
let mut catalog = GameCatalog::empty();
|
||||
async fn load_catalog(catalog_db: Option<&Path>, fixtures: &[FixtureSeed]) -> HashSet<String> {
|
||||
let mut catalog = HashSet::new();
|
||||
if let Some(path) = catalog_db
|
||||
&& path.exists()
|
||||
{
|
||||
match get_games(path).await {
|
||||
Ok(games) => {
|
||||
for game in games {
|
||||
catalog.insert(game.game_id, Some(game.game_version));
|
||||
}
|
||||
}
|
||||
Ok(games) => catalog.extend(games.into_iter().map(|game| game.game_id)),
|
||||
Err(err) => eprintln!("failed to load catalog db {}: {err}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
for seed in fixtures {
|
||||
catalog.insert(
|
||||
seed.game_id.clone(),
|
||||
Some(DEFAULT_FIXTURE_VERSION.to_string()),
|
||||
);
|
||||
}
|
||||
catalog.extend(fixtures.iter().map(|seed| seed.game_id.clone()));
|
||||
catalog
|
||||
}
|
||||
|
||||
@@ -765,15 +697,6 @@ fn default_catalog_db() -> Option<PathBuf> {
|
||||
.find(|path| path.exists())
|
||||
}
|
||||
|
||||
fn default_unrar_program() -> Option<PathBuf> {
|
||||
[
|
||||
PathBuf::from("/usr/local/bin/unrar"),
|
||||
PathBuf::from("/usr/bin/unrar"),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|path| path.exists())
|
||||
}
|
||||
|
||||
fn next_string(args: &mut impl Iterator<Item = OsString>, flag: &str) -> eyre::Result<String> {
|
||||
args.next()
|
||||
.ok_or_else(|| eyre::eyre!("{flag} requires a value"))?
|
||||
|
||||
@@ -116,9 +116,6 @@ Downloaded and installed are independent predicates:
|
||||
- `installed` is true when `<game_root>/local/` is a directory. The contents of
|
||||
`local/` are user-owned and are skipped by manifests, fingerprints, and file
|
||||
serving.
|
||||
- Install and update transactions unpack into staging, then overwrite the first
|
||||
discovered game-provided `account_name.txt` and `language.txt` files under
|
||||
the staged tree from launcher settings before promoting it to `local/`.
|
||||
|
||||
Reserved per-game paths:
|
||||
|
||||
@@ -166,18 +163,6 @@ Most scans become O(number of game dirs), with full recursion only when needed.
|
||||
scratch sentinel files. `local/` and install transaction metadata are
|
||||
preserved, so a cancelled update of an installed game settles as local-only.
|
||||
|
||||
### Streamed install integrity
|
||||
|
||||
- Low-disk streamed installs request archive-derived file bytes from one peer
|
||||
and write them directly into the install transaction staging directory.
|
||||
- The receiver verifies every streamed file against the sender archive's file
|
||||
size and RAR CRC32 before the transaction may commit. This catches truncated
|
||||
streams, transport corruption, and provider bugs.
|
||||
- This is not malicious-peer protection: the peer controls both the archive
|
||||
metadata and the streamed bytes. A trusted-content model needs catalog-owned
|
||||
hashes, either for the root archives or for extracted files, and receiver-side
|
||||
SHA-256 verification against those catalog values before commit.
|
||||
|
||||
## Fault tolerance rules
|
||||
|
||||
- Every peer is keyed by `peer_id`, not by IP address.
|
||||
|
||||
@@ -3,8 +3,10 @@ name = "lanspread-peer"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
|
||||
[dependencies]
|
||||
lanspread-db = { path = "../lanspread-db" }
|
||||
@@ -14,7 +16,6 @@ lanspread-utils = { path = "../lanspread-utils" }
|
||||
|
||||
# external
|
||||
bytes = { workspace = true }
|
||||
crc32fast = { workspace = true }
|
||||
eyre = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
gethostname = { workspace = true }
|
||||
@@ -30,7 +31,5 @@ tokio-util = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
walkdir = { workspace = true }
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
@@ -14,8 +14,8 @@ It is designed to run headless – other crates (most notably
|
||||
roots are announced or served.
|
||||
- `PeerCommand` represents the small control surface exposed to the UI layer:
|
||||
`ListGames`, `GetGame`, `FetchLatestFromPeers`, `DownloadGameFiles`,
|
||||
`StreamInstallGame`, `InstallGame`, `UninstallGame`, `RemoveDownloadedGame`,
|
||||
`CancelDownload`, `SetGameDir`, and `GetPeerCount`.
|
||||
`InstallGame`, `UninstallGame`, `RemoveDownloadedGame`, `CancelDownload`,
|
||||
`SetGameDir`, and `GetPeerCount`.
|
||||
- `PeerEvent` enumerates everything the peer runtime reports back to the UI:
|
||||
library snapshots, download/install/uninstall lifecycle updates, runtime
|
||||
failures, and peer membership changes.
|
||||
@@ -28,8 +28,8 @@ lifetime of the process:
|
||||
|
||||
1. **Server component** (`run_server_component`) – listens for QUIC connections,
|
||||
advertises via mDNS, and serves `Request::ListGames`, `Request::GetGame`,
|
||||
`Request::GetGameFileData`, `Request::GetGameFileChunk`, and
|
||||
`Request::StreamInstall` by reading from the local game directory.
|
||||
`Request::GetGameFileData`, and `Request::GetGameFileChunk` by reading from
|
||||
the local game directory.
|
||||
2. **Discovery loop** (`run_peer_discovery`) – uses the `lanspread-mdns`
|
||||
helper to discover other peers. The blocking mDNS work is executed on a
|
||||
dedicated thread via `tokio::task::spawn_blocking` so that the Tokio runtime
|
||||
@@ -87,26 +87,6 @@ When the UI asks to download a game:
|
||||
7. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished`
|
||||
is emitted and the peer auto-runs the install transaction.
|
||||
|
||||
### Streamed Install Pipeline
|
||||
|
||||
Low-disk installs use `PeerCommand::StreamInstallGame` instead of the normal
|
||||
archive download pipeline. The peer core owns the whole operation: it refreshes
|
||||
file metadata from catalog-version peers, runs the same majority file-size
|
||||
validation used by normal downloads, selects a validated peer list, and emits
|
||||
the regular download/install lifecycle events while streaming archive-expanded
|
||||
bytes directly into a `StreamedInstallTransaction`.
|
||||
|
||||
The sender-side `StreamInstallProvider` writes control and chunk frames through
|
||||
a cancellable `StreamInstallFrameSink`. If the QUIC writer fails because the
|
||||
receiver cancelled or disconnected, the sink wakes any producer blocked on the
|
||||
bounded frame channel and lets the transfer guard drop normally.
|
||||
|
||||
Each failed peer attempt rolls back its staging directory before trying the next
|
||||
validated peer. A transaction that created a previously missing game root
|
||||
removes that root again when rollback leaves it empty. Once staging has been
|
||||
renamed to `local/`, post-promote intent or launch-settings cleanup failures are
|
||||
logged for startup recovery rather than reported as a failed install.
|
||||
|
||||
`PeerCommand::CancelDownload` cancels the tracked download token for an active
|
||||
transfer. The transfer task remains responsible for clearing `active_operations`,
|
||||
discarding partial payload files, and refreshing the settled local snapshot, so
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
//! Shared context types for the peer system.
|
||||
|
||||
use std::{collections::HashMap, net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
net::SocketAddr,
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use lanspread_db::db::{GameCatalog, GameDB};
|
||||
use lanspread_db::db::GameDB;
|
||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
use crate::{
|
||||
PeerEvent,
|
||||
StreamInstallProvider,
|
||||
Unpacker,
|
||||
events,
|
||||
library::LocalLibraryState,
|
||||
peer_db::PeerGameDB,
|
||||
};
|
||||
|
||||
/// Thread-safe map of active outbound file transfers grouped by game ID.
|
||||
pub type OutboundTransfers = Arc<RwLock<HashMap<String, Vec<(u64, CancellationToken)>>>>;
|
||||
use crate::{PeerEvent, Unpacker, events, library::LocalLibraryState, peer_db::PeerGameDB};
|
||||
|
||||
/// Mutating filesystem operation currently in flight for a game root.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -45,12 +40,10 @@ pub struct Ctx {
|
||||
pub active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
pub active_downloads: Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
pub unpacker: Arc<dyn Unpacker>,
|
||||
pub stream_install_provider: Arc<dyn StreamInstallProvider>,
|
||||
pub catalog: Arc<RwLock<GameCatalog>>,
|
||||
pub catalog: Arc<RwLock<HashSet<String>>>,
|
||||
pub peer_id: Arc<String>,
|
||||
pub shutdown: CancellationToken,
|
||||
pub task_tracker: TaskTracker,
|
||||
pub active_outbound_transfers: OutboundTransfers,
|
||||
}
|
||||
|
||||
/// Context for peer connection handling.
|
||||
@@ -62,13 +55,11 @@ pub struct PeerCtx {
|
||||
pub local_peer_addr: Arc<RwLock<Option<SocketAddr>>>,
|
||||
pub active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
pub peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
pub catalog: Arc<RwLock<GameCatalog>>,
|
||||
pub catalog: Arc<RwLock<HashSet<String>>>,
|
||||
pub peer_id: Arc<String>,
|
||||
pub tx_notify_ui: tokio::sync::mpsc::UnboundedSender<PeerEvent>,
|
||||
pub stream_install_provider: Arc<dyn StreamInstallProvider>,
|
||||
pub shutdown: CancellationToken,
|
||||
pub task_tracker: TaskTracker,
|
||||
pub active_outbound_transfers: OutboundTransfers,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PeerCtx {
|
||||
@@ -93,9 +84,7 @@ impl Ctx {
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
shutdown: CancellationToken,
|
||||
task_tracker: TaskTracker,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
active_outbound_transfers: OutboundTransfers,
|
||||
stream_install_provider: Arc<dyn StreamInstallProvider>,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
game_dir: Arc::new(RwLock::new(game_dir)),
|
||||
@@ -107,12 +96,10 @@ impl Ctx {
|
||||
active_operations: Arc::new(RwLock::new(HashMap::new())),
|
||||
active_downloads: Arc::new(RwLock::new(HashMap::new())),
|
||||
unpacker,
|
||||
stream_install_provider,
|
||||
catalog,
|
||||
peer_id: Arc::new(peer_id),
|
||||
shutdown,
|
||||
task_tracker,
|
||||
active_outbound_transfers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,10 +118,8 @@ impl Ctx {
|
||||
catalog: self.catalog.clone(),
|
||||
peer_id: self.peer_id.clone(),
|
||||
tx_notify_ui,
|
||||
stream_install_provider: self.stream_install_provider.clone(),
|
||||
shutdown: self.shutdown.clone(),
|
||||
task_tracker: self.task_tracker.clone(),
|
||||
active_outbound_transfers: self.active_outbound_transfers.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
|
||||
use crate::{
|
||||
@@ -66,13 +65,9 @@ fn active_operation_kind(operation: OperationKind) -> ActiveOperationKind {
|
||||
|
||||
pub async fn emit_peer_game_list(
|
||||
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
||||
catalog: &Arc<RwLock<GameCatalog>>,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
) {
|
||||
let games = {
|
||||
let catalog = catalog.read().await;
|
||||
peer_game_db.read().await.get_catalog_games(&catalog)
|
||||
};
|
||||
let games = { peer_game_db.read().await.get_all_games() };
|
||||
send(tx_notify_ui, PeerEvent::ListGames(games));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,10 @@ use std::{
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use lanspread_db::db::{GameDB, GameFileDescription};
|
||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{
|
||||
InstallOperation,
|
||||
@@ -25,7 +23,7 @@ use crate::{
|
||||
game_from_summary,
|
||||
get_game_file_descriptions,
|
||||
local_dir_is_directory,
|
||||
local_download_matches_catalog,
|
||||
local_download_available,
|
||||
rescan_local_game,
|
||||
scan_local_library,
|
||||
version_ini_is_regular_file,
|
||||
@@ -34,20 +32,16 @@ use crate::{
|
||||
peer_db::PeerGameDB,
|
||||
remote_peer::ensure_peer_id_for_addr,
|
||||
services::{HandshakeCtx, perform_handshake_with_peer},
|
||||
stream_install::receive_streamed_install,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Command handlers
|
||||
// =============================================================================
|
||||
|
||||
const OUTBOUND_TRANSFER_DRAIN_POLL_INTERVAL: Duration = Duration::from_millis(10);
|
||||
const OUTBOUND_TRANSFER_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Handles the `ListGames` command.
|
||||
pub async fn handle_list_games_command(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
|
||||
log::info!("ListGames command received");
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, tx_notify_ui).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, tx_notify_ui).await;
|
||||
}
|
||||
|
||||
/// Tries to serve a game from local files.
|
||||
@@ -60,7 +54,7 @@ async fn try_serve_local_game(
|
||||
|
||||
let active_operations = ctx.active_operations.read().await;
|
||||
let catalog = ctx.catalog.read().await;
|
||||
if !local_download_matches_catalog(&game_dir, id, &active_operations, &catalog).await {
|
||||
if !local_download_available(&game_dir, id, &active_operations, &catalog).await {
|
||||
return false;
|
||||
}
|
||||
drop(active_operations);
|
||||
@@ -96,10 +90,9 @@ pub(crate) async fn handle_get_game_command(
|
||||
}
|
||||
|
||||
log::info!("Requesting game from peers: {id}");
|
||||
let expected_version = catalog_expected_version(ctx, &id).await;
|
||||
let peers = {
|
||||
let peer_game_db = ctx.peer_game_db.read().await;
|
||||
source.select_peers(&peer_game_db, &id, expected_version.as_deref())
|
||||
source.select_peers(&peer_game_db, &id)
|
||||
};
|
||||
if peers.is_empty() {
|
||||
log::warn!("No peers have game {id}");
|
||||
@@ -114,7 +107,6 @@ pub(crate) async fn handle_get_game_command(
|
||||
ctx.task_tracker.spawn(fetch_game_details_from_peers(
|
||||
peers,
|
||||
id,
|
||||
expected_version,
|
||||
peer_game_db,
|
||||
tx_notify_ui,
|
||||
|peer_addr, game_id, peer_game_db| async move {
|
||||
@@ -134,16 +126,10 @@ impl GameDetailSource {
|
||||
matches!(self, Self::LocalOrPeers)
|
||||
}
|
||||
|
||||
fn select_peers(
|
||||
self,
|
||||
peer_game_db: &PeerGameDB,
|
||||
id: &str,
|
||||
expected_version: Option<&str>,
|
||||
) -> Vec<SocketAddr> {
|
||||
fn select_peers(self, peer_game_db: &PeerGameDB, id: &str) -> Vec<SocketAddr> {
|
||||
match self {
|
||||
Self::LocalOrPeers | Self::LatestPeersOnly => {
|
||||
peer_game_db.peers_with_expected_version(id, expected_version)
|
||||
}
|
||||
Self::LocalOrPeers => peer_game_db.peers_with_game(id),
|
||||
Self::LatestPeersOnly => peer_game_db.peers_with_latest_version(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,7 +154,6 @@ async fn request_game_details_and_update(
|
||||
async fn fetch_game_details_from_peers<F, Fut>(
|
||||
peers: Vec<SocketAddr>,
|
||||
id: String,
|
||||
expected_version: Option<String>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
mut fetch_details: F,
|
||||
@@ -190,12 +175,7 @@ async fn fetch_game_details_from_peers<F, Fut>(
|
||||
}
|
||||
|
||||
if fetched_any {
|
||||
let aggregated_files = {
|
||||
peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.aggregated_game_files(&id, expected_version.as_deref())
|
||||
};
|
||||
let aggregated_files = { peer_game_db.read().await.aggregated_game_files(&id) };
|
||||
|
||||
if let Err(e) = tx_notify_ui.send(PeerEvent::GotGameFiles {
|
||||
id: id.clone(),
|
||||
@@ -219,6 +199,7 @@ pub async fn handle_download_game_files_command(
|
||||
id: String,
|
||||
file_descriptions: Vec<GameFileDescription>,
|
||||
install_after_download: bool,
|
||||
account_name: Option<String>,
|
||||
) {
|
||||
log::info!("Got PeerCommand::DownloadGameFiles");
|
||||
if !catalog_contains(ctx, &id).await {
|
||||
@@ -230,7 +211,6 @@ pub async fn handle_download_game_files_command(
|
||||
}
|
||||
|
||||
let games_folder = { ctx.game_dir.read().await.clone() };
|
||||
let expected_version = catalog_expected_version(ctx, &id).await;
|
||||
|
||||
// Use majority validation to get trusted file descriptions and peer whitelist
|
||||
let (validated_descriptions, peer_whitelist, file_peer_map) = {
|
||||
@@ -238,7 +218,7 @@ pub async fn handle_download_game_files_command(
|
||||
.peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.validate_file_sizes_majority(&id, expected_version.as_deref())
|
||||
.validate_file_sizes_majority(&id)
|
||||
{
|
||||
Ok((files, peers, file_peer_map)) => {
|
||||
log::info!(
|
||||
@@ -281,7 +261,7 @@ pub async fn handle_download_game_files_command(
|
||||
let local_dl_available = {
|
||||
let active_operations = ctx.active_operations.read().await;
|
||||
let catalog = ctx.catalog.read().await;
|
||||
local_download_matches_catalog(&games_folder, &id, &active_operations, &catalog).await
|
||||
local_download_available(&games_folder, &id, &active_operations, &catalog).await
|
||||
};
|
||||
|
||||
if peer_whitelist.is_empty() {
|
||||
@@ -297,7 +277,7 @@ pub async fn handle_download_game_files_command(
|
||||
log::error!("Failed to send DownloadGameFilesFinished event: {e}");
|
||||
}
|
||||
if install_after_download {
|
||||
spawn_install_operation(ctx, tx_notify_ui, id.clone());
|
||||
spawn_install_operation(ctx, tx_notify_ui, id.clone(), account_name);
|
||||
}
|
||||
} else {
|
||||
log::error!("No trusted peers available after majority validation for game {id}");
|
||||
@@ -310,17 +290,9 @@ pub async fn handle_download_game_files_command(
|
||||
return;
|
||||
}
|
||||
|
||||
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
|
||||
BeginOperationResult::Started => {}
|
||||
BeginOperationResult::AlreadyActive => {
|
||||
log::warn!("Operation for {id} already in progress; ignoring new download request");
|
||||
return;
|
||||
}
|
||||
BeginOperationResult::DrainTimedOut => {
|
||||
log::error!("Timed out waiting for outbound transfers before downloading {id}");
|
||||
send_download_failed(tx_notify_ui, &id);
|
||||
return;
|
||||
}
|
||||
if !begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
|
||||
log::warn!("Operation for {id} already in progress; ignoring new download request");
|
||||
return;
|
||||
}
|
||||
|
||||
let active_operations = ctx.active_operations.clone();
|
||||
@@ -391,6 +363,7 @@ pub async fn handle_download_game_files_command(
|
||||
&tx_notify_ui_clone,
|
||||
download_id,
|
||||
prepared,
|
||||
account_name,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
@@ -448,62 +421,9 @@ pub async fn handle_install_game_command(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
account_name: Option<String>,
|
||||
) {
|
||||
spawn_install_operation(ctx, tx_notify_ui, id);
|
||||
}
|
||||
|
||||
pub async fn handle_stream_install_game_command(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
) {
|
||||
if !catalog_contains(ctx, &id).await {
|
||||
log::warn!("Ignoring streamed install command for non-catalog game {id}");
|
||||
send_download_failed(tx_notify_ui, &id);
|
||||
return;
|
||||
}
|
||||
|
||||
let games_folder = { ctx.game_dir.read().await.clone() };
|
||||
let game_root = games_folder.join(&id);
|
||||
if local_dir_is_directory(&game_root).await {
|
||||
log::warn!("Ignoring streamed install command for already-installed game {id}");
|
||||
send_download_failed(tx_notify_ui, &id);
|
||||
return;
|
||||
}
|
||||
|
||||
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
|
||||
BeginOperationResult::Started => {}
|
||||
BeginOperationResult::AlreadyActive => {
|
||||
log::warn!("Operation for {id} already in progress; ignoring streamed install request");
|
||||
return;
|
||||
}
|
||||
BeginOperationResult::DrainTimedOut => {
|
||||
log::error!("Timed out waiting for outbound transfers before streamed install of {id}");
|
||||
send_download_failed(tx_notify_ui, &id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let expected_version = catalog_expected_version(ctx, &id).await;
|
||||
let cancel_token = ctx.shutdown.child_token();
|
||||
ctx.active_downloads
|
||||
.write()
|
||||
.await
|
||||
.insert(id.clone(), cancel_token.clone());
|
||||
|
||||
let ctx_clone = ctx.clone();
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
ctx.task_tracker.spawn(async move {
|
||||
run_stream_install_operation(
|
||||
ctx_clone,
|
||||
tx_notify_ui,
|
||||
id,
|
||||
game_root,
|
||||
expected_version,
|
||||
cancel_token,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
spawn_install_operation(ctx, tx_notify_ui, id, account_name);
|
||||
}
|
||||
|
||||
/// Handles the `UninstallGame` command.
|
||||
@@ -546,314 +466,35 @@ pub async fn handle_cancel_download_command(
|
||||
cancel_token.cancel();
|
||||
}
|
||||
|
||||
async fn run_stream_install_operation(
|
||||
ctx: Ctx,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
game_root: PathBuf,
|
||||
expected_version: Option<String>,
|
||||
cancel_token: CancellationToken,
|
||||
) {
|
||||
let download_guard = OperationGuard::download(
|
||||
id.clone(),
|
||||
ctx.active_operations.clone(),
|
||||
ctx.active_downloads.clone(),
|
||||
tx_notify_ui.clone(),
|
||||
);
|
||||
|
||||
events::send(
|
||||
&tx_notify_ui,
|
||||
PeerEvent::DownloadGameFilesBegin { id: id.clone() },
|
||||
);
|
||||
|
||||
let peer_addrs =
|
||||
match select_stream_install_peers(&ctx, &id, expected_version.as_deref(), &cancel_token)
|
||||
.await
|
||||
{
|
||||
Ok(peers) => peers,
|
||||
Err(err) => {
|
||||
let download_was_cancelled = cancel_token.is_cancelled();
|
||||
if download_was_cancelled {
|
||||
log::info!("Streamed install preflight cancelled for {id}: {err}");
|
||||
} else {
|
||||
log::error!("Streamed install preflight failed for {id}: {err}");
|
||||
}
|
||||
finish_failed_stream_download(
|
||||
&ctx,
|
||||
&tx_notify_ui,
|
||||
&id,
|
||||
download_guard,
|
||||
download_was_cancelled,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match receive_streamed_install_from_peers(
|
||||
&ctx,
|
||||
&tx_notify_ui,
|
||||
&id,
|
||||
&game_root,
|
||||
&peer_addrs,
|
||||
&cancel_token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(transaction) => {
|
||||
if transition_download_to_install(&ctx, &tx_notify_ui, &id, OperationKind::Installing)
|
||||
.await
|
||||
{
|
||||
clear_active_download(&ctx, &id).await;
|
||||
send_download_finished(&tx_notify_ui, &id);
|
||||
download_guard.disarm();
|
||||
commit_streamed_install(&ctx, &tx_notify_ui, id, transaction).await;
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = transaction.rollback().await {
|
||||
log::error!("Failed to roll back streamed install for {id}: {err}");
|
||||
}
|
||||
finish_failed_stream_download(&ctx, &tx_notify_ui, &id, download_guard, false).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let download_was_cancelled = cancel_token.is_cancelled();
|
||||
if download_was_cancelled {
|
||||
log::info!("Streamed install download cancelled for {id}: {err}");
|
||||
} else {
|
||||
log::error!("Streamed install download failed for {id}: {err}");
|
||||
}
|
||||
finish_failed_stream_download(
|
||||
&ctx,
|
||||
&tx_notify_ui,
|
||||
&id,
|
||||
download_guard,
|
||||
download_was_cancelled,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn receive_streamed_install_from_peers(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: &str,
|
||||
game_root: &Path,
|
||||
peer_addrs: &[SocketAddr],
|
||||
cancel_token: &CancellationToken,
|
||||
) -> eyre::Result<install::StreamedInstallTransaction> {
|
||||
let mut last_receive_error = None;
|
||||
for &peer_addr in peer_addrs {
|
||||
if cancel_token.is_cancelled() {
|
||||
eyre::bail!("streamed install for {id} was cancelled");
|
||||
}
|
||||
|
||||
let transaction =
|
||||
install::begin_streamed_install(game_root, ctx.state_dir.as_ref(), id).await?;
|
||||
let receive_result = receive_streamed_install(
|
||||
peer_addr,
|
||||
id,
|
||||
transaction.staging_dir(),
|
||||
tx_notify_ui.clone(),
|
||||
cancel_token.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match receive_result {
|
||||
Ok(()) => return Ok(transaction),
|
||||
Err(err) => {
|
||||
if let Err(rollback_err) = transaction.rollback().await {
|
||||
log::error!("Failed to roll back streamed install for {id}: {rollback_err}");
|
||||
}
|
||||
if cancel_token.is_cancelled() {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"Streamed install attempt from {peer_addr} failed for {id}; trying another peer if available: {err}"
|
||||
);
|
||||
last_receive_error = Some(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_receive_error.unwrap_or_else(|| {
|
||||
eyre::eyre!("streamed install download failed for {id}: no peer attempts were made")
|
||||
}))
|
||||
}
|
||||
|
||||
async fn select_stream_install_peers(
|
||||
ctx: &Ctx,
|
||||
id: &str,
|
||||
expected_version: Option<&str>,
|
||||
cancel_token: &CancellationToken,
|
||||
) -> eyre::Result<Vec<SocketAddr>> {
|
||||
let mut metadata_peers = {
|
||||
ctx.peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.peers_with_expected_version(id, expected_version)
|
||||
};
|
||||
metadata_peers.sort();
|
||||
if metadata_peers.is_empty() {
|
||||
eyre::bail!("no peers have game {id}");
|
||||
}
|
||||
|
||||
refresh_stream_install_file_details(ctx, id, &metadata_peers, cancel_token).await?;
|
||||
|
||||
let mut peers = match ctx
|
||||
.peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.validate_file_sizes_majority(id, expected_version)
|
||||
{
|
||||
Ok((validated_files, peer_whitelist, _)) if !validated_files.is_empty() => peer_whitelist,
|
||||
Ok(_) => {
|
||||
eyre::bail!("no trusted peers available for streamed install of {id}");
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err.wrap_err(format!(
|
||||
"file size majority validation failed for streamed install {id}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
peers.sort();
|
||||
if peers.is_empty() {
|
||||
eyre::bail!("no peer selected for streamed install of {id}");
|
||||
}
|
||||
|
||||
Ok(peers)
|
||||
}
|
||||
|
||||
async fn refresh_stream_install_file_details(
|
||||
ctx: &Ctx,
|
||||
id: &str,
|
||||
peers: &[SocketAddr],
|
||||
cancel_token: &CancellationToken,
|
||||
) -> eyre::Result<()> {
|
||||
let mut fetched_any = false;
|
||||
for &peer_addr in peers {
|
||||
if cancel_token.is_cancelled() {
|
||||
eyre::bail!("streamed install for {id} was cancelled");
|
||||
}
|
||||
|
||||
match request_game_details_and_update(peer_addr, id, ctx.peer_game_db.clone()).await {
|
||||
Ok(_) => {
|
||||
log::info!("Fetched streamed-install file list for {id} from peer {peer_addr}");
|
||||
fetched_any = true;
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"Failed to fetch streamed-install files for {id} from {peer_addr}: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !fetched_any {
|
||||
eyre::bail!("failed to retrieve game files for {id} from any peer");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn finish_failed_stream_download(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: &str,
|
||||
guard: OperationGuard,
|
||||
cancelled: bool,
|
||||
) {
|
||||
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, id).await {
|
||||
log::error!("Failed to refresh local library after streamed install failure: {err}");
|
||||
}
|
||||
end_download_operation(ctx, tx_notify_ui, id).await;
|
||||
guard.disarm();
|
||||
send_download_failed_unless_cancelled(tx_notify_ui, id, cancelled);
|
||||
}
|
||||
|
||||
async fn commit_streamed_install(
|
||||
fn spawn_install_operation(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
transaction: install::StreamedInstallTransaction,
|
||||
account_name: Option<String>,
|
||||
) {
|
||||
let operation_guard = OperationGuard::new(
|
||||
id.clone(),
|
||||
ctx.active_operations.clone(),
|
||||
tx_notify_ui.clone(),
|
||||
);
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::InstallGameBegin {
|
||||
id: id.clone(),
|
||||
operation: InstallOperation::Installing,
|
||||
},
|
||||
);
|
||||
|
||||
match transaction.commit().await {
|
||||
Ok(()) => {
|
||||
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
||||
{
|
||||
log::error!("Failed to refresh local library after streamed install: {err}");
|
||||
}
|
||||
end_operation(ctx, tx_notify_ui, &id).await;
|
||||
operation_guard.disarm();
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::InstallGameFinished { id: id.clone() },
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Streamed install commit failed for {id}: {err}");
|
||||
if let Err(refresh_err) =
|
||||
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
||||
{
|
||||
log::error!(
|
||||
"Failed to refresh local library after streamed install commit failure: {refresh_err}"
|
||||
);
|
||||
}
|
||||
end_operation(ctx, tx_notify_ui, &id).await;
|
||||
operation_guard.disarm();
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::InstallGameFailed { id: id.clone() },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
||||
let ctx = ctx.clone();
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
ctx.task_tracker.clone().spawn(async move {
|
||||
run_install_operation(&ctx, &tx_notify_ui, id).await;
|
||||
run_install_operation(&ctx, &tx_notify_ui, id, account_name).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
||||
async fn run_install_operation(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
account_name: Option<String>,
|
||||
) {
|
||||
let Some(prepared) = prepare_install_operation(ctx, tx_notify_ui, &id).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
match begin_operation(ctx, tx_notify_ui, &id, prepared.operation_kind).await {
|
||||
BeginOperationResult::Started => {}
|
||||
BeginOperationResult::AlreadyActive => {
|
||||
log::warn!("Operation for {id} already in progress; ignoring install command");
|
||||
return;
|
||||
}
|
||||
BeginOperationResult::DrainTimedOut => {
|
||||
log::error!("Timed out waiting for outbound transfers before install/update of {id}");
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::InstallGameFailed { id: id.clone() },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if !begin_operation(ctx, tx_notify_ui, &id, prepared.operation_kind).await {
|
||||
log::warn!("Operation for {id} already in progress; ignoring install command");
|
||||
return;
|
||||
}
|
||||
|
||||
run_started_install_operation(ctx, tx_notify_ui, id, prepared).await;
|
||||
run_started_install_operation(ctx, tx_notify_ui, id, prepared, account_name).await;
|
||||
}
|
||||
|
||||
struct PreparedInstallOperation {
|
||||
@@ -905,6 +546,7 @@ async fn run_started_install_operation(
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
prepared: PreparedInstallOperation,
|
||||
account_name: Option<String>,
|
||||
) {
|
||||
let PreparedInstallOperation {
|
||||
game_root,
|
||||
@@ -929,10 +571,24 @@ async fn run_started_install_operation(
|
||||
let state_dir = ctx.state_dir.as_ref();
|
||||
match operation {
|
||||
InstallOperation::Installing => {
|
||||
install::install(&game_root, state_dir, &id, ctx.unpacker.clone()).await
|
||||
install::install(
|
||||
&game_root,
|
||||
state_dir,
|
||||
&id,
|
||||
ctx.unpacker.clone(),
|
||||
account_name.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
InstallOperation::Updating => {
|
||||
install::update(&game_root, state_dir, &id, ctx.unpacker.clone()).await
|
||||
install::update(
|
||||
&game_root,
|
||||
state_dir,
|
||||
&id,
|
||||
ctx.unpacker.clone(),
|
||||
account_name.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -973,20 +629,9 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
|
||||
return;
|
||||
}
|
||||
|
||||
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Uninstalling).await {
|
||||
BeginOperationResult::Started => {}
|
||||
BeginOperationResult::AlreadyActive => {
|
||||
log::warn!("Operation for {id} already in progress; ignoring uninstall command");
|
||||
return;
|
||||
}
|
||||
BeginOperationResult::DrainTimedOut => {
|
||||
log::error!("Timed out waiting for outbound transfers before uninstall of {id}");
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::UninstallGameFailed { id: id.clone() },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if !begin_operation(ctx, tx_notify_ui, &id, OperationKind::Uninstalling).await {
|
||||
log::warn!("Operation for {id} already in progress; ignoring uninstall command");
|
||||
return;
|
||||
}
|
||||
|
||||
let game_root = { ctx.game_dir.read().await.join(&id) };
|
||||
@@ -1046,20 +691,9 @@ async fn run_remove_downloaded_operation(
|
||||
return;
|
||||
}
|
||||
|
||||
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::RemovingDownload).await {
|
||||
BeginOperationResult::Started => {}
|
||||
BeginOperationResult::AlreadyActive => {
|
||||
log::warn!("Operation for {id} already in progress; ignoring downloaded-file removal");
|
||||
return;
|
||||
}
|
||||
BeginOperationResult::DrainTimedOut => {
|
||||
log::error!("Timed out waiting for outbound transfers before removal of {id}");
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::RemoveDownloadedGameFailed { id: id.clone() },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if !begin_operation(ctx, tx_notify_ui, &id, OperationKind::RemovingDownload).await {
|
||||
log::warn!("Operation for {id} already in progress; ignoring downloaded-file removal");
|
||||
return;
|
||||
}
|
||||
|
||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||
@@ -1109,36 +743,12 @@ async fn run_remove_downloaded_operation(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum BeginOperationResult {
|
||||
Started,
|
||||
AlreadyActive,
|
||||
DrainTimedOut,
|
||||
}
|
||||
|
||||
async fn begin_operation(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: &str,
|
||||
operation: OperationKind,
|
||||
) -> BeginOperationResult {
|
||||
begin_operation_with_drain_timeout(
|
||||
ctx,
|
||||
tx_notify_ui,
|
||||
id,
|
||||
operation,
|
||||
OUTBOUND_TRANSFER_DRAIN_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn begin_operation_with_drain_timeout(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: &str,
|
||||
operation: OperationKind,
|
||||
drain_timeout: Duration,
|
||||
) -> BeginOperationResult {
|
||||
) -> bool {
|
||||
let started = {
|
||||
let mut active_operations = ctx.active_operations.write().await;
|
||||
match active_operations.entry(id.to_string()) {
|
||||
@@ -1150,70 +760,11 @@ async fn begin_operation_with_drain_timeout(
|
||||
}
|
||||
};
|
||||
|
||||
if !started {
|
||||
return BeginOperationResult::AlreadyActive;
|
||||
if started {
|
||||
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
|
||||
}
|
||||
|
||||
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
|
||||
|
||||
if operation_requires_outbound_drain(operation)
|
||||
&& !cancel_and_wait_for_outbound_transfers(ctx, id, drain_timeout).await
|
||||
{
|
||||
end_operation(ctx, tx_notify_ui, id).await;
|
||||
return BeginOperationResult::DrainTimedOut;
|
||||
}
|
||||
|
||||
BeginOperationResult::Started
|
||||
}
|
||||
|
||||
fn operation_requires_outbound_drain(operation: OperationKind) -> bool {
|
||||
operation == OperationKind::Updating || operation == OperationKind::RemovingDownload
|
||||
}
|
||||
|
||||
async fn cancel_and_wait_for_outbound_transfers(
|
||||
ctx: &Ctx,
|
||||
id: &str,
|
||||
drain_timeout: Duration,
|
||||
) -> bool {
|
||||
let mut tokens_to_cancel = Vec::new();
|
||||
{
|
||||
let active = ctx.active_outbound_transfers.read().await;
|
||||
if let Some(transfers) = active.get(id) {
|
||||
for (_, token) in transfers {
|
||||
tokens_to_cancel.push(token.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for token in tokens_to_cancel {
|
||||
token.cancel();
|
||||
}
|
||||
|
||||
let drained = tokio::time::timeout(drain_timeout, async {
|
||||
loop {
|
||||
let count = {
|
||||
let active = ctx.active_outbound_transfers.read().await;
|
||||
active.get(id).map_or(0, Vec::len)
|
||||
};
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(OUTBOUND_TRANSFER_DRAIN_POLL_INTERVAL).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
|
||||
if !drained {
|
||||
let count = {
|
||||
let active = ctx.active_outbound_transfers.read().await;
|
||||
active.get(id).map_or(0, Vec::len)
|
||||
};
|
||||
log::error!(
|
||||
"Timed out after {drain_timeout:?} waiting for {count} outbound transfer(s) to drain for {id}"
|
||||
);
|
||||
}
|
||||
|
||||
drained
|
||||
started
|
||||
}
|
||||
|
||||
async fn transition_download_to_install(
|
||||
@@ -1295,14 +846,6 @@ async fn catalog_contains(ctx: &Ctx, id: &str) -> bool {
|
||||
ctx.catalog.read().await.contains(id)
|
||||
}
|
||||
|
||||
async fn catalog_expected_version(ctx: &Ctx, id: &str) -> Option<String> {
|
||||
ctx.catalog
|
||||
.read()
|
||||
.await
|
||||
.expected_version(id)
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
/// Handles the `SetGameDir` command.
|
||||
pub async fn handle_set_game_dir_command(
|
||||
ctx: &Ctx,
|
||||
@@ -1493,8 +1036,13 @@ async fn update_and_announce_games_with_policy(
|
||||
active_operation_ids.remove(id);
|
||||
}
|
||||
if !active_operation_ids.is_empty() {
|
||||
let previous = ctx.local_library.read().await.games.clone();
|
||||
for id in &active_operation_ids {
|
||||
summaries.remove(id);
|
||||
if let Some(summary) = previous.get(id.as_str()) {
|
||||
summaries.insert(id.clone(), summary.clone());
|
||||
} else {
|
||||
summaries.remove(id);
|
||||
}
|
||||
}
|
||||
game_db = GameDB::from(summaries.values().map(game_from_summary).collect());
|
||||
}
|
||||
@@ -1548,14 +1096,13 @@ async fn update_and_announce_games_with_policy(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::HashSet,
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use lanspread_proto::{Availability, GameSummary};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
@@ -1596,9 +1143,7 @@ mod tests {
|
||||
Arc::new(FakeUnpacker),
|
||||
CancellationToken::new(),
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(GameCatalog::from_ids(["game".to_string()]))),
|
||||
Arc::new(RwLock::new(HashMap::new())),
|
||||
Arc::new(crate::NoopStreamInstallProvider),
|
||||
Arc::new(RwLock::new(HashSet::from(["game".to_string()]))),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1703,7 +1248,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_source_selects_expected_ready_peer_manifest() {
|
||||
fn update_source_selects_latest_ready_peer_manifest() {
|
||||
let old_addr = addr(12_000);
|
||||
let new_addr = addr(12_001);
|
||||
let local_only_addr = addr(12_002);
|
||||
@@ -1725,13 +1270,13 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
GameDetailSource::LatestPeersOnly.select_peers(&db, "game", Some("20250101")),
|
||||
GameDetailSource::LatestPeersOnly.select_peers(&db, "game"),
|
||||
vec![new_addr]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_fetch_emits_fresh_manifest_from_expected_peer() {
|
||||
async fn update_fetch_emits_fresh_manifest_from_latest_peer() {
|
||||
let old_addr = addr(12_010);
|
||||
let new_addr = addr(12_011);
|
||||
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
||||
@@ -1750,40 +1295,33 @@ mod tests {
|
||||
}
|
||||
let peers = {
|
||||
let db = peer_game_db.read().await;
|
||||
GameDetailSource::LatestPeersOnly.select_peers(&db, "game", Some("20250101"))
|
||||
GameDetailSource::LatestPeersOnly.select_peers(&db, "game")
|
||||
};
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let fetched_peers = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
fetch_game_details_from_peers(
|
||||
peers,
|
||||
"game".to_string(),
|
||||
Some("20250101".to_string()),
|
||||
peer_game_db.clone(),
|
||||
tx,
|
||||
{
|
||||
fetch_game_details_from_peers(peers, "game".to_string(), peer_game_db.clone(), tx, {
|
||||
let fetched_peers = fetched_peers.clone();
|
||||
move |peer_addr, game_id, peer_game_db| {
|
||||
let fetched_peers = fetched_peers.clone();
|
||||
move |peer_addr, game_id, peer_game_db| {
|
||||
let fetched_peers = fetched_peers.clone();
|
||||
async move {
|
||||
fetched_peers
|
||||
.lock()
|
||||
.expect("fetched peer list should not be poisoned")
|
||||
.push(peer_addr);
|
||||
let files = vec![
|
||||
file_desc(&game_id, "game/version.ini", 8),
|
||||
file_desc(&game_id, "game/new.eti", 11),
|
||||
];
|
||||
peer_game_db.write().await.update_peer_game_files(
|
||||
&"new".to_string(),
|
||||
&game_id,
|
||||
files.clone(),
|
||||
);
|
||||
Ok(files)
|
||||
}
|
||||
async move {
|
||||
fetched_peers
|
||||
.lock()
|
||||
.expect("fetched peer list should not be poisoned")
|
||||
.push(peer_addr);
|
||||
let files = vec![
|
||||
file_desc(&game_id, "game/version.ini", 8),
|
||||
file_desc(&game_id, "game/new.eti", 11),
|
||||
];
|
||||
peer_game_db.write().await.update_peer_game_files(
|
||||
&"new".to_string(),
|
||||
&game_id,
|
||||
files.clone(),
|
||||
);
|
||||
Ok(files)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
@@ -1804,7 +1342,7 @@ mod tests {
|
||||
file_descriptions
|
||||
.iter()
|
||||
.any(|desc| desc.relative_path == "game/new.eti" && desc.size == 11),
|
||||
"expected-version peer manifest should be emitted to the download path"
|
||||
"latest peer manifest should be emitted to the download path"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1819,7 +1357,6 @@ mod tests {
|
||||
fetch_game_details_from_peers(
|
||||
vec![first_addr, second_addr],
|
||||
"game".to_string(),
|
||||
Some("20250101".to_string()),
|
||||
peer_game_db,
|
||||
tx.clone(),
|
||||
{
|
||||
@@ -1853,7 +1390,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_request_skips_local_manifest_even_when_download_exists() {
|
||||
let temp = TempDir::new("lanspread-handler-expected-peer");
|
||||
let temp = TempDir::new("lanspread-handler-latest-peer");
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20240101");
|
||||
write_file(&root.join("game.eti"), b"old archive");
|
||||
@@ -1876,37 +1413,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_library_scan_hides_active_game_state() {
|
||||
let temp = TempDir::new("lanspread-handler-active-hide");
|
||||
async fn local_library_scan_freezes_active_game_state() {
|
||||
let temp = TempDir::new("lanspread-handler-active-freeze");
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
|
||||
// 1. Initial scan: the game is ready and announced
|
||||
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
||||
.await
|
||||
.expect("scan should succeed");
|
||||
update_and_announce_games(&ctx, &tx, scan).await;
|
||||
|
||||
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
||||
panic!("expected LocalLibraryChanged");
|
||||
};
|
||||
assert_eq!(games.len(), 1);
|
||||
assert_eq!(games[0].id, "game");
|
||||
|
||||
// 2. Set the game as active/in-progress and scan again
|
||||
ctx.active_operations
|
||||
.write()
|
||||
.await
|
||||
.insert("game".to_string(), OperationKind::Installing);
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
||||
.await
|
||||
.expect("scan should succeed");
|
||||
|
||||
update_and_announce_games(&ctx, &tx, scan).await;
|
||||
|
||||
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
||||
@@ -1914,7 +1437,7 @@ mod tests {
|
||||
};
|
||||
assert!(
|
||||
games.is_empty(),
|
||||
"active game should be hidden/unannounced during operations"
|
||||
"active game should keep its previous announced state"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1928,10 +1451,7 @@ mod tests {
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
assert_eq!(
|
||||
begin_operation(&ctx, &tx, "game", OperationKind::Updating).await,
|
||||
BeginOperationResult::Started
|
||||
);
|
||||
assert!(begin_operation(&ctx, &tx, "game", OperationKind::Updating).await);
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
vec![ActiveOperation {
|
||||
@@ -1941,48 +1461,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn begin_operation_timeout_clears_active_operation_snapshot() {
|
||||
let temp = TempDir::new("lanspread-handler-active-drain-timeout");
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let token = CancellationToken::new();
|
||||
ctx.active_outbound_transfers
|
||||
.write()
|
||||
.await
|
||||
.insert("game".to_string(), vec![(1, token.clone())]);
|
||||
|
||||
assert_eq!(
|
||||
begin_operation_with_drain_timeout(
|
||||
&ctx,
|
||||
&tx,
|
||||
"game",
|
||||
OperationKind::Updating,
|
||||
Duration::from_millis(1),
|
||||
)
|
||||
.await,
|
||||
BeginOperationResult::DrainTimedOut
|
||||
);
|
||||
|
||||
assert!(token.is_cancelled());
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
vec![ActiveOperation {
|
||||
id: "game".to_string(),
|
||||
operation: ActiveOperationKind::Updating,
|
||||
}],
|
||||
);
|
||||
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
||||
assert!(
|
||||
!ctx.active_operations.read().await.contains_key("game"),
|
||||
"timed-out drain should not leave the operation stuck active"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unchanged_settled_scan_is_not_reemitted() {
|
||||
let temp = TempDir::new("lanspread-handler-settled-unchanged");
|
||||
@@ -2025,7 +1503,7 @@ mod tests {
|
||||
update_and_announce_games(&ctx, &tx, scan).await;
|
||||
assert_local_update(recv_event(&mut rx).await, true, true);
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
run_install_operation(&ctx, &tx, "game".to_string(), None).await;
|
||||
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
@@ -2056,7 +1534,7 @@ mod tests {
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
run_install_operation(&ctx, &tx, "game".to_string(), None).await;
|
||||
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
@@ -2110,7 +1588,7 @@ mod tests {
|
||||
.await
|
||||
);
|
||||
clear_active_download(&ctx, "game").await;
|
||||
run_started_install_operation(&ctx, &tx, "game".to_string(), prepared).await;
|
||||
run_started_install_operation(&ctx, &tx, "game".to_string(), prepared, None).await;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2177,7 +1655,7 @@ mod tests {
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
run_install_operation(&ctx, &tx, "game".to_string(), None).await;
|
||||
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
@@ -2209,7 +1687,7 @@ mod tests {
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
run_install_operation(&ctx, &tx, "game".to_string(), None).await;
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
active_update("game", ActiveOperationKind::Installing),
|
||||
@@ -2232,7 +1710,7 @@ mod tests {
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("game.eti"), b"new archive");
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
run_install_operation(&ctx, &tx, "game".to_string(), None).await;
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
active_update("game", ActiveOperationKind::Updating),
|
||||
|
||||
@@ -4,13 +4,5 @@ mod transaction;
|
||||
pub mod unpack;
|
||||
|
||||
pub use remove::remove_downloaded;
|
||||
pub(crate) use transaction::root_eti_archives;
|
||||
pub use transaction::{
|
||||
StreamedInstallTransaction,
|
||||
begin_streamed_install,
|
||||
install,
|
||||
recover_on_startup,
|
||||
uninstall,
|
||||
update,
|
||||
};
|
||||
pub use transaction::{install, recover_on_startup, uninstall, update};
|
||||
pub use unpack::{UnpackFuture, Unpacker};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
ffi::OsStr,
|
||||
io::ErrorKind,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
@@ -11,7 +12,7 @@ use super::{
|
||||
intent::{InstallIntent, InstallIntentState, read_intent, write_intent},
|
||||
unpack::Unpacker,
|
||||
};
|
||||
use crate::{local_games::version_ini_is_regular_file, state_paths::launch_settings_applied_path};
|
||||
use crate::local_games::version_ini_is_regular_file;
|
||||
|
||||
const LOCAL_DIR: &str = "local";
|
||||
const INSTALLING_DIR: &str = ".local.installing";
|
||||
@@ -19,6 +20,7 @@ const BACKUP_DIR: &str = ".local.backup";
|
||||
const OWNED_MARKER: &str = ".lanspread_owned";
|
||||
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
|
||||
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";
|
||||
const ACCOUNT_NAME_FILE: &str = "account_name.txt";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum FsEntryState {
|
||||
@@ -33,149 +35,12 @@ struct InstallFsState {
|
||||
backup: FsEntryState,
|
||||
}
|
||||
|
||||
pub struct StreamedInstallTransaction {
|
||||
game_root: PathBuf,
|
||||
state_dir: PathBuf,
|
||||
id: String,
|
||||
staging: PathBuf,
|
||||
eti_version: Option<String>,
|
||||
created_game_root: bool,
|
||||
}
|
||||
|
||||
impl StreamedInstallTransaction {
|
||||
#[must_use]
|
||||
pub fn staging_dir(&self) -> &Path {
|
||||
&self.staging
|
||||
}
|
||||
|
||||
pub async fn commit(self) -> eyre::Result<()> {
|
||||
let local = local_dir(&self.game_root);
|
||||
if let Err(err) = tokio::fs::rename(&self.staging, &local)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to promote streamed install for {}", self.id))
|
||||
{
|
||||
if let Err(cleanup_err) = remove_dir_all_if_exists(&self.staging).await {
|
||||
log::warn!(
|
||||
"Failed to clean streamed install staging {}: {cleanup_err}",
|
||||
self.staging.display()
|
||||
);
|
||||
}
|
||||
if let Err(cleanup_err) =
|
||||
remove_created_empty_game_root(&self.game_root, self.created_game_root).await
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to clean streamed install game root {}: {cleanup_err}",
|
||||
self.game_root.display()
|
||||
);
|
||||
}
|
||||
let _ = write_intent(
|
||||
&self.state_dir,
|
||||
&self.id,
|
||||
&InstallIntent::none(&self.id, self.eti_version.clone()),
|
||||
)
|
||||
.await;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if let Err(err) = reset_launch_settings_marker(&self.state_dir, &self.id).await {
|
||||
log::error!(
|
||||
"Streamed install for {} was promoted but launch-settings marker reset failed: {err}",
|
||||
self.id
|
||||
);
|
||||
}
|
||||
if let Err(err) = write_intent(
|
||||
&self.state_dir,
|
||||
&self.id,
|
||||
&InstallIntent::none(&self.id, self.eti_version.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!(
|
||||
"Streamed install for {} was promoted but intent cleanup failed: {err}",
|
||||
self.id
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rollback(self) -> eyre::Result<()> {
|
||||
let cleanup_result = async {
|
||||
remove_dir_all_if_exists(&self.staging).await?;
|
||||
remove_created_empty_game_root(&self.game_root, self.created_game_root).await
|
||||
}
|
||||
.await;
|
||||
let intent_result = write_intent(
|
||||
&self.state_dir,
|
||||
&self.id,
|
||||
&InstallIntent::none(&self.id, self.eti_version.clone()),
|
||||
)
|
||||
.await;
|
||||
|
||||
cleanup_result?;
|
||||
intent_result
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn begin_streamed_install(
|
||||
game_root: &Path,
|
||||
state_dir: &Path,
|
||||
id: &str,
|
||||
) -> eyre::Result<StreamedInstallTransaction> {
|
||||
if path_is_dir(&local_dir(game_root)).await {
|
||||
eyre::bail!("game {id} is already installed");
|
||||
}
|
||||
|
||||
let created_game_root = !path_exists(game_root).await;
|
||||
tokio::fs::create_dir_all(game_root).await?;
|
||||
let eti_version = read_downloaded_version(game_root).await;
|
||||
if let Err(err) = write_intent(
|
||||
state_dir,
|
||||
id,
|
||||
&InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
if let Err(cleanup_err) = remove_created_empty_game_root(game_root, created_game_root).await
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to clean streamed install game root {}: {cleanup_err}",
|
||||
game_root.display()
|
||||
);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let staging = installing_dir(game_root);
|
||||
if let Err(err) = prepare_owned_empty_dir(&staging).await {
|
||||
let _ = write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await;
|
||||
if let Err(cleanup_err) = remove_created_empty_game_root(game_root, created_game_root).await
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to clean streamed install game root {}: {cleanup_err}",
|
||||
game_root.display()
|
||||
);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let staging = tokio::fs::canonicalize(&staging).await.unwrap_or(staging);
|
||||
|
||||
Ok(StreamedInstallTransaction {
|
||||
game_root: game_root.to_path_buf(),
|
||||
state_dir: state_dir.to_path_buf(),
|
||||
id: id.to_string(),
|
||||
staging,
|
||||
eti_version,
|
||||
created_game_root,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn install(
|
||||
game_root: &Path,
|
||||
state_dir: &Path,
|
||||
id: &str,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
account_name: Option<&str>,
|
||||
) -> eyre::Result<()> {
|
||||
let eti_version = read_downloaded_version(game_root).await;
|
||||
write_intent(
|
||||
@@ -185,10 +50,9 @@ pub async fn install(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result = install_inner(game_root, id, unpacker).await;
|
||||
let result = install_inner(game_root, id, unpacker, account_name).await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
reset_launch_settings_marker(state_dir, id).await?;
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -210,6 +74,7 @@ pub async fn update(
|
||||
state_dir: &Path,
|
||||
id: &str,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
account_name: Option<&str>,
|
||||
) -> eyre::Result<()> {
|
||||
let eti_version = read_downloaded_version(game_root).await;
|
||||
write_intent(
|
||||
@@ -219,10 +84,9 @@ pub async fn update(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result = update_inner(game_root, id, unpacker).await;
|
||||
let result = update_inner(game_root, id, unpacker, account_name).await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
reset_launch_settings_marker(state_dir, id).await?;
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||
if let Err(err) = remove_dir_all_if_exists(&backup_dir(game_root)).await {
|
||||
log::warn!(
|
||||
@@ -328,6 +192,7 @@ async fn install_inner(
|
||||
game_root: &Path,
|
||||
id: &str,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
account_name: Option<&str>,
|
||||
) -> eyre::Result<()> {
|
||||
let local = local_dir(game_root);
|
||||
if path_is_dir(&local).await {
|
||||
@@ -337,13 +202,19 @@ async fn install_inner(
|
||||
let staging = installing_dir(game_root);
|
||||
prepare_owned_empty_dir(&staging).await?;
|
||||
unpack_archives(game_root, &staging, unpacker).await?;
|
||||
write_account_name_if_present(&staging, account_name).await?;
|
||||
tokio::fs::rename(&staging, &local)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to promote install for {id}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_inner(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
|
||||
async fn update_inner(
|
||||
game_root: &Path,
|
||||
id: &str,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
account_name: Option<&str>,
|
||||
) -> eyre::Result<()> {
|
||||
let local = local_dir(game_root);
|
||||
let backup = backup_dir(game_root);
|
||||
let staging = installing_dir(game_root);
|
||||
@@ -359,6 +230,7 @@ async fn update_inner(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -
|
||||
|
||||
prepare_owned_empty_dir(&staging).await?;
|
||||
unpack_archives(game_root, &staging, unpacker).await?;
|
||||
write_account_name_if_present(&staging, account_name).await?;
|
||||
tokio::fs::rename(&staging, &local)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to promote update for {id}"))?;
|
||||
@@ -396,7 +268,7 @@ async fn unpack_archives(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
|
||||
async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
|
||||
let mut entries = tokio::fs::read_dir(game_root).await?;
|
||||
let mut archives = Vec::new();
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
@@ -412,16 +284,46 @@ pub(crate) async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<Path
|
||||
Ok(archives)
|
||||
}
|
||||
|
||||
/// Drop the per-game launch-settings marker before committing install/update
|
||||
/// success, so recovery can retry the reset before publishing a clean intent.
|
||||
async fn reset_launch_settings_marker(state_dir: &Path, id: &str) -> eyre::Result<()> {
|
||||
let marker = launch_settings_applied_path(state_dir, id);
|
||||
remove_file_if_exists(&marker).await.wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to reset launch-settings marker {}",
|
||||
marker.display()
|
||||
)
|
||||
})
|
||||
async fn write_account_name_if_present(
|
||||
install_root: &Path,
|
||||
account_name: Option<&str>,
|
||||
) -> eyre::Result<()> {
|
||||
let Some(account_name) = account_name else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(path) = find_account_name_file(install_root).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
tokio::fs::write(&path, account_name)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_account_name_file(root: &Path) -> eyre::Result<Option<PathBuf>> {
|
||||
let mut pending_dirs = vec![root.to_path_buf()];
|
||||
while let Some(dir) = pending_dirs.pop() {
|
||||
let mut entries = tokio::fs::read_dir(&dir).await?;
|
||||
let mut child_dirs = Vec::new();
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let file_type = entry.file_type().await?;
|
||||
let path = entry.path();
|
||||
if entry.file_name() == OsStr::new(ACCOUNT_NAME_FILE) && file_type.is_file() {
|
||||
return Ok(Some(path));
|
||||
}
|
||||
if file_type.is_dir() {
|
||||
child_dirs.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
child_dirs.sort();
|
||||
child_dirs.reverse();
|
||||
pending_dirs.extend(child_dirs);
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
|
||||
@@ -437,7 +339,6 @@ async fn recover_installing(
|
||||
intent: InstallIntent,
|
||||
fs: InstallFsState,
|
||||
) -> eyre::Result<()> {
|
||||
let commit_landed = fs.local == FsEntryState::Present;
|
||||
if let InstallFsState {
|
||||
installing: FsEntryState::Present,
|
||||
..
|
||||
@@ -445,9 +346,6 @@ async fn recover_installing(
|
||||
{
|
||||
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
|
||||
}
|
||||
if commit_landed {
|
||||
reset_launch_settings_marker(state_dir, id).await?;
|
||||
}
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
|
||||
}
|
||||
|
||||
@@ -458,16 +356,6 @@ async fn recover_updating(
|
||||
intent: InstallIntent,
|
||||
fs: InstallFsState,
|
||||
) -> eyre::Result<()> {
|
||||
if matches!(
|
||||
fs,
|
||||
InstallFsState {
|
||||
local: FsEntryState::Present,
|
||||
backup: FsEntryState::Present,
|
||||
..
|
||||
}
|
||||
) {
|
||||
reset_launch_settings_marker(state_dir, id).await?;
|
||||
}
|
||||
match fs {
|
||||
InstallFsState {
|
||||
local: FsEntryState::Missing,
|
||||
@@ -627,28 +515,6 @@ async fn remove_dir_all_if_exists(path: &Path) -> eyre::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_created_empty_game_root(game_root: &Path, created: bool) -> eyre::Result<()> {
|
||||
if !created {
|
||||
return Ok(());
|
||||
}
|
||||
remove_empty_dir_if_exists(game_root).await
|
||||
}
|
||||
|
||||
async fn remove_empty_dir_if_exists(path: &Path) -> eyre::Result<()> {
|
||||
match tokio::fs::remove_dir(path).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err)
|
||||
if matches!(
|
||||
err.kind(),
|
||||
ErrorKind::NotFound | ErrorKind::DirectoryNotEmpty
|
||||
) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn path_is_dir(path: &Path) -> bool {
|
||||
tokio::fs::metadata(path)
|
||||
.await
|
||||
@@ -764,7 +630,7 @@ mod tests {
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
|
||||
install(&root, state.path(), "game", successful_unpacker())
|
||||
install(&root, state.path(), "game", successful_unpacker(), None)
|
||||
.await
|
||||
.expect("install should succeed");
|
||||
|
||||
@@ -775,87 +641,25 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_resets_launch_settings_marker() {
|
||||
async fn install_account_name_missing_file_is_noop() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&launch_settings_applied_path(state.path(), "game"), b"");
|
||||
|
||||
install(&root, state.path(), "game", successful_unpacker())
|
||||
.await
|
||||
.expect("install should succeed");
|
||||
install(
|
||||
&root,
|
||||
state.path(),
|
||||
"game",
|
||||
successful_unpacker(),
|
||||
Some("Alice"),
|
||||
)
|
||||
.await
|
||||
.expect("install should succeed without account file");
|
||||
|
||||
assert!(!launch_settings_applied_path(state.path(), "game").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_install_rollback_removes_new_empty_game_root() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
let root = temp.path().join("streamed-game");
|
||||
|
||||
let transaction = begin_streamed_install(&root, state.path(), "streamed-game")
|
||||
.await
|
||||
.expect("streamed transaction should begin");
|
||||
assert!(transaction.staging_dir().is_dir());
|
||||
|
||||
transaction
|
||||
.rollback()
|
||||
.await
|
||||
.expect("streamed rollback should succeed");
|
||||
|
||||
assert!(!root.exists());
|
||||
let intent = read_intent(state.path(), "streamed-game").await;
|
||||
assert_eq!(intent.state, InstallIntentState::None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_install_rollback_keeps_existing_game_root() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
|
||||
let transaction = begin_streamed_install(&root, state.path(), "game")
|
||||
.await
|
||||
.expect("streamed transaction should begin");
|
||||
|
||||
transaction
|
||||
.rollback()
|
||||
.await
|
||||
.expect("streamed rollback should succeed");
|
||||
|
||||
assert!(root.is_dir());
|
||||
assert!(root.join("version.ini").is_file());
|
||||
assert!(!root.join(INSTALLING_DIR).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_install_commit_succeeds_when_post_promote_intent_cleanup_fails() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
let root = temp.game_root();
|
||||
let transaction = begin_streamed_install(&root, state.path(), "game")
|
||||
.await
|
||||
.expect("streamed transaction should begin");
|
||||
write_file(&transaction.staging_dir().join("payload.txt"), b"installed");
|
||||
|
||||
let game_state_dir = crate::state_paths::game_state_dir(state.path(), "game");
|
||||
std::fs::remove_dir_all(&game_state_dir).expect("game state dir should be removed");
|
||||
write_file(&game_state_dir, b"not a directory");
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.expect("promoted streamed install should be reported as success");
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(root.join(LOCAL_DIR).join("payload.txt"))
|
||||
.expect("promoted payload should be present"),
|
||||
b"installed"
|
||||
);
|
||||
assert!(root.join("local").join("payload.txt").is_file());
|
||||
assert!(!root.join("local").join(ACCOUNT_NAME_FILE).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -868,7 +672,7 @@ mod tests {
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
let unpacker = Arc::new(FakeUnpacker::default());
|
||||
|
||||
install(&root, state.path(), "game", unpacker.clone())
|
||||
install(&root, state.path(), "game", unpacker.clone(), None)
|
||||
.await
|
||||
.expect("install should succeed");
|
||||
|
||||
@@ -882,6 +686,50 @@ mod tests {
|
||||
assert_eq!(archives, vec!["a.eti", "b.eti"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_overwrites_first_account_name_file() {
|
||||
struct AccountNameUnpacker;
|
||||
|
||||
impl Unpacker for AccountNameUnpacker {
|
||||
fn unpack<'a>(&'a self, _archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
||||
Box::pin(async move {
|
||||
tokio::fs::create_dir_all(dest.join("a")).await?;
|
||||
tokio::fs::create_dir_all(dest.join("z")).await?;
|
||||
tokio::fs::write(dest.join("a").join(ACCOUNT_NAME_FILE), b"old-a").await?;
|
||||
tokio::fs::write(dest.join("z").join(ACCOUNT_NAME_FILE), b"old-z").await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
|
||||
install(
|
||||
&root,
|
||||
state.path(),
|
||||
"game",
|
||||
Arc::new(AccountNameUnpacker),
|
||||
Some("Alice"),
|
||||
)
|
||||
.await
|
||||
.expect("install should succeed");
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("local").join("a").join(ACCOUNT_NAME_FILE))
|
||||
.expect("first account file should be readable"),
|
||||
"Alice"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("local").join("z").join(ACCOUNT_NAME_FILE))
|
||||
.expect("second account file should be readable"),
|
||||
"old-z"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_failure_restores_previous_local() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
@@ -896,6 +744,7 @@ mod tests {
|
||||
state.path(),
|
||||
"game",
|
||||
Arc::new(FakeUnpacker::failing()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("update should fail");
|
||||
@@ -922,6 +771,7 @@ mod tests {
|
||||
state.path(),
|
||||
"game",
|
||||
Arc::new(FakeUnpacker::commit_conflict()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("update should fail at commit rename");
|
||||
@@ -950,9 +800,8 @@ mod tests {
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("local").join("old.txt"), b"old");
|
||||
write_file(&launch_settings_applied_path(state.path(), "game"), b"");
|
||||
|
||||
update(&root, state.path(), "game", successful_unpacker())
|
||||
update(&root, state.path(), "game", successful_unpacker(), None)
|
||||
.await
|
||||
.expect("update should succeed");
|
||||
|
||||
@@ -960,7 +809,6 @@ mod tests {
|
||||
assert!(!root.join("local").join("old.txt").exists());
|
||||
assert!(!root.join(".local.installing").exists());
|
||||
assert!(!root.join(".local.backup").exists());
|
||||
assert!(!launch_settings_applied_path(state.path(), "game").exists());
|
||||
let intent = read_intent(state.path(), "game").await;
|
||||
assert_eq!(intent.state, InstallIntentState::None);
|
||||
}
|
||||
@@ -1202,84 +1050,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovery_resets_marker_for_commit_landed_install_or_update() {
|
||||
let state = test_state();
|
||||
let cases = [
|
||||
(
|
||||
"installing-committed",
|
||||
InstallIntentState::Installing,
|
||||
false,
|
||||
),
|
||||
("updating-committed", InstallIntentState::Updating, true),
|
||||
];
|
||||
|
||||
for (id, intent_state, has_backup) in cases {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join(LOCAL_DIR).join("payload.txt"), LOCAL_PAYLOAD);
|
||||
if has_backup {
|
||||
write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD);
|
||||
}
|
||||
write_file(&launch_settings_applied_path(state.path(), id), b"");
|
||||
write_intent(
|
||||
state.path(),
|
||||
id,
|
||||
&InstallIntent::new(id, intent_state, Some("20250101".into())),
|
||||
)
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
|
||||
recover_game_root(&root, state.path(), id)
|
||||
.await
|
||||
.expect("recovery should succeed");
|
||||
|
||||
assert!(
|
||||
!launch_settings_applied_path(state.path(), id).exists(),
|
||||
"{id} marker should be reset"
|
||||
);
|
||||
let intent = read_intent(state.path(), id).await;
|
||||
assert_eq!(intent.state, InstallIntentState::None, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovery_keeps_marker_when_update_rolls_back() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(
|
||||
&root.join(INSTALLING_DIR).join("payload.txt"),
|
||||
INSTALLING_PAYLOAD,
|
||||
);
|
||||
write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD);
|
||||
write_file(&launch_settings_applied_path(state.path(), "game"), b"");
|
||||
write_intent(
|
||||
state.path(),
|
||||
"game",
|
||||
&InstallIntent::new(
|
||||
"game",
|
||||
InstallIntentState::Updating,
|
||||
Some("20250101".into()),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
|
||||
recover_game_root(&root, state.path(), "game")
|
||||
.await
|
||||
.expect("recovery should succeed");
|
||||
|
||||
assert!(launch_settings_applied_path(state.path(), "game").exists());
|
||||
assert_eq!(
|
||||
std::fs::read(root.join(LOCAL_DIR).join("payload.txt"))
|
||||
.expect("backup payload should be restored"),
|
||||
BACKUP_PAYLOAD
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn none_recovery_leaves_markerless_reserved_dirs_untouched() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
|
||||
@@ -1,502 +0,0 @@
|
||||
//! One-shot launcher-setting application performed the first time a game is played.
|
||||
//!
|
||||
//! Some games ship per-user setting files somewhere under their installed
|
||||
//! `local/` tree — an `account_name.txt`, a `language.txt`, and/or a
|
||||
//! `SmartSteamEmu.ini` carrying a `PersonaName = ...` line. The first time the
|
||||
//! user launches a game we stamp the launcher's configured username and language
|
||||
//! into whichever of those files exist, then record a per-game marker so the
|
||||
//! step never runs again.
|
||||
//!
|
||||
//! The marker only records that we *tried*: it is written unconditionally after
|
||||
//! the first attempt, whether or not any file or matching line was found. Moving
|
||||
//! this out of the install transaction means already-installed games are fixed
|
||||
//! up on their next play rather than only on a fresh (re)install.
|
||||
|
||||
use std::{
|
||||
ffi::OsStr,
|
||||
io::ErrorKind,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use eyre::WrapErr;
|
||||
|
||||
use crate::state_paths::launch_settings_applied_path;
|
||||
|
||||
const LOCAL_DIR: &str = "local";
|
||||
const ACCOUNT_NAME_FILE: &str = "account_name.txt";
|
||||
const LANGUAGE_FILE: &str = "language.txt";
|
||||
const SMART_STEAM_EMU_INI: &str = "SmartSteamEmu.ini";
|
||||
const PERSONA_NAME_KEY: &str = "PersonaName";
|
||||
|
||||
/// What the one-shot launcher-setting step did for a game.
|
||||
///
|
||||
/// These flags are an independent, observable report of each file's fate rather
|
||||
/// than a state machine, so a plain record of bools is the clearest shape here.
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct LaunchSettingsOutcome {
|
||||
/// The marker already existed, so nothing was searched or changed.
|
||||
pub already_applied: bool,
|
||||
/// An `account_name.txt` was found and overwritten with the username.
|
||||
pub account_name_written: bool,
|
||||
/// A `language.txt` was found and overwritten with the language.
|
||||
pub language_written: bool,
|
||||
/// A `SmartSteamEmu.ini` `PersonaName` line was found and rewritten.
|
||||
pub persona_name_written: bool,
|
||||
}
|
||||
|
||||
/// Apply launcher settings once for `game_id`, then mark the attempt as done.
|
||||
///
|
||||
/// If the per-game marker already exists this is a no-op returning
|
||||
/// `already_applied = true`. Otherwise it searches `<game_root>/local/` for the
|
||||
/// known setting files, stamps `account_name` into the first `account_name.txt`
|
||||
/// and first `SmartSteamEmu.ini` with a `PersonaName` line (preserving that
|
||||
/// line's existing line ending) and `language` into the first `language.txt`,
|
||||
/// and — whatever was or was not found — records the marker so the step never
|
||||
/// runs again for this game.
|
||||
pub async fn apply_launch_settings_once(
|
||||
state_dir: &Path,
|
||||
game_root: &Path,
|
||||
game_id: &str,
|
||||
account_name: Option<&str>,
|
||||
language: Option<&str>,
|
||||
) -> eyre::Result<LaunchSettingsOutcome> {
|
||||
let marker = launch_settings_applied_path(state_dir, game_id);
|
||||
if tokio::fs::try_exists(&marker).await.unwrap_or(false) {
|
||||
return Ok(LaunchSettingsOutcome {
|
||||
already_applied: true,
|
||||
..LaunchSettingsOutcome::default()
|
||||
});
|
||||
}
|
||||
|
||||
let local_root = game_root.join(LOCAL_DIR);
|
||||
let outcome = LaunchSettingsOutcome {
|
||||
already_applied: false,
|
||||
account_name_written: overwrite_first_file(&local_root, ACCOUNT_NAME_FILE, account_name)
|
||||
.await?,
|
||||
language_written: overwrite_first_file(&local_root, LANGUAGE_FILE, language).await?,
|
||||
persona_name_written: rewrite_first_persona_name(&local_root, account_name).await?,
|
||||
};
|
||||
|
||||
mark_applied(&marker).await?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Overwrite the first file named `file_name` under `root` with `value`.
|
||||
///
|
||||
/// Returns `false` without touching anything when `value` is `None` or no such
|
||||
/// file exists.
|
||||
async fn overwrite_first_file(
|
||||
root: &Path,
|
||||
file_name: &str,
|
||||
value: Option<&str>,
|
||||
) -> eyre::Result<bool> {
|
||||
let Some(value) = value else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(path) = find_first_file(root, file_name).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
tokio::fs::write(&path, value)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Rewrite the first `PersonaName` line found in any `SmartSteamEmu.ini` under `root`.
|
||||
async fn rewrite_first_persona_name(root: &Path, persona_name: Option<&str>) -> eyre::Result<bool> {
|
||||
let Some(persona_name) = persona_name else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
for path in find_files(root, SMART_STEAM_EMU_INI).await? {
|
||||
let content = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to read {}", path.display()))?;
|
||||
let Some(rewritten) = rewrite_persona_name_content(&content, persona_name) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
tokio::fs::write(&path, rewritten)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Find the first regular file named `file_name` anywhere under `root`.
|
||||
///
|
||||
/// A missing `root` (for example an uninstalled game with no `local/`) yields
|
||||
/// `None`. Directories are visited in sorted order for deterministic results.
|
||||
async fn find_first_file(root: &Path, file_name: &str) -> eyre::Result<Option<PathBuf>> {
|
||||
let mut pending_dirs = vec![root.to_path_buf()];
|
||||
while let Some(dir) = pending_dirs.pop() {
|
||||
let mut entries = match tokio::fs::read_dir(&dir).await {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => continue,
|
||||
Err(err) => {
|
||||
return Err(err).wrap_err_with(|| format!("failed to read {}", dir.display()));
|
||||
}
|
||||
};
|
||||
|
||||
let mut child_dirs = Vec::new();
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let file_type = entry.file_type().await?;
|
||||
let path = entry.path();
|
||||
if file_type.is_dir() {
|
||||
child_dirs.push(path);
|
||||
} else if file_type.is_file() && entry.file_name() == OsStr::new(file_name) {
|
||||
return Ok(Some(path));
|
||||
}
|
||||
}
|
||||
|
||||
child_dirs.sort();
|
||||
child_dirs.reverse();
|
||||
pending_dirs.extend(child_dirs);
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Find every regular file named `file_name` anywhere under `root`.
|
||||
///
|
||||
/// A missing `root` yields an empty list. Directories are visited in sorted
|
||||
/// order for deterministic results.
|
||||
async fn find_files(root: &Path, file_name: &str) -> eyre::Result<Vec<PathBuf>> {
|
||||
let mut matches = Vec::new();
|
||||
let mut pending_dirs = vec![root.to_path_buf()];
|
||||
while let Some(dir) = pending_dirs.pop() {
|
||||
let mut entries = match tokio::fs::read_dir(&dir).await {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => continue,
|
||||
Err(err) => {
|
||||
return Err(err).wrap_err_with(|| format!("failed to read {}", dir.display()));
|
||||
}
|
||||
};
|
||||
|
||||
let mut child_dirs = Vec::new();
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let file_type = entry.file_type().await?;
|
||||
let path = entry.path();
|
||||
if file_type.is_dir() {
|
||||
child_dirs.push(path);
|
||||
} else if file_type.is_file() && entry.file_name() == OsStr::new(file_name) {
|
||||
matches.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
child_dirs.sort();
|
||||
child_dirs.reverse();
|
||||
pending_dirs.extend(child_dirs);
|
||||
}
|
||||
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
/// Rewrite the first `PersonaName` line in `content`, preserving its line ending.
|
||||
///
|
||||
/// Returns `None` when no matching line exists, so the caller can skip writing.
|
||||
fn rewrite_persona_name_content(content: &str, persona_name: &str) -> Option<String> {
|
||||
let mut output = String::with_capacity(content.len() + persona_name.len());
|
||||
let mut replaced = false;
|
||||
for segment in content.split_inclusive('\n') {
|
||||
if replaced {
|
||||
output.push_str(segment);
|
||||
continue;
|
||||
}
|
||||
|
||||
let (body, ending) = split_trailing_newline(segment);
|
||||
if let Some(new_body) = rewrite_persona_line(body, persona_name) {
|
||||
output.push_str(&new_body);
|
||||
output.push_str(ending);
|
||||
replaced = true;
|
||||
} else {
|
||||
output.push_str(segment);
|
||||
}
|
||||
}
|
||||
|
||||
replaced.then_some(output)
|
||||
}
|
||||
|
||||
/// Split a `split_inclusive('\n')` segment into its body and trailing newline.
|
||||
fn split_trailing_newline(segment: &str) -> (&str, &str) {
|
||||
if let Some(body) = segment.strip_suffix("\r\n") {
|
||||
(body, "\r\n")
|
||||
} else if let Some(body) = segment.strip_suffix('\n') {
|
||||
(body, "\n")
|
||||
} else {
|
||||
(segment, "")
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite a single line matching `^\s*PersonaName\s*=\s*...` to use `persona_name`.
|
||||
///
|
||||
/// Leading whitespace and the spacing around `=` are preserved; everything after
|
||||
/// the separator's trailing whitespace is replaced with `persona_name`.
|
||||
fn rewrite_persona_line(line: &str, persona_name: &str) -> Option<String> {
|
||||
let leading_len = line.len() - line.trim_start().len();
|
||||
let (leading, rest) = line.split_at(leading_len);
|
||||
|
||||
let rest = rest.strip_prefix(PERSONA_NAME_KEY)?;
|
||||
let mid_len = rest.len() - rest.trim_start().len();
|
||||
let (mid_ws, after_mid) = rest.split_at(mid_len);
|
||||
|
||||
let after_eq = after_mid.strip_prefix('=')?;
|
||||
let post_len = after_eq.len() - after_eq.trim_start().len();
|
||||
let post_ws = &after_eq[..post_len];
|
||||
|
||||
Some(format!(
|
||||
"{leading}{PERSONA_NAME_KEY}{mid_ws}={post_ws}{persona_name}"
|
||||
))
|
||||
}
|
||||
|
||||
async fn mark_applied(marker: &Path) -> eyre::Result<()> {
|
||||
if let Some(parent) = marker.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
tokio::fs::write(marker, [])
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to write {}", marker.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::TempDir;
|
||||
|
||||
#[test]
|
||||
fn rewrites_simple_line_and_preserves_unix_ending() {
|
||||
let content = "[User]\nPersonaName = stubname\nLanguage = english\n";
|
||||
let rewritten =
|
||||
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
|
||||
assert_eq!(
|
||||
rewritten,
|
||||
"[User]\nPersonaName = realuser\nLanguage = english\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_crlf_line_ending() {
|
||||
let content = "[User]\r\nPersonaName = stubname\r\nLanguage = english\r\n";
|
||||
let rewritten =
|
||||
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
|
||||
assert_eq!(
|
||||
rewritten,
|
||||
"[User]\r\nPersonaName = realuser\r\nLanguage = english\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_leading_whitespace_and_separator_spacing() {
|
||||
let content = "\tPersonaName=stubname\n";
|
||||
let rewritten =
|
||||
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
|
||||
assert_eq!(rewritten, "\tPersonaName=realuser\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_final_line_without_trailing_newline() {
|
||||
let content = "PersonaName = stubname";
|
||||
let rewritten =
|
||||
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
|
||||
assert_eq!(rewritten, "PersonaName = realuser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_similar_keys() {
|
||||
assert!(rewrite_persona_line("PersonaNameExtra = x", "realuser").is_none());
|
||||
assert!(rewrite_persona_line("MyPersonaName = x", "realuser").is_none());
|
||||
assert!(rewrite_persona_line("PersonaName foo", "realuser").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_no_persona_line() {
|
||||
let content = "[User]\nLanguage = english\n";
|
||||
assert!(rewrite_persona_name_content(content, "realuser").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn applies_username_to_both_files_then_marks_done() {
|
||||
let state = TempDir::new("lanspread-launch-state");
|
||||
let game = TempDir::new("lanspread-launch-game");
|
||||
let root = game.path();
|
||||
let account = root.join(LOCAL_DIR).join("profile").join(ACCOUNT_NAME_FILE);
|
||||
let ini = root
|
||||
.join(LOCAL_DIR)
|
||||
.join("config")
|
||||
.join("emu")
|
||||
.join(SMART_STEAM_EMU_INI);
|
||||
let language = root.join(LOCAL_DIR).join(LANGUAGE_FILE);
|
||||
write_file(&account, b"stubname");
|
||||
write_file(&ini, b"[User]\r\nPersonaName = stubname\r\n");
|
||||
write_file(&language, b"english");
|
||||
|
||||
let outcome = apply_launch_settings_once(
|
||||
state.path(),
|
||||
root,
|
||||
"game",
|
||||
Some("realuser"),
|
||||
Some("german"),
|
||||
)
|
||||
.await
|
||||
.expect("apply should succeed");
|
||||
|
||||
assert_eq!(
|
||||
outcome,
|
||||
LaunchSettingsOutcome {
|
||||
already_applied: false,
|
||||
account_name_written: true,
|
||||
language_written: true,
|
||||
persona_name_written: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&account).expect("account file should be readable"),
|
||||
"realuser"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&ini).expect("ini should be readable"),
|
||||
"[User]\r\nPersonaName = realuser\r\n"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&language).expect("language file should be readable"),
|
||||
"german"
|
||||
);
|
||||
assert!(launch_settings_applied_path(state.path(), "game").is_file());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_noop_once_marker_exists() {
|
||||
let state = TempDir::new("lanspread-launch-state");
|
||||
let game = TempDir::new("lanspread-launch-game");
|
||||
let root = game.path();
|
||||
let ini = root.join(LOCAL_DIR).join(SMART_STEAM_EMU_INI);
|
||||
write_file(&ini, b"PersonaName = stubname\n");
|
||||
|
||||
let first = apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
|
||||
.await
|
||||
.expect("first apply should succeed");
|
||||
assert!(first.persona_name_written);
|
||||
assert!(!first.already_applied);
|
||||
|
||||
// Externally reset the value; a second apply must not touch it again.
|
||||
write_file(&ini, b"PersonaName = stubname\n");
|
||||
let second = apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
|
||||
.await
|
||||
.expect("second apply should succeed");
|
||||
|
||||
assert!(second.already_applied);
|
||||
assert!(!second.persona_name_written);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&ini).expect("ini should be readable"),
|
||||
"PersonaName = stubname\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marks_done_even_when_nothing_found() {
|
||||
let state = TempDir::new("lanspread-launch-state");
|
||||
let game = TempDir::new("lanspread-launch-game");
|
||||
let root = game.path();
|
||||
write_file(
|
||||
&root.join(LOCAL_DIR).join("readme.txt"),
|
||||
b"no settings here",
|
||||
);
|
||||
|
||||
let outcome = apply_launch_settings_once(
|
||||
state.path(),
|
||||
root,
|
||||
"game",
|
||||
Some("realuser"),
|
||||
Some("german"),
|
||||
)
|
||||
.await
|
||||
.expect("apply should succeed");
|
||||
|
||||
assert_eq!(outcome, LaunchSettingsOutcome::default());
|
||||
assert!(launch_settings_applied_path(state.path(), "game").is_file());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marks_done_when_local_missing() {
|
||||
let state = TempDir::new("lanspread-launch-state");
|
||||
let game = TempDir::new("lanspread-launch-game");
|
||||
|
||||
let outcome = apply_launch_settings_once(
|
||||
state.path(),
|
||||
game.path(),
|
||||
"game",
|
||||
Some("realuser"),
|
||||
Some("german"),
|
||||
)
|
||||
.await
|
||||
.expect("apply should succeed");
|
||||
|
||||
assert_eq!(outcome, LaunchSettingsOutcome::default());
|
||||
assert!(launch_settings_applied_path(state.path(), "game").is_file());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overwrites_first_account_file_in_sorted_order() {
|
||||
let state = TempDir::new("lanspread-launch-state");
|
||||
let game = TempDir::new("lanspread-launch-game");
|
||||
let root = game.path();
|
||||
let first = root.join(LOCAL_DIR).join("a").join(ACCOUNT_NAME_FILE);
|
||||
let second = root.join(LOCAL_DIR).join("z").join(ACCOUNT_NAME_FILE);
|
||||
write_file(&first, b"old-a");
|
||||
write_file(&second, b"old-z");
|
||||
|
||||
apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
|
||||
.await
|
||||
.expect("apply should succeed");
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&first).expect("first account file should be readable"),
|
||||
"realuser"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&second).expect("second account file should be readable"),
|
||||
"old-z"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn searches_past_ini_files_without_persona_name() {
|
||||
let state = TempDir::new("lanspread-launch-state");
|
||||
let game = TempDir::new("lanspread-launch-game");
|
||||
let root = game.path();
|
||||
let first = root.join(LOCAL_DIR).join("a").join(SMART_STEAM_EMU_INI);
|
||||
let second = root.join(LOCAL_DIR).join("b").join(SMART_STEAM_EMU_INI);
|
||||
write_file(&first, b"[User]\nLanguage = english\n");
|
||||
write_file(&second, b"PersonaName = stubname\n");
|
||||
|
||||
let outcome =
|
||||
apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
|
||||
.await
|
||||
.expect("apply should succeed");
|
||||
|
||||
assert!(outcome.persona_name_written);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&first).expect("first ini should be readable"),
|
||||
"[User]\nLanguage = english\n"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&second).expect("second ini should be readable"),
|
||||
"PersonaName = realuser\n"
|
||||
);
|
||||
assert!(launch_settings_applied_path(state.path(), "game").is_file());
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, bytes: &[u8]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("parent dir should be created");
|
||||
}
|
||||
std::fs::write(path, bytes).expect("file should be written");
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ mod events;
|
||||
mod handlers;
|
||||
mod identity;
|
||||
mod install;
|
||||
mod launch_settings;
|
||||
mod library;
|
||||
mod local_games;
|
||||
mod migration;
|
||||
@@ -32,7 +31,6 @@ mod remote_peer;
|
||||
mod services;
|
||||
mod startup;
|
||||
mod state_paths;
|
||||
mod stream_install;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
|
||||
@@ -40,12 +38,12 @@ mod test_support;
|
||||
// Public re-exports
|
||||
// =============================================================================
|
||||
|
||||
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
use std::{collections::HashSet, net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
|
||||
pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT};
|
||||
pub use error::PeerError;
|
||||
pub use install::{UnpackFuture, Unpacker};
|
||||
use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
|
||||
use lanspread_db::db::{Game, GameFileDescription};
|
||||
pub use migration::{MigrationReport, migrate_legacy_state};
|
||||
pub use peer_db::{
|
||||
MajorityValidationResult,
|
||||
@@ -79,19 +77,7 @@ use crate::{
|
||||
},
|
||||
state_paths::resolve_state_dir,
|
||||
};
|
||||
pub use crate::{
|
||||
context::OutboundTransfers,
|
||||
launch_settings::{LaunchSettingsOutcome, apply_launch_settings_once},
|
||||
startup::PeerRuntimeHandle,
|
||||
state_paths::{launch_settings_applied_path, setup_done_path},
|
||||
stream_install::{
|
||||
ExternalUnrarStreamProvider,
|
||||
NoopStreamInstallProvider,
|
||||
StreamInstallFrameSink,
|
||||
StreamInstallFuture,
|
||||
StreamInstallProvider,
|
||||
},
|
||||
};
|
||||
pub use crate::{startup::PeerRuntimeHandle, state_paths::setup_done_path};
|
||||
|
||||
// =============================================================================
|
||||
// Public API types
|
||||
@@ -162,8 +148,6 @@ pub enum PeerEvent {
|
||||
PeerCountUpdated(usize),
|
||||
/// The local library contents changed after a scan.
|
||||
LocalLibraryChanged { games: Vec<Game> },
|
||||
/// The number of active outbound transfers changed.
|
||||
OutboundTransferCountChanged,
|
||||
/// The set of in-progress local operations changed.
|
||||
ActiveOperationsChanged {
|
||||
active_operations: Vec<ActiveOperation>,
|
||||
@@ -245,17 +229,20 @@ pub enum PeerCommand {
|
||||
DownloadGameFiles {
|
||||
id: String,
|
||||
file_descriptions: Vec<GameFileDescription>,
|
||||
account_name: Option<String>,
|
||||
},
|
||||
/// Download game files with an explicit install policy.
|
||||
DownloadGameFilesWithOptions {
|
||||
id: String,
|
||||
file_descriptions: Vec<GameFileDescription>,
|
||||
install_after_download: bool,
|
||||
account_name: Option<String>,
|
||||
},
|
||||
/// Stream archive-expanded bytes directly into `local/` without keeping root archives.
|
||||
StreamInstallGame { id: String },
|
||||
/// Install already-downloaded archives into `local/`.
|
||||
InstallGame { id: String },
|
||||
InstallGame {
|
||||
id: String,
|
||||
account_name: Option<String>,
|
||||
},
|
||||
/// Remove only the `local/` install for a game.
|
||||
UninstallGame { id: String },
|
||||
/// Remove downloaded archive files for an uninstalled game.
|
||||
@@ -271,29 +258,10 @@ pub enum PeerCommand {
|
||||
}
|
||||
|
||||
/// Optional startup settings for non-GUI callers and tests.
|
||||
#[derive(Clone, Default)]
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PeerStartOptions {
|
||||
/// Directory used for peer identity and other state.
|
||||
pub state_dir: Option<PathBuf>,
|
||||
pub active_outbound_transfers: Option<crate::context::OutboundTransfers>,
|
||||
/// Provider used to stream archive entries for low-disk streamed installs.
|
||||
pub stream_install_provider: Option<Arc<dyn StreamInstallProvider>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PeerStartOptions {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PeerStartOptions")
|
||||
.field("state_dir", &self.state_dir)
|
||||
.field(
|
||||
"active_outbound_transfers",
|
||||
&self.active_outbound_transfers.as_ref().map(|_| "..."),
|
||||
)
|
||||
.field(
|
||||
"stream_install_provider",
|
||||
&self.stream_install_provider.as_ref().map(|_| "..."),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -318,7 +286,7 @@ pub fn start_peer(
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
) -> eyre::Result<PeerRuntimeHandle> {
|
||||
start_peer_with_options(
|
||||
game_dir,
|
||||
@@ -337,20 +305,12 @@ pub fn start_peer_with_options(
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
options: PeerStartOptions,
|
||||
) -> eyre::Result<PeerRuntimeHandle> {
|
||||
let PeerStartOptions {
|
||||
state_dir,
|
||||
active_outbound_transfers,
|
||||
stream_install_provider,
|
||||
} = options;
|
||||
let PeerStartOptions { state_dir } = options;
|
||||
let state_dir = resolve_state_dir(state_dir.as_deref());
|
||||
let game_dir = game_dir.into();
|
||||
let active_outbound_transfers = active_outbound_transfers
|
||||
.unwrap_or_else(|| Arc::new(RwLock::new(std::collections::HashMap::new())));
|
||||
let stream_install_provider =
|
||||
stream_install_provider.unwrap_or_else(|| Arc::new(NoopStreamInstallProvider));
|
||||
log::info!(
|
||||
"Starting peer system with game directory: {}",
|
||||
game_dir.display()
|
||||
@@ -369,8 +329,6 @@ pub fn start_peer_with_options(
|
||||
state_dir,
|
||||
unpacker,
|
||||
catalog,
|
||||
active_outbound_transfers,
|
||||
stream_install_provider,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -386,9 +344,7 @@ async fn run_peer(
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
shutdown: CancellationToken,
|
||||
task_tracker: TaskTracker,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
active_outbound_transfers: crate::context::OutboundTransfers,
|
||||
stream_install_provider: Arc<dyn StreamInstallProvider>,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
) -> eyre::Result<()> {
|
||||
let ctx = Ctx::new(
|
||||
peer_game_db,
|
||||
@@ -399,8 +355,6 @@ async fn run_peer(
|
||||
shutdown,
|
||||
task_tracker,
|
||||
catalog,
|
||||
active_outbound_transfers,
|
||||
stream_install_provider,
|
||||
);
|
||||
if let Err(err) = load_local_library(&ctx, &tx_notify_ui).await {
|
||||
log::error!("Failed to load initial local game database: {err}");
|
||||
@@ -456,14 +410,23 @@ async fn handle_peer_commands(
|
||||
PeerCommand::DownloadGameFiles {
|
||||
id,
|
||||
file_descriptions,
|
||||
account_name,
|
||||
} => {
|
||||
handle_download_game_files_command(ctx, tx_notify_ui, id, file_descriptions, true)
|
||||
.await;
|
||||
handle_download_game_files_command(
|
||||
ctx,
|
||||
tx_notify_ui,
|
||||
id,
|
||||
file_descriptions,
|
||||
true,
|
||||
account_name,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
PeerCommand::DownloadGameFilesWithOptions {
|
||||
id,
|
||||
file_descriptions,
|
||||
install_after_download,
|
||||
account_name,
|
||||
} => {
|
||||
handle_download_game_files_command(
|
||||
ctx,
|
||||
@@ -471,14 +434,12 @@ async fn handle_peer_commands(
|
||||
id,
|
||||
file_descriptions,
|
||||
install_after_download,
|
||||
account_name,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
PeerCommand::StreamInstallGame { id } => {
|
||||
handlers::handle_stream_install_game_command(ctx, tx_notify_ui, id).await;
|
||||
}
|
||||
PeerCommand::InstallGame { id } => {
|
||||
handle_install_game_command(ctx, tx_notify_ui, id).await;
|
||||
PeerCommand::InstallGame { id, account_name } => {
|
||||
handle_install_game_command(ctx, tx_notify_ui, id, account_name).await;
|
||||
}
|
||||
PeerCommand::UninstallGame { id } => {
|
||||
handle_uninstall_game_command(ctx, tx_notify_ui, id).await;
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::{
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use lanspread_db::db::{Game, GameCatalog, GameDB, GameFileDescription};
|
||||
use lanspread_db::db::{Game, GameDB, GameFileDescription};
|
||||
use lanspread_proto::{Availability, GameSummary};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::{io::AsyncWriteExt, sync::Mutex};
|
||||
@@ -51,7 +51,7 @@ pub async fn local_download_available(
|
||||
game_dir: &Path,
|
||||
game_id: &str,
|
||||
active_operations: &HashMap<String, OperationKind>,
|
||||
catalog: &GameCatalog,
|
||||
catalog: &HashSet<String>,
|
||||
) -> bool {
|
||||
if !catalog.contains(game_id) {
|
||||
log::debug!("Not serving game {game_id} locally because it is not in the catalog");
|
||||
@@ -67,40 +67,6 @@ pub async fn local_download_available(
|
||||
version_ini_is_regular_file(game_path.as_path()).await
|
||||
}
|
||||
|
||||
/// Checks if a local game may be served to peers under the authoritative catalog version.
|
||||
pub async fn local_download_matches_catalog(
|
||||
game_dir: &Path,
|
||||
game_id: &str,
|
||||
active_operations: &HashMap<String, OperationKind>,
|
||||
catalog: &GameCatalog,
|
||||
) -> bool {
|
||||
if !local_download_available(game_dir, game_id, active_operations, catalog).await {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(expected_version) = catalog.expected_version(game_id) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let game_path = game_dir.join(game_id);
|
||||
match lanspread_db::db::read_version_from_ini(&game_path) {
|
||||
Ok(Some(local_version)) if local_version == expected_version => true,
|
||||
Ok(Some(local_version)) => {
|
||||
log::debug!(
|
||||
"Not serving game {game_id}: local version.ini {local_version} does not match catalog {expected_version}"
|
||||
);
|
||||
false
|
||||
}
|
||||
Ok(None) => false,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Not serving game {game_id}: failed to read local version.ini for catalog comparison: {err}"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Local library index and scanning
|
||||
// =============================================================================
|
||||
@@ -502,7 +468,7 @@ struct IndexUpdate {
|
||||
async fn update_index_for_game(
|
||||
game_root: &Path,
|
||||
game_id: &str,
|
||||
catalog: &GameCatalog,
|
||||
catalog: &HashSet<String>,
|
||||
index: &mut LibraryIndex,
|
||||
) -> eyre::Result<IndexUpdate> {
|
||||
if !catalog.contains(game_id) {
|
||||
@@ -591,7 +557,7 @@ fn scan_from_index(index: &LibraryIndex) -> LocalLibraryScan {
|
||||
pub async fn scan_local_library(
|
||||
game_dir: impl AsRef<Path>,
|
||||
state_dir: impl AsRef<Path>,
|
||||
catalog: &GameCatalog,
|
||||
catalog: &HashSet<String>,
|
||||
) -> eyre::Result<LocalLibraryScan> {
|
||||
let game_path = game_dir.as_ref();
|
||||
let state_path = state_dir.as_ref();
|
||||
@@ -679,7 +645,7 @@ pub async fn scan_local_library(
|
||||
pub async fn rescan_local_game(
|
||||
game_dir: impl AsRef<Path>,
|
||||
state_dir: impl AsRef<Path>,
|
||||
catalog: &GameCatalog,
|
||||
catalog: &HashSet<String>,
|
||||
game_id: &str,
|
||||
) -> eyre::Result<LocalLibraryScan> {
|
||||
let game_path = game_dir.as_ref();
|
||||
@@ -716,7 +682,10 @@ pub async fn get_game_file_descriptions(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{collections::HashMap, path::Path};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use lanspread_proto::Availability;
|
||||
|
||||
@@ -807,7 +776,7 @@ mod tests {
|
||||
async fn scan_uses_version_ini_and_local_dir_as_independent_state() {
|
||||
let temp = TempDir::new("lanspread-local-games");
|
||||
let state = TempDir::new("lanspread-local-games-state");
|
||||
let catalog = GameCatalog::from_ids([
|
||||
let catalog = HashSet::from([
|
||||
"ready".to_string(),
|
||||
"local-only".to_string(),
|
||||
"eti-only".to_string(),
|
||||
@@ -861,7 +830,7 @@ mod tests {
|
||||
async fn rescan_promotes_installed_only_game_to_ready_when_sentinel_appears() {
|
||||
let temp = TempDir::new("lanspread-local-games");
|
||||
let state = TempDir::new("lanspread-local-games-state");
|
||||
let catalog = GameCatalog::from_ids(["game".to_string()]);
|
||||
let catalog = HashSet::from(["game".to_string()]);
|
||||
std::fs::create_dir_all(temp.path().join("game").join("local"))
|
||||
.expect("local install dir should be created");
|
||||
|
||||
@@ -895,7 +864,7 @@ mod tests {
|
||||
async fn concurrent_rescans_preserve_both_index_updates() {
|
||||
let temp = TempDir::new("lanspread-local-games-concurrent");
|
||||
let state = TempDir::new("lanspread-local-games-state");
|
||||
let catalog = GameCatalog::from_ids(["game-a".to_string(), "game-b".to_string()]);
|
||||
let catalog = HashSet::from(["game-a".to_string(), "game-b".to_string()]);
|
||||
write_file(&temp.path().join("game-a").join("version.ini"), b"20250101");
|
||||
write_file(&temp.path().join("game-b").join("version.ini"), b"20250101");
|
||||
|
||||
@@ -940,7 +909,7 @@ mod tests {
|
||||
let game_root = temp.path().join("game");
|
||||
write_file(&game_root.join("version.ini"), b"20250101");
|
||||
|
||||
let catalog = GameCatalog::from_ids(["game".to_string()]);
|
||||
let catalog = HashSet::from(["game".to_string()]);
|
||||
let no_operations = HashMap::new();
|
||||
assert!(local_download_available(temp.path(), "game", &no_operations, &catalog).await);
|
||||
|
||||
@@ -948,29 +917,8 @@ mod tests {
|
||||
assert!(!local_download_available(temp.path(), "game", &active_operations, &catalog).await);
|
||||
|
||||
assert!(
|
||||
!local_download_available(temp.path(), "game", &no_operations, &GameCatalog::empty())
|
||||
.await
|
||||
!local_download_available(temp.path(), "game", &no_operations, &HashSet::new()).await
|
||||
);
|
||||
assert!(!local_download_available(temp.path(), "missing", &no_operations, &catalog).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_download_matches_catalog_requires_expected_version() {
|
||||
let temp = TempDir::new("lanspread-local-games");
|
||||
let game_root = temp.path().join("game");
|
||||
write_file(&game_root.join("version.ini"), b"20260101");
|
||||
|
||||
let mut catalog = GameCatalog::empty();
|
||||
catalog.insert("game".to_string(), Some("20250101".to_string()));
|
||||
let no_operations = HashMap::new();
|
||||
|
||||
assert!(
|
||||
!local_download_matches_catalog(temp.path(), "game", &no_operations, &catalog).await
|
||||
);
|
||||
|
||||
catalog.insert("game".to_string(), Some("20260101".to_string()));
|
||||
assert!(
|
||||
local_download_matches_catalog(temp.path(), "game", &no_operations, &catalog).await
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ use bytes::Bytes;
|
||||
use lanspread_db::db::GameFileDescription;
|
||||
use lanspread_utils::maybe_addr;
|
||||
use s2n_quic::{
|
||||
application,
|
||||
connection,
|
||||
stream::{Error as StreamError, SendStream},
|
||||
};
|
||||
@@ -15,24 +14,12 @@ use tokio::{
|
||||
|
||||
use crate::{config::FILE_TRANSFER_BUFFER_SIZE, path_validation::validate_game_file_path};
|
||||
|
||||
fn cancel_send_stream(tx: &mut SendStream, remote_addr: impl std::fmt::Display, path: &Path) {
|
||||
// Reset instead of finishing so truncated whole-file transfers cannot look like EOF.
|
||||
if let Err(err) = tx.reset(application::Error::UNKNOWN) {
|
||||
log::debug!(
|
||||
"{remote_addr} failed to reset cancelled transfer for {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn stream_file_bytes(
|
||||
tx: &mut SendStream,
|
||||
base_dir: &Path,
|
||||
relative_path: &str,
|
||||
offset: u64,
|
||||
length: Option<u64>,
|
||||
cancel_token: tokio_util::sync::CancellationToken,
|
||||
) -> eyre::Result<()> {
|
||||
let remote_addr = maybe_addr!(tx.connection().remote_addr());
|
||||
|
||||
@@ -58,34 +45,13 @@ async fn stream_file_bytes(
|
||||
let mut buf = vec![0u8; FILE_TRANSFER_BUFFER_SIZE];
|
||||
|
||||
while remaining > 0 {
|
||||
if cancel_token.is_cancelled() {
|
||||
log::info!(
|
||||
"{remote_addr} transfer cancelled for {}",
|
||||
validated_path.display()
|
||||
);
|
||||
cancel_send_stream(tx, remote_addr, &validated_path);
|
||||
return Err(eyre::eyre!("File transfer cancelled by user"));
|
||||
}
|
||||
|
||||
let read_len = std::cmp::min(remaining, buf.len() as u64);
|
||||
let read_len: usize = read_len.try_into().unwrap_or(usize::MAX);
|
||||
if read_len == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let bytes_read = tokio::select! {
|
||||
() = cancel_token.cancelled() => {
|
||||
log::info!(
|
||||
"{remote_addr} transfer cancelled for {}",
|
||||
validated_path.display()
|
||||
);
|
||||
cancel_send_stream(tx, remote_addr, &validated_path);
|
||||
return Err(eyre::eyre!("File transfer cancelled by user"));
|
||||
}
|
||||
res = file.read(&mut buf[..read_len]) => {
|
||||
res?
|
||||
}
|
||||
};
|
||||
let bytes_read = file.read(&mut buf[..read_len]).await?;
|
||||
if bytes_read == 0 {
|
||||
if !expect_exact {
|
||||
transfer_complete = true;
|
||||
@@ -93,19 +59,7 @@ async fn stream_file_bytes(
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
() = cancel_token.cancelled() => {
|
||||
log::info!(
|
||||
"{remote_addr} transfer cancelled for {}",
|
||||
validated_path.display()
|
||||
);
|
||||
cancel_send_stream(tx, remote_addr, &validated_path);
|
||||
return Err(eyre::eyre!("File transfer cancelled by user"));
|
||||
}
|
||||
res = tx.send(Bytes::copy_from_slice(&buf[..bytes_read])) => {
|
||||
res?;
|
||||
}
|
||||
}
|
||||
tx.send(Bytes::copy_from_slice(&buf[..bytes_read])).await?;
|
||||
remaining = remaining.saturating_sub(bytes_read as u64);
|
||||
total_bytes += bytes_read as u64;
|
||||
|
||||
@@ -143,21 +97,12 @@ async fn stream_file_bytes(
|
||||
validated_path.display()
|
||||
);
|
||||
|
||||
tokio::select! {
|
||||
() = cancel_token.cancelled() => {
|
||||
log::info!("{remote_addr} transfer cancelled while closing stream");
|
||||
cancel_send_stream(tx, remote_addr, &validated_path);
|
||||
return Err(eyre::eyre!("File transfer cancelled by user"));
|
||||
}
|
||||
res = tx.close() => {
|
||||
match res {
|
||||
Ok(()) => {}
|
||||
Err(err) if transfer_complete && is_clean_remote_close(&err) => {
|
||||
log::debug!("{remote_addr} closed stream after transfer completion: {err}");
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
match tx.close().await {
|
||||
Ok(()) => {}
|
||||
Err(err) if transfer_complete && is_clean_remote_close(&err) => {
|
||||
log::debug!("{remote_addr} closed stream after transfer completion: {err}");
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -176,18 +121,8 @@ pub async fn send_game_file_data(
|
||||
game_file_desc: &GameFileDescription,
|
||||
tx: &mut SendStream,
|
||||
game_dir: &Path,
|
||||
cancel_token: tokio_util::sync::CancellationToken,
|
||||
) {
|
||||
if let Err(e) = stream_file_bytes(
|
||||
tx,
|
||||
game_dir,
|
||||
&game_file_desc.relative_path,
|
||||
0,
|
||||
None,
|
||||
cancel_token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if let Err(e) = stream_file_bytes(tx, game_dir, &game_file_desc.relative_path, 0, None).await {
|
||||
let remote_addr = maybe_addr!(tx.connection().remote_addr());
|
||||
log::error!(
|
||||
"{remote_addr} failed to stream file {}: {e}",
|
||||
@@ -203,18 +138,8 @@ pub async fn send_game_file_chunk(
|
||||
length: u64,
|
||||
tx: &mut SendStream,
|
||||
game_dir: &Path,
|
||||
cancel_token: tokio_util::sync::CancellationToken,
|
||||
) {
|
||||
if let Err(e) = stream_file_bytes(
|
||||
tx,
|
||||
game_dir,
|
||||
relative_path,
|
||||
offset,
|
||||
Some(length),
|
||||
cancel_token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if let Err(e) = stream_file_bytes(tx, game_dir, relative_path, offset, Some(length)).await {
|
||||
let remote_addr = maybe_addr!(tx.connection().remote_addr());
|
||||
log::error!(
|
||||
"{remote_addr} failed to stream chunk {game_id}/{relative_path} offset {offset} length {length}: {e}"
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use lanspread_db::db::{Availability, Game, GameCatalog, GameFileDescription};
|
||||
use lanspread_db::db::{Availability, Game, GameFileDescription};
|
||||
use lanspread_proto::{GameSummary, LibraryDelta, LibrarySnapshot};
|
||||
|
||||
use crate::library::compute_library_digest;
|
||||
@@ -357,54 +357,6 @@ impl PeerGameDB {
|
||||
games
|
||||
}
|
||||
|
||||
/// Returns catalog games aggregated from peers that advertise the expected catalog version.
|
||||
#[must_use]
|
||||
pub fn get_catalog_games(&self, catalog: &GameCatalog) -> Vec<Game> {
|
||||
let mut aggregated: HashMap<String, Game> = HashMap::new();
|
||||
let mut peer_counts: HashMap<String, u32> = HashMap::new();
|
||||
|
||||
for peer in self.peers.values() {
|
||||
for game in peer.games.values().filter(|game| {
|
||||
catalog.contains(&game.id)
|
||||
&& game_matches_expected_version(game, catalog.expected_version(&game.id))
|
||||
}) {
|
||||
*peer_counts.entry(game.id.clone()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for peer in self.peers.values() {
|
||||
for game in peer.games.values().filter(|game| {
|
||||
catalog.contains(&game.id)
|
||||
&& game_matches_expected_version(game, catalog.expected_version(&game.id))
|
||||
}) {
|
||||
aggregated
|
||||
.entry(game.id.clone())
|
||||
.and_modify(|existing| {
|
||||
existing.peer_count = *peer_counts.get(&game.id).unwrap_or(&0);
|
||||
if game.size > existing.size {
|
||||
existing.size = game.size;
|
||||
}
|
||||
existing.set_downloaded(true);
|
||||
if game.installed {
|
||||
existing.installed = true;
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| {
|
||||
let mut game_clone = summary_to_game(game);
|
||||
if let Some(expected_version) = catalog.expected_version(&game.id) {
|
||||
game_clone.eti_game_version = Some(expected_version.to_string());
|
||||
}
|
||||
game_clone.peer_count = *peer_counts.get(&game.id).unwrap_or(&0);
|
||||
game_clone
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut games: Vec<Game> = aggregated.into_values().collect();
|
||||
games.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
games
|
||||
}
|
||||
|
||||
/// Returns the latest version of a game across all peers.
|
||||
#[must_use]
|
||||
pub fn get_latest_version_for_game(&self, game_id: &str) -> Option<String> {
|
||||
@@ -499,24 +451,6 @@ impl PeerGameDB {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns addresses of peers that have the expected catalog version of a game.
|
||||
#[must_use]
|
||||
pub fn peers_with_expected_version(
|
||||
&self,
|
||||
game_id: &str,
|
||||
expected_version: Option<&str>,
|
||||
) -> Vec<SocketAddr> {
|
||||
self.peers
|
||||
.iter()
|
||||
.filter(|(_, peer)| {
|
||||
peer.games
|
||||
.get(game_id)
|
||||
.is_some_and(|game| game_matches_expected_version(game, expected_version))
|
||||
})
|
||||
.map(|(_, peer)| peer.addr)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns addresses of peers that have the latest version of a game.
|
||||
#[must_use]
|
||||
pub fn peers_with_latest_version(&self, game_id: &str) -> Vec<SocketAddr> {
|
||||
@@ -580,33 +514,11 @@ impl PeerGameDB {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns file descriptions from peers that advertise the expected catalog version.
|
||||
#[must_use]
|
||||
pub fn expected_version_game_files_for(
|
||||
&self,
|
||||
game_id: &str,
|
||||
expected_version: Option<&str>,
|
||||
) -> Vec<(SocketAddr, Vec<GameFileDescription>)> {
|
||||
let expected_peers = self.peers_with_expected_version(game_id, expected_version);
|
||||
if expected_peers.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.game_files_for(game_id)
|
||||
.into_iter()
|
||||
.filter(|(addr, _)| expected_peers.contains(addr))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns aggregated file descriptions for a game across all peers.
|
||||
#[must_use]
|
||||
pub fn aggregated_game_files(
|
||||
&self,
|
||||
game_id: &str,
|
||||
expected_version: Option<&str>,
|
||||
) -> Vec<GameFileDescription> {
|
||||
pub fn aggregated_game_files(&self, game_id: &str) -> Vec<GameFileDescription> {
|
||||
let mut seen: HashMap<String, GameFileDescription> = HashMap::new();
|
||||
for (_, files) in self.expected_version_game_files_for(game_id, expected_version) {
|
||||
for (_, files) in self.latest_game_files_for(game_id) {
|
||||
for file in files {
|
||||
seen.entry(file.relative_path.clone()).or_insert(file);
|
||||
}
|
||||
@@ -647,9 +559,8 @@ impl PeerGameDB {
|
||||
pub fn validate_file_sizes_majority(
|
||||
&self,
|
||||
game_id: &str,
|
||||
expected_version: Option<&str>,
|
||||
) -> eyre::Result<MajorityValidationResult> {
|
||||
let game_files = self.expected_version_game_files_for(game_id, expected_version);
|
||||
let game_files = self.latest_game_files_for(game_id);
|
||||
if game_files.is_empty() {
|
||||
return Ok((Vec::new(), Vec::new(), HashMap::new()));
|
||||
}
|
||||
@@ -902,14 +813,6 @@ fn game_is_ready(summary: &GameSummary) -> bool {
|
||||
summary.availability == Availability::Ready
|
||||
}
|
||||
|
||||
fn game_matches_expected_version(summary: &GameSummary, expected_version: Option<&str>) -> bool {
|
||||
if !game_is_ready(summary) {
|
||||
return false;
|
||||
}
|
||||
|
||||
expected_version.is_none_or(|expected| summary.eti_version.as_deref() == Some(expected))
|
||||
}
|
||||
|
||||
fn summary_to_game(summary: &GameSummary) -> Game {
|
||||
let eti_game_version = game_is_ready(summary)
|
||||
.then(|| summary.eti_version.clone())
|
||||
@@ -1022,41 +925,6 @@ mod tests {
|
||||
assert!(db.peers_with_latest_version("game").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_aggregation_counts_only_expected_version_peers() {
|
||||
let old_addr = addr(12003);
|
||||
let expected_addr = addr(12004);
|
||||
let newer_addr = addr(12005);
|
||||
let mut db = PeerGameDB::new();
|
||||
db.upsert_peer("old".to_string(), old_addr);
|
||||
db.upsert_peer("expected".to_string(), expected_addr);
|
||||
db.upsert_peer("newer".to_string(), newer_addr);
|
||||
db.update_peer_games(
|
||||
&"old".to_string(),
|
||||
vec![summary("game", "20240101", Availability::Ready)],
|
||||
);
|
||||
db.update_peer_games(
|
||||
&"expected".to_string(),
|
||||
vec![summary("game", "20250101", Availability::Ready)],
|
||||
);
|
||||
db.update_peer_games(
|
||||
&"newer".to_string(),
|
||||
vec![summary("game", "20260101", Availability::Ready)],
|
||||
);
|
||||
let mut catalog = GameCatalog::empty();
|
||||
catalog.insert("game".to_string(), Some("20250101".to_string()));
|
||||
|
||||
let games = db.get_catalog_games(&catalog);
|
||||
|
||||
assert_eq!(games.len(), 1);
|
||||
assert_eq!(games[0].peer_count, 1);
|
||||
assert_eq!(games[0].eti_game_version.as_deref(), Some("20250101"));
|
||||
assert_eq!(
|
||||
db.peers_with_expected_version("game", Some("20250101")),
|
||||
vec![expected_addr]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_addr_matches_known_peer_on_ephemeral_port() {
|
||||
let advertised = ip_addr([10, 66, 0, 2], 40000);
|
||||
@@ -1111,7 +979,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_uses_expected_version_file_metadata() {
|
||||
fn validation_uses_latest_version_file_metadata() {
|
||||
let old_addr = addr(12003);
|
||||
let new_addr = addr(12004);
|
||||
let mut db = PeerGameDB::new();
|
||||
@@ -1142,21 +1010,21 @@ mod tests {
|
||||
],
|
||||
);
|
||||
|
||||
let aggregated = db.aggregated_game_files("game", Some("20250101"));
|
||||
let aggregated = db.aggregated_game_files("game");
|
||||
let archive = aggregated
|
||||
.iter()
|
||||
.find(|desc| desc.relative_path == "game/archive.eti")
|
||||
.expect("expected-version archive should be present");
|
||||
.expect("latest archive should be present");
|
||||
assert_eq!(archive.size, 20);
|
||||
|
||||
let (validated, peers, file_peer_map) = db
|
||||
.validate_file_sizes_majority("game", Some("20250101"))
|
||||
.validate_file_sizes_majority("game")
|
||||
.expect("old-version file metadata should not create ambiguity");
|
||||
assert_eq!(peers, vec![new_addr]);
|
||||
let archive = validated
|
||||
.iter()
|
||||
.find(|desc| desc.relative_path == "game/archive.eti")
|
||||
.expect("expected-version archive should validate");
|
||||
.expect("latest archive should validate");
|
||||
assert_eq!(archive.size, 20);
|
||||
assert_eq!(file_peer_map.get("game/archive.eti"), Some(&vec![new_addr]));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use lanspread_proto::{Hello, HelloAck, PROTOCOL_VERSION};
|
||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
|
||||
@@ -23,7 +22,6 @@ pub(crate) struct HandshakeCtx {
|
||||
local_library: Arc<RwLock<LocalLibraryState>>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
}
|
||||
|
||||
impl HandshakeCtx {
|
||||
@@ -34,7 +32,6 @@ impl HandshakeCtx {
|
||||
local_library: ctx.local_library.clone(),
|
||||
peer_game_db: ctx.peer_game_db.clone(),
|
||||
tx_notify_ui: tx_notify_ui.clone(),
|
||||
catalog: ctx.catalog.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +42,6 @@ impl HandshakeCtx {
|
||||
local_library: ctx.local_library.clone(),
|
||||
peer_game_db: ctx.peer_game_db.clone(),
|
||||
tx_notify_ui: ctx.tx_notify_ui.clone(),
|
||||
catalog: ctx.catalog.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,7 +121,7 @@ pub(crate) async fn perform_handshake_with_peer(
|
||||
.await;
|
||||
|
||||
after_peer_library_recorded(&ctx, upsert, record_addr).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -160,7 +156,7 @@ pub(super) async fn accept_inbound_hello(
|
||||
.await;
|
||||
|
||||
after_peer_library_recorded(&handshake_ctx, upsert, addr).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
|
||||
|
||||
build_hello_ack(ctx).await
|
||||
}
|
||||
@@ -205,13 +201,12 @@ async fn after_peer_library_recorded(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, HashSet},
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use lanspread_proto::{Availability, GameSummary, Hello, LibrarySnapshot, PROTOCOL_VERSION};
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
@@ -247,7 +242,6 @@ mod tests {
|
||||
local_library: Arc::new(RwLock::new(LocalLibraryState::empty())),
|
||||
peer_game_db,
|
||||
tx_notify_ui,
|
||||
catalog: Arc::new(RwLock::new(GameCatalog::empty())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,8 +301,6 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn inbound_hello_applies_remote_library_snapshot() {
|
||||
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
||||
let mut catalog = GameCatalog::empty();
|
||||
catalog.insert("remote-game".to_string(), Some("20250101".to_string()));
|
||||
let ctx = Ctx::new(
|
||||
peer_game_db.clone(),
|
||||
"local-peer".to_string(),
|
||||
@@ -317,9 +309,7 @@ mod tests {
|
||||
Arc::new(NoopUnpacker),
|
||||
CancellationToken::new(),
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(catalog)),
|
||||
Arc::new(RwLock::new(HashMap::new())),
|
||||
Arc::new(crate::NoopStreamInstallProvider),
|
||||
Arc::new(RwLock::new(HashSet::new())),
|
||||
);
|
||||
*ctx.local_peer_addr.write().await = Some(addr([127, 0, 0, 1], 4000));
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
@@ -19,7 +18,6 @@ use crate::{
|
||||
pub async fn run_ping_service(
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
active_downloads: Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
shutdown: CancellationToken,
|
||||
@@ -42,7 +40,6 @@ pub async fn run_ping_service(
|
||||
|
||||
ping_idle_peers(
|
||||
&peer_game_db,
|
||||
&catalog,
|
||||
&active_operations,
|
||||
&active_downloads,
|
||||
&tx_notify_ui,
|
||||
@@ -53,7 +50,6 @@ pub async fn run_ping_service(
|
||||
|
||||
prune_stale_peers(
|
||||
&peer_game_db,
|
||||
&catalog,
|
||||
&active_operations,
|
||||
&active_downloads,
|
||||
&tx_notify_ui,
|
||||
@@ -64,7 +60,6 @@ pub async fn run_ping_service(
|
||||
|
||||
async fn ping_idle_peers(
|
||||
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
||||
catalog: &Arc<RwLock<GameCatalog>>,
|
||||
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
@@ -80,7 +75,6 @@ async fn ping_idle_peers(
|
||||
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
let peer_game_db = peer_game_db.clone();
|
||||
let catalog = catalog.clone();
|
||||
let active_operations = active_operations.clone();
|
||||
let active_downloads = active_downloads.clone();
|
||||
let shutdown = shutdown.clone();
|
||||
@@ -99,7 +93,6 @@ async fn ping_idle_peers(
|
||||
log::warn!("Peer {peer_addr} failed ping check");
|
||||
remove_peer_and_refresh(
|
||||
&peer_game_db,
|
||||
&catalog,
|
||||
&active_operations,
|
||||
&active_downloads,
|
||||
&tx_notify_ui,
|
||||
@@ -112,7 +105,6 @@ async fn ping_idle_peers(
|
||||
log::error!("Failed to ping peer {peer_addr}: {err}");
|
||||
remove_peer_and_refresh(
|
||||
&peer_game_db,
|
||||
&catalog,
|
||||
&active_operations,
|
||||
&active_downloads,
|
||||
&tx_notify_ui,
|
||||
@@ -128,7 +120,6 @@ async fn ping_idle_peers(
|
||||
|
||||
async fn prune_stale_peers(
|
||||
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
||||
catalog: &Arc<RwLock<GameCatalog>>,
|
||||
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
@@ -146,7 +137,7 @@ async fn prune_stale_peers(
|
||||
}
|
||||
|
||||
if removed_any {
|
||||
events::emit_peer_game_list(peer_game_db, catalog, tx_notify_ui).await;
|
||||
events::emit_peer_game_list(peer_game_db, tx_notify_ui).await;
|
||||
handle_active_downloads_without_peers(
|
||||
peer_game_db,
|
||||
active_operations,
|
||||
@@ -159,7 +150,6 @@ async fn prune_stale_peers(
|
||||
|
||||
async fn remove_peer_and_refresh(
|
||||
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
||||
catalog: &Arc<RwLock<GameCatalog>>,
|
||||
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
@@ -167,7 +157,7 @@ async fn remove_peer_and_refresh(
|
||||
log_label: &str,
|
||||
) {
|
||||
if remove_peer(peer_game_db, tx_notify_ui, peer_id, log_label).await {
|
||||
events::emit_peer_game_list(peer_game_db, catalog, tx_notify_ui).await;
|
||||
events::emit_peer_game_list(peer_game_db, tx_notify_ui).await;
|
||||
handle_active_downloads_without_peers(
|
||||
peer_game_db,
|
||||
active_operations,
|
||||
|
||||
@@ -336,12 +336,12 @@ fn should_ignore_game_child(name: &str) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use notify::{
|
||||
EventKind,
|
||||
event::{AccessKind, AccessMode},
|
||||
@@ -373,7 +373,7 @@ mod tests {
|
||||
std::fs::write(path, bytes).expect("file should be written");
|
||||
}
|
||||
|
||||
fn test_ctx(game_dir: PathBuf, catalog: GameCatalog) -> Ctx {
|
||||
fn test_ctx(game_dir: PathBuf, catalog: HashSet<String>) -> Ctx {
|
||||
Ctx::new(
|
||||
Arc::new(RwLock::new(PeerGameDB::new())),
|
||||
"peer".to_string(),
|
||||
@@ -383,8 +383,6 @@ mod tests {
|
||||
CancellationToken::new(),
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(catalog)),
|
||||
Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||
Arc::new(crate::NoopStreamInstallProvider),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -447,7 +445,7 @@ mod tests {
|
||||
let temp = TempDir::new("lanspread-local-monitor");
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
GameCatalog::from_ids(["game".to_string()]),
|
||||
HashSet::from(["game".to_string()]),
|
||||
);
|
||||
ctx.active_operations
|
||||
.write()
|
||||
@@ -482,7 +480,7 @@ mod tests {
|
||||
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
GameCatalog::from_ids(["game".to_string()]),
|
||||
HashSet::from(["game".to_string()]),
|
||||
);
|
||||
let gate = RescanGate::default();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
@@ -517,7 +515,7 @@ mod tests {
|
||||
write_file(&game_root.join("version.ini"), b"20250101");
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
GameCatalog::from_ids(["game".to_string()]),
|
||||
HashSet::from(["game".to_string()]),
|
||||
);
|
||||
let gate = RescanGate::default();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
@@ -553,7 +551,7 @@ mod tests {
|
||||
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
GameCatalog::from_ids(["game".to_string()]),
|
||||
HashSet::from(["game".to_string()]),
|
||||
);
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
@@ -577,7 +575,7 @@ mod tests {
|
||||
);
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
GameCatalog::from_ids(["game".to_string()]),
|
||||
HashSet::from(["game".to_string()]),
|
||||
);
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
|
||||
@@ -12,10 +12,9 @@ use crate::{
|
||||
context::PeerCtx,
|
||||
error::PeerError,
|
||||
events,
|
||||
local_games::{get_game_file_descriptions, is_local_dir_name, local_download_matches_catalog},
|
||||
local_games::{get_game_file_descriptions, is_local_dir_name, local_download_available},
|
||||
peer::{send_game_file_chunk, send_game_file_data},
|
||||
services::handshake::{HandshakeCtx, accept_inbound_hello, spawn_library_resync},
|
||||
stream_install::{send_game_install_stream, send_stream_install_error},
|
||||
};
|
||||
|
||||
type ResponseWriter = FramedWrite<SendStream, LengthDelimitedCodec>;
|
||||
@@ -100,9 +99,6 @@ async fn dispatch_request(
|
||||
} => {
|
||||
handle_file_chunk_request(ctx, game_id, relative_path, offset, length, framed_tx).await
|
||||
}
|
||||
Request::StreamInstall { game_id } => {
|
||||
handle_stream_install_request(ctx, game_id, framed_tx).await
|
||||
}
|
||||
Request::Goodbye { peer_id } => {
|
||||
handle_goodbye(ctx, remote_addr, peer_id).await;
|
||||
framed_tx
|
||||
@@ -166,7 +162,7 @@ async fn handle_library_delta(ctx: &PeerCtx, peer_id: String, delta: LibraryDelt
|
||||
};
|
||||
|
||||
if applied {
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
|
||||
} else {
|
||||
let addr = {
|
||||
let db = ctx.peer_game_db.read().await;
|
||||
@@ -213,7 +209,7 @@ async fn get_game_response(ctx: &PeerCtx, id: String) -> Response {
|
||||
async fn can_serve_game(ctx: &PeerCtx, game_dir: &std::path::Path, game_id: &str) -> bool {
|
||||
let active_operations = ctx.active_operations.read().await;
|
||||
let catalog = ctx.catalog.read().await;
|
||||
local_download_matches_catalog(game_dir, game_id, &active_operations, &catalog).await
|
||||
local_download_available(game_dir, game_id, &active_operations, &catalog).await
|
||||
}
|
||||
|
||||
async fn can_dispatch_file_transfer(
|
||||
@@ -222,23 +218,10 @@ async fn can_dispatch_file_transfer(
|
||||
game_id: &str,
|
||||
relative_path: &str,
|
||||
) -> bool {
|
||||
relative_path_belongs_to_game(game_id, relative_path)
|
||||
&& !path_points_inside_local(game_id, relative_path)
|
||||
!path_points_inside_local(game_id, relative_path)
|
||||
&& can_serve_game(ctx, game_dir, game_id).await
|
||||
}
|
||||
|
||||
fn relative_path_belongs_to_game(game_id: &str, relative_path: &str) -> bool {
|
||||
let normalised = relative_path.replace('\\', "/");
|
||||
if normalised.starts_with('/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
normalised
|
||||
.split('/')
|
||||
.find(|part| !part.is_empty())
|
||||
.is_some_and(|first| first == game_id)
|
||||
}
|
||||
|
||||
fn path_points_inside_local(game_id: &str, relative_path: &str) -> bool {
|
||||
let normalised = relative_path.replace('\\', "/");
|
||||
let mut parts = normalised.split('/').filter(|part| !part.is_empty());
|
||||
@@ -249,67 +232,6 @@ fn path_points_inside_local(game_id: &str, relative_path: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT_TRANSFER_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
struct TransferGuard {
|
||||
game_id: String,
|
||||
id: u64,
|
||||
active_outbound_transfers: crate::context::OutboundTransfers,
|
||||
tx_notify_ui: tokio::sync::mpsc::UnboundedSender<crate::PeerEvent>,
|
||||
}
|
||||
|
||||
impl TransferGuard {
|
||||
async fn new(
|
||||
game_id: String,
|
||||
active_outbound_transfers: crate::context::OutboundTransfers,
|
||||
tx_notify_ui: tokio::sync::mpsc::UnboundedSender<crate::PeerEvent>,
|
||||
shutdown: &tokio_util::sync::CancellationToken,
|
||||
) -> (Self, tokio_util::sync::CancellationToken) {
|
||||
let id = NEXT_TRANSFER_ID.fetch_add(1, Ordering::SeqCst);
|
||||
let token = shutdown.child_token();
|
||||
{
|
||||
let mut active = active_outbound_transfers.write().await;
|
||||
active
|
||||
.entry(game_id.clone())
|
||||
.or_default()
|
||||
.push((id, token.clone()));
|
||||
}
|
||||
let _ = tx_notify_ui.send(crate::PeerEvent::OutboundTransferCountChanged);
|
||||
(
|
||||
Self {
|
||||
game_id,
|
||||
id,
|
||||
active_outbound_transfers,
|
||||
tx_notify_ui,
|
||||
},
|
||||
token,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TransferGuard {
|
||||
fn drop(&mut self) {
|
||||
let game_id = self.game_id.clone();
|
||||
let id = self.id;
|
||||
let active_outbound_transfers = self.active_outbound_transfers.clone();
|
||||
let tx_notify_ui = self.tx_notify_ui.clone();
|
||||
tokio::spawn(async move {
|
||||
{
|
||||
let mut active = active_outbound_transfers.write().await;
|
||||
if let Some(tokens) = active.get_mut(&game_id) {
|
||||
tokens.retain(|(tid, _)| *tid != id);
|
||||
if tokens.is_empty() {
|
||||
active.remove(&game_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = tx_notify_ui.send(crate::PeerEvent::OutboundTransferCountChanged);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_file_data_request(
|
||||
ctx: &PeerCtx,
|
||||
desc: GameFileDescription,
|
||||
@@ -320,14 +242,6 @@ async fn handle_file_data_request(
|
||||
desc.relative_path
|
||||
);
|
||||
|
||||
let (guard, cancel_token) = TransferGuard::new(
|
||||
desc.game_id.clone(),
|
||||
ctx.active_outbound_transfers.clone(),
|
||||
ctx.tx_notify_ui.clone(),
|
||||
&ctx.shutdown,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut tx = framed_tx.into_inner();
|
||||
let game_dir = ctx.game_dir.read().await.clone();
|
||||
if !can_dispatch_file_transfer(ctx, &game_dir, &desc.game_id, &desc.relative_path).await {
|
||||
@@ -335,13 +249,11 @@ async fn handle_file_data_request(
|
||||
"Declining GetGameFileData for {} because the game is not currently transferable",
|
||||
desc.relative_path
|
||||
);
|
||||
drop(guard);
|
||||
let _ = tx.close().await;
|
||||
return FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
}
|
||||
|
||||
send_game_file_data(&desc, &mut tx, &game_dir, cancel_token).await;
|
||||
drop(guard);
|
||||
send_game_file_data(&desc, &mut tx, &game_dir).await;
|
||||
FramedWrite::new(tx, LengthDelimitedCodec::new())
|
||||
}
|
||||
|
||||
@@ -357,99 +269,37 @@ async fn handle_file_chunk_request(
|
||||
"Received GetGameFileChunk request for {relative_path} (offset {offset}, length {length})"
|
||||
);
|
||||
|
||||
let (guard, cancel_token) = TransferGuard::new(
|
||||
game_id.clone(),
|
||||
ctx.active_outbound_transfers.clone(),
|
||||
ctx.tx_notify_ui.clone(),
|
||||
&ctx.shutdown,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut tx = framed_tx.into_inner();
|
||||
let game_dir = ctx.game_dir.read().await.clone();
|
||||
if !can_dispatch_file_transfer(ctx, &game_dir, &game_id, &relative_path).await {
|
||||
log::info!(
|
||||
"Declining GetGameFileChunk for {relative_path} because the game is not currently transferable"
|
||||
);
|
||||
drop(guard);
|
||||
let _ = tx.close().await;
|
||||
return FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
}
|
||||
|
||||
send_game_file_chunk(
|
||||
&game_id,
|
||||
&relative_path,
|
||||
offset,
|
||||
length,
|
||||
&mut tx,
|
||||
&game_dir,
|
||||
cancel_token,
|
||||
)
|
||||
.await;
|
||||
drop(guard);
|
||||
send_game_file_chunk(&game_id, &relative_path, offset, length, &mut tx, &game_dir).await;
|
||||
FramedWrite::new(tx, LengthDelimitedCodec::new())
|
||||
}
|
||||
|
||||
async fn handle_stream_install_request(
|
||||
ctx: &PeerCtx,
|
||||
game_id: String,
|
||||
framed_tx: ResponseWriter,
|
||||
) -> ResponseWriter {
|
||||
log::info!("Received StreamInstall request for {game_id} from peer");
|
||||
|
||||
let (guard, cancel_token) = TransferGuard::new(
|
||||
game_id.clone(),
|
||||
ctx.active_outbound_transfers.clone(),
|
||||
ctx.tx_notify_ui.clone(),
|
||||
&ctx.shutdown,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut tx = framed_tx.into_inner();
|
||||
let game_dir = ctx.game_dir.read().await.clone();
|
||||
if !can_serve_game(ctx, &game_dir, &game_id).await {
|
||||
log::info!(
|
||||
"Declining StreamInstall for {game_id} because the game is not currently transferable"
|
||||
);
|
||||
tx = send_stream_install_error(tx, format!("game {game_id} is not transferable")).await;
|
||||
drop(guard);
|
||||
return FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
}
|
||||
|
||||
let game_root = game_dir.join(&game_id);
|
||||
let (returned_tx, result) = send_game_install_stream(
|
||||
ctx.stream_install_provider.clone(),
|
||||
tx,
|
||||
&game_root,
|
||||
&game_id,
|
||||
cancel_token,
|
||||
)
|
||||
.await;
|
||||
if let Err(err) = result {
|
||||
log::warn!("StreamInstall for {game_id} ended with error: {err}");
|
||||
}
|
||||
|
||||
drop(guard);
|
||||
FramedWrite::new(returned_tx, LengthDelimitedCodec::new())
|
||||
}
|
||||
|
||||
async fn handle_goodbye(ctx: &PeerCtx, _remote_addr: Option<SocketAddr>, peer_id: String) {
|
||||
log::info!("Received Goodbye from peer {peer_id}");
|
||||
let removed = { ctx.peer_game_db.write().await.remove_peer(&peer_id) };
|
||||
let Some(peer) = removed else { return };
|
||||
|
||||
events::emit_peer_lost(&ctx.peer_game_db, &ctx.tx_notify_ui, peer.addr).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
@@ -477,7 +327,7 @@ mod tests {
|
||||
std::fs::write(path, bytes).expect("file should be written");
|
||||
}
|
||||
|
||||
fn test_ctx(game_dir: PathBuf, catalog: GameCatalog) -> PeerCtx {
|
||||
fn test_ctx(game_dir: PathBuf, catalog: HashSet<String>) -> PeerCtx {
|
||||
let (tx_notify_ui, _rx) = mpsc::unbounded_channel();
|
||||
Ctx::new(
|
||||
Arc::new(RwLock::new(PeerGameDB::new())),
|
||||
@@ -488,8 +338,6 @@ mod tests {
|
||||
CancellationToken::new(),
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(catalog)),
|
||||
Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||
Arc::new(crate::NoopStreamInstallProvider),
|
||||
)
|
||||
.to_peer_ctx(tx_notify_ui)
|
||||
}
|
||||
@@ -503,19 +351,6 @@ mod tests {
|
||||
assert!(!path_points_inside_local("game", "game/archive.eti"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transferable_paths_must_belong_to_requested_game() {
|
||||
assert!(relative_path_belongs_to_game("game", "game/version.ini"));
|
||||
assert!(relative_path_belongs_to_game("game", "game\\archive.eti"));
|
||||
assert!(!relative_path_belongs_to_game("game", "other/archive.eti"));
|
||||
assert!(!relative_path_belongs_to_game("game", "archive.eti"));
|
||||
assert!(!relative_path_belongs_to_game("game", "/game/archive.eti"));
|
||||
assert!(!relative_path_belongs_to_game(
|
||||
"game",
|
||||
"../game/archive.eti"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_game_response_respects_serve_gates() {
|
||||
let temp = TempDir::new("lanspread-stream");
|
||||
@@ -525,19 +360,17 @@ mod tests {
|
||||
b"20250101",
|
||||
);
|
||||
write_file(&temp.path().join("active").join("version.ini"), b"20250101");
|
||||
write_file(
|
||||
&temp.path().join("wrong-version").join("version.ini"),
|
||||
b"20260101",
|
||||
);
|
||||
std::fs::create_dir_all(temp.path().join("missing-sentinel"))
|
||||
.expect("missing sentinel root should be created");
|
||||
|
||||
let mut catalog = GameCatalog::empty();
|
||||
catalog.insert("ready".to_string(), Some("20250101".to_string()));
|
||||
catalog.insert("active".to_string(), Some("20250101".to_string()));
|
||||
catalog.insert("missing-sentinel".to_string(), Some("20250101".to_string()));
|
||||
catalog.insert("wrong-version".to_string(), Some("20250101".to_string()));
|
||||
let ctx = test_ctx(temp.path().to_path_buf(), catalog);
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
HashSet::from([
|
||||
"ready".to_string(),
|
||||
"active".to_string(),
|
||||
"missing-sentinel".to_string(),
|
||||
]),
|
||||
);
|
||||
ctx.active_operations
|
||||
.write()
|
||||
.await
|
||||
@@ -555,10 +388,6 @@ mod tests {
|
||||
get_game_response(&ctx, "active".to_string()).await,
|
||||
Response::GameNotFound(id) if id == "active"
|
||||
));
|
||||
assert!(matches!(
|
||||
get_game_response(&ctx, "wrong-version".to_string()).await,
|
||||
Response::GameNotFound(id) if id == "wrong-version"
|
||||
));
|
||||
assert!(matches!(
|
||||
get_game_response(&ctx, "missing-sentinel".to_string()).await,
|
||||
Response::GameNotFound(id) if id == "missing-sentinel"
|
||||
@@ -574,28 +403,23 @@ mod tests {
|
||||
b"20250101",
|
||||
);
|
||||
write_file(&temp.path().join("active").join("version.ini"), b"20250101");
|
||||
write_file(
|
||||
&temp.path().join("wrong-version").join("version.ini"),
|
||||
b"20260101",
|
||||
);
|
||||
std::fs::create_dir_all(temp.path().join("missing-sentinel"))
|
||||
.expect("missing sentinel root should be created");
|
||||
|
||||
let mut catalog = GameCatalog::empty();
|
||||
catalog.insert("ready".to_string(), Some("20250101".to_string()));
|
||||
catalog.insert("active".to_string(), Some("20250101".to_string()));
|
||||
catalog.insert("missing-sentinel".to_string(), Some("20250101".to_string()));
|
||||
catalog.insert("wrong-version".to_string(), Some("20250101".to_string()));
|
||||
let ctx = test_ctx(temp.path().to_path_buf(), catalog);
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
HashSet::from([
|
||||
"ready".to_string(),
|
||||
"active".to_string(),
|
||||
"missing-sentinel".to_string(),
|
||||
]),
|
||||
);
|
||||
ctx.active_operations
|
||||
.write()
|
||||
.await
|
||||
.insert("active".to_string(), OperationKind::Downloading);
|
||||
|
||||
assert!(can_dispatch_file_transfer(&ctx, temp.path(), "ready", "ready/version.ini").await);
|
||||
assert!(
|
||||
!can_dispatch_file_transfer(&ctx, temp.path(), "ready", "active/version.ini").await
|
||||
);
|
||||
assert!(
|
||||
!can_dispatch_file_transfer(
|
||||
&ctx,
|
||||
@@ -608,15 +432,6 @@ mod tests {
|
||||
assert!(
|
||||
!can_dispatch_file_transfer(&ctx, temp.path(), "active", "active/version.ini").await
|
||||
);
|
||||
assert!(
|
||||
!can_dispatch_file_transfer(
|
||||
&ctx,
|
||||
temp.path(),
|
||||
"wrong-version",
|
||||
"wrong-version/version.ini",
|
||||
)
|
||||
.await
|
||||
);
|
||||
assert!(
|
||||
!can_dispatch_file_transfer(
|
||||
&ctx,
|
||||
|
||||
@@ -11,7 +11,6 @@ use std::{
|
||||
};
|
||||
|
||||
use futures::FutureExt as _;
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use tokio::sync::{
|
||||
RwLock,
|
||||
mpsc::{UnboundedReceiver, UnboundedSender},
|
||||
@@ -23,7 +22,6 @@ use crate::{
|
||||
PeerCommand,
|
||||
PeerEvent,
|
||||
PeerRuntimeComponent,
|
||||
StreamInstallProvider,
|
||||
Unpacker,
|
||||
context::Ctx,
|
||||
events,
|
||||
@@ -86,9 +84,7 @@ pub(crate) fn spawn_peer_runtime(
|
||||
game_dir: PathBuf,
|
||||
state_dir: PathBuf,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
active_outbound_transfers: crate::context::OutboundTransfers,
|
||||
stream_install_provider: Arc<dyn StreamInstallProvider>,
|
||||
catalog: Arc<RwLock<std::collections::HashSet<String>>>,
|
||||
) -> PeerRuntimeHandle {
|
||||
let shutdown = CancellationToken::new();
|
||||
let task_tracker = TaskTracker::new();
|
||||
@@ -108,8 +104,6 @@ pub(crate) fn spawn_peer_runtime(
|
||||
runtime_shutdown.clone(),
|
||||
runtime_tracker.clone(),
|
||||
catalog,
|
||||
active_outbound_transfers,
|
||||
stream_install_provider,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -196,7 +190,6 @@ fn spawn_peer_discovery_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEv
|
||||
fn spawn_peer_liveness_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
let peer_game_db = ctx.peer_game_db.clone();
|
||||
let catalog = ctx.catalog.clone();
|
||||
let active_operations = ctx.active_operations.clone();
|
||||
let active_downloads = ctx.active_downloads.clone();
|
||||
let shutdown = ctx.shutdown.clone();
|
||||
@@ -214,7 +207,6 @@ fn spawn_peer_liveness_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEve
|
||||
move || {
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
let peer_game_db = peer_game_db.clone();
|
||||
let catalog = catalog.clone();
|
||||
let active_operations = active_operations.clone();
|
||||
let active_downloads = active_downloads.clone();
|
||||
let shutdown = shutdown.clone();
|
||||
@@ -223,7 +215,6 @@ fn spawn_peer_liveness_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEve
|
||||
run_ping_service(
|
||||
tx_notify_ui,
|
||||
peer_game_db,
|
||||
catalog,
|
||||
active_operations,
|
||||
active_downloads,
|
||||
shutdown,
|
||||
|
||||
@@ -5,7 +5,6 @@ const LOCAL_LIBRARY_DIR: &str = "local_library";
|
||||
const LOCAL_LIBRARY_INDEX_FILE: &str = "index.json";
|
||||
const GAMES_DIR: &str = "games";
|
||||
const SETUP_DONE_FILE: &str = "setup_done";
|
||||
const LAUNCH_SETTINGS_APPLIED_FILE: &str = "launch_settings_applied";
|
||||
|
||||
pub(crate) fn resolve_state_dir(explicit: Option<&Path>) -> PathBuf {
|
||||
if let Some(dir) = explicit {
|
||||
@@ -41,8 +40,3 @@ pub(crate) fn game_state_dir(state_dir: &Path, game_id: &str) -> PathBuf {
|
||||
pub fn setup_done_path(state_dir: &Path, game_id: &str) -> PathBuf {
|
||||
game_state_dir(state_dir, game_id).join(SETUP_DONE_FILE)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn launch_settings_applied_path(state_dir: &Path, game_id: &str) -> PathBuf {
|
||||
game_state_dir(state_dir, game_id).join(LAUNCH_SETTINGS_APPLIED_FILE)
|
||||
}
|
||||
|
||||
@@ -1,958 +0,0 @@
|
||||
use std::{
|
||||
future::Future,
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
pin::Pin,
|
||||
process::Stdio,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use bytes::Bytes;
|
||||
use crc32fast::Hasher;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use lanspread_proto::{Message, Request, StreamInstallFrame};
|
||||
use s2n_quic::stream::SendStream;
|
||||
use tokio::{
|
||||
fs::File,
|
||||
io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
|
||||
process::Command,
|
||||
sync::{mpsc, mpsc::UnboundedSender},
|
||||
time::{self, MissedTickBehavior},
|
||||
};
|
||||
use tokio_util::{
|
||||
codec::{FramedRead, FramedWrite, LengthDelimitedCodec},
|
||||
sync::CancellationToken,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
DownloadProgress,
|
||||
PeerEvent,
|
||||
install::root_eti_archives,
|
||||
network::connect_to_peer,
|
||||
path_validation::validate_game_file_path,
|
||||
};
|
||||
|
||||
const FRAME_CHANNEL_DEPTH: usize = 16;
|
||||
const STREAM_INSTALL_PROGRESS_UPDATE_INTERVAL: Duration = Duration::from_millis(500);
|
||||
const STREAM_CHUNK_SIZE: usize = 256 * 1024;
|
||||
|
||||
/// Integrity metadata advertised by the sender's RAR archive.
|
||||
///
|
||||
/// This catches transport corruption, truncation, and provider bugs. It is not
|
||||
/// a trusted-content guarantee because a malicious peer controls both the bytes
|
||||
/// and the archive metadata. Trusted content would need catalog-owned hashes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct SenderArchiveIntegrity {
|
||||
expected_size: u64,
|
||||
expected_crc32: u32,
|
||||
}
|
||||
|
||||
impl SenderArchiveIntegrity {
|
||||
fn new(expected_size: u64, expected_crc32: u32) -> Self {
|
||||
Self {
|
||||
expected_size,
|
||||
expected_crc32,
|
||||
}
|
||||
}
|
||||
|
||||
fn verify(self, relative_path: &str, received: u64, actual_crc32: u32) -> eyre::Result<()> {
|
||||
if received != self.expected_size {
|
||||
eyre::bail!(
|
||||
"streamed file {relative_path} size mismatch: got {received}, expected {}",
|
||||
self.expected_size
|
||||
);
|
||||
}
|
||||
|
||||
if actual_crc32 != self.expected_crc32 {
|
||||
eyre::bail!(
|
||||
"streamed file {relative_path} sender RAR CRC32 mismatch: got {actual_crc32:08X}, expected {:08X}",
|
||||
self.expected_crc32
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub type StreamInstallFuture<'a> = Pin<Box<dyn Future<Output = eyre::Result<()>> + Send + 'a>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct StreamInstallFrameSink {
|
||||
frames: mpsc::Sender<StreamInstallFrame>,
|
||||
cancel_token: CancellationToken,
|
||||
}
|
||||
|
||||
impl StreamInstallFrameSink {
|
||||
fn new(frames: mpsc::Sender<StreamInstallFrame>, cancel_token: CancellationToken) -> Self {
|
||||
Self {
|
||||
frames,
|
||||
cancel_token,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send(&self, frame: StreamInstallFrame) -> eyre::Result<()> {
|
||||
tokio::select! {
|
||||
() = self.cancel_token.cancelled() => {
|
||||
eyre::bail!("streamed install frame send was cancelled");
|
||||
}
|
||||
result = self.frames.send(frame) => {
|
||||
result.map_err(|_| eyre::eyre!("streamed install frame receiver closed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait StreamInstallProvider: Send + Sync {
|
||||
fn stream_archive<'a>(
|
||||
&'a self,
|
||||
archive: &'a Path,
|
||||
frames: StreamInstallFrameSink,
|
||||
cancel_token: CancellationToken,
|
||||
) -> StreamInstallFuture<'a>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct NoopStreamInstallProvider;
|
||||
|
||||
impl StreamInstallProvider for NoopStreamInstallProvider {
|
||||
fn stream_archive<'a>(
|
||||
&'a self,
|
||||
archive: &'a Path,
|
||||
_frames: StreamInstallFrameSink,
|
||||
_cancel_token: CancellationToken,
|
||||
) -> StreamInstallFuture<'a> {
|
||||
Box::pin(async move {
|
||||
eyre::bail!(
|
||||
"streamed install provider is not configured for {}",
|
||||
archive.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ExternalUnrarStreamProvider {
|
||||
program: PathBuf,
|
||||
}
|
||||
|
||||
impl ExternalUnrarStreamProvider {
|
||||
#[must_use]
|
||||
pub fn new(program: PathBuf) -> Self {
|
||||
Self { program }
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamInstallProvider for ExternalUnrarStreamProvider {
|
||||
fn stream_archive<'a>(
|
||||
&'a self,
|
||||
archive: &'a Path,
|
||||
frames: StreamInstallFrameSink,
|
||||
cancel_token: CancellationToken,
|
||||
) -> StreamInstallFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let listing = unrar_listing(&self.program, archive).await?;
|
||||
let archive_name = archive
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("archive.eti")
|
||||
.to_string();
|
||||
|
||||
frames
|
||||
.send(StreamInstallFrame::ArchiveBegin {
|
||||
archive_name: archive_name.clone(),
|
||||
solid: listing.solid,
|
||||
unpacked_size: listing.unpacked_size(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
stream_unrar_entries(
|
||||
&self.program,
|
||||
archive,
|
||||
&listing.entries,
|
||||
&frames,
|
||||
cancel_token.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
frames
|
||||
.send(StreamInstallFrame::ArchiveEnd { archive_name })
|
||||
.await
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct RarListing {
|
||||
solid: bool,
|
||||
entries: Vec<RarEntry>,
|
||||
}
|
||||
|
||||
impl RarListing {
|
||||
fn unpacked_size(&self) -> u64 {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.kind == RarEntryKind::File)
|
||||
.map(|entry| entry.size)
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct RarEntry {
|
||||
relative_path: String,
|
||||
kind: RarEntryKind,
|
||||
size: u64,
|
||||
crc32: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RarEntryKind {
|
||||
File,
|
||||
Directory,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RarEntryDraft {
|
||||
relative_path: Option<String>,
|
||||
kind: Option<RarEntryKind>,
|
||||
size: Option<u64>,
|
||||
crc32: Option<u32>,
|
||||
}
|
||||
|
||||
async fn unrar_listing(program: &Path, archive: &Path) -> eyre::Result<RarListing> {
|
||||
let output = Command::new(program)
|
||||
.arg("lt")
|
||||
.arg("-cfg-")
|
||||
.arg(archive)
|
||||
.output()
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
eyre::bail!(
|
||||
"unrar lt failed for {} with status {}: {}",
|
||||
archive.display(),
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
parse_unrar_listing(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
fn parse_unrar_listing(output: &str) -> eyre::Result<RarListing> {
|
||||
let mut solid = false;
|
||||
let mut entries = Vec::new();
|
||||
let mut current = RarEntryDraft::default();
|
||||
|
||||
for line in output.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(details) = trimmed.strip_prefix("Details:") {
|
||||
solid = details.to_ascii_lowercase().contains("solid");
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(name) = trimmed.strip_prefix("Name:") {
|
||||
push_rar_entry(&mut entries, std::mem::take(&mut current))?;
|
||||
current.relative_path = Some(name.trim().to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(kind) = trimmed.strip_prefix("Type:") {
|
||||
current.kind = match kind.trim() {
|
||||
"File" => Some(RarEntryKind::File),
|
||||
"Directory" => Some(RarEntryKind::Directory),
|
||||
_ => None,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(size) = trimmed.strip_prefix("Size:") {
|
||||
current.size = Some(size.trim().parse()?);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(crc) = trimmed.strip_prefix("CRC32:") {
|
||||
current.crc32 = Some(u32::from_str_radix(crc.trim(), 16)?);
|
||||
}
|
||||
}
|
||||
|
||||
push_rar_entry(&mut entries, current)?;
|
||||
Ok(RarListing { solid, entries })
|
||||
}
|
||||
|
||||
fn push_rar_entry(entries: &mut Vec<RarEntry>, draft: RarEntryDraft) -> eyre::Result<()> {
|
||||
let Some(relative_path) = draft.relative_path else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(kind) = draft.kind else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let (size, crc32) = match kind {
|
||||
RarEntryKind::File => {
|
||||
let size = draft
|
||||
.size
|
||||
.ok_or_else(|| eyre::eyre!("RAR file entry {relative_path} has no Size"))?;
|
||||
let crc32 = match (size, draft.crc32) {
|
||||
(_, Some(crc32)) => crc32,
|
||||
(0, None) => 0,
|
||||
(_, None) => {
|
||||
eyre::bail!("RAR file entry {relative_path} has no CRC32");
|
||||
}
|
||||
};
|
||||
(size, Some(crc32))
|
||||
}
|
||||
RarEntryKind::Directory => (0, None),
|
||||
};
|
||||
|
||||
entries.push(RarEntry {
|
||||
relative_path,
|
||||
kind,
|
||||
size,
|
||||
crc32,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stream_unrar_entries(
|
||||
program: &Path,
|
||||
archive: &Path,
|
||||
entries: &[RarEntry],
|
||||
frames: &StreamInstallFrameSink,
|
||||
cancel_token: CancellationToken,
|
||||
) -> eyre::Result<()> {
|
||||
let mut child = Command::new(program)
|
||||
.arg("p")
|
||||
.arg("-inul")
|
||||
.arg("-cfg-")
|
||||
.arg(archive)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
|
||||
let result = async {
|
||||
let mut stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| eyre::eyre!("unrar stdout was not captured"))?;
|
||||
let mut buffer = vec![0_u8; STREAM_CHUNK_SIZE];
|
||||
|
||||
for entry in entries {
|
||||
if cancel_token.is_cancelled() {
|
||||
eyre::bail!("streamed archive {} was cancelled", archive.display());
|
||||
}
|
||||
|
||||
match entry.kind {
|
||||
RarEntryKind::Directory => {
|
||||
frames
|
||||
.send(StreamInstallFrame::Directory {
|
||||
relative_path: entry.relative_path.clone(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
RarEntryKind::File => {
|
||||
let Some(crc32) = entry.crc32 else {
|
||||
eyre::bail!("RAR file entry {} has no CRC32", entry.relative_path);
|
||||
};
|
||||
frames
|
||||
.send(StreamInstallFrame::FileBegin {
|
||||
relative_path: entry.relative_path.clone(),
|
||||
size: entry.size,
|
||||
crc32,
|
||||
})
|
||||
.await?;
|
||||
stream_unrar_file_from_stdout(
|
||||
&mut stdout,
|
||||
archive,
|
||||
entry,
|
||||
frames,
|
||||
&mut buffer,
|
||||
&cancel_token,
|
||||
)
|
||||
.await?;
|
||||
frames
|
||||
.send(StreamInstallFrame::FileEnd {
|
||||
relative_path: entry.relative_path.clone(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let extra =
|
||||
read_unrar_stdout(&mut stdout, &mut buffer[..1], &cancel_token, archive).await?;
|
||||
if extra != 0 {
|
||||
eyre::bail!(
|
||||
"unrar produced bytes after listed entries for {}",
|
||||
archive.display()
|
||||
);
|
||||
}
|
||||
|
||||
let status = wait_unrar_child(&mut child, &cancel_token, archive).await?;
|
||||
if !status.success() {
|
||||
eyre::bail!(
|
||||
"unrar p failed for {} with status {status}",
|
||||
archive.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn stream_unrar_file_from_stdout(
|
||||
stdout: &mut (impl AsyncRead + Unpin),
|
||||
archive: &Path,
|
||||
entry: &RarEntry,
|
||||
frames: &StreamInstallFrameSink,
|
||||
buffer: &mut [u8],
|
||||
cancel_token: &CancellationToken,
|
||||
) -> eyre::Result<()> {
|
||||
let mut remaining = entry.size;
|
||||
while remaining > 0 {
|
||||
let read_len = usize::try_from(remaining.min(u64::try_from(buffer.len())?))?;
|
||||
let read =
|
||||
read_unrar_stdout(stdout, &mut buffer[..read_len], cancel_token, archive).await?;
|
||||
if read == 0 {
|
||||
eyre::bail!(
|
||||
"unrar ended while streaming {} from {}; {remaining} bytes missing",
|
||||
entry.relative_path,
|
||||
archive.display()
|
||||
);
|
||||
}
|
||||
|
||||
frames
|
||||
.send(StreamInstallFrame::FileChunk {
|
||||
bytes: Bytes::copy_from_slice(&buffer[..read]),
|
||||
})
|
||||
.await?;
|
||||
remaining = remaining.saturating_sub(u64::try_from(read)?);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_unrar_stdout(
|
||||
stdout: &mut (impl AsyncRead + Unpin),
|
||||
buffer: &mut [u8],
|
||||
cancel_token: &CancellationToken,
|
||||
archive: &Path,
|
||||
) -> eyre::Result<usize> {
|
||||
tokio::select! {
|
||||
() = cancel_token.cancelled() => {
|
||||
eyre::bail!("streamed archive {} was cancelled", archive.display());
|
||||
}
|
||||
read = stdout.read(buffer) => Ok(read?),
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_unrar_child(
|
||||
child: &mut tokio::process::Child,
|
||||
cancel_token: &CancellationToken,
|
||||
archive: &Path,
|
||||
) -> eyre::Result<std::process::ExitStatus> {
|
||||
tokio::select! {
|
||||
() = cancel_token.cancelled() => {
|
||||
let _ = child.kill().await;
|
||||
eyre::bail!("streamed archive {} was cancelled", archive.display());
|
||||
}
|
||||
status = child.wait() => Ok(status?),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn send_stream_install_error(
|
||||
tx: SendStream,
|
||||
message: impl Into<String>,
|
||||
) -> SendStream {
|
||||
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
if let Err(err) = framed_tx
|
||||
.send(
|
||||
StreamInstallFrame::Error {
|
||||
message: message.into(),
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to send streamed install error frame: {err}");
|
||||
}
|
||||
if let Err(err) = framed_tx.close().await {
|
||||
log::debug!("Failed to close streamed install error response: {err}");
|
||||
}
|
||||
framed_tx.into_inner()
|
||||
}
|
||||
|
||||
pub(crate) async fn send_game_install_stream(
|
||||
provider: Arc<dyn StreamInstallProvider>,
|
||||
tx: SendStream,
|
||||
game_root: &Path,
|
||||
game_id: &str,
|
||||
cancel_token: CancellationToken,
|
||||
) -> (SendStream, eyre::Result<()>) {
|
||||
let archives = match root_eti_archives(game_root).await {
|
||||
Ok(archives) => archives,
|
||||
Err(err) => {
|
||||
let message = err.to_string();
|
||||
let tx = send_stream_install_error(tx, message.clone()).await;
|
||||
return (tx, Err(eyre::eyre!(message)));
|
||||
}
|
||||
};
|
||||
if archives.is_empty() {
|
||||
let message = format!("no .eti archives found for {game_id}");
|
||||
let tx = send_stream_install_error(tx, message.clone()).await;
|
||||
return (tx, Err(eyre::eyre!(message)));
|
||||
}
|
||||
|
||||
let (frame_tx, mut frame_rx) = mpsc::channel(FRAME_CHANNEL_DEPTH);
|
||||
let producer_cancel = cancel_token.child_token();
|
||||
let frame_sink = StreamInstallFrameSink::new(frame_tx, producer_cancel.clone());
|
||||
let game_id_for_producer = game_id.to_string();
|
||||
let producer = tokio::spawn({
|
||||
let provider = provider.clone();
|
||||
let producer_cancel = producer_cancel.clone();
|
||||
async move {
|
||||
for archive in archives {
|
||||
if producer_cancel.is_cancelled() {
|
||||
eyre::bail!("streamed install for {game_id_for_producer} was cancelled");
|
||||
}
|
||||
|
||||
if let Err(err) = provider
|
||||
.stream_archive(&archive, frame_sink.clone(), producer_cancel.clone())
|
||||
.await
|
||||
{
|
||||
let message = err.to_string();
|
||||
let _ = frame_sink.send(StreamInstallFrame::Error { message }).await;
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = frame_sink.send(StreamInstallFrame::Complete).await;
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
|
||||
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
let mut send_result = Ok(());
|
||||
|
||||
while let Some(frame) = frame_rx.recv().await {
|
||||
if let Err(err) = framed_tx.send(frame.encode()).await {
|
||||
producer_cancel.cancel();
|
||||
send_result = Err(eyre::eyre!("failed to send streamed install frame: {err}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
drop(frame_rx);
|
||||
|
||||
let close_result = framed_tx
|
||||
.close()
|
||||
.await
|
||||
.map_err(|err| eyre::eyre!("failed to close streamed install stream: {err}"));
|
||||
let tx = framed_tx.into_inner();
|
||||
let producer_result = match producer.await {
|
||||
Ok(result) => result,
|
||||
Err(err) => Err(eyre::eyre!("streamed install producer task failed: {err}")),
|
||||
};
|
||||
let result = send_result.and(producer_result).and(close_result);
|
||||
|
||||
(tx, result)
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_streamed_install(
|
||||
peer_addr: SocketAddr,
|
||||
game_id: &str,
|
||||
staging_dir: &Path,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
cancel_token: CancellationToken,
|
||||
) -> eyre::Result<()> {
|
||||
let staging_dir = tokio::fs::canonicalize(staging_dir)
|
||||
.await
|
||||
.unwrap_or_else(|_| staging_dir.to_path_buf());
|
||||
let mut conn = connect_to_peer(peer_addr).await?;
|
||||
let stream = conn.open_bidirectional_stream().await?;
|
||||
let (rx, tx) = stream.split();
|
||||
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
|
||||
framed_tx
|
||||
.send(
|
||||
Request::StreamInstall {
|
||||
game_id: game_id.to_string(),
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await?;
|
||||
framed_tx.close().await?;
|
||||
|
||||
let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new());
|
||||
let mut current_file: Option<IncomingFile> = None;
|
||||
let mut progress = StreamInstallProgress::new(game_id.to_string());
|
||||
let mut progress_interval = time::interval(STREAM_INSTALL_PROGRESS_UPDATE_INTERVAL);
|
||||
progress_interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
progress_interval.tick().await;
|
||||
|
||||
loop {
|
||||
let next = tokio::select! {
|
||||
() = cancel_token.cancelled() => eyre::bail!("streamed install for {game_id} was cancelled"),
|
||||
_ = progress_interval.tick() => {
|
||||
progress.emit_current(&tx_notify_ui);
|
||||
continue;
|
||||
}
|
||||
next = framed_rx.next() => next,
|
||||
};
|
||||
|
||||
let Some(frame) = next else {
|
||||
eyre::bail!("streamed install ended before Complete");
|
||||
};
|
||||
let frame = frame?.freeze();
|
||||
let frame = StreamInstallFrame::decode(frame);
|
||||
|
||||
match frame {
|
||||
StreamInstallFrame::ArchiveBegin {
|
||||
archive_name,
|
||||
solid,
|
||||
unpacked_size,
|
||||
} => {
|
||||
progress.add_total(unpacked_size);
|
||||
progress.emit_snapshot(&tx_notify_ui, 0);
|
||||
log::info!(
|
||||
"Receiving streamed install archive {archive_name} for {game_id} \
|
||||
(solid={solid}, unpacked_size={unpacked_size})"
|
||||
);
|
||||
}
|
||||
StreamInstallFrame::Directory { relative_path } => {
|
||||
let path = resolve_stream_path(&staging_dir, &relative_path)?;
|
||||
tokio::fs::create_dir_all(path).await?;
|
||||
}
|
||||
StreamInstallFrame::FileBegin {
|
||||
relative_path,
|
||||
size,
|
||||
crc32,
|
||||
} => {
|
||||
if current_file.is_some() {
|
||||
eyre::bail!("received FileBegin for {relative_path} before previous FileEnd");
|
||||
}
|
||||
let path = resolve_stream_path(&staging_dir, &relative_path)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let file = File::create(&path).await?;
|
||||
current_file = Some(IncomingFile::new(relative_path, path, size, crc32, file));
|
||||
}
|
||||
StreamInstallFrame::FileChunk { bytes } => {
|
||||
let Some(file) = current_file.as_mut() else {
|
||||
eyre::bail!("received FileChunk without FileBegin");
|
||||
};
|
||||
let length = file
|
||||
.write_chunk(game_id, peer_addr, &tx_notify_ui, bytes)
|
||||
.await?;
|
||||
progress.record_bytes(length);
|
||||
}
|
||||
StreamInstallFrame::FileEnd { relative_path } => {
|
||||
let Some(file) = current_file.take() else {
|
||||
eyre::bail!("received FileEnd for {relative_path} without FileBegin");
|
||||
};
|
||||
file.finish(&relative_path).await?;
|
||||
}
|
||||
StreamInstallFrame::ArchiveEnd { archive_name } => {
|
||||
log::info!("Finished streamed install archive {archive_name} for {game_id}");
|
||||
}
|
||||
StreamInstallFrame::Complete => {
|
||||
if current_file.is_some() {
|
||||
eyre::bail!("streamed install completed with an open file");
|
||||
}
|
||||
progress.emit_snapshot(&tx_notify_ui, 0);
|
||||
return Ok(());
|
||||
}
|
||||
StreamInstallFrame::Error { message } => {
|
||||
eyre::bail!("streamed install sender failed: {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamInstallProgress {
|
||||
id: String,
|
||||
total_bytes: u64,
|
||||
downloaded_bytes: u64,
|
||||
last_downloaded_bytes: u64,
|
||||
last_at: Instant,
|
||||
}
|
||||
|
||||
impl StreamInstallProgress {
|
||||
fn new(id: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
total_bytes: 0,
|
||||
downloaded_bytes: 0,
|
||||
last_downloaded_bytes: 0,
|
||||
last_at: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_total(&mut self, bytes: u64) {
|
||||
self.total_bytes = self.total_bytes.saturating_add(bytes);
|
||||
}
|
||||
|
||||
fn record_bytes(&mut self, bytes: u64) {
|
||||
self.downloaded_bytes = self.downloaded_bytes.saturating_add(bytes);
|
||||
}
|
||||
|
||||
fn emit_current(&mut self, tx_notify_ui: &UnboundedSender<PeerEvent>) {
|
||||
let now = Instant::now();
|
||||
let speed = bytes_per_second(
|
||||
self.downloaded_bytes
|
||||
.saturating_sub(self.last_downloaded_bytes),
|
||||
now.duration_since(self.last_at),
|
||||
);
|
||||
|
||||
self.last_downloaded_bytes = self.downloaded_bytes;
|
||||
self.last_at = now;
|
||||
self.emit_snapshot(tx_notify_ui, speed);
|
||||
}
|
||||
|
||||
fn emit_snapshot(&self, tx_notify_ui: &UnboundedSender<PeerEvent>, bytes_per_second: u64) {
|
||||
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFilesProgress(DownloadProgress {
|
||||
id: self.id.clone(),
|
||||
downloaded_bytes: self.downloaded_bytes,
|
||||
total_bytes: self.total_bytes,
|
||||
bytes_per_second,
|
||||
active_peer_count: 1,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes_per_second(bytes: u64, elapsed: Duration) -> u64 {
|
||||
let millis = elapsed.as_millis().max(1);
|
||||
let rate = u128::from(bytes).saturating_mul(1_000) / millis;
|
||||
u64::try_from(rate).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
struct IncomingFile {
|
||||
relative_path: String,
|
||||
path: PathBuf,
|
||||
integrity: SenderArchiveIntegrity,
|
||||
received: u64,
|
||||
crc32: Hasher,
|
||||
file: File,
|
||||
}
|
||||
|
||||
impl IncomingFile {
|
||||
fn new(
|
||||
relative_path: String,
|
||||
path: PathBuf,
|
||||
expected_size: u64,
|
||||
expected_crc32: u32,
|
||||
file: File,
|
||||
) -> Self {
|
||||
Self {
|
||||
relative_path,
|
||||
path,
|
||||
integrity: SenderArchiveIntegrity::new(expected_size, expected_crc32),
|
||||
received: 0,
|
||||
crc32: Hasher::new(),
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_chunk(
|
||||
&mut self,
|
||||
game_id: &str,
|
||||
peer_addr: SocketAddr,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
bytes: Bytes,
|
||||
) -> eyre::Result<u64> {
|
||||
let offset = self.received;
|
||||
let length = u64::try_from(bytes.len())?;
|
||||
if offset.saturating_add(length) > self.integrity.expected_size {
|
||||
eyre::bail!(
|
||||
"streamed file {} exceeded expected size {}",
|
||||
self.relative_path,
|
||||
self.integrity.expected_size
|
||||
);
|
||||
}
|
||||
self.file.write_all(&bytes).await?;
|
||||
self.crc32.update(&bytes);
|
||||
self.received = self.received.saturating_add(length);
|
||||
|
||||
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished {
|
||||
id: game_id.to_string(),
|
||||
peer_addr,
|
||||
relative_path: format!("{game_id}/.local.installing/{}", self.relative_path),
|
||||
offset,
|
||||
length,
|
||||
});
|
||||
Ok(length)
|
||||
}
|
||||
|
||||
async fn finish(mut self, relative_path: &str) -> eyre::Result<()> {
|
||||
if self.relative_path != relative_path {
|
||||
eyre::bail!(
|
||||
"streamed file end mismatch: began {}, ended {relative_path}",
|
||||
self.relative_path
|
||||
);
|
||||
}
|
||||
self.file.flush().await?;
|
||||
|
||||
let actual_crc32 = self.crc32.finalize();
|
||||
self.integrity
|
||||
.verify(&self.relative_path, self.received, actual_crc32)?;
|
||||
|
||||
log::debug!(
|
||||
"Received streamed file {} -> {}",
|
||||
self.relative_path,
|
||||
self.path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_stream_path(staging_dir: &Path, relative_path: &str) -> eyre::Result<PathBuf> {
|
||||
validate_game_file_path(staging_dir, relative_path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::TempDir;
|
||||
|
||||
#[test]
|
||||
fn stream_paths_stay_inside_staging_dir() {
|
||||
let temp = TempDir::new("lanspread-stream-install-path");
|
||||
let staging = temp.path().join("staging");
|
||||
std::fs::create_dir_all(&staging).expect("staging should be created");
|
||||
let staging = std::fs::canonicalize(staging).expect("staging should canonicalize");
|
||||
|
||||
assert!(resolve_stream_path(&staging, "bin/game.exe").is_ok());
|
||||
assert!(resolve_stream_path(&staging, "../outside").is_err());
|
||||
assert!(resolve_stream_path(&staging, "/absolute").is_err());
|
||||
assert!(resolve_stream_path(&staging, "C:/windows").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_unrar_technical_listing() {
|
||||
let listing = parse_unrar_listing(
|
||||
r#"
|
||||
Archive: game.eti
|
||||
Details: RAR 5, solid
|
||||
|
||||
Name: bin/payload.bin
|
||||
Type: File
|
||||
Size: 123
|
||||
CRC32: 38B488A7
|
||||
|
||||
Name: bin
|
||||
Type: Directory
|
||||
"#,
|
||||
)
|
||||
.expect("listing should parse");
|
||||
|
||||
assert!(listing.solid);
|
||||
assert_eq!(
|
||||
listing.entries,
|
||||
vec![
|
||||
RarEntry {
|
||||
relative_path: "bin/payload.bin".to_string(),
|
||||
kind: RarEntryKind::File,
|
||||
size: 123,
|
||||
crc32: Some(0x38B4_88A7),
|
||||
},
|
||||
RarEntry {
|
||||
relative_path: "bin".to_string(),
|
||||
kind: RarEntryKind::Directory,
|
||||
size: 0,
|
||||
crc32: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unrar_file_entries_without_crc32() {
|
||||
let err = parse_unrar_listing(
|
||||
r#"
|
||||
Archive: game.eti
|
||||
Details: RAR 5
|
||||
|
||||
Name: bin/payload.bin
|
||||
Type: File
|
||||
Size: 123
|
||||
"#,
|
||||
)
|
||||
.expect_err("file entries without CRC32 should be rejected");
|
||||
|
||||
assert!(err.to_string().contains("has no CRC32"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_zero_size_unrar_file_entries_without_crc32() {
|
||||
let listing = parse_unrar_listing(
|
||||
r#"
|
||||
Archive: game.eti
|
||||
Details: RAR 5
|
||||
|
||||
Name: bin/empty.cfg
|
||||
Type: File
|
||||
Size: 0
|
||||
"#,
|
||||
)
|
||||
.expect("empty file without CRC32 should parse as CRC32 zero");
|
||||
|
||||
assert_eq!(
|
||||
listing.entries,
|
||||
vec![RarEntry {
|
||||
relative_path: "bin/empty.cfg".to_string(),
|
||||
kind: RarEntryKind::File,
|
||||
size: 0,
|
||||
crc32: Some(0),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sender_archive_integrity_accepts_matching_size_and_crc32() {
|
||||
let bytes = b"payload";
|
||||
let integrity =
|
||||
SenderArchiveIntegrity::new(u64::try_from(bytes.len()).unwrap(), crc32_of(bytes));
|
||||
|
||||
integrity
|
||||
.verify(
|
||||
"bin/payload.bin",
|
||||
u64::try_from(bytes.len()).unwrap(),
|
||||
crc32_of(bytes),
|
||||
)
|
||||
.expect("matching sender archive metadata should verify");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sender_archive_integrity_rejects_size_mismatch() {
|
||||
let integrity = SenderArchiveIntegrity::new(7, crc32_of(b"payload"));
|
||||
let err = integrity
|
||||
.verify("bin/payload.bin", 6, crc32_of(b"payload"))
|
||||
.expect_err("truncated file should fail sender archive integrity");
|
||||
|
||||
assert!(err.to_string().contains("size mismatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sender_archive_integrity_rejects_crc32_mismatch() {
|
||||
let integrity = SenderArchiveIntegrity::new(7, crc32_of(b"payload"));
|
||||
let err = integrity
|
||||
.verify("bin/payload.bin", 7, crc32_of(b"paylord"))
|
||||
.expect_err("mutated file should fail sender archive integrity");
|
||||
|
||||
assert!(err.to_string().contains("sender RAR CRC32 mismatch"));
|
||||
}
|
||||
|
||||
fn crc32_of(bytes: &[u8]) -> u32 {
|
||||
let mut hasher = Hasher::new();
|
||||
hasher.update(bytes);
|
||||
hasher.finalize()
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,13 @@ name = "lanspread-proto"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
test = false
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
|
||||
[dependencies]
|
||||
# local
|
||||
@@ -17,10 +21,6 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
[lib]
|
||||
test = false
|
||||
doctest = false
|
||||
|
||||
@@ -4,7 +4,7 @@ use bytes::Bytes;
|
||||
use lanspread_db::db::{Game, GameFileDescription};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const PROTOCOL_VERSION: u32 = 5;
|
||||
pub const PROTOCOL_VERSION: u32 = 4;
|
||||
|
||||
pub use lanspread_db::db::Availability;
|
||||
|
||||
@@ -67,9 +67,6 @@ pub enum Request {
|
||||
offset: u64,
|
||||
length: u64,
|
||||
},
|
||||
StreamInstall {
|
||||
game_id: String,
|
||||
},
|
||||
Hello(Hello),
|
||||
LibraryDelta {
|
||||
peer_id: String,
|
||||
@@ -97,41 +94,6 @@ pub enum Response {
|
||||
InternalPeerError(String),
|
||||
}
|
||||
|
||||
const STREAM_INSTALL_CONTROL_FRAME_TAG: u8 = 0;
|
||||
const STREAM_INSTALL_FILE_CHUNK_FRAME_TAG: u8 = 1;
|
||||
const STREAM_INSTALL_ENCODE_ERROR_FRAME: &[u8] =
|
||||
b"\0{\"Error\":{\"message\":\"stream install frame encoding error\"}}";
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum StreamInstallFrame {
|
||||
ArchiveBegin {
|
||||
archive_name: String,
|
||||
solid: bool,
|
||||
unpacked_size: u64,
|
||||
},
|
||||
Directory {
|
||||
relative_path: String,
|
||||
},
|
||||
FileBegin {
|
||||
relative_path: String,
|
||||
size: u64,
|
||||
crc32: u32,
|
||||
},
|
||||
FileChunk {
|
||||
bytes: Bytes,
|
||||
},
|
||||
FileEnd {
|
||||
relative_path: String,
|
||||
},
|
||||
ArchiveEnd {
|
||||
archive_name: String,
|
||||
},
|
||||
Complete,
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
// Add Message trait
|
||||
pub trait Message {
|
||||
fn decode(bytes: Bytes) -> Self;
|
||||
@@ -183,62 +145,3 @@ impl Message for Response {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Message for StreamInstallFrame {
|
||||
fn decode(bytes: Bytes) -> Self {
|
||||
if bytes.is_empty() {
|
||||
return stream_install_decode_error("stream install frame is empty");
|
||||
}
|
||||
|
||||
let tag = bytes[0];
|
||||
let payload = bytes.slice(1..);
|
||||
match tag {
|
||||
STREAM_INSTALL_CONTROL_FRAME_TAG => decode_stream_install_control_frame(&payload),
|
||||
STREAM_INSTALL_FILE_CHUNK_FRAME_TAG => StreamInstallFrame::FileChunk { bytes: payload },
|
||||
_ => stream_install_decode_error(format!("unknown stream install frame tag {tag}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode(&self) -> Bytes {
|
||||
match self {
|
||||
StreamInstallFrame::FileChunk { bytes } => {
|
||||
tagged_stream_install_frame(STREAM_INSTALL_FILE_CHUNK_FRAME_TAG, bytes)
|
||||
}
|
||||
_ => match serde_json::to_vec(self) {
|
||||
Ok(payload) => {
|
||||
tagged_stream_install_frame(STREAM_INSTALL_CONTROL_FRAME_TAG, &payload)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "StreamInstallFrame encoding error");
|
||||
Bytes::from_static(STREAM_INSTALL_ENCODE_ERROR_FRAME)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_stream_install_control_frame(payload: &[u8]) -> StreamInstallFrame {
|
||||
match serde_json::from_slice(payload) {
|
||||
Ok(StreamInstallFrame::FileChunk { .. }) => {
|
||||
stream_install_decode_error("stream install control frame cannot contain file bytes")
|
||||
}
|
||||
Ok(frame) => frame,
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "StreamInstallFrame decoding error");
|
||||
stream_install_decode_error(format!("stream install frame decoding error: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tagged_stream_install_frame(tag: u8, payload: &[u8]) -> Bytes {
|
||||
let mut frame = Vec::with_capacity(1 + payload.len());
|
||||
frame.push(tag);
|
||||
frame.extend_from_slice(payload);
|
||||
Bytes::from(frame)
|
||||
}
|
||||
|
||||
fn stream_install_decode_error(message: impl Into<String>) -> StreamInstallFrame {
|
||||
StreamInstallFrame::Error {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
use bytes::Bytes;
|
||||
use lanspread_proto::{Message, StreamInstallFrame};
|
||||
|
||||
#[test]
|
||||
fn file_chunks_encode_raw_bytes() {
|
||||
let bytes = Bytes::from_static(&[0, 1, 2, 255]);
|
||||
let encoded = StreamInstallFrame::FileChunk {
|
||||
bytes: bytes.clone(),
|
||||
}
|
||||
.encode();
|
||||
|
||||
assert_eq!(&encoded[..], &[1, 0, 1, 2, 255]);
|
||||
assert_eq!(
|
||||
StreamInstallFrame::decode(encoded),
|
||||
StreamInstallFrame::FileChunk { bytes }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_frames_are_tagged_json() {
|
||||
let frame = StreamInstallFrame::FileBegin {
|
||||
relative_path: "bin/game.exe".to_string(),
|
||||
size: 42,
|
||||
crc32: 0x38B4_88A7,
|
||||
};
|
||||
let encoded = frame.encode();
|
||||
|
||||
assert_eq!(encoded[0], 0);
|
||||
assert_eq!(StreamInstallFrame::decode(encoded), frame);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_frames_decode_as_errors() {
|
||||
match StreamInstallFrame::decode(Bytes::new()) {
|
||||
StreamInstallFrame::Error { message } => {
|
||||
assert!(message.contains("empty"));
|
||||
}
|
||||
other => {
|
||||
panic!("expected error frame, got {other:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+58
-58
@@ -6,13 +6,13 @@
|
||||
"npm:@tauri-apps/plugin-dialog@^2.7.1": "2.7.1",
|
||||
"npm:@tauri-apps/plugin-shell@^2.3.5": "2.3.5",
|
||||
"npm:@tauri-apps/plugin-store@^2.4.3": "2.4.3",
|
||||
"npm:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.17",
|
||||
"npm:@types/react@^19.2.17": "19.2.17",
|
||||
"npm:@vitejs/plugin-react@^6.0.2": "6.0.2_vite@8.0.16",
|
||||
"npm:react-dom@^19.2.7": "19.2.7_react@19.2.7",
|
||||
"npm:react@^19.2.7": "19.2.7",
|
||||
"npm:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.14",
|
||||
"npm:@types/react@^19.2.14": "19.2.14",
|
||||
"npm:@vitejs/plugin-react@^6.0.2": "6.0.2_vite@8.0.13",
|
||||
"npm:react-dom@^19.2.6": "19.2.6_react@19.2.6",
|
||||
"npm:react@^19.2.6": "19.2.6",
|
||||
"npm:typescript@^6.0.3": "6.0.3",
|
||||
"npm:vite@^8.0.16": "8.0.16"
|
||||
"npm:vite@^8.0.13": "8.0.13"
|
||||
},
|
||||
"npm": {
|
||||
"@emnapi/core@1.10.0": {
|
||||
@@ -42,71 +42,71 @@
|
||||
"@tybys/wasm-util"
|
||||
]
|
||||
},
|
||||
"@oxc-project/types@0.133.0": {
|
||||
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="
|
||||
"@oxc-project/types@0.130.0": {
|
||||
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="
|
||||
},
|
||||
"@rolldown/binding-android-arm64@1.0.3": {
|
||||
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
|
||||
"@rolldown/binding-android-arm64@1.0.1": {
|
||||
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
|
||||
"os": ["android"],
|
||||
"cpu": ["arm64"]
|
||||
},
|
||||
"@rolldown/binding-darwin-arm64@1.0.3": {
|
||||
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
|
||||
"@rolldown/binding-darwin-arm64@1.0.1": {
|
||||
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
|
||||
"os": ["darwin"],
|
||||
"cpu": ["arm64"]
|
||||
},
|
||||
"@rolldown/binding-darwin-x64@1.0.3": {
|
||||
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
|
||||
"@rolldown/binding-darwin-x64@1.0.1": {
|
||||
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
|
||||
"os": ["darwin"],
|
||||
"cpu": ["x64"]
|
||||
},
|
||||
"@rolldown/binding-freebsd-x64@1.0.3": {
|
||||
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
|
||||
"@rolldown/binding-freebsd-x64@1.0.1": {
|
||||
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
|
||||
"os": ["freebsd"],
|
||||
"cpu": ["x64"]
|
||||
},
|
||||
"@rolldown/binding-linux-arm-gnueabihf@1.0.3": {
|
||||
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
|
||||
"@rolldown/binding-linux-arm-gnueabihf@1.0.1": {
|
||||
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm"]
|
||||
},
|
||||
"@rolldown/binding-linux-arm64-gnu@1.0.3": {
|
||||
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
|
||||
"@rolldown/binding-linux-arm64-gnu@1.0.1": {
|
||||
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"]
|
||||
},
|
||||
"@rolldown/binding-linux-arm64-musl@1.0.3": {
|
||||
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
|
||||
"@rolldown/binding-linux-arm64-musl@1.0.1": {
|
||||
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"]
|
||||
},
|
||||
"@rolldown/binding-linux-ppc64-gnu@1.0.3": {
|
||||
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
|
||||
"@rolldown/binding-linux-ppc64-gnu@1.0.1": {
|
||||
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["ppc64"]
|
||||
},
|
||||
"@rolldown/binding-linux-s390x-gnu@1.0.3": {
|
||||
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
|
||||
"@rolldown/binding-linux-s390x-gnu@1.0.1": {
|
||||
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["s390x"]
|
||||
},
|
||||
"@rolldown/binding-linux-x64-gnu@1.0.3": {
|
||||
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
|
||||
"@rolldown/binding-linux-x64-gnu@1.0.1": {
|
||||
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"]
|
||||
},
|
||||
"@rolldown/binding-linux-x64-musl@1.0.3": {
|
||||
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
|
||||
"@rolldown/binding-linux-x64-musl@1.0.1": {
|
||||
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"]
|
||||
},
|
||||
"@rolldown/binding-openharmony-arm64@1.0.3": {
|
||||
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
|
||||
"@rolldown/binding-openharmony-arm64@1.0.1": {
|
||||
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
|
||||
"os": ["openharmony"],
|
||||
"cpu": ["arm64"]
|
||||
},
|
||||
"@rolldown/binding-wasm32-wasi@1.0.3": {
|
||||
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
|
||||
"@rolldown/binding-wasm32-wasi@1.0.1": {
|
||||
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
|
||||
"dependencies": [
|
||||
"@emnapi/core",
|
||||
"@emnapi/runtime",
|
||||
@@ -114,13 +114,13 @@
|
||||
],
|
||||
"cpu": ["wasm32"]
|
||||
},
|
||||
"@rolldown/binding-win32-arm64-msvc@1.0.3": {
|
||||
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
|
||||
"@rolldown/binding-win32-arm64-msvc@1.0.1": {
|
||||
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
|
||||
"os": ["win32"],
|
||||
"cpu": ["arm64"]
|
||||
},
|
||||
"@rolldown/binding-win32-x64-msvc@1.0.3": {
|
||||
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
|
||||
"@rolldown/binding-win32-x64-msvc@1.0.1": {
|
||||
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
|
||||
"os": ["win32"],
|
||||
"cpu": ["x64"]
|
||||
},
|
||||
@@ -226,19 +226,19 @@
|
||||
"tslib"
|
||||
]
|
||||
},
|
||||
"@types/react-dom@19.2.3_@types+react@19.2.17": {
|
||||
"@types/react-dom@19.2.3_@types+react@19.2.14": {
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dependencies": [
|
||||
"@types/react"
|
||||
]
|
||||
},
|
||||
"@types/react@19.2.17": {
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"@types/react@19.2.14": {
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dependencies": [
|
||||
"csstype"
|
||||
]
|
||||
},
|
||||
"@vitejs/plugin-react@6.0.2_vite@8.0.16": {
|
||||
"@vitejs/plugin-react@6.0.2_vite@8.0.13": {
|
||||
"integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==",
|
||||
"dependencies": [
|
||||
"@rolldown/pluginutils",
|
||||
@@ -349,26 +349,26 @@
|
||||
"picomatch@4.0.4": {
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="
|
||||
},
|
||||
"postcss@8.5.15": {
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"postcss@8.5.14": {
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"dependencies": [
|
||||
"nanoid",
|
||||
"picocolors",
|
||||
"source-map-js"
|
||||
]
|
||||
},
|
||||
"react-dom@19.2.7_react@19.2.7": {
|
||||
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
||||
"react-dom@19.2.6_react@19.2.6": {
|
||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||
"dependencies": [
|
||||
"react",
|
||||
"scheduler"
|
||||
]
|
||||
},
|
||||
"react@19.2.7": {
|
||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="
|
||||
"react@19.2.6": {
|
||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="
|
||||
},
|
||||
"rolldown@1.0.3": {
|
||||
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||
"rolldown@1.0.1": {
|
||||
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
|
||||
"dependencies": [
|
||||
"@oxc-project/types",
|
||||
"@rolldown/pluginutils"
|
||||
@@ -398,8 +398,8 @@
|
||||
"source-map-js@1.2.1": {
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
|
||||
},
|
||||
"tinyglobby@0.2.17": {
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"tinyglobby@0.2.16": {
|
||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||
"dependencies": [
|
||||
"fdir",
|
||||
"picomatch"
|
||||
@@ -412,8 +412,8 @@
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"bin": true
|
||||
},
|
||||
"vite@8.0.16": {
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"vite@8.0.13": {
|
||||
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
|
||||
"dependencies": [
|
||||
"lightningcss",
|
||||
"picomatch",
|
||||
@@ -436,12 +436,12 @@
|
||||
"npm:@tauri-apps/plugin-shell@^2.3.5",
|
||||
"npm:@tauri-apps/plugin-store@^2.4.3",
|
||||
"npm:@types/react-dom@^19.2.3",
|
||||
"npm:@types/react@^19.2.17",
|
||||
"npm:@types/react@^19.2.14",
|
||||
"npm:@vitejs/plugin-react@^6.0.2",
|
||||
"npm:react-dom@^19.2.7",
|
||||
"npm:react@^19.2.7",
|
||||
"npm:react-dom@^19.2.6",
|
||||
"npm:react@^19.2.6",
|
||||
"npm:typescript@^6.0.3",
|
||||
"npm:vite@^8.0.16"
|
||||
"npm:vite@^8.0.13"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,17 +12,17 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-store": "^2.4.3",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vite": "^8.0.13",
|
||||
"@tauri-apps/cli": "^2.11.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,19 +8,28 @@ edition = "2024"
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib", "staticlib"]
|
||||
doctest = false
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "lanspread_tauri_deno_ts_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
test = true
|
||||
doctest = false
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
needless_pass_by_value = "allow"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
lanspread-compat = { path = "../../lanspread-compat" }
|
||||
lanspread-db = { path = "../../lanspread-db" }
|
||||
# local
|
||||
lanspread-peer = { path = "../../lanspread-peer" }
|
||||
lanspread-db = { path = "../../lanspread-db" }
|
||||
lanspread-compat = { path = "../../lanspread-compat" }
|
||||
|
||||
# external
|
||||
base64 = { workspace = true }
|
||||
@@ -30,25 +39,12 @@ mimalloc = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tauri = { workspace = true }
|
||||
tauri-plugin-dialog = { workspace = true }
|
||||
tauri-plugin-log = { workspace = true }
|
||||
tauri-plugin-shell = { workspace = true }
|
||||
tauri-plugin-dialog = { workspace = true }
|
||||
tauri-plugin-store = { workspace = true }
|
||||
time = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-log = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
walkdir = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { workspace = true }
|
||||
|
||||
[lints.clippy]
|
||||
needless_pass_by_value = "allow"
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main-logs",
|
||||
"description": "Capability for the main-logs window",
|
||||
"windows": [
|
||||
"main-logs"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
]
|
||||
}
|
||||
@@ -1,27 +1,22 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs::{self, OpenOptions},
|
||||
io::{self, Read as _, Seek as _, SeekFrom, Write as _},
|
||||
net::SocketAddr,
|
||||
path::{Component, Path, PathBuf},
|
||||
sync::{Arc, Mutex, OnceLock},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
sync::{Arc, OnceLock},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use eyre::bail;
|
||||
use lanspread_compat::eti::get_games;
|
||||
use lanspread_db::db::{Availability, Game, GameCatalog, GameDB, GameFileDescription};
|
||||
use lanspread_db::db::{Availability, Game, GameDB, GameFileDescription};
|
||||
use lanspread_peer::{
|
||||
ActiveOperation,
|
||||
ActiveOperationKind,
|
||||
ExternalUnrarStreamProvider,
|
||||
NoopStreamInstallProvider,
|
||||
PeerCommand,
|
||||
PeerEvent,
|
||||
PeerGameDB,
|
||||
PeerRuntimeHandle,
|
||||
PeerStartOptions,
|
||||
StreamInstallProvider,
|
||||
UnpackFuture,
|
||||
Unpacker,
|
||||
migrate_legacy_state,
|
||||
@@ -33,51 +28,9 @@ use tokio::sync::{
|
||||
RwLock,
|
||||
mpsc::{UnboundedReceiver, UnboundedSender},
|
||||
};
|
||||
use tracing::{Event, Level, Metadata, Subscriber, field::Visit};
|
||||
use tracing_subscriber::{
|
||||
layer::{Context, Layer},
|
||||
prelude::*,
|
||||
registry::LookupSpan,
|
||||
};
|
||||
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
|
||||
type OutboundTransfers =
|
||||
Arc<RwLock<std::collections::HashMap<String, Vec<(u64, tokio_util::sync::CancellationToken)>>>>;
|
||||
|
||||
const OUTBOUND_TRANSFER_EMIT_DEBOUNCE: Duration = Duration::from_millis(100);
|
||||
|
||||
#[derive(Default)]
|
||||
struct OutboundTransferEmitState {
|
||||
scheduled: bool,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl OutboundTransferEmitState {
|
||||
fn record_change(&mut self) -> bool {
|
||||
self.generation = self.generation.saturating_add(1);
|
||||
if self.scheduled {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.scheduled = true;
|
||||
true
|
||||
}
|
||||
|
||||
fn observed_generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
fn finish_emit(&mut self, observed_generation: u64) -> bool {
|
||||
if self.generation != observed_generation {
|
||||
return true;
|
||||
}
|
||||
|
||||
self.scheduled = false;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri-managed runtime state shared by commands and setup tasks.
|
||||
#[derive(Default)]
|
||||
struct LanSpreadState {
|
||||
@@ -87,18 +40,10 @@ struct LanSpreadState {
|
||||
active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
|
||||
games_folder: Arc<RwLock<String>>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
|
||||
pending_install_account_names: Arc<RwLock<HashMap<String, String>>>,
|
||||
state_dir: OnceLock<PathBuf>,
|
||||
main_log_sink: OnceLock<MainLogSink>,
|
||||
active_outbound_transfers: OutboundTransfers,
|
||||
outbound_transfer_emit: Arc<RwLock<OutboundTransferEmitState>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct InstallSettings {
|
||||
account_name: String,
|
||||
language: String,
|
||||
}
|
||||
|
||||
struct PeerEventTx(UnboundedSender<PeerEvent>);
|
||||
@@ -129,7 +74,6 @@ struct LauncherGame {
|
||||
#[serde(flatten)]
|
||||
game: Game,
|
||||
can_host_server: bool,
|
||||
active_outbound_transfers: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
||||
@@ -144,29 +88,12 @@ struct UnpackLogEntry {
|
||||
success: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
struct MainLogLinePayload {
|
||||
line: String,
|
||||
level: String,
|
||||
sequence: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MainLogHistoryPayload {
|
||||
contents: String,
|
||||
last_sequence: u64,
|
||||
}
|
||||
|
||||
struct SidecarUnpacker {
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
const MAX_UNPACK_LOGS: usize = 20;
|
||||
const UNPACK_LOGS_FILE_NAME: &str = "unpack-logs.json";
|
||||
const MAIN_LOG_FILE_NAME: &str = "lanspread.log";
|
||||
const MAX_MAIN_LOG_BYTES: u64 = 2 * 1024 * 1024;
|
||||
const MAIN_LOG_TRIM_SLACK_BYTES: u64 = 64 * 1024;
|
||||
|
||||
impl Unpacker for SidecarUnpacker {
|
||||
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
||||
@@ -185,32 +112,6 @@ async fn get_unpack_logs(
|
||||
Ok(state.inner().unpack_logs.read().await.clone())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_main_logs(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<MainLogHistoryPayload> {
|
||||
if let Some(sink) = state.inner().main_log_sink.get() {
|
||||
return Ok(sink.read_history()?);
|
||||
}
|
||||
|
||||
let state_dir = app_handle.path().app_data_dir()?;
|
||||
fs::create_dir_all(&state_dir)?;
|
||||
let path = main_log_path(&state_dir);
|
||||
|
||||
match read_main_log_file_to_limit(&path, MAX_MAIN_LOG_BYTES) {
|
||||
Ok(contents) => Ok(MainLogHistoryPayload {
|
||||
contents,
|
||||
last_sequence: 0,
|
||||
}),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(MainLogHistoryPayload {
|
||||
contents: String::new(),
|
||||
last_sequence: 0,
|
||||
}),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
const GAME_SETUP_SCRIPT: &str = "game_setup.cmd";
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
@@ -244,7 +145,6 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result
|
||||
#[tauri::command]
|
||||
async fn install_game(
|
||||
id: String,
|
||||
language: String,
|
||||
username: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<bool> {
|
||||
@@ -273,12 +173,21 @@ async fn install_game(
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let _ = (language, username);
|
||||
let account_name = sanitize_username(&username);
|
||||
let handled = if let Some(peer_ctrl) = peer_ctrl {
|
||||
let command = if !downloaded {
|
||||
state
|
||||
.inner()
|
||||
.pending_install_account_names
|
||||
.write()
|
||||
.await
|
||||
.insert(id.clone(), account_name);
|
||||
PeerCommand::GetGame(id.clone())
|
||||
} else if !installed {
|
||||
PeerCommand::InstallGame { id: id.clone() }
|
||||
PeerCommand::InstallGame {
|
||||
id: id.clone(),
|
||||
account_name: Some(account_name),
|
||||
}
|
||||
} else {
|
||||
log::info!("Game is already installed: {id}");
|
||||
return Ok(false);
|
||||
@@ -286,6 +195,12 @@ async fn install_game(
|
||||
|
||||
if let Err(e) = peer_ctrl.send(command) {
|
||||
log::error!("Failed to send message to peer: {e:?}");
|
||||
state
|
||||
.inner()
|
||||
.pending_install_account_names
|
||||
.write()
|
||||
.await
|
||||
.remove(&id);
|
||||
return Ok(false);
|
||||
}
|
||||
true
|
||||
@@ -297,60 +212,9 @@ async fn install_game(
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stream_install_game(
|
||||
id: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<bool> {
|
||||
if state
|
||||
.inner()
|
||||
.active_operations
|
||||
.read()
|
||||
.await
|
||||
.contains_key(&id)
|
||||
{
|
||||
log::warn!("Game already has an active operation: {id}");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let Some((downloaded, installed, peer_count)) = state
|
||||
.inner()
|
||||
.games
|
||||
.read()
|
||||
.await
|
||||
.get_game_by_id(&id)
|
||||
.map(|game| (game.downloaded, game.installed, game.peer_count))
|
||||
else {
|
||||
log::warn!("Ignoring streamed install request for unknown game: {id}");
|
||||
return Ok(false);
|
||||
};
|
||||
if downloaded || installed || peer_count == 0 {
|
||||
log::warn!(
|
||||
"Ignoring streamed install request for {id}: downloaded={downloaded}, \
|
||||
installed={installed}, peer_count={peer_count}"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
|
||||
let peer_ctrl = peer_ctrl_arc.read().await.clone();
|
||||
let Some(peer_ctrl) = peer_ctrl else {
|
||||
log::warn!("Peer system not initialized yet");
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
if let Err(e) = peer_ctrl.send(PeerCommand::StreamInstallGame { id }) {
|
||||
log::error!("Failed to send PeerCommand::StreamInstallGame: {e:?}");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_game(
|
||||
id: String,
|
||||
language: String,
|
||||
username: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<bool> {
|
||||
@@ -369,10 +233,21 @@ async fn update_game(
|
||||
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
|
||||
let peer_ctrl = peer_ctrl_arc.read().await.clone();
|
||||
|
||||
let _ = (language, username);
|
||||
if let Some(peer_ctrl) = peer_ctrl {
|
||||
state
|
||||
.inner()
|
||||
.pending_install_account_names
|
||||
.write()
|
||||
.await
|
||||
.insert(id.clone(), sanitize_username(&username));
|
||||
if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id: id.clone() }) {
|
||||
log::error!("Failed to send message to peer: {e:?}");
|
||||
state
|
||||
.inner()
|
||||
.pending_install_account_names
|
||||
.write()
|
||||
.await
|
||||
.remove(&id);
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(true)
|
||||
@@ -465,6 +340,13 @@ async fn cancel_download(
|
||||
id: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<bool> {
|
||||
state
|
||||
.inner()
|
||||
.pending_install_account_names
|
||||
.write()
|
||||
.await
|
||||
.remove(&id);
|
||||
|
||||
let is_active_download = {
|
||||
let active_operations = state.inner().active_operations.read().await;
|
||||
matches!(
|
||||
@@ -577,20 +459,6 @@ fn launch_settings(language: &str, username: &str) -> LaunchSettings {
|
||||
}
|
||||
}
|
||||
|
||||
fn install_settings(language: &str, username: &str) -> InstallSettings {
|
||||
InstallSettings {
|
||||
account_name: sanitize_username(username),
|
||||
language: install_language(language),
|
||||
}
|
||||
}
|
||||
|
||||
fn install_language(language: &str) -> String {
|
||||
match sanitize_language(language).as_str() {
|
||||
"de" => "german".to_string(),
|
||||
_ => "english".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn sanitize_language(language: &str) -> String {
|
||||
match language.trim().to_ascii_lowercase().as_str() {
|
||||
@@ -781,8 +649,6 @@ async fn run_game_windows(
|
||||
}
|
||||
}
|
||||
|
||||
apply_launch_settings(&state_dir, &game_path, &id, &language, &username).await;
|
||||
|
||||
if game_start_bin.exists() {
|
||||
let result = run_as_admin(
|
||||
"cmd.exe",
|
||||
@@ -799,32 +665,6 @@ async fn run_game_windows(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stamp the launcher's username and language into the installed game's setting
|
||||
/// files the first time it is played. Uses the same processed values the install
|
||||
/// transaction used to write before this step moved to play time.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
async fn apply_launch_settings(
|
||||
state_dir: &Path,
|
||||
game_path: &Path,
|
||||
id: &str,
|
||||
language: &str,
|
||||
username: &str,
|
||||
) {
|
||||
let settings = install_settings(language, username);
|
||||
match lanspread_peer::apply_launch_settings_once(
|
||||
state_dir,
|
||||
game_path,
|
||||
id,
|
||||
Some(&settings.account_name),
|
||||
Some(&settings.language),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(outcome) => log::info!("launch settings for {id}: {outcome:?}"),
|
||||
Err(e) => log::error!("failed to apply launch settings for {id}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn run_game(
|
||||
id: String,
|
||||
@@ -884,12 +724,6 @@ async fn start_server_windows(
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let Some(state_dir) = state.inner().state_dir.get().cloned() else {
|
||||
log::error!("app state directory is not initialized; cannot start server");
|
||||
return Ok(false);
|
||||
};
|
||||
apply_launch_settings(&state_dir, &game_path, &id, &language, &username).await;
|
||||
|
||||
let result = run_as_admin(
|
||||
"cmd.exe",
|
||||
&server_script_params(&server_start_bin, &id, &settings),
|
||||
@@ -973,24 +807,6 @@ fn apply_peer_local_games(game_db: &mut GameDB, local_games: &[Game]) {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_peer_remote_games(game_db: &mut GameDB, peer_games: Vec<Game>) {
|
||||
// Peer events update availability, but catalog metadata stays anchored to game.db.
|
||||
for game in game_db.games.values_mut() {
|
||||
game.peer_count = 0;
|
||||
}
|
||||
|
||||
for peer_game in peer_games {
|
||||
if let Some(existing) = game_db.get_mut_game_by_id(&peer_game.id) {
|
||||
existing.peer_count = peer_game.peer_count;
|
||||
} else {
|
||||
log::debug!(
|
||||
"Peer advertised unknown game {id}; ignoring because game.db is ground truth",
|
||||
id = peer_game.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_all_local_game_states(game_db: &mut GameDB) {
|
||||
for game in game_db.games.values_mut() {
|
||||
clear_local_game_state(game);
|
||||
@@ -1009,24 +825,17 @@ async fn emit_games_list(app_handle: &AppHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active_transfers = state.active_outbound_transfers.read().await;
|
||||
|
||||
let games_to_emit = game_db
|
||||
.all_games()
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.map(|game| {
|
||||
let active_outbound_transfers = active_transfers.get(&game.id).map_or(0, Vec::len);
|
||||
LauncherGame {
|
||||
can_host_server: game_can_host_server(&games_folder, &game),
|
||||
active_outbound_transfers,
|
||||
game,
|
||||
}
|
||||
.map(|game| LauncherGame {
|
||||
can_host_server: game_can_host_server(&games_folder, &game),
|
||||
game,
|
||||
})
|
||||
.collect::<Vec<LauncherGame>>();
|
||||
|
||||
drop(game_db);
|
||||
drop(active_transfers);
|
||||
|
||||
let active_operations = {
|
||||
let active_operations = state.active_operations.read().await;
|
||||
@@ -1165,7 +974,36 @@ async fn update_game_db(games: Vec<Game>, app: AppHandle) {
|
||||
|
||||
{
|
||||
let mut game_db = state.games.write().await;
|
||||
apply_peer_remote_games(&mut game_db, games);
|
||||
|
||||
// Reset peer counts up front. Presence/metadata stay anchored to the baked game.db.
|
||||
for game in game_db.games.values_mut() {
|
||||
game.peer_count = 0;
|
||||
}
|
||||
|
||||
for peer_game in games {
|
||||
if let Some(existing) = game_db.get_mut_game_by_id(&peer_game.id) {
|
||||
existing.peer_count = peer_game.peer_count;
|
||||
|
||||
if let Some(peer_version) = &peer_game.eti_game_version {
|
||||
match &existing.eti_game_version {
|
||||
Some(current_version) if current_version >= peer_version => {}
|
||||
_ => {
|
||||
existing.eti_game_version = Some(peer_version.clone());
|
||||
log::debug!(
|
||||
"Updated eti_game_version for {} to {} based on peer data",
|
||||
peer_game.id,
|
||||
peer_version
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::debug!(
|
||||
"Peer advertised unknown game {id}; ignoring because game.db is ground truth",
|
||||
id = peer_game.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit_games_list(&app).await;
|
||||
@@ -1454,362 +1292,6 @@ fn unpack_logs_path(state_dir: &Path) -> PathBuf {
|
||||
state_dir.join(UNPACK_LOGS_FILE_NAME)
|
||||
}
|
||||
|
||||
fn main_log_path(state_dir: &Path) -> PathBuf {
|
||||
state_dir.join(MAIN_LOG_FILE_NAME)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn trim_main_log_file_to_limit(path: &Path, max_bytes: u64) -> io::Result<()> {
|
||||
let mut file = match OpenOptions::new().read(true).write(true).open(path) {
|
||||
Ok(file) => file,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
trim_main_log_file_to_limit_with_file(&mut file, max_bytes)
|
||||
}
|
||||
|
||||
fn trim_main_log_file_to_limit_with_file(file: &mut fs::File, max_bytes: u64) -> io::Result<()> {
|
||||
let metadata = file.metadata()?;
|
||||
|
||||
if metadata.len() <= max_bytes {
|
||||
file.seek(SeekFrom::End(0))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let tail = if max_bytes == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
file.seek(SeekFrom::Start(metadata.len() - max_bytes))?;
|
||||
|
||||
let mut bytes = Vec::with_capacity(usize::try_from(max_bytes).unwrap_or(usize::MAX));
|
||||
file.read_to_end(&mut bytes)?;
|
||||
valid_utf8_tail(bytes)
|
||||
};
|
||||
|
||||
file.set_len(0)?;
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
file.write_all(tail.as_bytes())?;
|
||||
file.seek(SeekFrom::End(0))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_main_log_file_to_limit(path: &Path, max_bytes: u64) -> io::Result<String> {
|
||||
let mut file = fs::File::open(path)?;
|
||||
read_main_log_file_to_limit_with_file(&mut file, max_bytes)
|
||||
}
|
||||
|
||||
fn read_main_log_file_to_limit_with_file(
|
||||
file: &mut fs::File,
|
||||
max_bytes: u64,
|
||||
) -> io::Result<String> {
|
||||
let metadata = file.metadata()?;
|
||||
if metadata.len() == 0 || max_bytes == 0 {
|
||||
file.seek(SeekFrom::End(0))?;
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let start = metadata.len().saturating_sub(max_bytes);
|
||||
file.seek(SeekFrom::Start(start))?;
|
||||
|
||||
let capacity = usize::try_from(metadata.len() - start).unwrap_or(usize::MAX);
|
||||
let mut bytes = Vec::with_capacity(capacity);
|
||||
file.read_to_end(&mut bytes)?;
|
||||
file.seek(SeekFrom::End(0))?;
|
||||
|
||||
if start == 0 {
|
||||
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||
} else {
|
||||
Ok(valid_utf8_tail(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_utf8_tail(bytes: Vec<u8>) -> String {
|
||||
for offset in 0..bytes.len().min(4) {
|
||||
if let Ok(tail) = std::str::from_utf8(&bytes[offset..]) {
|
||||
return tail.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MainLogSink {
|
||||
app_handle: AppHandle,
|
||||
path: PathBuf,
|
||||
file_state: Arc<Mutex<MainLogFileState>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MainLogFileState {
|
||||
file: Option<fs::File>,
|
||||
last_sequence: u64,
|
||||
}
|
||||
|
||||
impl MainLogSink {
|
||||
fn new(app_handle: AppHandle, path: PathBuf) -> Self {
|
||||
Self {
|
||||
app_handle,
|
||||
path,
|
||||
file_state: Arc::new(Mutex::new(MainLogFileState::default())),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_line(&self, line: String, level: Level) {
|
||||
write_main_log_stdout(&line);
|
||||
let sequence = self.append_file_line(&line);
|
||||
|
||||
let _ = self.app_handle.emit(
|
||||
"main-log-line",
|
||||
MainLogLinePayload {
|
||||
line,
|
||||
level: level.as_str().to_string(),
|
||||
sequence,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn read_history(&self) -> io::Result<MainLogHistoryPayload> {
|
||||
let mut file_state = self
|
||||
.file_state
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("main log file lock poisoned"))?;
|
||||
|
||||
if file_state.file.is_none() && !self.path.exists() {
|
||||
return Ok(MainLogHistoryPayload {
|
||||
contents: String::new(),
|
||||
last_sequence: file_state.last_sequence,
|
||||
});
|
||||
}
|
||||
|
||||
let contents = {
|
||||
let file = self.cached_file(&mut file_state.file)?;
|
||||
trim_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES)?;
|
||||
read_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES)?
|
||||
};
|
||||
|
||||
Ok(MainLogHistoryPayload {
|
||||
contents,
|
||||
last_sequence: file_state.last_sequence,
|
||||
})
|
||||
}
|
||||
|
||||
fn append_file_line(&self, line: &str) -> Option<u64> {
|
||||
let Ok(mut file_state) = self.file_state.lock() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let write_result = self.cached_file(&mut file_state.file).and_then(|file| {
|
||||
file.seek(SeekFrom::End(0))
|
||||
.and_then(|_| writeln!(file, "{line}"))
|
||||
});
|
||||
|
||||
if write_result.is_err() {
|
||||
file_state.file = None;
|
||||
return None;
|
||||
}
|
||||
|
||||
file_state.last_sequence = file_state.last_sequence.saturating_add(1);
|
||||
let sequence = file_state.last_sequence;
|
||||
|
||||
let should_trim = file_state.file.as_ref().is_some_and(|file| {
|
||||
file.metadata().is_ok_and(|metadata| {
|
||||
metadata.len() > MAX_MAIN_LOG_BYTES.saturating_add(MAIN_LOG_TRIM_SLACK_BYTES)
|
||||
})
|
||||
});
|
||||
|
||||
if should_trim && let Some(file) = file_state.file.as_mut() {
|
||||
let _ = trim_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES);
|
||||
}
|
||||
|
||||
Some(sequence)
|
||||
}
|
||||
|
||||
fn cached_file<'a>(&self, file: &'a mut Option<fs::File>) -> io::Result<&'a mut fs::File> {
|
||||
if file.is_none() {
|
||||
*file = Some(open_main_log_file(&self.path)?);
|
||||
}
|
||||
|
||||
file.as_mut()
|
||||
.ok_or_else(|| io::Error::other("main log file was not opened"))
|
||||
}
|
||||
}
|
||||
|
||||
fn open_main_log_file(path: &Path) -> io::Result<fs::File> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(path)
|
||||
}
|
||||
|
||||
struct MainLogLayer {
|
||||
sink: MainLogSink,
|
||||
}
|
||||
|
||||
impl MainLogLayer {
|
||||
fn new(sink: MainLogSink) -> Self {
|
||||
Self { sink }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for MainLogLayer
|
||||
where
|
||||
S: Subscriber + for<'span> LookupSpan<'span>,
|
||||
{
|
||||
fn enabled(&self, metadata: &Metadata<'_>, _ctx: Context<'_, S>) -> bool {
|
||||
should_capture_main_log_metadata(metadata)
|
||||
}
|
||||
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||
let metadata = event.metadata();
|
||||
if !should_capture_main_log_metadata(metadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut visitor = MainLogFieldVisitor::default();
|
||||
event.record(&mut visitor);
|
||||
let target = visitor
|
||||
.log_target
|
||||
.clone()
|
||||
.unwrap_or_else(|| metadata.target().to_string());
|
||||
let message = visitor.into_message();
|
||||
let (date, time) = current_main_log_timestamp();
|
||||
let line =
|
||||
format_main_log_line_parts(&date, &time, &target, metadata.level().as_str(), &message);
|
||||
|
||||
self.sink.write_line(line, *metadata.level());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MainLogFieldVisitor {
|
||||
message: Option<String>,
|
||||
log_target: Option<String>,
|
||||
fields: Vec<String>,
|
||||
}
|
||||
|
||||
impl MainLogFieldVisitor {
|
||||
fn record_value(&mut self, field_name: &str, value: String) {
|
||||
match field_name {
|
||||
"message" => self.message = Some(value),
|
||||
"log.target" => self.log_target = Some(value),
|
||||
"log.module_path" | "log.file" | "log.line" => {}
|
||||
_ => self.fields.push(format!("{field_name}={value}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn into_message(self) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(message) = self.message
|
||||
&& !message.is_empty()
|
||||
{
|
||||
parts.push(message);
|
||||
}
|
||||
parts.extend(self.fields);
|
||||
|
||||
if parts.is_empty() {
|
||||
String::from("(no message)")
|
||||
} else {
|
||||
normalize_main_log_message(&parts.join(" "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Visit for MainLogFieldVisitor {
|
||||
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
|
||||
self.record_value(field.name(), value.to_string());
|
||||
}
|
||||
|
||||
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
|
||||
self.record_value(field.name(), value.to_string());
|
||||
}
|
||||
|
||||
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
|
||||
self.record_value(field.name(), value.to_string());
|
||||
}
|
||||
|
||||
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
|
||||
self.record_value(field.name(), value.to_string());
|
||||
}
|
||||
|
||||
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
||||
self.record_value(field.name(), format!("{value:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn should_capture_main_log_metadata(metadata: &Metadata<'_>) -> bool {
|
||||
if metadata.target().starts_with("mdns_sd::service_daemon") {
|
||||
return false;
|
||||
}
|
||||
|
||||
matches!(*metadata.level(), Level::ERROR | Level::WARN | Level::INFO)
|
||||
}
|
||||
|
||||
fn current_main_log_timestamp() -> (String, String) {
|
||||
let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
|
||||
let date = now.date();
|
||||
let clock = now.time();
|
||||
|
||||
(
|
||||
format!(
|
||||
"{:04}-{:02}-{:02}",
|
||||
date.year(),
|
||||
u8::from(date.month()),
|
||||
date.day()
|
||||
),
|
||||
format!(
|
||||
"{:02}:{:02}:{:02}",
|
||||
clock.hour(),
|
||||
clock.minute(),
|
||||
clock.second()
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_main_log_line_parts(
|
||||
date: &str,
|
||||
time: &str,
|
||||
target: &str,
|
||||
level: &str,
|
||||
message: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"[{date}][{time}][{}][{level}] {}",
|
||||
normalize_main_log_target(target),
|
||||
normalize_main_log_message(message)
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_main_log_target(target: &str) -> String {
|
||||
target.replace(['\r', '\n'], " ")
|
||||
}
|
||||
|
||||
fn normalize_main_log_message(message: &str) -> String {
|
||||
message.replace('\r', "\\r").replace('\n', "\\n")
|
||||
}
|
||||
|
||||
fn write_main_log_stdout(line: &str) {
|
||||
let stdout = std::io::stdout();
|
||||
let mut stdout = stdout.lock();
|
||||
let _ = writeln!(stdout, "{line}");
|
||||
}
|
||||
|
||||
fn init_main_logging(sink: MainLogSink) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let subscriber = tracing_subscriber::registry().with(MainLogLayer::new(sink));
|
||||
tracing::subscriber::set_global_default(subscriber)?;
|
||||
tracing_log::LogTracer::builder()
|
||||
.with_max_level(log::LevelFilter::Info)
|
||||
.init()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_unpack_logs(state_dir: &Path) -> Vec<UnpackLogEntry> {
|
||||
let path = unpack_logs_path(state_dir);
|
||||
let contents = match std::fs::read_to_string(&path) {
|
||||
@@ -1895,7 +1377,7 @@ async fn ensure_bundled_game_db_loaded(app_handle: &AppHandle) {
|
||||
|
||||
if needs_load {
|
||||
let game_db = load_bundled_game_db(app_handle).await;
|
||||
let catalog = GameCatalog::from_game_db(&game_db);
|
||||
let catalog = game_db.games.keys().cloned().collect::<HashSet<_>>();
|
||||
*state.games.write().await = game_db;
|
||||
*state.catalog.write().await = catalog;
|
||||
}
|
||||
@@ -1920,7 +1402,6 @@ async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) {
|
||||
let unpacker = Arc::new(SidecarUnpacker {
|
||||
app_handle: app_handle.clone(),
|
||||
});
|
||||
let stream_install_provider = stream_install_provider_for_app(app_handle);
|
||||
match start_peer_with_options(
|
||||
games_folder.to_path_buf(),
|
||||
tx_peer_event,
|
||||
@@ -1929,8 +1410,6 @@ async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) {
|
||||
state.catalog.clone(),
|
||||
PeerStartOptions {
|
||||
state_dir: Some(state_dir),
|
||||
active_outbound_transfers: Some(state.active_outbound_transfers.clone()),
|
||||
stream_install_provider: Some(stream_install_provider),
|
||||
},
|
||||
) {
|
||||
Ok(handle) => {
|
||||
@@ -1948,22 +1427,6 @@ async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_install_provider_for_app(app_handle: &AppHandle) -> Arc<dyn StreamInstallProvider> {
|
||||
match resolve_unrar_sidecar_program(app_handle) {
|
||||
Ok(program) => Arc::new(ExternalUnrarStreamProvider::new(program)),
|
||||
Err(err) => {
|
||||
log::error!("Failed to resolve streamed-install unrar sidecar: {err}");
|
||||
Arc::new(NoopStreamInstallProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_unrar_sidecar_program(app_handle: &AppHandle) -> eyre::Result<PathBuf> {
|
||||
let sidecar = app_handle.shell().sidecar("unrar")?;
|
||||
let command: std::process::Command = sidecar.into();
|
||||
Ok(PathBuf::from(command.get_program()))
|
||||
}
|
||||
|
||||
fn emit_game_id_event(app_handle: &AppHandle, event: &str, id: &str, label: &str) {
|
||||
if let Err(e) = app_handle.emit(event, Some(id.to_owned())) {
|
||||
log::error!("{label}: Failed to emit {event} event: {e}");
|
||||
@@ -1984,44 +1447,6 @@ fn spawn_peer_event_loop(app_handle: AppHandle, mut rx_peer_event: UnboundedRece
|
||||
});
|
||||
}
|
||||
|
||||
async fn schedule_outbound_transfer_emit(app_handle: &AppHandle) {
|
||||
let state = app_handle.state::<LanSpreadState>();
|
||||
let should_spawn = {
|
||||
let mut emit_state = state.outbound_transfer_emit.write().await;
|
||||
emit_state.record_change()
|
||||
};
|
||||
|
||||
if !should_spawn {
|
||||
return;
|
||||
}
|
||||
|
||||
let app_handle = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(OUTBOUND_TRANSFER_EMIT_DEBOUNCE).await;
|
||||
|
||||
let observed_generation = {
|
||||
let state = app_handle.state::<LanSpreadState>();
|
||||
state
|
||||
.outbound_transfer_emit
|
||||
.read()
|
||||
.await
|
||||
.observed_generation()
|
||||
};
|
||||
emit_games_list(&app_handle).await;
|
||||
|
||||
let needs_follow_up_emit = {
|
||||
let state = app_handle.state::<LanSpreadState>();
|
||||
let mut emit_state = state.outbound_transfer_emit.write().await;
|
||||
emit_state.finish_emit(observed_generation)
|
||||
};
|
||||
if !needs_follow_up_emit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
||||
match event {
|
||||
@@ -2048,10 +1473,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
||||
}
|
||||
emit_games_list(app_handle).await;
|
||||
}
|
||||
PeerEvent::OutboundTransferCountChanged => {
|
||||
log::info!("PeerEvent::OutboundTransferCountChanged received");
|
||||
schedule_outbound_transfer_emit(app_handle).await;
|
||||
}
|
||||
PeerEvent::GotGameFiles {
|
||||
id,
|
||||
file_descriptions,
|
||||
@@ -2060,6 +1481,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
||||
}
|
||||
PeerEvent::NoPeersHaveGame { id } => {
|
||||
log::warn!("PeerEvent::NoPeersHaveGame received for {id}");
|
||||
clear_pending_install_account_name(app_handle, &id).await;
|
||||
emit_game_id_event(
|
||||
app_handle,
|
||||
"game-no-peers",
|
||||
@@ -2098,6 +1520,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
||||
}
|
||||
PeerEvent::DownloadGameFilesFailed { id } => {
|
||||
log::warn!("PeerEvent::DownloadGameFilesFailed received");
|
||||
clear_pending_install_account_name(app_handle, &id).await;
|
||||
emit_game_id_event(
|
||||
app_handle,
|
||||
"game-download-failed",
|
||||
@@ -2107,6 +1530,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
||||
}
|
||||
PeerEvent::DownloadGameFilesAllPeersGone { id } => {
|
||||
log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {id}");
|
||||
clear_pending_install_account_name(app_handle, &id).await;
|
||||
emit_game_id_event(
|
||||
app_handle,
|
||||
"game-download-peers-gone",
|
||||
@@ -2231,6 +1655,11 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn clear_pending_install_account_name(app_handle: &AppHandle, id: &str) {
|
||||
let state = app_handle.state::<LanSpreadState>();
|
||||
state.pending_install_account_names.write().await.remove(id);
|
||||
}
|
||||
|
||||
async fn handle_got_game_files(
|
||||
app_handle: &AppHandle,
|
||||
id: String,
|
||||
@@ -2245,14 +1674,20 @@ async fn handle_got_game_files(
|
||||
);
|
||||
|
||||
let state = app_handle.state::<LanSpreadState>();
|
||||
let account_name = state
|
||||
.pending_install_account_names
|
||||
.write()
|
||||
.await
|
||||
.remove(&id);
|
||||
let peer_ctrl = state.peer_ctrl.read().await.clone();
|
||||
if let Some(peer_ctrl) = peer_ctrl
|
||||
&& let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles {
|
||||
id,
|
||||
file_descriptions,
|
||||
account_name,
|
||||
})
|
||||
{
|
||||
log::error!("Failed to continue queued game transfer: {e}");
|
||||
log::error!("Failed to send PeerCommand::DownloadGameFiles: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,33 +1739,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn eti_game_fixture(game_id: &str, game_version: &str) -> lanspread_compat::eti::EtiGame {
|
||||
lanspread_compat::eti::EtiGame {
|
||||
game_id: game_id.to_string(),
|
||||
game_title: "Catalog Game".to_string(),
|
||||
game_key: "catalog-game".to_string(),
|
||||
game_release: "2000".to_string(),
|
||||
game_publisher: "publisher".to_string(),
|
||||
game_size: 1.0,
|
||||
game_readme_de: "description".to_string(),
|
||||
game_readme_en: "description".to_string(),
|
||||
game_readme_fr: "description".to_string(),
|
||||
game_maxplayers: 4,
|
||||
game_master_req: 0,
|
||||
genre_de: "genre".to_string(),
|
||||
game_version: game_version.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eti_game_conversion_uses_catalog_version_as_authoritative_eti_version() {
|
||||
let game = Game::from(eti_game_fixture("alpha", "20200721"));
|
||||
|
||||
assert_eq!(game.version, "20200721");
|
||||
assert_eq!(game.eti_game_version.as_deref(), Some("20200721"));
|
||||
assert_eq!(game.local_version, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_log_cleanup_preserves_crlf_and_collapses_redrawn_lines() {
|
||||
let input = "Extracting foo 10%\rExtracting foo 80%\rExtracting foo OK\r\nAll done\r\n";
|
||||
@@ -2397,46 +1805,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_log_line_format_is_stable_and_single_line() {
|
||||
let line = format_main_log_line_parts(
|
||||
"2026-06-07",
|
||||
"12:34:56",
|
||||
"lanspread\napp",
|
||||
"WARN",
|
||||
"first line\nsecond line",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
line,
|
||||
"[2026-06-07][12:34:56][lanspread app][WARN] first line\\nsecond line"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_log_trim_keeps_utf8_tail_at_char_boundary() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"lanspread-main-log-test-{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&root).expect("test state dir should be created");
|
||||
let path = main_log_path(&root);
|
||||
std::fs::write(&path, format!("{}{}", "a".repeat(11), "é".repeat(20)))
|
||||
.expect("main log should be written");
|
||||
|
||||
trim_main_log_file_to_limit(&path, 21).expect("main log should trim");
|
||||
|
||||
let trimmed = std::fs::read_to_string(&path).expect("trimmed log should remain utf-8");
|
||||
assert!(trimmed.as_bytes().len() <= 21);
|
||||
assert!(trimmed.starts_with('é'));
|
||||
assert!(trimmed.ends_with('é'));
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_operation_reconciliation_replaces_stale_ui_history() {
|
||||
let mut active_operations = HashMap::from([
|
||||
@@ -2525,32 +1893,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outbound_transfer_emit_state_coalesces_bursts_without_losing_updates() {
|
||||
let mut state = OutboundTransferEmitState::default();
|
||||
|
||||
assert!(
|
||||
state.record_change(),
|
||||
"first change should schedule an emit"
|
||||
);
|
||||
assert_eq!(state.observed_generation(), 1);
|
||||
assert!(
|
||||
!state.record_change(),
|
||||
"second change should reuse the scheduled emit"
|
||||
);
|
||||
assert_eq!(state.observed_generation(), 2);
|
||||
|
||||
assert!(
|
||||
state.finish_emit(1),
|
||||
"a generation observed before the latest change needs a follow-up emit"
|
||||
);
|
||||
assert!(
|
||||
!state.finish_emit(2),
|
||||
"the latest observed generation clears the scheduled emit"
|
||||
);
|
||||
assert!(state.record_change(), "a later burst should schedule again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_file_viewer_ids_must_be_single_path_components() {
|
||||
assert!(is_single_component_game_id("game"));
|
||||
@@ -2579,24 +1921,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_settings_use_language_file_values() {
|
||||
assert_eq!(
|
||||
install_settings("de", " Alice \"Ace\"%PATH%\n "),
|
||||
InstallSettings {
|
||||
account_name: "Alice AcePATH".to_string(),
|
||||
language: "german".to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
install_settings("fr", ""),
|
||||
InstallSettings {
|
||||
account_name: DEFAULT_USERNAME.to_string(),
|
||||
language: "english".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_params_use_common_argument_shape() {
|
||||
let start_params = script_params(
|
||||
@@ -2698,58 +2022,30 @@ mod tests {
|
||||
|
||||
assert!(game_db.get_game_by_id("unknown").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_remote_snapshot_updates_counts_without_overwriting_catalog_version() {
|
||||
let mut alpha = game_fixture("alpha", "Catalog Alpha");
|
||||
alpha.size = 999;
|
||||
alpha.eti_game_version = Some("20200721".to_string());
|
||||
|
||||
let mut beta = game_fixture("beta", "Catalog Beta");
|
||||
beta.peer_count = 2;
|
||||
beta.eti_game_version = Some("20200101".to_string());
|
||||
|
||||
let mut game_db = GameDB::from(vec![alpha, beta]);
|
||||
|
||||
let mut peer_alpha = game_fixture("alpha", "Peer Alpha");
|
||||
peer_alpha.size = 42;
|
||||
peer_alpha.peer_count = 3;
|
||||
peer_alpha.eti_game_version = Some("20990101".to_string());
|
||||
|
||||
let mut unknown = game_fixture("unknown", "Unknown");
|
||||
unknown.peer_count = 1;
|
||||
unknown.eti_game_version = Some("20990101".to_string());
|
||||
|
||||
apply_peer_remote_games(&mut game_db, vec![peer_alpha, unknown]);
|
||||
|
||||
let alpha = game_db.get_game_by_id("alpha").expect("alpha remains");
|
||||
assert_eq!(alpha.name, "Catalog Alpha");
|
||||
assert_eq!(alpha.size, 999);
|
||||
assert_eq!(alpha.peer_count, 3);
|
||||
assert_eq!(alpha.eti_game_version.as_deref(), Some("20200721"));
|
||||
|
||||
let beta = game_db.get_game_by_id("beta").expect("beta remains");
|
||||
assert_eq!(beta.peer_count, 0);
|
||||
assert_eq!(beta.eti_game_version.as_deref(), Some("20200101"));
|
||||
|
||||
assert!(game_db.get_game_by_id("unknown").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::missing_panics_doc)]
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let tauri_logger_builder = tauri_plugin_log::Builder::new()
|
||||
.clear_targets()
|
||||
.target(tauri_plugin_log::Target::new(
|
||||
tauri_plugin_log::TargetKind::Stdout,
|
||||
))
|
||||
.level(log::LevelFilter::Info)
|
||||
.level_for("mdns_sd::service_daemon", log::LevelFilter::Off);
|
||||
|
||||
// channel to receive events from the peer
|
||||
let (tx_peer_event, rx_peer_event) = tokio::sync::mpsc::unbounded_channel::<PeerEvent>();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_logger_builder.build())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
request_games,
|
||||
install_game,
|
||||
stream_install_game,
|
||||
run_game,
|
||||
start_server,
|
||||
game_directory_exists,
|
||||
@@ -2761,20 +2057,14 @@ pub fn run() {
|
||||
open_game_files,
|
||||
get_peer_count,
|
||||
get_game_thumbnail,
|
||||
get_unpack_logs,
|
||||
get_main_logs
|
||||
get_unpack_logs
|
||||
])
|
||||
.manage(LanSpreadState::default())
|
||||
.manage(PeerEventTx(tx_peer_event))
|
||||
.setup(move |app| {
|
||||
let state_dir = app.path().app_data_dir()?;
|
||||
std::fs::create_dir_all(&state_dir)?;
|
||||
let main_log_sink = MainLogSink::new(app.handle().clone(), main_log_path(&state_dir));
|
||||
let state = app.state::<LanSpreadState>();
|
||||
if state.main_log_sink.set(main_log_sink.clone()).is_err() {
|
||||
log::warn!("main log sink was already initialized");
|
||||
}
|
||||
init_main_logging(main_log_sink)?;
|
||||
let unpack_logs = load_unpack_logs(&state_dir);
|
||||
tauri::async_runtime::block_on(async {
|
||||
*state.unpack_logs.write().await = unpack_logs;
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { MainWindow } from './windows/MainWindow';
|
||||
import { MainLogsWindow, isMainLogsView } from './MainLogsWindow';
|
||||
import { UnpackLogsWindow, isUnpackLogsView } from './UnpackLogsWindow';
|
||||
|
||||
/**
|
||||
* Tauri can spawn this bundle in either the main launcher window or the
|
||||
* companion log windows. The URL query string disambiguates the views so
|
||||
* unpack-logs companion window. The URL query string disambiguates the two so
|
||||
* a single Vite build serves both.
|
||||
*/
|
||||
const App = () => {
|
||||
if (isMainLogsView()) return <MainLogsWindow />;
|
||||
if (isUnpackLogsView()) return <UnpackLogsWindow />;
|
||||
return <MainWindow />;
|
||||
};
|
||||
const App = () => (isUnpackLogsView() ? <UnpackLogsWindow /> : <MainWindow />);
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
.main-log-window {
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 18px;
|
||||
background: #000313;
|
||||
color: #D5DBFE;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.main-log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main-log-header h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.main-log-controls,
|
||||
.main-log-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.main-log-controls {
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.main-log-filter-row {
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main-log-level-filter {
|
||||
--accent: #4866b9;
|
||||
}
|
||||
|
||||
.main-log-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #aeb7df;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.main-log-regex {
|
||||
flex: 1 1 340px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.main-log-copy-status {
|
||||
color: #8ee6a6;
|
||||
font-size: 12px;
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.main-log-load-error {
|
||||
flex-shrink: 0;
|
||||
color: #ff8a8a;
|
||||
font-size: 12px;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
.main-log-stats {
|
||||
color: #8892b0;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main-log-viewport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
border: 1px solid #2a3252;
|
||||
border-radius: 6px;
|
||||
background: #050813;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.42;
|
||||
}
|
||||
|
||||
.main-log-line {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: #D5DBFE;
|
||||
}
|
||||
|
||||
.main-log-line.level-trace,
|
||||
.main-log-line.level-debug {
|
||||
color: #9aa6c8;
|
||||
}
|
||||
|
||||
.main-log-line.level-warn {
|
||||
color: #ffd37a;
|
||||
}
|
||||
|
||||
.main-log-line.level-error {
|
||||
color: #ff8a8a;
|
||||
}
|
||||
|
||||
.main-log-empty {
|
||||
color: #8892b0;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.main-log-window {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.main-log-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-log-controls {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
import { SegmentedRadio } from './components/SegmentedRadio';
|
||||
import {
|
||||
capLogRows,
|
||||
consumeLoadedHistoryRow,
|
||||
dedupeBufferedRows,
|
||||
formatCount,
|
||||
LEVEL_FILTER_MIN,
|
||||
LEVEL_FILTER_OPTIONS,
|
||||
LEVEL_ORDER,
|
||||
lineCountsFromRows,
|
||||
type LevelFilter,
|
||||
type MainLogHistoryPayload,
|
||||
type MainLogLinePayload,
|
||||
type MainLogRow,
|
||||
rowFromPayload,
|
||||
rowsFromHistory,
|
||||
} from './lib/mainLogs';
|
||||
|
||||
import './MainLogsWindow.css';
|
||||
|
||||
export const isMainLogsView = (): boolean =>
|
||||
new URLSearchParams(window.location.search).get('view') === 'main-logs';
|
||||
|
||||
export const MainLogsWindow = () => {
|
||||
const [logs, setLogs] = useState<MainLogRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [regexInput, setRegexInput] = useState('');
|
||||
const [levelFilter, setLevelFilter] = useState<LevelFilter>('all');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [pausedBufferCount, setPausedBufferCount] = useState(0);
|
||||
const [copyStatus, setCopyStatus] = useState<string | null>(null);
|
||||
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const historyLoadedRef = useRef(false);
|
||||
const initialBufferRef = useRef<MainLogRow[]>([]);
|
||||
const pausedBufferRef = useRef<MainLogRow[]>([]);
|
||||
const pausedRef = useRef(false);
|
||||
const lastHistorySequenceRef = useRef(0);
|
||||
const historyLineCountsRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const appendVisibleRows = useCallback((rows: MainLogRow[]) => {
|
||||
setLogs(current => capLogRows([...current, ...rows]));
|
||||
}, []);
|
||||
|
||||
const bufferPausedRows = useCallback((rows: MainLogRow[]) => {
|
||||
pausedBufferRef.current = capLogRows([...pausedBufferRef.current, ...rows]);
|
||||
setPausedBufferCount(pausedBufferRef.current.length);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const handleIncomingRow = (row: MainLogRow) => {
|
||||
if (!historyLoadedRef.current) {
|
||||
initialBufferRef.current = capLogRows([...initialBufferRef.current, row]);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
consumeLoadedHistoryRow(
|
||||
historyLineCountsRef.current,
|
||||
row,
|
||||
lastHistorySequenceRef.current,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (pausedRef.current) {
|
||||
bufferPausedRows([row]);
|
||||
return;
|
||||
}
|
||||
appendVisibleRows([row]);
|
||||
};
|
||||
|
||||
const setup = async () => {
|
||||
try {
|
||||
unlisten = await listen<MainLogLinePayload>('main-log-line', event => {
|
||||
handleIncomingRow(rowFromPayload(event.payload));
|
||||
});
|
||||
|
||||
const history = await invoke<MainLogHistoryPayload>('get_main_logs');
|
||||
if (cancelled) return;
|
||||
|
||||
lastHistorySequenceRef.current = history.lastSequence;
|
||||
const historyRows = rowsFromHistory(history.contents);
|
||||
const historyLineCounts = lineCountsFromRows(historyRows);
|
||||
const liveRows = dedupeBufferedRows(
|
||||
historyLineCounts,
|
||||
initialBufferRef.current,
|
||||
lastHistorySequenceRef.current,
|
||||
);
|
||||
initialBufferRef.current = [];
|
||||
historyLineCountsRef.current = historyLineCounts;
|
||||
historyLoadedRef.current = true;
|
||||
|
||||
if (pausedRef.current) {
|
||||
setLogs(capLogRows(historyRows));
|
||||
bufferPausedRows(liveRows);
|
||||
} else {
|
||||
setLogs(capLogRows([...historyRows, ...liveRows]));
|
||||
}
|
||||
setLoadError(null);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
historyLoadedRef.current = true;
|
||||
setLoadError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
historyLoadedRef.current = false;
|
||||
initialBufferRef.current = [];
|
||||
lastHistorySequenceRef.current = 0;
|
||||
historyLineCountsRef.current = new Map();
|
||||
unlisten?.();
|
||||
};
|
||||
}, [appendVisibleRows, bufferPausedRows]);
|
||||
|
||||
const { regex, regexError } = useMemo(() => {
|
||||
if (!regexInput) {
|
||||
return { regex: null as RegExp | null, regexError: null as string | null };
|
||||
}
|
||||
try {
|
||||
return { regex: new RegExp(regexInput, 'i'), regexError: null };
|
||||
} catch (e) {
|
||||
return { regex: null, regexError: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}, [regexInput]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const minLevel = LEVEL_FILTER_MIN[levelFilter];
|
||||
return logs.filter(row => {
|
||||
if (LEVEL_ORDER[row.level] < minLevel) return false;
|
||||
return regex ? regex.test(row.line) : true;
|
||||
});
|
||||
}, [levelFilter, logs, regex]);
|
||||
|
||||
const lastVisibleRow = filteredRows.length > 0 ? filteredRows[filteredRows.length - 1] : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoScroll) return;
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
viewport.scrollTop = viewport.scrollHeight;
|
||||
});
|
||||
}, [autoScroll, filteredRows.length, lastVisibleRow?.id]);
|
||||
|
||||
const flushPausedRows = useCallback(() => {
|
||||
const buffered = pausedBufferRef.current;
|
||||
if (buffered.length === 0) return;
|
||||
pausedBufferRef.current = [];
|
||||
setPausedBufferCount(0);
|
||||
appendVisibleRows(buffered);
|
||||
}, [appendVisibleRows]);
|
||||
|
||||
const togglePaused = useCallback(() => {
|
||||
if (paused) {
|
||||
pausedRef.current = false;
|
||||
setPaused(false);
|
||||
flushPausedRows();
|
||||
return;
|
||||
}
|
||||
|
||||
pausedRef.current = true;
|
||||
setPaused(true);
|
||||
}, [flushPausedRows, paused]);
|
||||
|
||||
const clearLogs = useCallback(() => {
|
||||
setLogs([]);
|
||||
initialBufferRef.current = [];
|
||||
pausedBufferRef.current = [];
|
||||
setPausedBufferCount(0);
|
||||
}, []);
|
||||
|
||||
const copyFilteredLogs = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(filteredRows.map(row => row.line).join('\n'));
|
||||
setCopyStatus('Copied');
|
||||
} catch {
|
||||
setCopyStatus('Copy failed');
|
||||
}
|
||||
window.setTimeout(() => setCopyStatus(null), 1600);
|
||||
}, [filteredRows]);
|
||||
|
||||
return (
|
||||
<main className="main-log-window">
|
||||
<div className="main-log-header">
|
||||
<h1>Application Logs</h1>
|
||||
<div className="main-log-controls">
|
||||
<label className="main-log-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoScroll}
|
||||
onChange={(e) => setAutoScroll(e.target.checked)}
|
||||
/>
|
||||
Auto-scroll
|
||||
</label>
|
||||
<button className="settings-button" onClick={togglePaused}>
|
||||
{paused ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
<button className="settings-button" onClick={clearLogs} disabled={logs.length === 0}>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
className="settings-button"
|
||||
onClick={() => void copyFilteredLogs()}
|
||||
disabled={filteredRows.length === 0}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
{copyStatus && <span className="main-log-copy-status">{copyStatus}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="main-log-filter-row">
|
||||
<div className="main-log-level-filter">
|
||||
<SegmentedRadio
|
||||
value={levelFilter}
|
||||
options={LEVEL_FILTER_OPTIONS}
|
||||
onChange={setLevelFilter}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className={`unpack-log-regex main-log-regex ${regexError ? 'invalid' : ''}`}
|
||||
type="text"
|
||||
placeholder="Filter lines by regex (case-insensitive)..."
|
||||
value={regexInput}
|
||||
onChange={(e) => setRegexInput(e.target.value)}
|
||||
title={regexError ?? ''}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{regexError && (
|
||||
<div className="unpack-log-regex-error">regex error: {regexError}</div>
|
||||
)}
|
||||
{loadError && (
|
||||
<div className="main-log-load-error">load error: {loadError}</div>
|
||||
)}
|
||||
|
||||
<div className="main-log-stats">
|
||||
{loading ? 'loading' : `showing ${formatCount(filteredRows.length, 'line')} of ${formatCount(logs.length, 'line')}`}
|
||||
{paused && pausedBufferCount > 0 && ` - ${formatCount(pausedBufferCount, 'paused line')}`}
|
||||
</div>
|
||||
|
||||
<section ref={viewportRef} className="main-log-viewport" aria-live={paused ? 'off' : 'polite'}>
|
||||
{filteredRows.length === 0 ? (
|
||||
<div className="main-log-empty">
|
||||
{logs.length === 0 ? 'No application logs recorded yet.' : 'No log lines match the current filters.'}
|
||||
</div>
|
||||
) : filteredRows.map(row => (
|
||||
<div
|
||||
key={row.id}
|
||||
className={`main-log-line level-${row.level.toLowerCase()}`}
|
||||
>
|
||||
{row.line}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Game } from '../lib/types';
|
||||
import { deriveState, stateChipLabel } from '../lib/gameState';
|
||||
import { deriveState } from '../lib/gameState';
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
installed: 'Installed',
|
||||
local: 'Local',
|
||||
downloading: 'Downloading',
|
||||
busy: 'Working',
|
||||
none: '',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
game: Game;
|
||||
@@ -9,7 +17,7 @@ interface Props {
|
||||
|
||||
export const StateChip = ({ game, showNone = false }: Props) => {
|
||||
const state = deriveState(game);
|
||||
const label = stateChipLabel(game);
|
||||
const label = LABELS[state] ?? '';
|
||||
if (!label && !showNone) return null;
|
||||
return (
|
||||
<div className="state-chip" data-state={state}>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { JSX, KeyboardEvent } from 'react';
|
||||
import { Game } from '../../lib/types';
|
||||
import { CoverAspect } from '../../hooks/useSettings';
|
||||
import { formatBytes } from '../../lib/format';
|
||||
import { hasNewerLocalVersion } from '../../lib/gameState';
|
||||
|
||||
import { GameCover } from './GameCover';
|
||||
import { StateChip } from '../StateChip';
|
||||
@@ -43,14 +42,6 @@ export const GameCard = ({
|
||||
onOpen(game);
|
||||
}
|
||||
};
|
||||
const newerThanExpected = hasNewerLocalVersion(game);
|
||||
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
|
||||
const statusMessage = hasOutbound
|
||||
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}`
|
||||
: (game.status_message ?? (newerThanExpected ? 'Newer than expected' : ''));
|
||||
const statusLevel = hasOutbound
|
||||
? 'info'
|
||||
: (game.status_level ?? (newerThanExpected ? 'warning' : undefined));
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -75,8 +66,8 @@ export const GameCard = ({
|
||||
<div className="card-meta">
|
||||
{metaSeparator(formatBytes(game.size), game.genre || null)}
|
||||
</div>
|
||||
<div className={`card-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
|
||||
{statusMessage}
|
||||
<div className={`card-status${game.status_level === 'error' ? ' is-error' : ''}`}>
|
||||
{game.status_message ?? ''}
|
||||
</div>
|
||||
<ActionButton
|
||||
game={game}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { StateChip } from '../StateChip';
|
||||
import { ActionButton } from '../ActionButton';
|
||||
|
||||
import { Game, InstallStatus } from '../../lib/types';
|
||||
import { canStreamInstall, gameStatusLabel, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
|
||||
import { deriveState, isInProgress } from '../../lib/gameState';
|
||||
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
|
||||
|
||||
interface Props {
|
||||
@@ -13,7 +13,6 @@ interface Props {
|
||||
thumbnailUrl: string | null;
|
||||
onClose: () => void;
|
||||
onPrimary: (game: Game) => void;
|
||||
onStreamInstall: (game: Game) => void;
|
||||
onUninstall: (game: Game) => void;
|
||||
onRemoveDownload: (game: Game) => void;
|
||||
onCancelDownload: (game: Game) => void;
|
||||
@@ -29,12 +28,21 @@ const tagsFromGame = (game: Game): string[] => {
|
||||
return tags;
|
||||
};
|
||||
|
||||
const statusLabelFor = (game: Game): string => {
|
||||
switch (deriveState(game)) {
|
||||
case 'installed': return 'Installed';
|
||||
case 'local': return 'Downloaded';
|
||||
case 'downloading': return 'Downloading';
|
||||
case 'busy': return 'Working…';
|
||||
case 'none': return 'Not downloaded';
|
||||
}
|
||||
};
|
||||
|
||||
export const GameDetailModal = ({
|
||||
game,
|
||||
thumbnailUrl,
|
||||
onClose,
|
||||
onPrimary,
|
||||
onStreamInstall,
|
||||
onUninstall,
|
||||
onRemoveDownload,
|
||||
onCancelDownload,
|
||||
@@ -42,28 +50,13 @@ export const GameDetailModal = ({
|
||||
onViewFiles,
|
||||
}: Props) => {
|
||||
const tags = tagsFromGame(game);
|
||||
// Some game metadata contains a literal <br>; keep sanitization exact.
|
||||
const description = game.description.split('<br>').join('');
|
||||
const canRemoveDownload = game.downloaded
|
||||
&& !game.installed
|
||||
&& !isInProgress(game.install_status);
|
||||
const showStreamInstall = canStreamInstall(game);
|
||||
const canViewFiles = game.downloaded
|
||||
|| game.installed
|
||||
|| game.install_status === InstallStatus.Downloading
|
||||
|| game.install_status === InstallStatus.Installing;
|
||||
const newerThanExpected = hasNewerLocalVersion(game);
|
||||
const newerStatus = newerThanExpected
|
||||
? `Local version ${formatEtiVersion(game.local_version)} is newer than expected ${formatEtiVersion(game.eti_game_version)}.`
|
||||
: undefined;
|
||||
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
|
||||
const outboundStatus = hasOutbound
|
||||
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}.`
|
||||
: undefined;
|
||||
const statusMessage = outboundStatus ?? game.status_message ?? newerStatus;
|
||||
const statusLevel = hasOutbound
|
||||
? 'info'
|
||||
: (game.status_level ?? (newerStatus ? 'warning' : undefined));
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<button className="modal-close" type="button" onClick={onClose} aria-label="Close">
|
||||
@@ -100,22 +93,22 @@ export const GameDetailModal = ({
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Version</div>
|
||||
<div className="meta-value meta-mono">
|
||||
{formatEtiVersion(game.eti_game_version ?? game.local_version)}
|
||||
{formatEtiVersion(game.local_version ?? game.eti_game_version)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Status</div>
|
||||
<div className="meta-value">{gameStatusLabel(game)}</div>
|
||||
<div className="meta-value">{statusLabelFor(game)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<p className="modal-desc">{description}</p>
|
||||
{game.description && (
|
||||
<p className="modal-desc">{game.description}</p>
|
||||
)}
|
||||
|
||||
{statusMessage && (
|
||||
<p className={`modal-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
|
||||
{statusMessage}
|
||||
{game.status_message && (
|
||||
<p className={`modal-status${game.status_level === 'error' ? ' is-error' : ''}`}>
|
||||
{game.status_message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -126,17 +119,6 @@ export const GameDetailModal = ({
|
||||
onClick={() => onPrimary(game)}
|
||||
onCancelDownload={onCancelDownload}
|
||||
/>
|
||||
{showStreamInstall && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
title="Install without keeping archive files"
|
||||
onClick={() => onStreamInstall(game)}
|
||||
>
|
||||
<Icon.install />
|
||||
<span>Low disk install</span>
|
||||
</button>
|
||||
)}
|
||||
{game.installed && game.can_host_server === true && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ask } from '@tauri-apps/plugin-dialog';
|
||||
|
||||
import { type UseGamesResult } from './useGames';
|
||||
import { type UISettings } from './useSettings';
|
||||
@@ -9,7 +8,6 @@ export interface GameActions {
|
||||
play: (id: string) => Promise<void>;
|
||||
startServer: (id: string) => Promise<void>;
|
||||
install: (id: string) => Promise<void>;
|
||||
streamInstall: (id: string) => Promise<void>;
|
||||
update: (id: string) => Promise<void>;
|
||||
uninstall: (id: string) => Promise<void>;
|
||||
removeDownload: (id: string) => Promise<void>;
|
||||
@@ -55,7 +53,6 @@ export const useGameActions = (
|
||||
try {
|
||||
const success = await invoke<boolean>('install_game', {
|
||||
id,
|
||||
language: settings.language,
|
||||
username: settings.username,
|
||||
});
|
||||
if (!success) return;
|
||||
@@ -67,37 +64,19 @@ export const useGameActions = (
|
||||
} catch (err) {
|
||||
console.error('install_game failed:', err);
|
||||
}
|
||||
}, [games, settings.language, settings.username]);
|
||||
|
||||
const streamInstall = useCallback(async (id: string) => {
|
||||
try {
|
||||
const success = await invoke<boolean>('stream_install_game', { id });
|
||||
if (success) games.markChecking(id);
|
||||
} catch (err) {
|
||||
console.error('stream_install_game failed:', err);
|
||||
}
|
||||
}, [games]);
|
||||
}, [games, settings.username]);
|
||||
|
||||
const update = useCallback(async (id: string) => {
|
||||
try {
|
||||
const game = games.games.find(item => item.id === id);
|
||||
if (game && game.active_outbound_transfers && game.active_outbound_transfers > 0) {
|
||||
const confirmed = await ask(
|
||||
`Peers are currently downloading this game from you. Updating will abort their downloads. Do you want to proceed?`,
|
||||
{ title: 'Active Transfers in Progress', kind: 'warning' }
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
const success = await invoke<boolean>('update_game', {
|
||||
id,
|
||||
language: settings.language,
|
||||
username: settings.username,
|
||||
});
|
||||
if (success) games.markChecking(id);
|
||||
} catch (err) {
|
||||
console.error('update_game failed:', err);
|
||||
}
|
||||
}, [games, settings.language, settings.username]);
|
||||
}, [games, settings.username]);
|
||||
|
||||
const uninstall = useCallback(async (id: string) => {
|
||||
try {
|
||||
@@ -109,19 +88,11 @@ export const useGameActions = (
|
||||
|
||||
const removeDownload = useCallback(async (id: string) => {
|
||||
try {
|
||||
const game = games.games.find(item => item.id === id);
|
||||
if (game && game.active_outbound_transfers && game.active_outbound_transfers > 0) {
|
||||
const confirmed = await ask(
|
||||
`Peers are currently downloading this game from you. Removing game files will abort their downloads. Do you want to proceed?`,
|
||||
{ title: 'Active Transfers in Progress', kind: 'warning' }
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
await invoke('remove_downloaded_game', { id });
|
||||
} catch (err) {
|
||||
console.error('remove_downloaded_game failed:', err);
|
||||
}
|
||||
}, [games]);
|
||||
}, []);
|
||||
|
||||
const cancelDownload = useCallback(async (id: string) => {
|
||||
try {
|
||||
@@ -139,15 +110,5 @@ export const useGameActions = (
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
play,
|
||||
startServer,
|
||||
install,
|
||||
streamInstall,
|
||||
update,
|
||||
uninstall,
|
||||
removeDownload,
|
||||
cancelDownload,
|
||||
viewFiles,
|
||||
};
|
||||
return { play, startServer, install, update, uninstall, removeDownload, cancelDownload, viewFiles };
|
||||
};
|
||||
|
||||
@@ -82,73 +82,25 @@ export const deriveState = (game: Game): DerivedState => {
|
||||
return 'none';
|
||||
};
|
||||
|
||||
export const isInstalledNotShareable = (game: Game): boolean =>
|
||||
game.installed && !game.downloaded;
|
||||
|
||||
export const stateChipLabel = (game: Game): string => {
|
||||
const state = deriveState(game);
|
||||
if (state === 'installed' && isInstalledNotShareable(game)) return 'Not shareable';
|
||||
switch (state) {
|
||||
case 'installed': return 'Installed';
|
||||
case 'local': return 'Local';
|
||||
case 'downloading': return 'Downloading';
|
||||
case 'busy': return 'Working';
|
||||
case 'none': return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const gameStatusLabel = (game: Game): string => {
|
||||
const state = deriveState(game);
|
||||
if (state === 'installed' && isInstalledNotShareable(game)) {
|
||||
return 'Installed, not shareable';
|
||||
}
|
||||
switch (state) {
|
||||
case 'installed': return 'Installed';
|
||||
case 'local': return 'Downloaded';
|
||||
case 'downloading': return 'Downloading';
|
||||
case 'busy': return 'Working…';
|
||||
case 'none': return 'Not downloaded';
|
||||
}
|
||||
};
|
||||
|
||||
export const isUnavailable = (game: Game): boolean =>
|
||||
!game.installed
|
||||
&& !game.downloaded
|
||||
&& game.peer_count === 0
|
||||
&& game.install_status === InstallStatus.NotInstalled;
|
||||
|
||||
const parseVersionStamp = (version: string | undefined): number | null => {
|
||||
if (!version || !/^\d{8}$/.test(version)) return null;
|
||||
const parsed = parseInt(version, 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
};
|
||||
|
||||
export const compareVersionStamps = (
|
||||
left: string | undefined,
|
||||
right: string | undefined,
|
||||
): number | null => {
|
||||
const parsedLeft = parseVersionStamp(left);
|
||||
const parsedRight = parseVersionStamp(right);
|
||||
if (parsedLeft === null || parsedRight === null) return null;
|
||||
return parsedLeft - parsedRight;
|
||||
};
|
||||
|
||||
export const hasNewerLocalVersion = (game: Game): boolean =>
|
||||
(compareVersionStamps(game.local_version, game.eti_game_version) ?? 0) > 0;
|
||||
|
||||
export const needsUpdate = (game: Game): boolean => {
|
||||
if (!game.installed) return false;
|
||||
if (game.peer_count <= 0) return false;
|
||||
if (!game.local_version && game.eti_game_version) return true;
|
||||
return (compareVersionStamps(game.eti_game_version, game.local_version) ?? 0) > 0;
|
||||
const peer = game.eti_game_version;
|
||||
const local = game.local_version;
|
||||
if (!local && peer) return true;
|
||||
if (local && peer) {
|
||||
const l = parseInt(local, 10);
|
||||
const p = parseInt(peer, 10);
|
||||
if (!Number.isNaN(l) && !Number.isNaN(p)) return p > l;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const canStreamInstall = (game: Game): boolean =>
|
||||
!game.downloaded
|
||||
&& !game.installed
|
||||
&& game.peer_count > 0
|
||||
&& !isInProgress(game.install_status);
|
||||
|
||||
/** What pressing the card's main action button should do, given the state. */
|
||||
export type PrimaryAction = 'play' | 'install' | 'update' | 'download' | 'busy' | 'disabled';
|
||||
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
export const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] as const;
|
||||
export type LogLevel = typeof LOG_LEVELS[number];
|
||||
export type LevelFilter = 'all' | 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
export interface MainLogLinePayload {
|
||||
line: string;
|
||||
level: string;
|
||||
sequence?: number | null;
|
||||
}
|
||||
|
||||
export interface MainLogHistoryPayload {
|
||||
contents: string;
|
||||
lastSequence: number;
|
||||
}
|
||||
|
||||
export interface MainLogRow {
|
||||
id: string;
|
||||
line: string;
|
||||
level: LogLevel;
|
||||
sequence?: number;
|
||||
}
|
||||
|
||||
export const LEVEL_ORDER: Record<LogLevel, number> = {
|
||||
TRACE: 0,
|
||||
DEBUG: 1,
|
||||
INFO: 2,
|
||||
WARN: 3,
|
||||
ERROR: 4,
|
||||
};
|
||||
|
||||
export const LEVEL_FILTER_MIN: Record<LevelFilter, number> = {
|
||||
all: LEVEL_ORDER.TRACE,
|
||||
debug: LEVEL_ORDER.DEBUG,
|
||||
info: LEVEL_ORDER.INFO,
|
||||
warn: LEVEL_ORDER.WARN,
|
||||
error: LEVEL_ORDER.ERROR,
|
||||
};
|
||||
|
||||
export const LEVEL_FILTER_OPTIONS: ReadonlyArray<{ value: LevelFilter; label: string }> = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'debug', label: 'Debug+' },
|
||||
{ value: 'info', label: 'Info+' },
|
||||
{ value: 'warn', label: 'Warn+' },
|
||||
{ value: 'error', label: 'Error only' },
|
||||
];
|
||||
|
||||
const MAX_IN_MEMORY_LOG_ROWS = 12_000;
|
||||
const MAX_IN_MEMORY_LOG_CHARS = 2 * 1024 * 1024;
|
||||
|
||||
let nextSyntheticLogRowId = 0;
|
||||
|
||||
const syntheticLogRowId = (): string => {
|
||||
nextSyntheticLogRowId += 1;
|
||||
return `live-synthetic-${nextSyntheticLogRowId}`;
|
||||
};
|
||||
|
||||
const isLogLevel = (value: string | undefined): value is LogLevel =>
|
||||
typeof value === 'string' && (LOG_LEVELS as readonly string[]).includes(value);
|
||||
|
||||
export const normalizeLogLevel = (value: string | undefined): LogLevel => {
|
||||
const upper = value?.toUpperCase();
|
||||
return isLogLevel(upper) ? upper : 'INFO';
|
||||
};
|
||||
|
||||
export const parseLogLevelFromLine = (line: string): LogLevel => {
|
||||
const match = line.match(/\[(TRACE|DEBUG|INFO|WARN|ERROR)\](?:\s|$)/);
|
||||
return normalizeLogLevel(match?.[1]);
|
||||
};
|
||||
|
||||
export const rowFromPayload = (payload: MainLogLinePayload): MainLogRow => {
|
||||
const sequence = typeof payload.sequence === 'number' ? payload.sequence : undefined;
|
||||
|
||||
return {
|
||||
id: sequence === undefined ? syntheticLogRowId() : `live-${sequence}`,
|
||||
line: payload.line,
|
||||
level: normalizeLogLevel(payload.level),
|
||||
sequence,
|
||||
};
|
||||
};
|
||||
|
||||
export const rowsFromHistory = (text: string): MainLogRow[] =>
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.filter(line => line.length > 0)
|
||||
.map((line, index) => ({
|
||||
id: `history-${index}`,
|
||||
line,
|
||||
level: parseLogLevelFromLine(line),
|
||||
}));
|
||||
|
||||
export const capLogRows = (rows: MainLogRow[]): MainLogRow[] => {
|
||||
let charCount = 0;
|
||||
const capped: MainLogRow[] = [];
|
||||
|
||||
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
||||
const row = rows[index];
|
||||
const rowChars = row.line.length + 1;
|
||||
const wouldExceedRows = capped.length >= MAX_IN_MEMORY_LOG_ROWS;
|
||||
const wouldExceedChars = charCount + rowChars > MAX_IN_MEMORY_LOG_CHARS;
|
||||
if (wouldExceedRows || (wouldExceedChars && capped.length > 0)) {
|
||||
break;
|
||||
}
|
||||
|
||||
capped.push(row);
|
||||
charCount += rowChars;
|
||||
}
|
||||
|
||||
return capped.reverse();
|
||||
};
|
||||
|
||||
export const rowWasLoadedInHistory = (row: MainLogRow, lastHistorySequence: number): boolean =>
|
||||
typeof row.sequence === 'number' && row.sequence <= lastHistorySequence;
|
||||
|
||||
export const lineCountsFromRows = (rows: MainLogRow[]): Map<string, number> => {
|
||||
const lineCounts = new Map<string, number>();
|
||||
rows.forEach(row => {
|
||||
lineCounts.set(row.line, (lineCounts.get(row.line) ?? 0) + 1);
|
||||
});
|
||||
return lineCounts;
|
||||
};
|
||||
|
||||
export const consumeLoadedHistoryRow = (
|
||||
historyLineCounts: Map<string, number>,
|
||||
row: MainLogRow,
|
||||
lastHistorySequence: number,
|
||||
): boolean => {
|
||||
if (!rowWasLoadedInHistory(row, lastHistorySequence)) return false;
|
||||
|
||||
const count = historyLineCounts.get(row.line) ?? 0;
|
||||
if (count <= 0) return false;
|
||||
|
||||
if (count === 1) {
|
||||
historyLineCounts.delete(row.line);
|
||||
} else {
|
||||
historyLineCounts.set(row.line, count - 1);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const dedupeBufferedRows = (
|
||||
historyLineCounts: Map<string, number>,
|
||||
bufferedRows: MainLogRow[],
|
||||
lastHistorySequence: number,
|
||||
): MainLogRow[] =>
|
||||
bufferedRows.filter(row => !consumeLoadedHistoryRow(historyLineCounts, row, lastHistorySequence));
|
||||
|
||||
export const formatCount = (count: number, noun: string): string =>
|
||||
`${count.toLocaleString()} ${noun}${count === 1 ? '' : 's'}`;
|
||||
@@ -21,7 +21,7 @@ export enum ActiveOperationKind {
|
||||
RemovingDownload = 'RemovingDownload',
|
||||
}
|
||||
|
||||
export type StatusLevel = 'info' | 'warning' | 'error';
|
||||
export type StatusLevel = 'info' | 'error';
|
||||
|
||||
export interface DownloadProgress {
|
||||
downloaded_bytes: number;
|
||||
@@ -59,7 +59,6 @@ export interface Game {
|
||||
download_progress?: DownloadProgress;
|
||||
peer_count: number;
|
||||
can_host_server?: boolean;
|
||||
active_outbound_transfers?: number;
|
||||
}
|
||||
|
||||
export interface ActiveOperation {
|
||||
|
||||
@@ -739,12 +739,6 @@
|
||||
.card-status.is-error {
|
||||
color: #f87171;
|
||||
}
|
||||
.card-status.is-warning {
|
||||
color: #fbbf24;
|
||||
}
|
||||
.card-status.is-info {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.density-compact .card-body {
|
||||
padding: 9px 10px 10px;
|
||||
@@ -1389,16 +1383,6 @@
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
.modal-status.is-warning {
|
||||
color: #fbbf24;
|
||||
border-color: rgba(245, 158, 11, 0.4);
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
.modal-status.is-info {
|
||||
color: #60a5fa;
|
||||
border-color: rgba(96, 165, 250, 0.4);
|
||||
background: rgba(96, 165, 250, 0.08);
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -43,29 +43,6 @@ const openLogsWindow = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openMainLogsWindow = async () => {
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow');
|
||||
try {
|
||||
const existing = await WebviewWindow.getByLabel('main-logs');
|
||||
if (existing) {
|
||||
await existing.setFocus();
|
||||
return;
|
||||
}
|
||||
const win = new WebviewWindow('main-logs', {
|
||||
url: '/?view=main-logs',
|
||||
title: 'Application Logs',
|
||||
width: 980,
|
||||
height: 720,
|
||||
resizable: true,
|
||||
});
|
||||
await win.once<unknown>('tauri://error', (event) => {
|
||||
console.error('Error opening application logs window:', event.payload);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error opening application logs window:', err);
|
||||
}
|
||||
};
|
||||
|
||||
export const MainWindow = () => {
|
||||
const { settings, set: setSetting } = useSettings();
|
||||
const { gameDir, hasGameDirectory, setGameDir, rescan } = useGameDirectory();
|
||||
@@ -130,7 +107,6 @@ export const MainWindow = () => {
|
||||
{ kind: 'item', label: 'Settings', onClick: () => setSettingsOpen(true) },
|
||||
{ kind: 'item', label: 'Refresh library', onClick: () => rescan() },
|
||||
{ kind: 'separator' },
|
||||
{ kind: 'item', label: 'Application logs', onClick: () => void openMainLogsWindow() },
|
||||
{ kind: 'item', label: 'Unpack logs', onClick: () => void openLogsWindow() },
|
||||
], [rescan]);
|
||||
|
||||
@@ -192,7 +168,6 @@ export const MainWindow = () => {
|
||||
thumbnailUrl={thumbnails.get(openGame.id)}
|
||||
onClose={() => setOpenGameId(null)}
|
||||
onPrimary={handlePrimary}
|
||||
onStreamInstall={(g) => actions.streamInstall(g.id)}
|
||||
onUninstall={handleUninstall}
|
||||
onRemoveDownload={handleRemoveDownload}
|
||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
actionLabel,
|
||||
activeStatusById,
|
||||
applyFilterAndSort,
|
||||
canStreamInstall,
|
||||
countByFilter,
|
||||
deriveState,
|
||||
downloadProgressPercent,
|
||||
@@ -11,9 +10,7 @@ import {
|
||||
formatDownloadEta,
|
||||
formatDownloadSpeed,
|
||||
formatDownloadSpeedShort,
|
||||
gameStatusLabel,
|
||||
mergeGameUpdate,
|
||||
stateChipLabel,
|
||||
} from '../src/lib/gameState.ts';
|
||||
import {
|
||||
ActiveOperationKind,
|
||||
@@ -212,75 +209,3 @@ Deno.test('download progress formatting matches the progress-bar layouts', () =>
|
||||
);
|
||||
assertEquals(formatDownloadEta(485), '8 min', 'eta format should stay compact');
|
||||
});
|
||||
|
||||
Deno.test('stream install is available only for idle remote games', () => {
|
||||
assertEquals(
|
||||
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 1 })),
|
||||
true,
|
||||
'remote-only idle games should allow streamed install',
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(game({ downloaded: true, installed: false, peer_count: 1 })),
|
||||
false,
|
||||
'downloaded games should install from local archives',
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(game({ downloaded: false, installed: true, peer_count: 1 })),
|
||||
false,
|
||||
'installed games should not expose streamed install',
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 0 })),
|
||||
false,
|
||||
'games without peers should not expose streamed install',
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(game({
|
||||
downloaded: false,
|
||||
installed: false,
|
||||
peer_count: 1,
|
||||
install_status: InstallStatus.CheckingPeers,
|
||||
})),
|
||||
false,
|
||||
'busy games should not expose streamed install',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('streamed local installs are labeled installed but not shareable', () => {
|
||||
const streamed = game({
|
||||
downloaded: false,
|
||||
installed: true,
|
||||
install_status: InstallStatus.Installed,
|
||||
});
|
||||
const downloadedInstall = game({
|
||||
downloaded: true,
|
||||
installed: true,
|
||||
install_status: InstallStatus.Installed,
|
||||
});
|
||||
|
||||
assertEquals(
|
||||
deriveState(streamed),
|
||||
'installed',
|
||||
'streamed local installs should keep installed visual state',
|
||||
);
|
||||
assertEquals(
|
||||
stateChipLabel(streamed),
|
||||
'Not shareable',
|
||||
'card chip should make the non-shareable state visible',
|
||||
);
|
||||
assertEquals(
|
||||
gameStatusLabel(streamed),
|
||||
'Installed, not shareable',
|
||||
'detail status should spell out installed plus non-shareable',
|
||||
);
|
||||
assertEquals(
|
||||
stateChipLabel(downloadedInstall),
|
||||
'Installed',
|
||||
'normal downloaded installs should keep the installed chip label',
|
||||
);
|
||||
assertEquals(
|
||||
gameStatusLabel(downloadedInstall),
|
||||
'Installed',
|
||||
'normal downloaded installs should keep the installed detail label',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import {
|
||||
dedupeBufferedRows,
|
||||
lineCountsFromRows,
|
||||
rowFromPayload,
|
||||
rowsFromHistory,
|
||||
rowWasLoadedInHistory,
|
||||
} from '../src/lib/mainLogs.ts';
|
||||
|
||||
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||
}
|
||||
};
|
||||
|
||||
Deno.test('history rows parse levels and stable ids', () => {
|
||||
const rows = rowsFromHistory('[2026-06-07][12:00:00][app][WARN] careful\nplain line\n');
|
||||
|
||||
assertEquals(rows.length, 2, 'history should skip trailing empty line');
|
||||
assertEquals(rows[0].id, 'history-0', 'history id should include row position');
|
||||
assertEquals(rows[0].level, 'WARN', 'explicit level should be parsed');
|
||||
assertEquals(rows[1].level, 'INFO', 'unknown level should default to info');
|
||||
});
|
||||
|
||||
Deno.test('buffered main log rows covered by history sequence are removed', () => {
|
||||
const historyRows = rowsFromHistory('[2026-06-07][12:00:01][app][INFO] included\n');
|
||||
const included = rowFromPayload({
|
||||
line: '[2026-06-07][12:00:01][app][INFO] included',
|
||||
level: 'INFO',
|
||||
sequence: 4,
|
||||
});
|
||||
const fresh = rowFromPayload({
|
||||
line: '[2026-06-07][12:00:02][app][INFO] fresh',
|
||||
level: 'INFO',
|
||||
sequence: 5,
|
||||
});
|
||||
|
||||
const deduped = dedupeBufferedRows(lineCountsFromRows(historyRows), [included, fresh], 4);
|
||||
|
||||
assertEquals(rowWasLoadedInHistory(included, 4), true, 'included row should match history');
|
||||
assertEquals(deduped.length, 1, 'only fresh row should remain');
|
||||
assertEquals(deduped[0].line, fresh.line, 'fresh row should not be dropped');
|
||||
});
|
||||
|
||||
Deno.test('buffered rows missing from trimmed history are retained', () => {
|
||||
const retained = rowFromPayload({
|
||||
line: '[2026-06-07][12:00:00][app][INFO] trimmed out',
|
||||
level: 'INFO',
|
||||
sequence: 2,
|
||||
});
|
||||
|
||||
const deduped = dedupeBufferedRows(new Map(), [retained], 4);
|
||||
|
||||
assertEquals(deduped.length, 1, 'trimmed row should remain visible');
|
||||
assertEquals(deduped[0].line, retained.line, 'trimmed row should be preserved');
|
||||
});
|
||||
@@ -3,14 +3,14 @@ name = "lanspread-utils"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
test = false
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
[lib]
|
||||
test = false
|
||||
doctest = false
|
||||
|
||||
@@ -2,12 +2,6 @@ export RUSTFLAGS := "-C target-cpu=native"
|
||||
export WEBKIT_DISABLE_COMPOSITING_MODE := "1"
|
||||
export DOCKER_CONFIG := env_var_or_default("DOCKER_CONFIG", ".lanspread-peer-cli/docker-config")
|
||||
|
||||
default: run
|
||||
|
||||
setup:
|
||||
cargo install tauri-cli
|
||||
cd crates/lanspread-tauri-deno-ts && deno install --frozen=true
|
||||
|
||||
run:
|
||||
cargo tauri dev --release
|
||||
|
||||
@@ -19,7 +13,6 @@ bundle:
|
||||
|
||||
fmt:
|
||||
cargo +nightly fmt
|
||||
tombi format
|
||||
just --fmt
|
||||
|
||||
_fix:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
# cargo-vet audits file
|
||||
|
||||
[[audits.windows-link]]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
# cargo-vet config file
|
||||
|
||||
[cargo-vet]
|
||||
|
||||
Reference in New Issue
Block a user