Compare commits
59 Commits
47e2bbd454
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
c00e6eae84
|
|||
|
66c7d5912b
|
|||
|
9c765aba9c
|
|||
|
47ef87748f
|
|||
|
f62515451b
|
|||
|
9288fda037
|
|||
|
88bfaeb04a
|
|||
|
bb7497c0ff
|
|||
|
0e970dcec7
|
|||
|
c313f7c9ae
|
|||
|
40697a73e5
|
|||
|
389511f620
|
|||
|
5dd356eca8
|
|||
|
cc147def73
|
|||
|
373def6d44
|
|||
|
8a8437036d
|
|||
|
639a06e224
|
|||
|
27a054fcb7
|
|||
|
72659eab2e
|
|||
|
9d14e63613
|
|||
|
d63d4b9c2f
|
|||
|
6ef286e535
|
|||
|
0bd9e76e34
|
|||
|
febde452fb
|
|||
|
06398fe298
|
|||
|
9b700c7e3f
|
|||
|
7e40cf4bfb
|
|||
|
f89ff9ceea
|
|||
|
738095235f
|
|||
|
18f21bdf30
|
|||
|
09709cc008
|
|||
|
9bafd981d7
|
|||
|
e06a887da1
|
|||
|
574acfca45
|
|||
|
2e7a0cff2f
|
|||
|
eedfc0105d
|
|||
|
19ae1938f6
|
|||
|
a7d99261cf
|
|||
|
debb1c0c49
|
|||
|
0151d7a16c
|
|||
|
31ace174e3
|
|||
|
e0efb69bf0
|
|||
|
059c1e7720
|
|||
|
095bc9b9ff
|
|||
|
b169a05c31
|
|||
|
6e28f736e8
|
|||
|
8d96d99160
|
|||
|
a913e4c776
|
|||
|
a5307d3d6a
|
|||
|
9835e77e8d
|
|||
|
4f34c4a249
|
|||
|
91c709960a
|
|||
|
12a0d7abe9
|
|||
|
b96e8c5747
|
|||
|
8acb6dc246
|
|||
|
f1e915c379
|
|||
|
b56f4e2757
|
|||
|
7e97d6a83a
|
|||
|
c3800461a4
|
Generated
+250
-498
@@ -8,17 +8,6 @@ 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"
|
||||
@@ -49,23 +38,6 @@ 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"
|
||||
@@ -81,12 +53,6 @@ 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"
|
||||
@@ -127,9 +93,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
@@ -189,25 +155,13 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
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"
|
||||
@@ -226,35 +180,11 @@ dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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"
|
||||
version = "8.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
|
||||
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
@@ -263,9 +193,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.0"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
|
||||
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
@@ -282,43 +212,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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",
|
||||
]
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
@@ -347,7 +243,7 @@ version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"cairo-sys-rs",
|
||||
"glib",
|
||||
"libc",
|
||||
@@ -410,9 +306,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.62"
|
||||
version = "1.2.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -453,12 +349,6 @@ 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"
|
||||
@@ -472,9 +362,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
@@ -542,7 +432,7 @@ version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
@@ -555,7 +445,7 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation",
|
||||
"libc",
|
||||
]
|
||||
@@ -799,7 +689,7 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
@@ -807,9 +697,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -913,9 +803,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -949,16 +839,6 @@ 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"
|
||||
@@ -1022,15 +902,6 @@ 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"
|
||||
@@ -1068,6 +939,17 @@ 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"
|
||||
@@ -1137,12 +1019,6 @@ 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"
|
||||
@@ -1447,7 +1323,7 @@ version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
@@ -1568,19 +1444,25 @@ 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.1.5",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1596,11 +1478,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
|
||||
checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1639,9 +1521,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
@@ -1678,9 +1560,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1918,11 +1800,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.11.1"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199"
|
||||
checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
@@ -2055,13 +1937,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.98"
|
||||
version = "0.3.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
|
||||
checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -2093,16 +1974,16 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"serde",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
|
||||
checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
|
||||
dependencies = [
|
||||
"kqueue-sys",
|
||||
"libc",
|
||||
@@ -2114,7 +1995,7 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -2153,6 +2034,7 @@ name = "lanspread-peer"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"crc32fast",
|
||||
"eyre",
|
||||
"futures",
|
||||
"gethostname",
|
||||
@@ -2209,13 +2091,18 @@ dependencies = [
|
||||
"log",
|
||||
"mimalloc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"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",
|
||||
]
|
||||
@@ -2293,27 +2180,27 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.47"
|
||||
version = "0.1.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d1eacfa31c33ec25e873c136ba5669f00f9866d0688bea7be4d3f7e43067df6"
|
||||
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.16"
|
||||
version = "0.1.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
@@ -2343,12 +2230,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
dependencies = [
|
||||
"value-bag",
|
||||
]
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
@@ -2363,12 +2247,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mdns-sd"
|
||||
version = "0.19.2"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18148fee27e99e76dbf6e137f27727113d31f766e578d1b93a93c3615fca7081"
|
||||
checksum = "892f96f6d2ebe1ea641279f986ac52a2a6bac71e8f743bb258315cfe2bd7e88e"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"flume",
|
||||
"flume 0.11.1",
|
||||
"if-addrs",
|
||||
"log",
|
||||
"mio",
|
||||
@@ -2378,9 +2262,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
@@ -2393,9 +2277,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.50"
|
||||
version = "0.1.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3627c4272df786b9260cabaa46aec1d59c93ede723d4c3ef646c503816b0640"
|
||||
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
@@ -2418,9 +2302,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@@ -2430,9 +2314,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.19.1"
|
||||
version = "0.19.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb"
|
||||
checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
@@ -2455,7 +2339,7 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
@@ -2485,7 +2369,7 @@ version = "8.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
@@ -2503,14 +2387,23 @@ version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
@@ -2588,7 +2481,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
@@ -2601,7 +2494,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
@@ -2622,7 +2515,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
]
|
||||
@@ -2633,7 +2526,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
@@ -2666,7 +2559,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
@@ -2693,7 +2586,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
@@ -2706,7 +2599,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2717,7 +2610,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
@@ -2729,7 +2622,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
@@ -2760,7 +2653,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -2965,7 +2858,7 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
@@ -3038,7 +2931,7 @@ version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit 0.25.11+spec-1.1.0",
|
||||
"toml_edit 0.25.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3074,26 +2967,6 @@ 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"
|
||||
@@ -3124,12 +2997,6 @@ 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"
|
||||
@@ -3138,22 +3005,11 @@ checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
|
||||
dependencies = [
|
||||
"getrandom 0.1.16",
|
||||
"libc",
|
||||
"rand_chacha 0.2.2",
|
||||
"rand_chacha",
|
||||
"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"
|
||||
@@ -3175,16 +3031,6 @@ 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"
|
||||
@@ -3194,15 +3040,6 @@ 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"
|
||||
@@ -3230,7 +3067,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3266,9 +3103,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
version = "1.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -3289,24 +3126,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rend"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
|
||||
dependencies = [
|
||||
"bytecheck",
|
||||
]
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -3374,52 +3202,6 @@ 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"
|
||||
@@ -3441,7 +3223,7 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@@ -3490,17 +3272,11 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "s2n-codec"
|
||||
version = "0.80.0"
|
||||
version = "0.82.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80c081add6c80a35614d55ac1ecac3b0d609de48c7d17c643e9bab0e84e27539"
|
||||
checksum = "a650d3f187901f3519ec8a1fe7da3faccc0b2fb40f350eda2c7851fdf2bda0f6"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
@@ -3509,9 +3285,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-quic"
|
||||
version = "1.80.0"
|
||||
version = "1.82.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1949f3735d6c7003b74c0519afcddf7800bdc14cd65b03930710511802b4032"
|
||||
checksum = "c27c34127facefcd3e5530c4de5739a62cd4a593710b1194dacbd8e884b6be92"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
@@ -3533,9 +3309,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-quic-core"
|
||||
version = "0.80.0"
|
||||
version = "0.82.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf79f1e410296ae7bf17a0e877d3ce30cb589c73faf6ea31e9ee61aad486197d"
|
||||
checksum = "79fbc3f06797d985363f74de105d18554b5a272b924b166d73a6564943da1230"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"byteorder",
|
||||
@@ -3555,9 +3331,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-quic-crypto"
|
||||
version = "0.80.0"
|
||||
version = "0.82.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a6c42fa0e4a5305121fd0d55662614287283b2d54aeae8adecefe72d8d36b55"
|
||||
checksum = "e58ea5aa39eecc29559d1e1bb4a5d55a747fa7b80cff5a3400c57489510644e3"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"cfg-if",
|
||||
@@ -3569,9 +3345,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-quic-platform"
|
||||
version = "0.80.0"
|
||||
version = "0.82.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a161996e9c20557626704b883f8845d04133c0a27b176d1a1ee401932423d2f2"
|
||||
checksum = "4eebb6007139cfffdf3d473d39f01a214032c339432a6293b16b0f7b25343f40"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures",
|
||||
@@ -3584,9 +3360,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-quic-rustls"
|
||||
version = "0.80.0"
|
||||
version = "0.82.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68e0dffd40e31fb305e68ab9884a74b1c94949b3e258d02fb6e3a1f1c4305ba0"
|
||||
checksum = "eb0084afa65eefae2c37d9ab44118a14dfc5bb78dbf997c0f5176f7cf8d2e633"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"rustls",
|
||||
@@ -3598,9 +3374,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-quic-tls"
|
||||
version = "0.80.0"
|
||||
version = "0.82.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c085a4e5085a30f796270f383e6b9707e81c3abbdbbf9123ad7577628cf26d4a"
|
||||
checksum = "91150b25ce824ffea581b449ad04acf9b4aef2fa68a46f667cdc9cc6f7b87823"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"errno",
|
||||
@@ -3613,9 +3389,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-quic-tls-default"
|
||||
version = "0.80.0"
|
||||
version = "0.82.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac276a124e18b980cc8f8483a41a26f42564216f759f7f5822a8c4ffb8af8f04"
|
||||
checksum = "e1f5ae64863972facee778dc80a24317e613f035296631f267b71f225e569c22"
|
||||
dependencies = [
|
||||
"s2n-quic-rustls",
|
||||
"s2n-quic-tls",
|
||||
@@ -3623,9 +3399,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-quic-transport"
|
||||
version = "0.80.0"
|
||||
version = "0.82.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b6c77ed7cf87296886620917ba760ddb1e042446faa7be876887725c89dab63"
|
||||
checksum = "3b82fca53ce1734cc1d1dca96cc9ceb65ed528f27cb43b7de865215b6cf17908"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
@@ -3641,9 +3417,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-tls"
|
||||
version = "0.3.36"
|
||||
version = "0.3.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d22c36f207d0bb6a38272d5d1fa1cd99094335c70db1bfef54e0b8b672ae26f"
|
||||
checksum = "f60ada8adc59c848686744ff0ca93f1d1edc565a69d96a58a26abd84a521a5a0"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"hex",
|
||||
@@ -3654,9 +3430,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "s2n-tls-sys"
|
||||
version = "0.3.36"
|
||||
version = "0.3.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcf9e3b136cdd2c99f0cee2811d80b9f6bc44b5fd48c172857de32babb506f5e"
|
||||
checksum = "e6960ccb00e47fe4b641f9c7b769515a4f89cb65459ca7afbed20fa39aac0b1b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"cc",
|
||||
@@ -3729,19 +3505,13 @@ 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.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"cssparser",
|
||||
"derive_more",
|
||||
"log",
|
||||
@@ -3819,9 +3589,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -3859,23 +3629,11 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.20.0"
|
||||
version = "3.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
|
||||
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bs58",
|
||||
@@ -3893,9 +3651,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.20.0"
|
||||
version = "3.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
|
||||
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
@@ -3945,6 +3703,15 @@ 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"
|
||||
@@ -3958,9 +3725,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "sigchld"
|
||||
@@ -3999,12 +3766,6 @@ 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"
|
||||
@@ -4036,9 +3797,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -4103,9 +3864,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.8.6"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
|
||||
checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
|
||||
dependencies = [
|
||||
"sqlx-core",
|
||||
"sqlx-macros",
|
||||
@@ -4114,12 +3875,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-core"
|
||||
version = "0.8.6"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
|
||||
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
"crc",
|
||||
"crossbeam-queue",
|
||||
"either",
|
||||
@@ -4128,12 +3890,11 @@ dependencies = [
|
||||
"futures-intrusive",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"hashbrown 0.15.5",
|
||||
"hashbrown 0.16.1",
|
||||
"hashlink",
|
||||
"indexmap 2.14.0",
|
||||
"log",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"sha2",
|
||||
@@ -4147,9 +3908,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-macros"
|
||||
version = "0.8.6"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
|
||||
checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4160,15 +3921,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-macros-core"
|
||||
version = "0.8.6"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
|
||||
checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dotenvy",
|
||||
"either",
|
||||
"heck 0.5.0",
|
||||
"hex",
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
@@ -4183,12 +3944,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-sqlite"
|
||||
version = "0.8.6"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
|
||||
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"flume",
|
||||
"flume 0.12.0",
|
||||
"form_urlencoded",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
@@ -4198,7 +3960,6 @@ dependencies = [
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_urlencoded",
|
||||
"sqlx-core",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
@@ -4286,7 +4047,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
@@ -4336,11 +4096,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tao"
|
||||
version = "0.35.2"
|
||||
version = "0.35.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4"
|
||||
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-graphics",
|
||||
@@ -4385,12 +4145,6 @@ 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"
|
||||
@@ -4568,28 +4322,6 @@ 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"
|
||||
@@ -4777,6 +4509,15 @@ 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"
|
||||
@@ -4983,9 +4724,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.11+spec-1.1.0"
|
||||
version = "0.25.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
|
||||
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
@@ -5025,11 +4766,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.10"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
@@ -5083,6 +4824,32 @@ 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]]
|
||||
@@ -5121,9 +4888,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unic-char-property"
|
||||
@@ -5174,9 +4941,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.2"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
@@ -5227,12 +4994,6 @@ 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"
|
||||
@@ -5241,9 +5002,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.1"
|
||||
version = "1.23.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -5252,10 +5013,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "value-bag"
|
||||
version = "1.12.0"
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
@@ -5346,9 +5107,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.121"
|
||||
version = "0.2.123"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
|
||||
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -5359,9 +5120,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.71"
|
||||
version = "0.4.73"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
|
||||
checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -5369,9 +5130,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.121"
|
||||
version = "0.2.123"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
|
||||
checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -5379,9 +5140,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.121"
|
||||
version = "0.2.123"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
|
||||
checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -5392,9 +5153,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.121"
|
||||
version = "0.2.123"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
|
||||
checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -5440,7 +5201,7 @@ version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap 2.14.0",
|
||||
"semver",
|
||||
@@ -5448,9 +5209,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.98"
|
||||
version = "0.3.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
|
||||
checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -6131,7 +5892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"indexmap 2.14.0",
|
||||
"log",
|
||||
"serde",
|
||||
@@ -6211,15 +5972,6 @@ 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"
|
||||
@@ -6243,9 +5995,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
@@ -6266,18 +6018,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.48"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.48"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+23
-13
@@ -14,39 +14,49 @@ 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.19"
|
||||
mdns-sd = "0.20"
|
||||
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.8", default-features = false, features = [
|
||||
"derive",
|
||||
"runtime-tokio",
|
||||
"sqlite",
|
||||
] }
|
||||
sqlx = {
|
||||
version = "0.9",
|
||||
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
|
||||
|
||||
+14
@@ -2,6 +2,20 @@
|
||||
|
||||
## Open
|
||||
|
||||
### Crash-during-download leaves orphan archive files
|
||||
|
||||
`crates/lanspread-peer/src/install/transaction.rs:329` — `recover_download_transients`
|
||||
sweeps only `.version.ini.tmp` and `.version.ini.discarded` on startup. The new
|
||||
cancel-cleanup (`download/storage.rs::discard_cancelled_download`) is only invoked
|
||||
from the in-flight orchestrator, so a crash mid-download leaves partial `.eti`
|
||||
archives in the game root. After restart the user sees a game that looks
|
||||
half-downloaded with no way to clean it up except `RemoveDownloadedGame`. Closing
|
||||
this would mean calling the same discard pass during recovery for any game root
|
||||
whose intent is `None` and whose `version.ini` is absent.
|
||||
|
||||
Not blocking. The cancel-button fix is correct in its scope; this is the symmetric
|
||||
crash-recovery case.
|
||||
|
||||
### `handleErrorEvent` still writes status fields directly
|
||||
|
||||
`crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts:80-89` — the error
|
||||
|
||||
+4
-3
@@ -5,9 +5,10 @@
|
||||
directly.
|
||||
- Renamed the frontend success event to `game-install-finished`; the old
|
||||
unpack name no longer matched the transactional install/update lifecycle.
|
||||
- Implemented watcher rescans by reusing the existing `.lanspread/library_index.json`
|
||||
cache and updating a single game entry in that index. This satisfies the
|
||||
per-ID optimized rescan requirement without adding a second cache format.
|
||||
- Implemented watcher rescans by reusing the app-state
|
||||
`local_library/index.json` cache and updating a single game entry in that
|
||||
index. This satisfies the per-ID optimized rescan requirement without adding a
|
||||
second cache format.
|
||||
- Split full startup recovery from ordinary settled refreshes. Startup and real
|
||||
`SetGameDir` changes run recovery plus a scan; install/update/uninstall
|
||||
completion only rescans the affected game after operation tracking has been
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# 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.
|
||||
+203
-24
@@ -22,48 +22,57 @@ 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 | 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. |
|
||||
| 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. |
|
||||
| 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 gets a newer `version.ini`. | The other peer receives a library update without reconnecting, and the aggregated row reflects the newer `eti_game_version`. |
|
||||
| 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. |
|
||||
| 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 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. |
|
||||
| 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. |
|
||||
| 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 | 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. |
|
||||
| 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. |
|
||||
| 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 "newer" means when several peers have the same
|
||||
game ID:
|
||||
Use S15-S17 to pin down what happens when several peers have the same game ID
|
||||
but only some match the local catalog version:
|
||||
|
||||
- Version comparison uses the eight-digit `version.ini` string, so use sortable
|
||||
`YYYYMMDD` values in manual fixtures.
|
||||
- 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.
|
||||
- `list-games` aggregates by game ID. The game appears once; `peer_count`
|
||||
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.
|
||||
counts only ready peers with that ID and the catalog version.
|
||||
- The aggregated `eti_game_version` must be the catalog version.
|
||||
- The descriptor set emitted to the download path, file-size validation, and
|
||||
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.
|
||||
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.
|
||||
- Capture proof with the `list-games` row, `got-game-files` descriptors,
|
||||
`download-chunk-finished` source addresses, and source/receiver SHA-256
|
||||
manifests.
|
||||
@@ -77,7 +86,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 latest-version file.
|
||||
another validated source for the same catalog-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
|
||||
@@ -86,13 +95,183 @@ 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 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.
|
||||
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.
|
||||
|
||||
## 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,46 +1,48 @@
|
||||
# lanspread
|
||||
|
||||
## Description
|
||||
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.
|
||||
|
||||
Peer-to-peer game library sharing for LAN parties.
|
||||
## Build / install
|
||||
|
||||
- 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
|
||||
Install Rust, Deno, and `just` first, then bootstrap the project:
|
||||
|
||||
```bash
|
||||
# install Tauri CLI
|
||||
cargo install tauri-cli
|
||||
|
||||
# install Deno with a package manager or from https://deno.land/
|
||||
just setup
|
||||
```
|
||||
|
||||
### Build or Run
|
||||
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
|
||||
# build
|
||||
just build
|
||||
|
||||
# run
|
||||
just run
|
||||
|
||||
# test
|
||||
just test
|
||||
```
|
||||
|
||||
### 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.
|
||||
Build without bundling:
|
||||
|
||||
```bash
|
||||
just peer-cli-build
|
||||
just peer-cli-image
|
||||
just peer-cli-run alpha
|
||||
just build
|
||||
```
|
||||
|
||||
Create production bundles:
|
||||
|
||||
```bash
|
||||
just bundle
|
||||
```
|
||||
|
||||
## 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"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
[lib]
|
||||
doctest = false
|
||||
test = false
|
||||
|
||||
[dependencies]
|
||||
# local
|
||||
lanspread-db = { path = "../lanspread-db" }
|
||||
|
||||
eyre = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[lib]
|
||||
test = false
|
||||
doctest = false
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -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,
|
||||
version: eti_game.game_version.clone(),
|
||||
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: None,
|
||||
eti_game_version: Some(eti_game.game_version),
|
||||
local_version: None,
|
||||
peer_count: 0, // ETI games start with 0 peers until peer system discovers them
|
||||
}
|
||||
|
||||
@@ -3,13 +3,8 @@ name = "lanspread-db"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
eyre = { workspace = true }
|
||||
@@ -17,5 +12,10 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -78,7 +78,7 @@ pub struct Game {
|
||||
/// Backend-reported availability state for this game's local or peer summary.
|
||||
#[serde(default)]
|
||||
pub availability: Availability,
|
||||
/// ETI game version from version.ini (YYYYMMDD format) (server)
|
||||
/// Authoritative ETI game version from the bundled game.db (YYYYMMDD format).
|
||||
pub eti_game_version: Option<String>,
|
||||
/// Local game version from version.ini (YYYYMMDD format)
|
||||
pub local_version: Option<String>,
|
||||
@@ -198,6 +198,60 @@ 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"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
[lib]
|
||||
doctest = false
|
||||
test = false
|
||||
|
||||
[dependencies]
|
||||
eyre = { workspace = true }
|
||||
log = { workspace = true }
|
||||
mdns-sd = { workspace = true }
|
||||
|
||||
[lib]
|
||||
test = false
|
||||
doctest = false
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -3,14 +3,12 @@ name = "lanspread-peer-cli"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
needless_pass_by_value = "allow"
|
||||
[[bin]]
|
||||
name = "lanspread-peer-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
lanspread-compat = { path = "../lanspread-compat" }
|
||||
@@ -22,9 +20,11 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
[lints.clippy]
|
||||
needless_pass_by_value = "allow"
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
|
||||
[[bin]]
|
||||
name = "lanspread-peer-cli"
|
||||
path = "src/main.rs"
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -4,14 +4,16 @@ WORKDIR /work
|
||||
COPY . .
|
||||
RUN cargo build --release -p lanspread-peer-cli
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
FROM debian:trixie-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates libstdc++6 \
|
||||
&& 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,3 +42,7 @@ 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 @@
|
||||
20250101
|
||||
20190317
|
||||
@@ -1 +1 @@
|
||||
20250103
|
||||
20160130
|
||||
@@ -1 +1 @@
|
||||
20250102
|
||||
20200721
|
||||
@@ -1 +1 @@
|
||||
20250201
|
||||
20210416
|
||||
@@ -1 +1 @@
|
||||
20250202
|
||||
20170204
|
||||
@@ -1 +1 @@
|
||||
20250203
|
||||
20160128
|
||||
@@ -1 +1 @@
|
||||
20250102
|
||||
20200721
|
||||
@@ -1 +1 @@
|
||||
20250202
|
||||
20170204
|
||||
@@ -1 +1 @@
|
||||
20250301
|
||||
20160920
|
||||
@@ -1 +1 @@
|
||||
20250302
|
||||
20200315
|
||||
@@ -1 +1 @@
|
||||
20250303
|
||||
20200907
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
20160128
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
20240623
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
20160128
|
||||
@@ -1,13 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the peer-cli scenarios S1-S36 through Docker."""
|
||||
"""Run the peer-cli scenarios S1-S47 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
|
||||
@@ -26,7 +28,20 @@ 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"}
|
||||
|
||||
@@ -303,8 +318,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_latest_fanout_with_stale),
|
||||
("S17", self.s17_latest_conflict_rejection),
|
||||
("S16", self.s16_catalog_fanout_with_stale),
|
||||
("S17", self.s17_catalog_conflict_rejection),
|
||||
("S18", self.s18_redundant_source_drop),
|
||||
("S19", self.s19_sole_source_drop),
|
||||
("S20", self.s20_receiver_write_failure),
|
||||
@@ -323,8 +338,18 @@ class Runner:
|
||||
("S33", self.s33_install_after_mutation),
|
||||
("S34", self.s34_many_small_files),
|
||||
("S35", self.s35_unknown_game_filtered),
|
||||
("S36", self.s36_latest_singleton),
|
||||
("S36", self.s36_catalog_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:
|
||||
@@ -509,20 +534,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, version="20260101")
|
||||
copy_game("ggoo", dir_b, version="20260101")
|
||||
copy_game("ggoo", dir_a)
|
||||
copy_game("ggoo", dir_b)
|
||||
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="20260101")
|
||||
wait_remote_game(client, "ggoo", peer_count=2, version=CATALOG_VERSIONS["ggoo"])
|
||||
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 latest ggoo file sizes emitted download-failed and left no version.ini"
|
||||
return "conflicting catalog-version ggoo file sizes emitted download-failed and left no version.ini"
|
||||
|
||||
def s9_missing_game(self) -> str:
|
||||
client = self.peer("s9-client")
|
||||
@@ -592,30 +617,37 @@ class Runner:
|
||||
return "small bfbc2 and large alienswarm transfers both diffed cleanly against sources"
|
||||
|
||||
def s14_large_multi_peer_chunking(self) -> str:
|
||||
alpha = self.peer("s14-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
|
||||
game_id = PERF_GAME_ID
|
||||
source_dir = self.fixture_root / "s14-alpha"
|
||||
create_large_sparse_game(source_dir / game_id, size=CHUNK_SIZE + 1024 * 1024)
|
||||
alpha = self.peer("s14-alpha", games_dir=source_dir)
|
||||
stage = self.peer("s14-stage")
|
||||
connect_many(stage, [alpha])
|
||||
waiter = LineWaiter(len(stage.output))
|
||||
stage.send({"cmd": "download", "game_id": "alienswarm", "install": False})
|
||||
stage.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="stage finish", waiter=waiter)
|
||||
diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", stage.host_games_dir / "alienswarm")
|
||||
stage.send({"cmd": "download", "game_id": game_id, "install": False})
|
||||
stage.wait_for(event_is("download-finished", game_id), timeout=90, description="stage finish", waiter=waiter)
|
||||
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, "alienswarm", peer_count=2)
|
||||
wait_remote_game(client, game_id, peer_count=2, version=PERF_GAME_VERSION)
|
||||
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="client finish", waiter=waiter)
|
||||
diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", client.host_games_dir / "alienswarm")
|
||||
totals = chunk_totals(client, "alienswarm", "alienswarm/alienswarm.eti")
|
||||
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)
|
||||
diff_game_dirs(source_dir / game_id, client.host_games_dir / game_id)
|
||||
totals = chunk_totals(client, game_id, f"{game_id}/{game_id}.eti")
|
||||
if len(totals) < 2:
|
||||
raise ScenarioError(f"expected chunks from two peers, got {totals}")
|
||||
values = list(totals.values())
|
||||
if max(values) - min(values) > CHUNK_SIZE:
|
||||
raise ScenarioError(f"chunk totals not balanced within one chunk: {totals}")
|
||||
return f"alienswarm downloaded from two sources, diff matched, chunk totals={totals}"
|
||||
return f"{game_id} downloaded from two sources, diff matched, chunk totals={totals}"
|
||||
|
||||
def s15_three_way_version_skew(self) -> str:
|
||||
specs = [("s15-a", "20250101"), ("s15-b", "20250201"), ("s15-c", "20250301")]
|
||||
specs = [
|
||||
("s15-a", "20150101"),
|
||||
("s15-b", "20160101"),
|
||||
("s15-c", CATALOG_VERSIONS["cnc4"]),
|
||||
]
|
||||
peers = []
|
||||
for name, version in specs:
|
||||
game_dir = self.fixture_root / name
|
||||
@@ -623,19 +655,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=3, version="20250301")
|
||||
wait_remote_game(client, "cnc4", peer_count=1, version=CATALOG_VERSIONS["cnc4"])
|
||||
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 selected only 20250301 peer and receiver diffed cleanly"
|
||||
return "three-way skew exposed only the catalog-version peer and receiver diffed cleanly"
|
||||
|
||||
def s16_latest_fanout_with_stale(self) -> str:
|
||||
def s16_catalog_fanout_with_stale(self) -> str:
|
||||
specs = [
|
||||
("s16-a", "20250101"),
|
||||
("s16-b", "20250301"),
|
||||
("s16-c", "20250301"),
|
||||
("s16-a", "20180101"),
|
||||
("s16-b", CATALOG_VERSIONS["alienswarm"]),
|
||||
("s16-c", CATALOG_VERSIONS["alienswarm"]),
|
||||
]
|
||||
peers = []
|
||||
for name, version in specs:
|
||||
@@ -644,7 +676,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=3, version="20250301")
|
||||
wait_remote_game(client, "alienswarm", peer_count=2, version=CATALOG_VERSIONS["alienswarm"])
|
||||
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)
|
||||
@@ -653,13 +685,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"latest B/C peers served alienswarm while stale A contributed zero; totals={totals}"
|
||||
return f"catalog-version B/C peers served alienswarm while stale A contributed zero; totals={totals}"
|
||||
|
||||
def s17_latest_conflict_rejection(self) -> str:
|
||||
def s17_catalog_conflict_rejection(self) -> str:
|
||||
specs = [
|
||||
("s17-a", "20250101", False),
|
||||
("s17-b", "20250301", False),
|
||||
("s17-c", "20250301", True),
|
||||
("s17-a", "20150101", False),
|
||||
("s17-b", CATALOG_VERSIONS["cnc4"], False),
|
||||
("s17-c", CATALOG_VERSIONS["cnc4"], True),
|
||||
]
|
||||
peers = []
|
||||
for name, version, conflict in specs:
|
||||
@@ -671,12 +703,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=3, version="20250301")
|
||||
wait_remote_game(client, "cnc4", peer_count=2, version=CATALOG_VERSIONS["cnc4"])
|
||||
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 "latest-version file conflict failed download and left no committed version.ini"
|
||||
return "catalog-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"
|
||||
@@ -759,13 +791,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="20250101")
|
||||
copy_game("cnc4", bravo_dir, version="20160101")
|
||||
bravo = self.peer("s23-bravo", games_dir=bravo_dir)
|
||||
connect_many(alpha, [bravo])
|
||||
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"
|
||||
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"
|
||||
|
||||
def s24_two_clients_one_source(self) -> str:
|
||||
source = self.peer("s24-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
|
||||
@@ -862,11 +894,11 @@ class Runner:
|
||||
def s30_mesh_aggregation(self) -> str:
|
||||
dirs = []
|
||||
specs = [
|
||||
("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")]),
|
||||
("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"])]),
|
||||
]
|
||||
peers = []
|
||||
for name, games in specs:
|
||||
@@ -878,12 +910,12 @@ class Runner:
|
||||
client = self.peer("s30-client")
|
||||
connect_many(client, peers)
|
||||
expected = {
|
||||
"ggoo": (2, "20250101"),
|
||||
"bf1942": (2, "20250201"),
|
||||
"cnc4": (2, "20250301"),
|
||||
"cod5": (1, "20250101"),
|
||||
"cnctw": (2, "20250201"),
|
||||
"coh": (1, "20250101"),
|
||||
"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"]),
|
||||
}
|
||||
for game_id, (peer_count, version) in expected.items():
|
||||
wait_remote_game(client, game_id, peer_count=peer_count, version=version)
|
||||
@@ -893,7 +925,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/latest versions"
|
||||
return f"client aggregated {len(expected)} IDs from 5 peers with expected peer_count/catalog versions"
|
||||
|
||||
def s31_bootstrapped_peer_source(self) -> str:
|
||||
source = self.peer("s31-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
|
||||
@@ -989,34 +1021,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_latest_singleton(self) -> str:
|
||||
def s36_catalog_singleton(self) -> str:
|
||||
peers = []
|
||||
for index in range(5):
|
||||
game_dir = self.fixture_root / f"s36-{index}"
|
||||
version = "20260501" if index == 0 else "20250101"
|
||||
version = CATALOG_VERSIONS["cnc4"] if index == 0 else "20160101"
|
||||
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=5, version="20260501")
|
||||
wait_remote_game(client, "cnc4", peer_count=1, version=CATALOG_VERSIONS["cnc4"])
|
||||
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)
|
||||
latest_addr = peers[0].ready_addr
|
||||
if latest_addr is None:
|
||||
raise ScenarioError("latest peer had no ready addr")
|
||||
catalog_addr = peers[0].ready_addr
|
||||
if catalog_addr is None:
|
||||
raise ScenarioError("catalog-version 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") != latest_addr:
|
||||
if data.get("game_id") == "cnc4" and data.get("peer_addr") != catalog_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 latest 20260501 with peer_count=5; only singleton latest peer sent chunks; diff matched"
|
||||
return "client reported singleton catalog-version peer; stale peers stayed hidden and sent no chunks; diff matched"
|
||||
|
||||
def s37_single_source_download_throughput(self) -> str:
|
||||
source_dir = self.fixture_root / "s37-source"
|
||||
@@ -1024,7 +1056,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="20260520")
|
||||
wait_remote_game(client, PERF_GAME_ID, peer_count=1, version=PERF_GAME_VERSION)
|
||||
|
||||
waiter = LineWaiter(len(client.output))
|
||||
client.send({"cmd": "download", "game_id": PERF_GAME_ID, "install": False})
|
||||
@@ -1043,7 +1075,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("20260520")
|
||||
expected_bytes = PERF_GAME_SIZE + len(PERF_GAME_VERSION)
|
||||
if int(throughput["bytes"]) != expected_bytes:
|
||||
raise ScenarioError(
|
||||
f"throughput byte count mismatch: {throughput['bytes']} != {expected_bytes}"
|
||||
@@ -1057,6 +1089,535 @@ 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(
|
||||
@@ -1125,6 +1686,7 @@ 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")
|
||||
|
||||
@@ -1161,19 +1723,62 @@ 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("20250101", encoding="utf-8")
|
||||
(root / "version.ini").write_text(CATALOG_VERSIONS.get(root.name, "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("20260520", encoding="utf-8")
|
||||
(root / "version.ini").write_text(PERF_GAME_VERSION, 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"
|
||||
|
||||
@@ -1250,6 +1855,32 @@ 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],
|
||||
*,
|
||||
@@ -1296,7 +1927,10 @@ 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") for row in rows):
|
||||
if any(
|
||||
row["id"] == game_id and (row.get("downloaded") or row.get("installed"))
|
||||
for row in rows
|
||||
):
|
||||
raise ScenarioError(f"{peer.name} advertises failed local {game_id}: {rows}")
|
||||
|
||||
|
||||
@@ -1312,6 +1946,15 @@ 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:
|
||||
@@ -1323,6 +1966,17 @@ 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:
|
||||
@@ -1330,6 +1984,13 @@ 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,
|
||||
@@ -1355,6 +2016,16 @@ 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,12 +33,23 @@ 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,
|
||||
@@ -58,8 +69,11 @@ 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",
|
||||
@@ -95,12 +109,26 @@ 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()
|
||||
@@ -330,6 +358,32 @@ 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,11 +12,14 @@ use std::{
|
||||
|
||||
use eyre::Context;
|
||||
use lanspread_compat::eti::get_games;
|
||||
use lanspread_db::db::{Game, GameFileDescription};
|
||||
use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
|
||||
use lanspread_peer::{
|
||||
ActiveOperation,
|
||||
ActiveOperationKind,
|
||||
ExternalUnrarStreamProvider,
|
||||
InstallOperation,
|
||||
NoopStreamInstallProvider,
|
||||
OutboundTransfers,
|
||||
PeerCommand,
|
||||
PeerEvent,
|
||||
PeerGameDB,
|
||||
@@ -24,11 +27,14 @@ 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,
|
||||
@@ -113,8 +119,11 @@ struct DownloadMeasurement {
|
||||
struct SharedState {
|
||||
state: RwLock<CliState>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
active_outbound_transfers: OutboundTransfers,
|
||||
notify: Notify,
|
||||
games_dir: PathBuf,
|
||||
state_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -125,14 +134,21 @@ async fn main() -> eyre::Result<()> {
|
||||
|
||||
let fixture_seeds = seed_fixtures(&args.games_dir, &args.fixtures)?;
|
||||
let catalog = load_catalog(args.catalog_db.as_deref(), &fixture_seeds).await;
|
||||
let migration = migrate_legacy_state(&args.games_dir, &args.state_dir).await;
|
||||
|
||||
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 unpacker: Arc<dyn lanspread_peer::Unpacker> = match args.unrar {
|
||||
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() {
|
||||
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(),
|
||||
@@ -142,6 +158,8 @@ 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();
|
||||
@@ -150,7 +168,10 @@ 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();
|
||||
|
||||
@@ -162,6 +183,7 @@ async fn main() -> eyre::Result<()> {
|
||||
"name": args.name,
|
||||
"games_dir": args.games_dir,
|
||||
"state_dir": args.state_dir,
|
||||
"migration": migration,
|
||||
"fixtures": fixture_seeds,
|
||||
}),
|
||||
));
|
||||
@@ -240,6 +262,21 @@ async fn handle_command(
|
||||
})?;
|
||||
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?;
|
||||
@@ -248,6 +285,11 @@ async fn handle_command(
|
||||
})?;
|
||||
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?;
|
||||
@@ -275,12 +317,20 @@ 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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -291,15 +341,8 @@ 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.clone();
|
||||
let remote = shared
|
||||
.peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.get_all_games()
|
||||
.into_iter()
|
||||
.filter(|game| catalog.contains(&game.id))
|
||||
.collect::<Vec<_>>();
|
||||
let catalog = shared.catalog.read().await;
|
||||
let remote = shared.peer_game_db.read().await.get_catalog_games(&catalog);
|
||||
Ok(json!({
|
||||
"local": state.local_games.clone(),
|
||||
"remote": remote,
|
||||
@@ -307,6 +350,25 @@ 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(());
|
||||
@@ -403,6 +465,7 @@ 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 } => {
|
||||
@@ -427,6 +490,7 @@ 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);
|
||||
@@ -637,18 +701,27 @@ fn seed_fixtures(game_dir: &Path, fixtures: &[String]) -> eyre::Result<Vec<Fixtu
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn load_catalog(catalog_db: Option<&Path>, fixtures: &[FixtureSeed]) -> HashSet<String> {
|
||||
let mut catalog = HashSet::new();
|
||||
async fn load_catalog(catalog_db: Option<&Path>, fixtures: &[FixtureSeed]) -> GameCatalog {
|
||||
let mut catalog = GameCatalog::empty();
|
||||
if let Some(path) = catalog_db
|
||||
&& path.exists()
|
||||
{
|
||||
match get_games(path).await {
|
||||
Ok(games) => catalog.extend(games.into_iter().map(|game| game.game_id)),
|
||||
Ok(games) => {
|
||||
for game in games {
|
||||
catalog.insert(game.game_id, Some(game.game_version));
|
||||
}
|
||||
}
|
||||
Err(err) => eprintln!("failed to load catalog db {}: {err}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
catalog.extend(fixtures.iter().map(|seed| seed.game_id.clone()));
|
||||
for seed in fixtures {
|
||||
catalog.insert(
|
||||
seed.game_id.clone(),
|
||||
Some(DEFAULT_FIXTURE_VERSION.to_string()),
|
||||
);
|
||||
}
|
||||
catalog
|
||||
}
|
||||
|
||||
@@ -692,6 +765,15 @@ 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,6 +116,9 @@ 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:
|
||||
|
||||
@@ -124,7 +127,8 @@ Reserved per-game paths:
|
||||
- `.local.installing/` is extraction staging.
|
||||
- `.local.backup/` holds the previous install while an update or uninstall is in
|
||||
flight.
|
||||
- `.lanspread.json` is the atomic per-game intent log.
|
||||
- `games/<game_id>/install_intent.json` in the configured state directory is the
|
||||
atomic per-game intent log.
|
||||
- `.lanspread_owned` inside `.local.*` directories proves Lanspread ownership
|
||||
when the current intent is `None`.
|
||||
|
||||
@@ -133,11 +137,17 @@ game root only for a catalog ID that is a single direct child of the configured
|
||||
game directory, has a regular root-level `version.ini`, and has no `local/`,
|
||||
`.local.installing/`, or `.local.backup/` path.
|
||||
|
||||
Recovery reads `.lanspread.json` and combines the recorded intent with the
|
||||
observed `local/`, `.local.installing/`, and `.local.backup/` state. Intent
|
||||
states `Installing`, `Updating`, and `Uninstalling` prove ownership of the
|
||||
corresponding reserved directories even if the marker was not flushed before a
|
||||
crash. With intent `None`, markerless `.local.*` directories are left untouched.
|
||||
Recovery reads app-state `install_intent.json` and combines the recorded intent
|
||||
with the observed `local/`, `.local.installing/`, and `.local.backup/` state.
|
||||
Intent states `Installing`, `Updating`, and `Uninstalling` prove ownership of
|
||||
the corresponding reserved directories even if the marker was not flushed before
|
||||
a crash. With intent `None`, markerless `.local.*` directories are left
|
||||
untouched.
|
||||
|
||||
Legacy `.lanspread/`, `.lanspread.json`, `.lanspread.json.tmp`,
|
||||
`.softlan_game_installed`, and `local/.softlan_first_start_done` files are
|
||||
handled only by the dedicated pre-start migration phase. Normal operation does
|
||||
not read legacy state paths.
|
||||
|
||||
### Result
|
||||
|
||||
@@ -152,6 +162,21 @@ Most scans become O(number of game dirs), with full recursion only when needed.
|
||||
active for that ID, and the root-level `version.ini` sentinel exists.
|
||||
- `local/` paths are never served, even if a stale or malicious manifest request
|
||||
asks for them.
|
||||
- Cancelling a download discards the peer-owned root download payload and
|
||||
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
|
||||
|
||||
@@ -192,8 +217,8 @@ Most scans become O(number of game dirs), with full recursion only when needed.
|
||||
- Cache the last accepted `manifest_hash` per peer to short-circuit
|
||||
manifest requests when unchanged.
|
||||
5. Local index + scan optimizations:
|
||||
- Introduce a cached index file (e.g., `.lanspread/index.json`) that stores
|
||||
per-root fingerprints and computed manifests.
|
||||
- Use the cached `local_library/index.json` file in the configured state
|
||||
directory to store per-root fingerprints and computed manifests.
|
||||
- Use filesystem watchers with a debounce window to collect changes and
|
||||
incrementally update the cache.
|
||||
- Schedule a low-frequency full scan to reconcile missed watcher events.
|
||||
|
||||
@@ -3,10 +3,8 @@ name = "lanspread-peer"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
lanspread-db = { path = "../lanspread-db" }
|
||||
@@ -16,6 +14,7 @@ lanspread-utils = { path = "../lanspread-utils" }
|
||||
|
||||
# external
|
||||
bytes = { workspace = true }
|
||||
crc32fast = { workspace = true }
|
||||
eyre = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
gethostname = { workspace = true }
|
||||
@@ -31,5 +30,7 @@ tokio-util = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
walkdir = { workspace = true }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
|
||||
@@ -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`,
|
||||
`InstallGame`, `UninstallGame`, `RemoveDownloadedGame`, `CancelDownload`,
|
||||
`SetGameDir`, and `GetPeerCount`.
|
||||
`StreamInstallGame`, `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`, and `Request::GetGameFileChunk` by reading from
|
||||
the local game directory.
|
||||
`Request::GetGameFileData`, `Request::GetGameFileChunk`, and
|
||||
`Request::StreamInstall` 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
|
||||
@@ -75,33 +75,63 @@ When the UI asks to download a game:
|
||||
keeps existing data intact and only truncates when we intentionally fall back
|
||||
to a full file transfer, which prevents corruption when multiple peers fill
|
||||
different regions of the same file.
|
||||
5. `version.ini` chunks are buffered in memory and committed last via
|
||||
5. `DownloadProgressTracker` samples byte counters, transfer speed, and the
|
||||
number of unique peers that are actively streaming chunks. The Tauri UI sees
|
||||
those values together through the regular download-progress event.
|
||||
6. `version.ini` chunks are buffered in memory and committed last via
|
||||
`.version.ini.tmp` followed by an atomic rename. Failures are accumulated and
|
||||
retried (up to `MAX_RETRY_COUNT`) via `retry_failed_chunks`; failed or
|
||||
cancelled downloads sweep `.version.ini.tmp` and `.version.ini.discarded`
|
||||
without restoring the previous sentinel.
|
||||
6. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished`
|
||||
retried (up to `MAX_RETRY_COUNT`) via `retry_failed_chunks`; failed downloads
|
||||
sweep `.version.ini.tmp` and `.version.ini.discarded` without restoring the
|
||||
previous sentinel. Cancelled downloads also discard the peer-owned download
|
||||
payload while preserving `local/` and install transaction metadata.
|
||||
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`,
|
||||
so the UI continues to treat active-operation snapshots as the single source of
|
||||
discarding partial payload files, and refreshing the settled local snapshot, so
|
||||
the UI continues to treat active-operation snapshots as the single source of
|
||||
truth for whether a download is still running.
|
||||
|
||||
### Install Transactions
|
||||
|
||||
Install, update, uninstall, downloaded-file removal, and startup recovery live
|
||||
under `src/install/`.
|
||||
Each game root has an atomic `.lanspread.json` intent log for install-side
|
||||
operations and uses Lanspread-owned `.local.installing/` and `.local.backup/`
|
||||
directories marked by `.lanspread_owned`. Startup recovery combines the recorded
|
||||
intent with the observed filesystem state and only deletes reserved directories
|
||||
when intent or marker ownership proves they belong to Lanspread.
|
||||
Install-side operation intent is stored atomically under the configured peer
|
||||
state directory, at `games/<game_id>/install_intent.json`. Game roots still use
|
||||
Lanspread-owned `.local.installing/` and `.local.backup/` directories marked by
|
||||
`.lanspread_owned`. Startup recovery combines the recorded intent with the
|
||||
observed filesystem state and only deletes reserved directories when intent or
|
||||
marker ownership proves they belong to Lanspread.
|
||||
Downloaded-file removal is deliberately separate from uninstall: it only accepts
|
||||
catalog IDs that are direct children of the configured game directory, refuses
|
||||
installed or in-flight roots, and deletes the whole game root only after finding
|
||||
a regular root-level `version.ini` sentinel.
|
||||
|
||||
Legacy launcher-owned files in game directories are migrated by a dedicated
|
||||
pre-start phase. Normal install, recovery, scan, and transfer paths use only the
|
||||
configured state directory for launcher-owned metadata.
|
||||
|
||||
## Integration with `lanspread-tauri-deno-ts`
|
||||
|
||||
The Tauri application embeds this crate in
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
//! Shared context types for the peer system.
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
net::SocketAddr,
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
};
|
||||
use std::{collections::HashMap, net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
|
||||
use lanspread_db::db::GameDB;
|
||||
use lanspread_db::db::{GameCatalog, GameDB};
|
||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
use crate::{PeerEvent, Unpacker, events, library::LocalLibraryState, peer_db::PeerGameDB};
|
||||
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)>>>>;
|
||||
|
||||
/// Mutating filesystem operation currently in flight for a game root.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -32,6 +37,7 @@ pub enum OperationKind {
|
||||
#[derive(Clone)]
|
||||
pub struct Ctx {
|
||||
pub game_dir: Arc<RwLock<PathBuf>>,
|
||||
pub state_dir: Arc<PathBuf>,
|
||||
pub local_game_db: Arc<RwLock<Option<GameDB>>>,
|
||||
pub local_library: Arc<RwLock<LocalLibraryState>>,
|
||||
pub peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
@@ -39,10 +45,12 @@ 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 catalog: Arc<RwLock<HashSet<String>>>,
|
||||
pub stream_install_provider: Arc<dyn StreamInstallProvider>,
|
||||
pub catalog: Arc<RwLock<GameCatalog>>,
|
||||
pub peer_id: Arc<String>,
|
||||
pub shutdown: CancellationToken,
|
||||
pub task_tracker: TaskTracker,
|
||||
pub active_outbound_transfers: OutboundTransfers,
|
||||
}
|
||||
|
||||
/// Context for peer connection handling.
|
||||
@@ -54,11 +62,13 @@ 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<HashSet<String>>>,
|
||||
pub catalog: Arc<RwLock<GameCatalog>>,
|
||||
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 {
|
||||
@@ -79,13 +89,17 @@ impl Ctx {
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
peer_id: String,
|
||||
game_dir: PathBuf,
|
||||
state_dir: PathBuf,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
shutdown: CancellationToken,
|
||||
task_tracker: TaskTracker,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
active_outbound_transfers: OutboundTransfers,
|
||||
stream_install_provider: Arc<dyn StreamInstallProvider>,
|
||||
) -> Self {
|
||||
Self {
|
||||
game_dir: Arc::new(RwLock::new(game_dir)),
|
||||
state_dir: Arc::new(state_dir),
|
||||
local_game_db: Arc::new(RwLock::new(None)),
|
||||
local_library: Arc::new(RwLock::new(LocalLibraryState::empty())),
|
||||
peer_game_db,
|
||||
@@ -93,10 +107,12 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,8 +131,10 @@ 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ mod orchestrator;
|
||||
mod planning;
|
||||
mod progress;
|
||||
mod retry;
|
||||
mod storage;
|
||||
mod transport;
|
||||
mod version_ini;
|
||||
|
||||
|
||||
@@ -10,15 +10,10 @@ use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::{
|
||||
planning::{
|
||||
ChunkDownloadResult,
|
||||
DownloadChunk,
|
||||
build_peer_plans,
|
||||
extract_version_descriptor,
|
||||
prepare_game_storage,
|
||||
},
|
||||
planning::{ChunkDownloadResult, DownloadChunk, build_peer_plans, extract_version_descriptor},
|
||||
progress::{DownloadProgressTracker, sample_download_progress},
|
||||
retry::{RetryContext, retry_failed_chunks},
|
||||
storage::{discard_cancelled_download, prepare_game_storage},
|
||||
transport::download_from_peer,
|
||||
version_ini::{
|
||||
VersionIniBuffer,
|
||||
@@ -48,34 +43,30 @@ pub async fn download_game_files(
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
}
|
||||
|
||||
let (version_desc, transfer_descs) =
|
||||
extract_version_descriptor(game_id, game_file_descs, &tx_notify_ui)?;
|
||||
let (version_desc, transfer_descs) = extract_version_descriptor(game_id, game_file_descs)?;
|
||||
let version_buffer = match VersionIniBuffer::new(&version_desc) {
|
||||
Ok(buffer) => Arc::new(buffer),
|
||||
Err(err) => {
|
||||
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
|
||||
id: game_id.to_string(),
|
||||
})?;
|
||||
return Err(err);
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let game_root = games_folder.join(game_id);
|
||||
|
||||
if let Err(err) = begin_version_ini_transaction(&game_root).await {
|
||||
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
|
||||
id: game_id.to_string(),
|
||||
})?;
|
||||
return Err(err);
|
||||
begin_version_ini_transaction(&game_root).await?;
|
||||
if cancel_token.is_cancelled() {
|
||||
rollback_version_ini_transaction(&game_root).await;
|
||||
discard_cancelled_download_best_effort(&games_folder, game_id).await;
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
}
|
||||
if let Err(err) = prepare_game_storage(&games_folder, &transfer_descs).await {
|
||||
rollback_version_ini_transaction(&game_root).await;
|
||||
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
|
||||
id: game_id.to_string(),
|
||||
})?;
|
||||
if cancel_token.is_cancelled() {
|
||||
discard_cancelled_download_best_effort(&games_folder, game_id).await;
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
if cancel_token.is_cancelled() {
|
||||
rollback_version_ini_transaction(&game_root).await;
|
||||
discard_cancelled_download_best_effort(&games_folder, game_id).await;
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
}
|
||||
|
||||
@@ -104,28 +95,32 @@ pub async fn download_game_files(
|
||||
|
||||
if let Err(err) = transfer_result {
|
||||
rollback_version_ini_transaction(&game_root).await;
|
||||
if !cancel_token.is_cancelled() {
|
||||
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
|
||||
id: game_id.to_string(),
|
||||
})?;
|
||||
if cancel_token.is_cancelled() {
|
||||
discard_cancelled_download_best_effort(&games_folder, game_id).await;
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if cancel_token.is_cancelled() {
|
||||
rollback_version_ini_transaction(&game_root).await;
|
||||
discard_cancelled_download_best_effort(&games_folder, game_id).await;
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
}
|
||||
|
||||
if let Err(err) = commit_version_ini_buffer(&game_root, &version_buffer).await {
|
||||
rollback_version_ini_transaction(&game_root).await;
|
||||
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
|
||||
id: game_id.to_string(),
|
||||
})?;
|
||||
return Err(err);
|
||||
}
|
||||
log::info!("all files downloaded for game: {game_id}");
|
||||
tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished {
|
||||
id: game_id.to_string(),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn discard_cancelled_download_best_effort(games_folder: &Path, game_id: &str) {
|
||||
if let Err(err) = discard_cancelled_download(games_folder, game_id).await {
|
||||
log::warn!("Failed to discard cancelled download payload for {game_id}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
struct TransferContext<'a> {
|
||||
game_id: &'a str,
|
||||
games_folder: &'a Path,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use std::{collections::HashMap, net::SocketAddr, path::Path};
|
||||
use std::{collections::HashMap, net::SocketAddr};
|
||||
|
||||
use lanspread_db::db::GameFileDescription;
|
||||
use tokio::{fs::OpenOptions, sync::mpsc::UnboundedSender};
|
||||
|
||||
use crate::{PeerEvent, config::CHUNK_SIZE, path_validation::validate_game_file_path};
|
||||
use crate::config::CHUNK_SIZE;
|
||||
|
||||
/// Represents a chunk of a file to be downloaded.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -34,7 +33,6 @@ pub(super) struct ChunkDownloadResult {
|
||||
pub(super) fn extract_version_descriptor(
|
||||
game_id: &str,
|
||||
game_file_descs: Vec<GameFileDescription>,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
) -> eyre::Result<(GameFileDescription, Vec<GameFileDescription>)> {
|
||||
let mut version_descs = Vec::new();
|
||||
let mut transfer_descs = Vec::new();
|
||||
@@ -47,9 +45,6 @@ pub(super) fn extract_version_descriptor(
|
||||
}
|
||||
|
||||
if version_descs.len() != 1 {
|
||||
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
|
||||
id: game_id.to_string(),
|
||||
});
|
||||
eyre::bail!(
|
||||
"expected exactly one root-level version.ini sentinel for {game_id}, found {}",
|
||||
version_descs.len()
|
||||
@@ -60,56 +55,6 @@ pub(super) fn extract_version_descriptor(
|
||||
Ok((version_desc, transfer_descs))
|
||||
}
|
||||
|
||||
/// Prepares storage for game files by creating directories and pre-allocating files.
|
||||
pub(super) async fn prepare_game_storage(
|
||||
games_folder: &Path,
|
||||
file_descs: &[GameFileDescription],
|
||||
) -> eyre::Result<()> {
|
||||
for desc in file_descs {
|
||||
if desc.is_version_ini() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate the path to prevent directory traversal
|
||||
let validated_path = validate_game_file_path(games_folder, &desc.relative_path)?;
|
||||
|
||||
if desc.is_dir {
|
||||
tokio::fs::create_dir_all(&validated_path).await?;
|
||||
} else {
|
||||
if let Some(parent) = validated_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
// Create and pre-allocate the file with the expected size
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&validated_path)
|
||||
.await?;
|
||||
|
||||
// Pre-allocate the file with the expected size
|
||||
let size = desc.size;
|
||||
if let Err(e) = file.set_len(size).await {
|
||||
log::warn!(
|
||||
"Failed to pre-allocate file {} (size: {}): {}",
|
||||
desc.relative_path,
|
||||
size,
|
||||
e
|
||||
);
|
||||
// Continue without pre-allocation - the file will grow as chunks are written
|
||||
} else {
|
||||
log::debug!(
|
||||
"Pre-allocated file {} with {} bytes",
|
||||
desc.relative_path,
|
||||
size
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves which peers have a specific file.
|
||||
pub(super) fn resolve_file_peers<'a>(
|
||||
relative_path: &str,
|
||||
@@ -206,8 +151,6 @@ mod tests {
|
||||
use lanspread_db::db::GameFileDescription;
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::TempDir;
|
||||
|
||||
fn loopback_addr(port: u16) -> SocketAddr {
|
||||
SocketAddr::from(([127, 0, 0, 1], port))
|
||||
}
|
||||
@@ -346,26 +289,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_game_storage_skips_version_ini_sentinel() {
|
||||
let temp = TempDir::new("lanspread-download");
|
||||
let descs = vec![GameFileDescription {
|
||||
game_id: "game".to_string(),
|
||||
relative_path: "game/version.ini".to_string(),
|
||||
is_dir: false,
|
||||
size: 8,
|
||||
}];
|
||||
|
||||
prepare_game_storage(temp.path(), &descs)
|
||||
.await
|
||||
.expect("storage preparation should succeed");
|
||||
|
||||
assert!(!temp.path().join("game").join("version.ini").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_descriptor_extraction_keeps_nested_decoy_in_transfer_list() {
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let nested_decoy = vec![
|
||||
GameFileDescription {
|
||||
game_id: "game".to_string(),
|
||||
@@ -382,26 +307,24 @@ mod tests {
|
||||
];
|
||||
|
||||
let (version, transfer) =
|
||||
extract_version_descriptor("game", nested_decoy, &tx).expect("only one root sentinel");
|
||||
extract_version_descriptor("game", nested_decoy).expect("only one root sentinel");
|
||||
assert_eq!(version.relative_path, "game/version.ini");
|
||||
assert_eq!(transfer.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_descriptor_extraction_requires_a_root_version_ini() {
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let missing = vec![GameFileDescription {
|
||||
game_id: "game".to_string(),
|
||||
relative_path: "game/archive.eti".to_string(),
|
||||
is_dir: false,
|
||||
size: 1,
|
||||
}];
|
||||
assert!(extract_version_descriptor("game", missing, &tx).is_err());
|
||||
assert!(extract_version_descriptor("game", missing).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_descriptor_extraction_rejects_duplicate_root_version_ini() {
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let multiple = vec![
|
||||
GameFileDescription {
|
||||
game_id: "game".to_string(),
|
||||
@@ -416,6 +339,6 @@ mod tests {
|
||||
size: 8,
|
||||
},
|
||||
];
|
||||
assert!(extract_version_descriptor("game", multiple, &tx).is_err());
|
||||
assert!(extract_version_descriptor("game", multiple).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
future::Future,
|
||||
net::SocketAddr,
|
||||
sync::{
|
||||
Arc,
|
||||
Mutex,
|
||||
@@ -29,6 +30,7 @@ pub(super) struct DownloadProgressTracker {
|
||||
downloaded_bytes: AtomicU64,
|
||||
transferred_bytes: AtomicU64,
|
||||
chunks: Mutex<HashMap<ChunkProgressKey, u64>>,
|
||||
active_peers: Mutex<HashMap<SocketAddr, usize>>,
|
||||
}
|
||||
|
||||
impl DownloadProgressTracker {
|
||||
@@ -38,11 +40,13 @@ impl DownloadProgressTracker {
|
||||
downloaded_bytes: AtomicU64::new(0),
|
||||
transferred_bytes: AtomicU64::new(0),
|
||||
chunks: Mutex::new(HashMap::new()),
|
||||
active_peers: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn track_chunk(
|
||||
self: &Arc<Self>,
|
||||
peer_addr: SocketAddr,
|
||||
relative_path: &str,
|
||||
offset: u64,
|
||||
expected_bytes: u64,
|
||||
@@ -53,6 +57,7 @@ impl DownloadProgressTracker {
|
||||
relative_path: relative_path.to_string(),
|
||||
offset,
|
||||
},
|
||||
_peer_activity: self.track_active_peer(peer_addr),
|
||||
expected_bytes,
|
||||
received_bytes: 0,
|
||||
}
|
||||
@@ -104,12 +109,51 @@ impl DownloadProgressTracker {
|
||||
}
|
||||
}
|
||||
|
||||
fn track_active_peer(self: &Arc<Self>, peer_addr: SocketAddr) -> ActivePeerDownload {
|
||||
{
|
||||
let mut active_peers = self
|
||||
.active_peers
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*active_peers.entry(peer_addr).or_default() += 1;
|
||||
}
|
||||
|
||||
ActivePeerDownload {
|
||||
tracker: self.clone(),
|
||||
peer_addr,
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_active_peer(&self, peer_addr: SocketAddr) {
|
||||
let mut active_peers = self
|
||||
.active_peers
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
|
||||
let Some(count) = active_peers.get_mut(&peer_addr) else {
|
||||
return;
|
||||
};
|
||||
if *count <= 1 {
|
||||
active_peers.remove(&peer_addr);
|
||||
} else {
|
||||
*count -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn active_peer_count(&self) -> usize {
|
||||
self.active_peers
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.len()
|
||||
}
|
||||
|
||||
fn snapshot(&self, id: &str, bytes_per_second: u64) -> DownloadProgress {
|
||||
DownloadProgress {
|
||||
id: id.to_string(),
|
||||
downloaded_bytes: self.reported_downloaded_bytes(),
|
||||
total_bytes: self.total_bytes,
|
||||
bytes_per_second,
|
||||
active_peer_count: self.active_peer_count(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +161,7 @@ impl DownloadProgressTracker {
|
||||
pub(super) struct ChunkProgress {
|
||||
tracker: Arc<DownloadProgressTracker>,
|
||||
key: ChunkProgressKey,
|
||||
_peer_activity: ActivePeerDownload,
|
||||
expected_bytes: u64,
|
||||
received_bytes: u64,
|
||||
}
|
||||
@@ -136,6 +181,17 @@ impl ChunkProgress {
|
||||
}
|
||||
}
|
||||
|
||||
struct ActivePeerDownload {
|
||||
tracker: Arc<DownloadProgressTracker>,
|
||||
peer_addr: SocketAddr,
|
||||
}
|
||||
|
||||
impl Drop for ActivePeerDownload {
|
||||
fn drop(&mut self) {
|
||||
self.tracker.finish_active_peer(self.peer_addr);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_saturating(counter: &AtomicU64, delta: u64) {
|
||||
let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
|
||||
Some(current.saturating_add(delta))
|
||||
@@ -230,14 +286,19 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn loopback_addr(port: u16) -> SocketAddr {
|
||||
SocketAddr::from(([127, 0, 0, 1], port))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_counts_only_new_bytes_for_a_retried_chunk() {
|
||||
let tracker = DownloadProgressTracker::new(100);
|
||||
let mut first_attempt = tracker.track_chunk("game/file.bin", 0, 100);
|
||||
let peer = loopback_addr(12000);
|
||||
let mut first_attempt = tracker.track_chunk(peer, "game/file.bin", 0, 100);
|
||||
first_attempt.record_bytes(40);
|
||||
first_attempt.record_bytes(10);
|
||||
|
||||
let mut retry = tracker.track_chunk("game/file.bin", 0, 100);
|
||||
let mut retry = tracker.track_chunk(peer, "game/file.bin", 0, 100);
|
||||
retry.record_bytes(25);
|
||||
retry.record_bytes(50);
|
||||
|
||||
@@ -248,10 +309,27 @@ mod tests {
|
||||
#[test]
|
||||
fn tracker_clamps_reported_bytes_to_total() {
|
||||
let tracker = DownloadProgressTracker::new(10);
|
||||
let mut chunk = tracker.track_chunk("game/file.bin", 0, 0);
|
||||
let mut chunk = tracker.track_chunk(loopback_addr(12000), "game/file.bin", 0, 0);
|
||||
chunk.record_bytes(25);
|
||||
|
||||
assert_eq!(tracker.raw_downloaded_bytes(), 25);
|
||||
assert_eq!(tracker.reported_downloaded_bytes(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_reports_unique_active_peer_count() {
|
||||
let tracker = DownloadProgressTracker::new(100);
|
||||
let first_peer = loopback_addr(12000);
|
||||
let second_peer = loopback_addr(12001);
|
||||
|
||||
{
|
||||
let _first_chunk = tracker.track_chunk(first_peer, "game/file.bin", 0, 50);
|
||||
let _second_chunk = tracker.track_chunk(first_peer, "game/file.bin", 50, 50);
|
||||
let _third_chunk = tracker.track_chunk(second_peer, "game/other.bin", 0, 10);
|
||||
|
||||
assert_eq!(tracker.snapshot("game", 0).active_peer_count, 2);
|
||||
}
|
||||
|
||||
assert_eq!(tracker.snapshot("game", 0).active_peer_count, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
use std::{io::ErrorKind, path::Path};
|
||||
|
||||
use lanspread_db::db::GameFileDescription;
|
||||
use tokio::fs::OpenOptions;
|
||||
|
||||
use crate::{local_games::is_local_dir_name, path_validation::validate_game_file_path};
|
||||
|
||||
const SYNC_DIR: &str = ".sync";
|
||||
|
||||
/// Prepares storage for game files by creating directories and pre-allocating files.
|
||||
pub(super) async fn prepare_game_storage(
|
||||
games_folder: &Path,
|
||||
file_descs: &[GameFileDescription],
|
||||
) -> eyre::Result<()> {
|
||||
for desc in file_descs {
|
||||
if desc.is_version_ini() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let validated_path = validate_game_file_path(games_folder, &desc.relative_path)?;
|
||||
|
||||
if desc.is_dir {
|
||||
tokio::fs::create_dir_all(&validated_path).await?;
|
||||
} else {
|
||||
if let Some(parent) = validated_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&validated_path)
|
||||
.await?;
|
||||
|
||||
let size = desc.size;
|
||||
if let Err(e) = file.set_len(size).await {
|
||||
log::warn!(
|
||||
"Failed to pre-allocate file {} (size: {}): {}",
|
||||
desc.relative_path,
|
||||
size,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
log::debug!(
|
||||
"Pre-allocated file {} with {} bytes",
|
||||
desc.relative_path,
|
||||
size
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discards the peer-owned downloaded payload after a cancelled transfer.
|
||||
///
|
||||
/// Downloads own the root archive/cache files, but not `local/` or install
|
||||
/// transaction metadata. Preserving those paths lets a cancelled update settle
|
||||
/// as a local-only install instead of deleting user-owned extracted files.
|
||||
pub(super) async fn discard_cancelled_download(
|
||||
games_folder: &Path,
|
||||
game_id: &str,
|
||||
) -> eyre::Result<()> {
|
||||
let game_root = games_folder.join(game_id);
|
||||
let Some(metadata) = symlink_metadata_if_exists(&game_root).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if metadata.file_type().is_symlink() {
|
||||
eyre::bail!(
|
||||
"refusing to discard cancelled download through symlink root {}",
|
||||
game_root.display()
|
||||
);
|
||||
}
|
||||
if !metadata.is_dir() {
|
||||
eyre::bail!(
|
||||
"refusing to discard cancelled download from non-directory root {}",
|
||||
game_root.display()
|
||||
);
|
||||
}
|
||||
|
||||
let mut entries = tokio::fs::read_dir(&game_root).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let name = entry.file_name();
|
||||
if name
|
||||
.to_str()
|
||||
.is_some_and(should_preserve_on_download_discard)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
remove_entry(&entry.path()).await?;
|
||||
}
|
||||
|
||||
remove_dir_if_empty(&game_root).await
|
||||
}
|
||||
|
||||
fn should_preserve_on_download_discard(name: &str) -> bool {
|
||||
is_local_dir_name(name) || name.starts_with(".local.") || name == SYNC_DIR
|
||||
}
|
||||
|
||||
async fn remove_entry(path: &Path) -> eyre::Result<()> {
|
||||
let Some(metadata) = symlink_metadata_if_exists(path).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if metadata.file_type().is_symlink() || metadata.is_file() {
|
||||
remove_file_if_exists(path).await
|
||||
} else if metadata.is_dir() {
|
||||
tokio::fs::remove_dir_all(path).await?;
|
||||
Ok(())
|
||||
} else {
|
||||
remove_file_if_exists(path).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> {
|
||||
match tokio::fs::remove_file(path).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_dir_if_empty(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 symlink_metadata_if_exists(path: &Path) -> eyre::Result<Option<std::fs::Metadata>> {
|
||||
match tokio::fs::symlink_metadata(path).await {
|
||||
Ok(metadata) => Ok(Some(metadata)),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use lanspread_db::db::GameFileDescription;
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::TempDir;
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_game_storage_skips_version_ini_sentinel() {
|
||||
let temp = TempDir::new("lanspread-download");
|
||||
let descs = vec![GameFileDescription {
|
||||
game_id: "game".to_string(),
|
||||
relative_path: "game/version.ini".to_string(),
|
||||
is_dir: false,
|
||||
size: 8,
|
||||
}];
|
||||
|
||||
prepare_game_storage(temp.path(), &descs)
|
||||
.await
|
||||
.expect("storage preparation should succeed");
|
||||
|
||||
assert!(!temp.path().join("game").join("version.ini").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discard_cancelled_download_removes_peer_owned_payload() {
|
||||
let temp = TempDir::new("lanspread-download-discard");
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join(".version.ini.tmp"), b"tmp");
|
||||
write_file(&root.join(".version.ini.discarded"), b"old");
|
||||
write_file(&root.join("archive.eti"), b"partial");
|
||||
write_file(&root.join("nested").join("payload.bin"), b"partial");
|
||||
|
||||
discard_cancelled_download(temp.path(), "game")
|
||||
.await
|
||||
.expect("cancelled payload should be discarded");
|
||||
|
||||
assert!(!root.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discard_cancelled_download_preserves_local_install_state() {
|
||||
let temp = TempDir::new("lanspread-download-discard-local");
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("archive.eti"), b"partial");
|
||||
write_file(&root.join("local").join("save.dat"), b"user-data");
|
||||
write_file(&root.join(".local.backup").join(".lanspread_owned"), b"");
|
||||
|
||||
discard_cancelled_download(temp.path(), "game")
|
||||
.await
|
||||
.expect("cancelled payload should be discarded");
|
||||
|
||||
assert!(!root.join("version.ini").exists());
|
||||
assert!(!root.join("archive.eti").exists());
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("local").join("save.dat"))
|
||||
.expect("local install should remain"),
|
||||
b"user-data"
|
||||
);
|
||||
assert!(root.join(".local.backup").is_dir());
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ async fn open_chunk_stream(
|
||||
|
||||
/// Receives one requested chunk from a peer stream.
|
||||
async fn receive_chunk(
|
||||
peer_addr: SocketAddr,
|
||||
mut rx: ReceiveStream,
|
||||
base_dir: &Path,
|
||||
chunk: &DownloadChunk,
|
||||
@@ -71,7 +72,7 @@ async fn receive_chunk(
|
||||
if let Some(buffer) = version_buffer
|
||||
&& buffer.matches(&chunk.relative_path)
|
||||
{
|
||||
return download_version_ini_chunk(rx, chunk, &buffer, progress_tracker).await;
|
||||
return download_version_ini_chunk(peer_addr, rx, chunk, &buffer, progress_tracker).await;
|
||||
}
|
||||
|
||||
// Validate the path to prevent directory traversal
|
||||
@@ -91,7 +92,7 @@ async fn receive_chunk(
|
||||
let mut remaining = chunk.length;
|
||||
let mut received_bytes = 0u64;
|
||||
let mut progress =
|
||||
progress_tracker.track_chunk(&chunk.relative_path, chunk.offset, chunk.length);
|
||||
progress_tracker.track_chunk(peer_addr, &chunk.relative_path, chunk.offset, chunk.length);
|
||||
|
||||
while let Some(bytes) = rx.receive().await? {
|
||||
file.write_all(&bytes).await?;
|
||||
@@ -135,7 +136,15 @@ async fn receive_chunk_result(
|
||||
version_buffer: Option<Arc<VersionIniBuffer>>,
|
||||
progress_tracker: Arc<DownloadProgressTracker>,
|
||||
) -> ChunkDownloadResult {
|
||||
let result = receive_chunk(rx, &base_dir, &chunk, version_buffer, progress_tracker).await;
|
||||
let result = receive_chunk(
|
||||
peer_addr,
|
||||
rx,
|
||||
&base_dir,
|
||||
&chunk,
|
||||
version_buffer,
|
||||
progress_tracker,
|
||||
)
|
||||
.await;
|
||||
ChunkDownloadResult {
|
||||
chunk,
|
||||
result,
|
||||
@@ -144,6 +153,7 @@ async fn receive_chunk_result(
|
||||
}
|
||||
|
||||
async fn download_version_ini_chunk(
|
||||
peer_addr: SocketAddr,
|
||||
mut rx: ReceiveStream,
|
||||
chunk: &DownloadChunk,
|
||||
buffer: &VersionIniBuffer,
|
||||
@@ -151,7 +161,7 @@ async fn download_version_ini_chunk(
|
||||
) -> eyre::Result<()> {
|
||||
let mut received = Vec::new();
|
||||
let mut progress =
|
||||
progress_tracker.track_chunk(&chunk.relative_path, chunk.offset, chunk.length);
|
||||
progress_tracker.track_chunk(peer_addr, &chunk.relative_path, chunk.offset, chunk.length);
|
||||
while let Some(bytes) = rx.receive().await? {
|
||||
progress.record_bytes(bytes.len());
|
||||
received.extend_from_slice(&bytes);
|
||||
@@ -311,7 +321,12 @@ pub(super) async fn download_from_peer(
|
||||
|
||||
ensure_download_not_cancelled(cancel_token, game_id)?;
|
||||
|
||||
let mut conn = match connect_to_peer(peer_addr).await {
|
||||
let mut conn = match tokio::select! {
|
||||
() = cancel_token.cancelled() => {
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
}
|
||||
result = connect_to_peer(peer_addr) => result,
|
||||
} {
|
||||
Ok(conn) => conn,
|
||||
Err(err) => return Ok(failed_plan_results(plan, peer_addr, err)),
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
|
||||
use crate::{
|
||||
@@ -65,9 +66,13 @@ 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 = { peer_game_db.read().await.get_all_games() };
|
||||
let games = {
|
||||
let catalog = catalog.read().await;
|
||||
peer_game_db.read().await.get_catalog_games(&catalog)
|
||||
};
|
||||
send(tx_notify_ui, PeerEvent::ListGames(games));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@ 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,
|
||||
@@ -23,7 +25,7 @@ use crate::{
|
||||
game_from_summary,
|
||||
get_game_file_descriptions,
|
||||
local_dir_is_directory,
|
||||
local_download_available,
|
||||
local_download_matches_catalog,
|
||||
rescan_local_game,
|
||||
scan_local_library,
|
||||
version_ini_is_regular_file,
|
||||
@@ -32,16 +34,20 @@ 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, tx_notify_ui).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, tx_notify_ui).await;
|
||||
}
|
||||
|
||||
/// Tries to serve a game from local files.
|
||||
@@ -54,7 +60,7 @@ async fn try_serve_local_game(
|
||||
|
||||
let active_operations = ctx.active_operations.read().await;
|
||||
let catalog = ctx.catalog.read().await;
|
||||
if !local_download_available(&game_dir, id, &active_operations, &catalog).await {
|
||||
if !local_download_matches_catalog(&game_dir, id, &active_operations, &catalog).await {
|
||||
return false;
|
||||
}
|
||||
drop(active_operations);
|
||||
@@ -90,9 +96,10 @@ 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)
|
||||
source.select_peers(&peer_game_db, &id, expected_version.as_deref())
|
||||
};
|
||||
if peers.is_empty() {
|
||||
log::warn!("No peers have game {id}");
|
||||
@@ -107,6 +114,7 @@ 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 {
|
||||
@@ -126,10 +134,16 @@ impl GameDetailSource {
|
||||
matches!(self, Self::LocalOrPeers)
|
||||
}
|
||||
|
||||
fn select_peers(self, peer_game_db: &PeerGameDB, id: &str) -> Vec<SocketAddr> {
|
||||
fn select_peers(
|
||||
self,
|
||||
peer_game_db: &PeerGameDB,
|
||||
id: &str,
|
||||
expected_version: Option<&str>,
|
||||
) -> Vec<SocketAddr> {
|
||||
match self {
|
||||
Self::LocalOrPeers => peer_game_db.peers_with_game(id),
|
||||
Self::LatestPeersOnly => peer_game_db.peers_with_latest_version(id),
|
||||
Self::LocalOrPeers | Self::LatestPeersOnly => {
|
||||
peer_game_db.peers_with_expected_version(id, expected_version)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,6 +168,7 @@ 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,
|
||||
@@ -175,7 +190,12 @@ async fn fetch_game_details_from_peers<F, Fut>(
|
||||
}
|
||||
|
||||
if fetched_any {
|
||||
let aggregated_files = { peer_game_db.read().await.aggregated_game_files(&id) };
|
||||
let aggregated_files = {
|
||||
peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.aggregated_game_files(&id, expected_version.as_deref())
|
||||
};
|
||||
|
||||
if let Err(e) = tx_notify_ui.send(PeerEvent::GotGameFiles {
|
||||
id: id.clone(),
|
||||
@@ -210,6 +230,7 @@ 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) = {
|
||||
@@ -217,7 +238,7 @@ pub async fn handle_download_game_files_command(
|
||||
.peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.validate_file_sizes_majority(&id)
|
||||
.validate_file_sizes_majority(&id, expected_version.as_deref())
|
||||
{
|
||||
Ok((files, peers, file_peer_map)) => {
|
||||
log::info!(
|
||||
@@ -260,7 +281,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_available(&games_folder, &id, &active_operations, &catalog).await
|
||||
local_download_matches_catalog(&games_folder, &id, &active_operations, &catalog).await
|
||||
};
|
||||
|
||||
if peer_whitelist.is_empty() {
|
||||
@@ -289,9 +310,17 @@ pub async fn handle_download_game_files_command(
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
let active_operations = ctx.active_operations.clone();
|
||||
@@ -321,7 +350,7 @@ pub async fn handle_download_game_files_command(
|
||||
peer_whitelist,
|
||||
file_peer_map,
|
||||
tx_notify_ui_clone.clone(),
|
||||
cancel_token,
|
||||
cancel_token.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -330,8 +359,18 @@ pub async fn handle_download_game_files_command(
|
||||
let Some(prepared) =
|
||||
prepare_install_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await
|
||||
else {
|
||||
if let Err(err) = refresh_local_game_for_ending_operation(
|
||||
&ctx_clone,
|
||||
&tx_notify_ui_clone,
|
||||
&download_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to refresh local library after download: {err}");
|
||||
}
|
||||
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||
download_state_guard.disarm();
|
||||
send_download_finished(&tx_notify_ui_clone, &download_id);
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -345,6 +384,8 @@ pub async fn handle_download_game_files_command(
|
||||
.await
|
||||
{
|
||||
clear_active_download(&ctx_clone, &download_id).await;
|
||||
send_download_finished(&tx_notify_ui_clone, &download_id);
|
||||
download_state_guard.disarm();
|
||||
run_started_install_operation(
|
||||
&ctx_clone,
|
||||
&tx_notify_ui_clone,
|
||||
@@ -353,22 +394,50 @@ pub async fn handle_download_game_files_command(
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
clear_active_download(&ctx_clone, &download_id).await;
|
||||
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||
download_state_guard.disarm();
|
||||
send_download_finished(&tx_notify_ui_clone, &download_id);
|
||||
}
|
||||
} else {
|
||||
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||
if let Err(err) =
|
||||
refresh_local_game(&ctx_clone, &tx_notify_ui_clone, &download_id).await
|
||||
if let Err(err) = refresh_local_game_for_ending_operation(
|
||||
&ctx_clone,
|
||||
&tx_notify_ui_clone,
|
||||
&download_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to refresh local library after download: {err}");
|
||||
}
|
||||
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||
download_state_guard.disarm();
|
||||
send_download_finished(&tx_notify_ui_clone, &download_id);
|
||||
}
|
||||
download_state_guard.disarm();
|
||||
}
|
||||
Err(e) => {
|
||||
if let Err(refresh_err) = refresh_local_game_for_ending_operation(
|
||||
&ctx_clone,
|
||||
&tx_notify_ui_clone,
|
||||
&download_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!(
|
||||
"Failed to refresh local library after download failure: {refresh_err}"
|
||||
);
|
||||
}
|
||||
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||
download_state_guard.disarm();
|
||||
log::error!("Download failed for {download_id}: {e}");
|
||||
let download_was_cancelled = cancel_token.is_cancelled();
|
||||
if download_was_cancelled {
|
||||
log::info!("Download cancelled for {download_id}: {e}");
|
||||
} else {
|
||||
log::error!("Download failed for {download_id}: {e}");
|
||||
}
|
||||
send_download_failed_unless_cancelled(
|
||||
&tx_notify_ui_clone,
|
||||
&download_id,
|
||||
download_was_cancelled,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -383,6 +452,60 @@ pub async fn handle_install_game_command(
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/// Handles the `UninstallGame` command.
|
||||
pub async fn handle_uninstall_game_command(
|
||||
ctx: &Ctx,
|
||||
@@ -423,6 +546,284 @@ 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(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
transaction: install::StreamedInstallTransaction,
|
||||
) {
|
||||
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();
|
||||
@@ -436,9 +837,20 @@ async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEve
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
run_started_install_operation(ctx, tx_notify_ui, id, prepared).await;
|
||||
@@ -514,12 +926,13 @@ async fn run_started_install_operation(
|
||||
},
|
||||
);
|
||||
|
||||
let state_dir = ctx.state_dir.as_ref();
|
||||
match operation {
|
||||
InstallOperation::Installing => {
|
||||
install::install(&game_root, &id, ctx.unpacker.clone()).await
|
||||
install::install(&game_root, state_dir, &id, ctx.unpacker.clone()).await
|
||||
}
|
||||
InstallOperation::Updating => {
|
||||
install::update(&game_root, &id, ctx.unpacker.clone()).await
|
||||
install::update(&game_root, state_dir, &id, ctx.unpacker.clone()).await
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -560,9 +973,20 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
|
||||
return;
|
||||
}
|
||||
|
||||
if !begin_operation(ctx, tx_notify_ui, &id, OperationKind::Uninstalling).await {
|
||||
log::warn!("Operation for {id} already in progress; ignoring uninstall command");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
let game_root = { ctx.game_dir.read().await.join(&id) };
|
||||
@@ -577,7 +1001,7 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
|
||||
PeerEvent::UninstallGameBegin { id: id.clone() },
|
||||
);
|
||||
|
||||
install::uninstall(&game_root, &id).await
|
||||
install::uninstall(&game_root, ctx.state_dir.as_ref(), &id).await
|
||||
};
|
||||
|
||||
match result {
|
||||
@@ -622,9 +1046,20 @@ async fn run_remove_downloaded_operation(
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||
@@ -674,12 +1109,36 @@ 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,
|
||||
) -> bool {
|
||||
) -> 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 {
|
||||
let started = {
|
||||
let mut active_operations = ctx.active_operations.write().await;
|
||||
match active_operations.entry(id.to_string()) {
|
||||
@@ -691,11 +1150,70 @@ async fn begin_operation(
|
||||
}
|
||||
};
|
||||
|
||||
if started {
|
||||
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
|
||||
if !started {
|
||||
return BeginOperationResult::AlreadyActive;
|
||||
}
|
||||
|
||||
started
|
||||
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
|
||||
}
|
||||
|
||||
async fn transition_download_to_install(
|
||||
@@ -743,6 +1261,31 @@ async fn clear_active_download(ctx: &Ctx, id: &str) {
|
||||
ctx.active_downloads.write().await.remove(id);
|
||||
}
|
||||
|
||||
fn send_download_finished(tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
|
||||
if let Err(err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished { id: id.into() }) {
|
||||
log::error!("Failed to send DownloadGameFilesFinished event: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn send_download_failed(tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
|
||||
if let Err(err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.into() }) {
|
||||
log::error!("Failed to send DownloadGameFilesFailed event: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn send_download_failed_unless_cancelled(
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: &str,
|
||||
cancelled: bool,
|
||||
) -> bool {
|
||||
if cancelled {
|
||||
return false;
|
||||
}
|
||||
|
||||
send_download_failed(tx_notify_ui, id);
|
||||
true
|
||||
}
|
||||
|
||||
async fn end_download_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
|
||||
end_operation(ctx, tx_notify_ui, id).await;
|
||||
clear_active_download(ctx, id).await;
|
||||
@@ -752,6 +1295,14 @@ 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,
|
||||
@@ -821,7 +1372,7 @@ async fn load_local_library_with_policy(
|
||||
) -> eyre::Result<()> {
|
||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||
let active_ids = active_operation_ids(ctx).await;
|
||||
install::recover_on_startup(&game_dir, &active_ids).await?;
|
||||
install::recover_on_startup(&game_dir, ctx.state_dir.as_ref(), &active_ids).await?;
|
||||
scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir, event_policy).await
|
||||
}
|
||||
|
||||
@@ -846,30 +1397,11 @@ async fn scan_and_announce_local_library(
|
||||
event_policy: LocalLibraryEventPolicy,
|
||||
) -> eyre::Result<()> {
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = scan_local_library(game_dir, &catalog).await?;
|
||||
let scan = scan_local_library(game_dir, ctx.state_dir.as_ref(), &catalog).await?;
|
||||
update_and_announce_games_with_policy(ctx, tx_notify_ui, scan, event_policy, None).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn refresh_local_game(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: &str,
|
||||
) -> eyre::Result<()> {
|
||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = rescan_local_game(&game_dir, &catalog, id).await?;
|
||||
update_and_announce_games_with_policy(
|
||||
ctx,
|
||||
tx_notify_ui,
|
||||
scan,
|
||||
LocalLibraryEventPolicy::OnChange,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refreshes the game whose operation has completed before clearing its
|
||||
/// active-operation snapshot, while preserving freeze behavior for other games.
|
||||
async fn refresh_local_game_for_ending_operation(
|
||||
@@ -879,7 +1411,7 @@ async fn refresh_local_game_for_ending_operation(
|
||||
) -> eyre::Result<()> {
|
||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = rescan_local_game(&game_dir, &catalog, id).await?;
|
||||
let scan = rescan_local_game(&game_dir, ctx.state_dir.as_ref(), &catalog, id).await?;
|
||||
update_and_announce_games_with_policy(
|
||||
ctx,
|
||||
tx_notify_ui,
|
||||
@@ -961,13 +1493,8 @@ 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 {
|
||||
if let Some(summary) = previous.get(id.as_str()) {
|
||||
summaries.insert(id.clone(), summary.clone());
|
||||
} else {
|
||||
summaries.remove(id);
|
||||
}
|
||||
summaries.remove(id);
|
||||
}
|
||||
game_db = GameDB::from(summaries.values().map(game_from_summary).collect());
|
||||
}
|
||||
@@ -1021,13 +1548,14 @@ async fn update_and_announce_games_with_policy(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
collections::HashMap,
|
||||
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};
|
||||
@@ -1063,14 +1591,40 @@ mod tests {
|
||||
Ctx::new(
|
||||
Arc::new(RwLock::new(PeerGameDB::new())),
|
||||
"peer".to_string(),
|
||||
game_dir,
|
||||
game_dir.clone(),
|
||||
game_dir.join(".test-state"),
|
||||
Arc::new(FakeUnpacker),
|
||||
CancellationToken::new(),
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(HashSet::from(["game".to_string()]))),
|
||||
Arc::new(RwLock::new(GameCatalog::from_ids(["game".to_string()]))),
|
||||
Arc::new(RwLock::new(HashMap::new())),
|
||||
Arc::new(crate::NoopStreamInstallProvider),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_download_error_does_not_emit_failed_event() {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
let emitted = send_download_failed_unless_cancelled(&tx, "game", true);
|
||||
|
||||
assert!(!emitted);
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncancelled_download_error_emits_failed_event() {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
let emitted = send_download_failed_unless_cancelled(&tx, "game", false);
|
||||
|
||||
assert!(emitted);
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(PeerEvent::DownloadGameFilesFailed { id }) if id == "game"
|
||||
));
|
||||
}
|
||||
|
||||
async fn recv_event(rx: &mut mpsc::UnboundedReceiver<PeerEvent>) -> PeerEvent {
|
||||
tokio::time::timeout(Duration::from_secs(1), rx.recv())
|
||||
.await
|
||||
@@ -1149,7 +1703,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_source_selects_latest_ready_peer_manifest() {
|
||||
fn update_source_selects_expected_ready_peer_manifest() {
|
||||
let old_addr = addr(12_000);
|
||||
let new_addr = addr(12_001);
|
||||
let local_only_addr = addr(12_002);
|
||||
@@ -1171,13 +1725,13 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
GameDetailSource::LatestPeersOnly.select_peers(&db, "game"),
|
||||
GameDetailSource::LatestPeersOnly.select_peers(&db, "game", Some("20250101")),
|
||||
vec![new_addr]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_fetch_emits_fresh_manifest_from_latest_peer() {
|
||||
async fn update_fetch_emits_fresh_manifest_from_expected_peer() {
|
||||
let old_addr = addr(12_010);
|
||||
let new_addr = addr(12_011);
|
||||
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
||||
@@ -1196,33 +1750,40 @@ mod tests {
|
||||
}
|
||||
let peers = {
|
||||
let db = peer_game_db.read().await;
|
||||
GameDetailSource::LatestPeersOnly.select_peers(&db, "game")
|
||||
GameDetailSource::LatestPeersOnly.select_peers(&db, "game", Some("20250101"))
|
||||
};
|
||||
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(), peer_game_db.clone(), tx, {
|
||||
let fetched_peers = fetched_peers.clone();
|
||||
move |peer_addr, game_id, peer_game_db| {
|
||||
fetch_game_details_from_peers(
|
||||
peers,
|
||||
"game".to_string(),
|
||||
Some("20250101".to_string()),
|
||||
peer_game_db.clone(),
|
||||
tx,
|
||||
{
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
@@ -1243,7 +1804,7 @@ mod tests {
|
||||
file_descriptions
|
||||
.iter()
|
||||
.any(|desc| desc.relative_path == "game/new.eti" && desc.size == 11),
|
||||
"latest peer manifest should be emitted to the download path"
|
||||
"expected-version peer manifest should be emitted to the download path"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1258,6 +1819,7 @@ mod tests {
|
||||
fetch_game_details_from_peers(
|
||||
vec![first_addr, second_addr],
|
||||
"game".to_string(),
|
||||
Some("20250101".to_string()),
|
||||
peer_game_db,
|
||||
tx.clone(),
|
||||
{
|
||||
@@ -1291,7 +1853,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_request_skips_local_manifest_even_when_download_exists() {
|
||||
let temp = TempDir::new("lanspread-handler-latest-peer");
|
||||
let temp = TempDir::new("lanspread-handler-expected-peer");
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20240101");
|
||||
write_file(&root.join("game.eti"), b"old archive");
|
||||
@@ -1314,23 +1876,37 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_library_scan_freezes_active_game_state() {
|
||||
let temp = TempDir::new("lanspread-handler-active-freeze");
|
||||
async fn local_library_scan_hides_active_game_state() {
|
||||
let temp = TempDir::new("lanspread-handler-active-hide");
|
||||
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(), &catalog)
|
||||
|
||||
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 {
|
||||
@@ -1338,7 +1914,7 @@ mod tests {
|
||||
};
|
||||
assert!(
|
||||
games.is_empty(),
|
||||
"active game should keep its previous announced state"
|
||||
"active game should be hidden/unannounced during operations"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1352,7 +1928,10 @@ mod tests {
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
assert!(begin_operation(&ctx, &tx, "game", OperationKind::Updating).await);
|
||||
assert_eq!(
|
||||
begin_operation(&ctx, &tx, "game", OperationKind::Updating).await,
|
||||
BeginOperationResult::Started
|
||||
);
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
vec![ActiveOperation {
|
||||
@@ -1362,6 +1941,48 @@ 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");
|
||||
@@ -1373,13 +1994,13 @@ mod tests {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
|
||||
let scan = scan_local_library(temp.path(), &catalog)
|
||||
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
||||
.await
|
||||
.expect("first scan should succeed");
|
||||
update_and_announce_games(&ctx, &tx, scan).await;
|
||||
assert_local_update(recv_event(&mut rx).await, false, true);
|
||||
|
||||
let scan = scan_local_library(temp.path(), &catalog)
|
||||
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
||||
.await
|
||||
.expect("second scan should succeed");
|
||||
update_and_announce_games(&ctx, &tx, scan).await;
|
||||
@@ -1398,7 +2019,7 @@ mod tests {
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = scan_local_library(temp.path(), &catalog)
|
||||
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
||||
.await
|
||||
.expect("initial scan should succeed");
|
||||
update_and_announce_games(&ctx, &tx, scan).await;
|
||||
@@ -1690,7 +2311,7 @@ mod tests {
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = scan_local_library(temp.path(), &catalog)
|
||||
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
||||
.await
|
||||
.expect("initial scan should succeed");
|
||||
update_and_announce_games(&ctx, &tx, scan).await;
|
||||
@@ -1776,7 +2397,7 @@ mod tests {
|
||||
let ctx = test_ctx(current.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = scan_local_library(current.path(), &catalog)
|
||||
let scan = scan_local_library(current.path(), ctx.state_dir.as_ref(), &catalog)
|
||||
.await
|
||||
.expect("initial scan should succeed");
|
||||
update_and_announce_games(&ctx, &tx, scan).await;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
const PEER_ID_FILE: &str = "peer_id";
|
||||
use crate::state_paths::peer_id_path;
|
||||
|
||||
pub const FEATURE_LIBRARY_DELTA: &str = "library-delta-v1";
|
||||
pub const FEATURE_LIBRARY_SNAPSHOT: &str = "library-snapshot-v1";
|
||||
|
||||
pub fn load_or_create_peer_id(state_dir: Option<&Path>) -> eyre::Result<String> {
|
||||
pub fn load_or_create_peer_id(state_dir: &Path) -> eyre::Result<String> {
|
||||
let path = peer_id_path(state_dir);
|
||||
if let Ok(existing) = std::fs::read_to_string(&path) {
|
||||
let trimmed = existing.trim();
|
||||
@@ -30,19 +30,3 @@ pub fn default_features() -> Vec<String> {
|
||||
FEATURE_LIBRARY_SNAPSHOT.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn peer_id_path(state_dir: Option<&Path>) -> PathBuf {
|
||||
if let Some(dir) = state_dir {
|
||||
return dir.join(PEER_ID_FILE);
|
||||
}
|
||||
|
||||
if let Some(dir) = std::env::var_os("LANSPREAD_STATE_DIR") {
|
||||
return PathBuf::from(dir).join(PEER_ID_FILE);
|
||||
}
|
||||
|
||||
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
|
||||
return PathBuf::from(home).join(".lanspread").join(PEER_ID_FILE);
|
||||
}
|
||||
|
||||
std::env::temp_dir().join("lanspread").join(PEER_ID_FILE)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
const INTENT_SCHEMA_VERSION: u32 = 1;
|
||||
const INTENT_FILE: &str = ".lanspread.json";
|
||||
const INTENT_TMP_FILE: &str = ".lanspread.json.tmp";
|
||||
pub(crate) const LEGACY_INTENT_FILE: &str = ".lanspread.json";
|
||||
pub(crate) const LEGACY_INTENT_TMP_FILE: &str = ".lanspread.json.tmp";
|
||||
const INTENT_FILE: &str = "install_intent.json";
|
||||
const INTENT_TMP_FILE: &str = "install_intent.json.tmp";
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub enum InstallIntentState {
|
||||
@@ -41,18 +43,22 @@ impl InstallIntent {
|
||||
pub fn none(id: &str, eti_version: Option<String>) -> Self {
|
||||
Self::new(id, InstallIntentState::None, eti_version)
|
||||
}
|
||||
|
||||
pub fn is_current_for(&self, id: &str) -> bool {
|
||||
self.schema_version == INTENT_SCHEMA_VERSION && self.id == id
|
||||
}
|
||||
}
|
||||
|
||||
pub fn intent_path(game_root: &Path) -> PathBuf {
|
||||
game_root.join(INTENT_FILE)
|
||||
pub fn intent_path(state_dir: &Path, id: &str) -> PathBuf {
|
||||
crate::state_paths::game_state_dir(state_dir, id).join(INTENT_FILE)
|
||||
}
|
||||
|
||||
pub fn intent_tmp_path(game_root: &Path) -> PathBuf {
|
||||
game_root.join(INTENT_TMP_FILE)
|
||||
pub fn intent_tmp_path(state_dir: &Path, id: &str) -> PathBuf {
|
||||
crate::state_paths::game_state_dir(state_dir, id).join(INTENT_TMP_FILE)
|
||||
}
|
||||
|
||||
pub async fn read_intent(game_root: &Path, id: &str) -> InstallIntent {
|
||||
let path = intent_path(game_root);
|
||||
pub async fn read_intent(state_dir: &Path, id: &str) -> InstallIntent {
|
||||
let path = intent_path(state_dir, id);
|
||||
let data = match tokio::fs::read_to_string(&path).await {
|
||||
Ok(data) => data,
|
||||
Err(err) => {
|
||||
@@ -64,7 +70,7 @@ pub async fn read_intent(game_root: &Path, id: &str) -> InstallIntent {
|
||||
};
|
||||
|
||||
match serde_json::from_str::<InstallIntent>(&data) {
|
||||
Ok(intent) if intent.schema_version == INTENT_SCHEMA_VERSION && intent.id == id => intent,
|
||||
Ok(intent) if intent.is_current_for(id) => intent,
|
||||
Ok(intent) => {
|
||||
log::warn!(
|
||||
"Ignoring install intent {} with schema {} for id {}",
|
||||
@@ -81,10 +87,11 @@ pub async fn read_intent(game_root: &Path, id: &str) -> InstallIntent {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write_intent(game_root: &Path, intent: &InstallIntent) -> eyre::Result<()> {
|
||||
tokio::fs::create_dir_all(game_root).await?;
|
||||
let path = intent_path(game_root);
|
||||
let tmp_path = intent_tmp_path(game_root);
|
||||
pub async fn write_intent(state_dir: &Path, id: &str, intent: &InstallIntent) -> eyre::Result<()> {
|
||||
let game_state_dir = crate::state_paths::game_state_dir(state_dir, id);
|
||||
tokio::fs::create_dir_all(&game_state_dir).await?;
|
||||
let path = intent_path(state_dir, id);
|
||||
let tmp_path = intent_tmp_path(state_dir, id);
|
||||
let data = serde_json::to_vec_pretty(intent)?;
|
||||
|
||||
let mut file = tokio::fs::File::create(&tmp_path).await?;
|
||||
@@ -122,6 +129,18 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::TempDir;
|
||||
|
||||
async fn write_raw_intent(state_dir: &Path, id: &str, bytes: impl AsRef<[u8]>) {
|
||||
let path = intent_path(state_dir, id);
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.expect("intent parent should be created");
|
||||
}
|
||||
tokio::fs::write(path, bytes)
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tmp_write_without_rename_leaves_previous_intent_intact() {
|
||||
let temp = TempDir::new("lanspread-intent");
|
||||
@@ -130,12 +149,12 @@ mod tests {
|
||||
InstallIntentState::Updating,
|
||||
Some("20240101".to_string()),
|
||||
);
|
||||
write_intent(temp.path(), &previous)
|
||||
write_intent(temp.path(), "game", &previous)
|
||||
.await
|
||||
.expect("previous intent should be written");
|
||||
|
||||
tokio::fs::write(
|
||||
intent_tmp_path(temp.path()),
|
||||
intent_tmp_path(temp.path(), "game"),
|
||||
serde_json::to_vec(&InstallIntent::new(
|
||||
"game",
|
||||
InstallIntentState::Installing,
|
||||
@@ -154,12 +173,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn schema_mismatch_is_treated_as_missing() {
|
||||
let temp = TempDir::new("lanspread-intent");
|
||||
tokio::fs::write(
|
||||
intent_path(temp.path()),
|
||||
write_raw_intent(
|
||||
temp.path(),
|
||||
"game",
|
||||
r#"{"schema_version":2,"id":"game","recorded_at":0,"state":"Updating"}"#,
|
||||
)
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
.await;
|
||||
|
||||
let recovered = read_intent(temp.path(), "game").await;
|
||||
assert_eq!(recovered.state, InstallIntentState::None);
|
||||
@@ -168,12 +187,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn mismatched_id_is_treated_as_missing() {
|
||||
let temp = TempDir::new("lanspread-intent");
|
||||
tokio::fs::write(
|
||||
intent_path(temp.path()),
|
||||
write_raw_intent(
|
||||
temp.path(),
|
||||
"game",
|
||||
r#"{"schema_version":1,"id":"other","recorded_at":0,"state":"Updating"}"#,
|
||||
)
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
.await;
|
||||
|
||||
let recovered = read_intent(temp.path(), "game").await;
|
||||
assert_eq!(recovered.state, InstallIntentState::None);
|
||||
@@ -182,9 +201,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn corrupt_intent_is_treated_as_missing() {
|
||||
let temp = TempDir::new("lanspread-intent");
|
||||
tokio::fs::write(intent_path(temp.path()), b"not json")
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
write_raw_intent(temp.path(), "game", b"not json").await;
|
||||
|
||||
let recovered = read_intent(temp.path(), "game").await;
|
||||
assert_eq!(recovered.state, InstallIntentState::None);
|
||||
@@ -193,21 +210,21 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn old_manifest_hash_field_is_ignored_and_new_writes_omit_it() {
|
||||
let temp = TempDir::new("lanspread-intent");
|
||||
tokio::fs::write(
|
||||
intent_path(temp.path()),
|
||||
write_raw_intent(
|
||||
temp.path(),
|
||||
"game",
|
||||
r#"{"schema_version":1,"id":"game","recorded_at":0,"state":"Updating","eti_version":"20240101","manifest_hash":42}"#,
|
||||
)
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
.await;
|
||||
|
||||
let recovered = read_intent(temp.path(), "game").await;
|
||||
assert_eq!(recovered.state, InstallIntentState::Updating);
|
||||
assert_eq!(recovered.eti_version.as_deref(), Some("20240101"));
|
||||
|
||||
write_intent(temp.path(), &InstallIntent::none("game", None))
|
||||
write_intent(temp.path(), "game", &InstallIntent::none("game", None))
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
let written = tokio::fs::read_to_string(intent_path(temp.path()))
|
||||
let written = tokio::fs::read_to_string(intent_path(temp.path(), "game"))
|
||||
.await
|
||||
.expect("intent should be readable");
|
||||
assert!(
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
mod intent;
|
||||
pub(crate) mod intent;
|
||||
mod remove;
|
||||
mod transaction;
|
||||
pub mod unpack;
|
||||
|
||||
pub use remove::remove_downloaded;
|
||||
pub use transaction::{install, recover_on_startup, uninstall, update};
|
||||
pub(crate) use transaction::root_eti_archives;
|
||||
pub use transaction::{
|
||||
StreamedInstallTransaction,
|
||||
begin_streamed_install,
|
||||
install,
|
||||
recover_on_startup,
|
||||
uninstall,
|
||||
update,
|
||||
};
|
||||
pub use unpack::{UnpackFuture, Unpacker};
|
||||
|
||||
@@ -11,7 +11,7 @@ use super::{
|
||||
intent::{InstallIntent, InstallIntentState, read_intent, write_intent},
|
||||
unpack::Unpacker,
|
||||
};
|
||||
use crate::local_games::version_ini_is_regular_file;
|
||||
use crate::{local_games::version_ini_is_regular_file, state_paths::launch_settings_applied_path};
|
||||
|
||||
const LOCAL_DIR: &str = "local";
|
||||
const INSTALLING_DIR: &str = ".local.installing";
|
||||
@@ -33,10 +33,154 @@ struct InstallFsState {
|
||||
backup: FsEntryState,
|
||||
}
|
||||
|
||||
pub async fn install(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
|
||||
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>,
|
||||
) -> eyre::Result<()> {
|
||||
let eti_version = read_downloaded_version(game_root).await;
|
||||
write_intent(
|
||||
game_root,
|
||||
state_dir,
|
||||
id,
|
||||
&InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()),
|
||||
)
|
||||
.await?;
|
||||
@@ -44,7 +188,8 @@ pub async fn install(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
|
||||
let result = install_inner(game_root, id, unpacker).await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
||||
reset_launch_settings_marker(state_dir, id).await?;
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -54,16 +199,22 @@ pub async fn install(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
|
||||
installing_dir(game_root).display()
|
||||
);
|
||||
}
|
||||
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
|
||||
pub async fn update(
|
||||
game_root: &Path,
|
||||
state_dir: &Path,
|
||||
id: &str,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
) -> eyre::Result<()> {
|
||||
let eti_version = read_downloaded_version(game_root).await;
|
||||
write_intent(
|
||||
game_root,
|
||||
state_dir,
|
||||
id,
|
||||
&InstallIntent::new(id, InstallIntentState::Updating, eti_version.clone()),
|
||||
)
|
||||
.await?;
|
||||
@@ -71,7 +222,8 @@ pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
|
||||
let result = update_inner(game_root, id, unpacker).await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
||||
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!(
|
||||
"Failed to clean install backup {}: {err}",
|
||||
@@ -82,7 +234,7 @@ pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
|
||||
}
|
||||
Err(err) => {
|
||||
let rollback = rollback_update(game_root).await;
|
||||
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||
if let Err(rollback_err) = rollback {
|
||||
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
|
||||
}
|
||||
@@ -91,10 +243,11 @@ pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
|
||||
pub async fn uninstall(game_root: &Path, state_dir: &Path, id: &str) -> eyre::Result<()> {
|
||||
let eti_version = read_downloaded_version(game_root).await;
|
||||
write_intent(
|
||||
game_root,
|
||||
state_dir,
|
||||
id,
|
||||
&InstallIntent::new(id, InstallIntentState::Uninstalling, eti_version.clone()),
|
||||
)
|
||||
.await?;
|
||||
@@ -102,7 +255,7 @@ pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
|
||||
let result = uninstall_inner(game_root).await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -110,13 +263,17 @@ pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
|
||||
if let Err(rollback_err) = rollback {
|
||||
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
|
||||
}
|
||||
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn recover_on_startup(game_dir: &Path, active_ids: &HashSet<String>) -> eyre::Result<()> {
|
||||
pub async fn recover_on_startup(
|
||||
game_dir: &Path,
|
||||
state_dir: &Path,
|
||||
active_ids: &HashSet<String>,
|
||||
) -> eyre::Result<()> {
|
||||
recover_download_transients(game_dir).await?;
|
||||
|
||||
let mut entries = match tokio::fs::read_dir(game_dir).await {
|
||||
@@ -141,22 +298,28 @@ pub async fn recover_on_startup(game_dir: &Path, active_ids: &HashSet<String>) -
|
||||
continue;
|
||||
}
|
||||
|
||||
recover_game_root(&entry.path(), &id).await?;
|
||||
recover_game_root(&entry.path(), state_dir, &id).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn recover_game_root(game_root: &Path, id: &str) -> eyre::Result<()> {
|
||||
pub async fn recover_game_root(game_root: &Path, state_dir: &Path, id: &str) -> eyre::Result<()> {
|
||||
recover_download_transients(game_root).await?;
|
||||
|
||||
let intent = read_intent(game_root, id).await;
|
||||
let intent = read_intent(state_dir, id).await;
|
||||
let fs = inspect_install_fs(game_root).await;
|
||||
match intent.state {
|
||||
InstallIntentState::None => recover_none_intent(game_root).await?,
|
||||
InstallIntentState::Installing => recover_installing(game_root, id, intent, fs).await?,
|
||||
InstallIntentState::Updating => recover_updating(game_root, id, intent, fs).await?,
|
||||
InstallIntentState::Uninstalling => recover_uninstalling(game_root, id, intent, fs).await?,
|
||||
InstallIntentState::Installing => {
|
||||
recover_installing(game_root, state_dir, id, intent, fs).await?;
|
||||
}
|
||||
InstallIntentState::Updating => {
|
||||
recover_updating(game_root, state_dir, id, intent, fs).await?;
|
||||
}
|
||||
InstallIntentState::Uninstalling => {
|
||||
recover_uninstalling(game_root, state_dir, id, intent, fs).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -233,7 +396,7 @@ async fn unpack_archives(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
|
||||
pub(crate) 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? {
|
||||
@@ -249,6 +412,18 @@ async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
|
||||
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 recover_none_intent(game_root: &Path) -> eyre::Result<()> {
|
||||
sweep_owned_orphan(&installing_dir(game_root)).await?;
|
||||
sweep_owned_orphan(&backup_dir(game_root)).await?;
|
||||
@@ -257,10 +432,12 @@ async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
|
||||
|
||||
async fn recover_installing(
|
||||
game_root: &Path,
|
||||
state_dir: &Path,
|
||||
id: &str,
|
||||
intent: InstallIntent,
|
||||
fs: InstallFsState,
|
||||
) -> eyre::Result<()> {
|
||||
let commit_landed = fs.local == FsEntryState::Present;
|
||||
if let InstallFsState {
|
||||
installing: FsEntryState::Present,
|
||||
..
|
||||
@@ -268,15 +445,29 @@ async fn recover_installing(
|
||||
{
|
||||
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
|
||||
}
|
||||
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
|
||||
if commit_landed {
|
||||
reset_launch_settings_marker(state_dir, id).await?;
|
||||
}
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
|
||||
}
|
||||
|
||||
async fn recover_updating(
|
||||
game_root: &Path,
|
||||
state_dir: &Path,
|
||||
id: &str,
|
||||
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,
|
||||
@@ -301,11 +492,12 @@ async fn recover_updating(
|
||||
} => remove_dir_all_if_exists(&backup_dir(game_root)).await?,
|
||||
_ => {}
|
||||
}
|
||||
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
|
||||
}
|
||||
|
||||
async fn recover_uninstalling(
|
||||
game_root: &Path,
|
||||
state_dir: &Path,
|
||||
id: &str,
|
||||
intent: InstallIntent,
|
||||
fs: InstallFsState,
|
||||
@@ -323,7 +515,7 @@ async fn recover_uninstalling(
|
||||
} => uninstall_inner(game_root).await?,
|
||||
_ => {}
|
||||
}
|
||||
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
|
||||
}
|
||||
|
||||
async fn recover_download_transients(root: &Path) -> eyre::Result<()> {
|
||||
@@ -416,6 +608,10 @@ async fn restore_backup(game_root: &Path) -> eyre::Result<()> {
|
||||
}
|
||||
|
||||
async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> {
|
||||
if !path_exists(path).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match tokio::fs::remove_file(path).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
|
||||
@@ -431,12 +627,38 @@ 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
|
||||
.is_ok_and(|metadata| metadata.is_dir())
|
||||
}
|
||||
|
||||
async fn path_exists(path: &Path) -> bool {
|
||||
tokio::fs::metadata(path).await.is_ok()
|
||||
}
|
||||
|
||||
fn local_dir(game_root: &Path) -> PathBuf {
|
||||
game_root.join(LOCAL_DIR)
|
||||
}
|
||||
@@ -530,33 +752,123 @@ mod tests {
|
||||
Arc::new(FakeUnpacker::default())
|
||||
}
|
||||
|
||||
fn test_state() -> TempDir {
|
||||
TempDir::new("lanspread-install-state")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_success_promotes_staging_and_clears_intent() {
|
||||
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, "game", successful_unpacker())
|
||||
install(&root, state.path(), "game", successful_unpacker())
|
||||
.await
|
||||
.expect("install should succeed");
|
||||
|
||||
assert!(root.join("local").join("payload.txt").is_file());
|
||||
assert!(!root.join(".local.installing").exists());
|
||||
let intent = read_intent(&root, "game").await;
|
||||
let intent = read_intent(state.path(), "game").await;
|
||||
assert_eq!(intent.state, InstallIntentState::None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_resets_launch_settings_marker() {
|
||||
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");
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_unpacks_multiple_root_eti_archives_in_sorted_order() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("b.eti"), b"archive");
|
||||
write_file(&root.join("a.eti"), b"archive");
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
let unpacker = Arc::new(FakeUnpacker::default());
|
||||
|
||||
install(&root, "game", unpacker.clone())
|
||||
install(&root, state.path(), "game", unpacker.clone())
|
||||
.await
|
||||
.expect("install should succeed");
|
||||
|
||||
@@ -573,34 +885,46 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn update_failure_restores_previous_local() {
|
||||
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(&root.join("local").join("old.txt"), b"old");
|
||||
|
||||
let err = update(&root, "game", Arc::new(FakeUnpacker::failing()))
|
||||
.await
|
||||
.expect_err("update should fail");
|
||||
let err = update(
|
||||
&root,
|
||||
state.path(),
|
||||
"game",
|
||||
Arc::new(FakeUnpacker::failing()),
|
||||
)
|
||||
.await
|
||||
.expect_err("update should fail");
|
||||
|
||||
assert!(err.to_string().contains("forced unpack failure"));
|
||||
assert!(root.join("local").join("old.txt").is_file());
|
||||
assert!(!root.join(".local.installing").exists());
|
||||
assert!(!root.join(".local.backup").exists());
|
||||
let intent = read_intent(&root, "game").await;
|
||||
let intent = read_intent(state.path(), "game").await;
|
||||
assert_eq!(intent.state, InstallIntentState::None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_commit_rename_failure_restores_previous_local() {
|
||||
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(&root.join("local").join("old.txt"), b"old");
|
||||
|
||||
let err = update(&root, "game", Arc::new(FakeUnpacker::commit_conflict()))
|
||||
.await
|
||||
.expect_err("update should fail at commit rename");
|
||||
let err = update(
|
||||
&root,
|
||||
state.path(),
|
||||
"game",
|
||||
Arc::new(FakeUnpacker::commit_conflict()),
|
||||
)
|
||||
.await
|
||||
.expect_err("update should fail at commit rename");
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("failed to promote update"),
|
||||
@@ -614,19 +938,21 @@ mod tests {
|
||||
assert!(!root.join("local").join("conflict.txt").exists());
|
||||
assert!(!root.join(".local.installing").exists());
|
||||
assert!(!root.join(".local.backup").exists());
|
||||
let intent = read_intent(&root, "game").await;
|
||||
let intent = read_intent(state.path(), "game").await;
|
||||
assert_eq!(intent.state, InstallIntentState::None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_success_promotes_new_local_and_removes_backup() {
|
||||
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(&root.join("local").join("old.txt"), b"old");
|
||||
write_file(&launch_settings_applied_path(state.path(), "game"), b"");
|
||||
|
||||
update(&root, "game", successful_unpacker())
|
||||
update(&root, state.path(), "game", successful_unpacker())
|
||||
.await
|
||||
.expect("update should succeed");
|
||||
|
||||
@@ -634,19 +960,21 @@ mod tests {
|
||||
assert!(!root.join("local").join("old.txt").exists());
|
||||
assert!(!root.join(".local.installing").exists());
|
||||
assert!(!root.join(".local.backup").exists());
|
||||
let intent = read_intent(&root, "game").await;
|
||||
assert!(!launch_settings_applied_path(state.path(), "game").exists());
|
||||
let intent = read_intent(state.path(), "game").await;
|
||||
assert_eq!(intent.state, InstallIntentState::None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uninstall_removes_only_local_install() {
|
||||
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(&root.join("local").join("payload.txt"), b"installed");
|
||||
|
||||
uninstall(&root, "game")
|
||||
uninstall(&root, state.path(), "game")
|
||||
.await
|
||||
.expect("uninstall should succeed");
|
||||
|
||||
@@ -661,6 +989,7 @@ mod tests {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
let root = temp.game_root();
|
||||
let locked_dir = root.join("local").join("locked");
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
@@ -669,7 +998,7 @@ mod tests {
|
||||
std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o500))
|
||||
.expect("locked dir permissions should be set");
|
||||
|
||||
let _err = uninstall(&root, "game")
|
||||
let _err = uninstall(&root, state.path(), "game")
|
||||
.await
|
||||
.expect_err("uninstall should fail while deleting backup");
|
||||
|
||||
@@ -697,7 +1026,7 @@ mod tests {
|
||||
b"locked"
|
||||
);
|
||||
assert!(!root.join(".local.backup").exists());
|
||||
let intent = read_intent(&root, "game").await;
|
||||
let intent = read_intent(state.path(), "game").await;
|
||||
assert_eq!(intent.state, InstallIntentState::None);
|
||||
}
|
||||
|
||||
@@ -844,23 +1173,25 @@ mod tests {
|
||||
},
|
||||
];
|
||||
|
||||
let state = test_state();
|
||||
for case in cases {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let root = temp.game_root();
|
||||
seed_recovery_case(&root, &case);
|
||||
write_intent(
|
||||
&root,
|
||||
state.path(),
|
||||
"game",
|
||||
&InstallIntent::new("game", case.intent_state.clone(), Some("20250101".into())),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("{} intent should be written: {err}", case.name));
|
||||
|
||||
recover_game_root(&root, "game")
|
||||
recover_game_root(&root, state.path(), "game")
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("{} recovery should succeed: {err}", case.name));
|
||||
|
||||
assert_recovered_case(&root, &case);
|
||||
let intent = read_intent(&root, "game").await;
|
||||
let intent = read_intent(state.path(), "game").await;
|
||||
assert_eq!(intent.state, InstallIntentState::None, "{}", case.name);
|
||||
assert_eq!(
|
||||
intent.eti_version.as_deref(),
|
||||
@@ -871,13 +1202,92 @@ 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");
|
||||
let state = test_state();
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join(".local.backup").join("user.txt"), b"user");
|
||||
|
||||
recover_game_root(&root, "game")
|
||||
recover_game_root(&root, state.path(), "game")
|
||||
.await
|
||||
.expect("recovery should succeed");
|
||||
|
||||
@@ -887,11 +1297,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn download_recovery_sweeps_reserved_version_files() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join(VERSION_TMP_FILE), b"tmp");
|
||||
write_file(&root.join(VERSION_DISCARDED_FILE), b"old");
|
||||
|
||||
recover_game_root(&root, "game")
|
||||
recover_game_root(&root, state.path(), "game")
|
||||
.await
|
||||
.expect("recovery should succeed");
|
||||
|
||||
@@ -902,14 +1313,19 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn startup_recovery_skips_active_game_roots() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
let active_root = temp.path().join("active");
|
||||
let inactive_root = temp.path().join("inactive");
|
||||
write_file(&active_root.join(VERSION_TMP_FILE), b"tmp");
|
||||
write_file(&inactive_root.join(VERSION_TMP_FILE), b"tmp");
|
||||
|
||||
recover_on_startup(temp.path(), &HashSet::from(["active".to_string()]))
|
||||
.await
|
||||
.expect("recovery should succeed");
|
||||
recover_on_startup(
|
||||
temp.path(),
|
||||
state.path(),
|
||||
&HashSet::from(["active".to_string()]),
|
||||
)
|
||||
.await
|
||||
.expect("recovery should succeed");
|
||||
|
||||
assert!(active_root.join(VERSION_TMP_FILE).is_file());
|
||||
assert!(!inactive_root.join(VERSION_TMP_FILE).exists());
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
//! 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,8 +20,10 @@ mod events;
|
||||
mod handlers;
|
||||
mod identity;
|
||||
mod install;
|
||||
mod launch_settings;
|
||||
mod library;
|
||||
mod local_games;
|
||||
mod migration;
|
||||
mod network;
|
||||
mod path_validation;
|
||||
mod peer;
|
||||
@@ -29,6 +31,8 @@ mod peer_db;
|
||||
mod remote_peer;
|
||||
mod services;
|
||||
mod startup;
|
||||
mod state_paths;
|
||||
mod stream_install;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
|
||||
@@ -36,12 +40,13 @@ mod test_support;
|
||||
// Public re-exports
|
||||
// =============================================================================
|
||||
|
||||
use std::{collections::HashSet, net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
use std::{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, GameFileDescription};
|
||||
use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
|
||||
pub use migration::{MigrationReport, migrate_legacy_state};
|
||||
pub use peer_db::{
|
||||
MajorityValidationResult,
|
||||
PeerGameDB,
|
||||
@@ -56,7 +61,6 @@ use tokio::sync::{
|
||||
};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
pub use crate::startup::PeerRuntimeHandle;
|
||||
use crate::{
|
||||
context::Ctx,
|
||||
handlers::{
|
||||
@@ -73,6 +77,20 @@ use crate::{
|
||||
handle_uninstall_game_command,
|
||||
load_local_library,
|
||||
},
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
@@ -144,6 +162,8 @@ 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>,
|
||||
@@ -162,6 +182,8 @@ pub struct DownloadProgress {
|
||||
pub downloaded_bytes: u64,
|
||||
pub total_bytes: u64,
|
||||
pub bytes_per_second: u64,
|
||||
/// Unique peers currently streaming at least one chunk for this download.
|
||||
pub active_peer_count: usize,
|
||||
}
|
||||
|
||||
/// Long-running peer runtime components reported in failure events.
|
||||
@@ -230,6 +252,8 @@ pub enum PeerCommand {
|
||||
file_descriptions: Vec<GameFileDescription>,
|
||||
install_after_download: bool,
|
||||
},
|
||||
/// Stream archive-expanded bytes directly into `local/` without keeping root archives.
|
||||
StreamInstallGame { id: String },
|
||||
/// Install already-downloaded archives into `local/`.
|
||||
InstallGame { id: String },
|
||||
/// Remove only the `local/` install for a game.
|
||||
@@ -247,10 +271,29 @@ pub enum PeerCommand {
|
||||
}
|
||||
|
||||
/// Optional startup settings for non-GUI callers and tests.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[derive(Clone, 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()
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -275,7 +318,7 @@ pub fn start_peer(
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
) -> eyre::Result<PeerRuntimeHandle> {
|
||||
start_peer_with_options(
|
||||
game_dir,
|
||||
@@ -294,16 +337,25 @@ pub fn start_peer_with_options(
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
options: PeerStartOptions,
|
||||
) -> eyre::Result<PeerRuntimeHandle> {
|
||||
let PeerStartOptions { state_dir } = options;
|
||||
let PeerStartOptions {
|
||||
state_dir,
|
||||
active_outbound_transfers,
|
||||
stream_install_provider,
|
||||
} = 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()
|
||||
);
|
||||
let peer_id = identity::load_or_create_peer_id(state_dir.as_deref())?;
|
||||
let peer_id = identity::load_or_create_peer_id(&state_dir)?;
|
||||
|
||||
let (tx_control, rx_control) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
||||
@@ -314,8 +366,11 @@ pub fn start_peer_with_options(
|
||||
peer_game_db,
|
||||
peer_id,
|
||||
game_dir,
|
||||
state_dir,
|
||||
unpacker,
|
||||
catalog,
|
||||
active_outbound_transfers,
|
||||
stream_install_provider,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -327,19 +382,25 @@ async fn run_peer(
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
peer_id: String,
|
||||
game_dir: PathBuf,
|
||||
state_dir: PathBuf,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
shutdown: CancellationToken,
|
||||
task_tracker: TaskTracker,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
active_outbound_transfers: crate::context::OutboundTransfers,
|
||||
stream_install_provider: Arc<dyn StreamInstallProvider>,
|
||||
) -> eyre::Result<()> {
|
||||
let ctx = Ctx::new(
|
||||
peer_game_db,
|
||||
peer_id,
|
||||
game_dir,
|
||||
state_dir,
|
||||
unpacker,
|
||||
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}");
|
||||
@@ -413,6 +474,9 @@ async fn handle_peer_commands(
|
||||
)
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::{
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use lanspread_db::db::{Game, GameDB, GameFileDescription};
|
||||
use lanspread_db::db::{Game, GameCatalog, 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: &HashSet<String>,
|
||||
catalog: &GameCatalog,
|
||||
) -> bool {
|
||||
if !catalog.contains(game_id) {
|
||||
log::debug!("Not serving game {game_id} locally because it is not in the catalog");
|
||||
@@ -67,11 +67,45 @@ 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
|
||||
// =============================================================================
|
||||
|
||||
const LIBRARY_INDEX_DIR: &str = ".lanspread";
|
||||
const LEGACY_LIBRARY_INDEX_DIR: &str = ".lanspread";
|
||||
const LIBRARY_INDEX_FILE: &str = "library_index.json";
|
||||
const INTENT_LOG_FILE: &str = ".lanspread.json";
|
||||
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
|
||||
@@ -114,8 +148,14 @@ pub struct LocalLibraryScan {
|
||||
pub revision: u64,
|
||||
}
|
||||
|
||||
fn library_index_path(game_dir: &Path) -> PathBuf {
|
||||
game_dir.join(LIBRARY_INDEX_DIR).join(LIBRARY_INDEX_FILE)
|
||||
pub(crate) fn legacy_library_index_path(game_dir: &Path) -> PathBuf {
|
||||
game_dir
|
||||
.join(LEGACY_LIBRARY_INDEX_DIR)
|
||||
.join(LIBRARY_INDEX_FILE)
|
||||
}
|
||||
|
||||
fn library_index_path(state_dir: &Path) -> PathBuf {
|
||||
crate::state_paths::local_library_index_path(state_dir)
|
||||
}
|
||||
|
||||
fn library_index_tmp_path(path: &Path) -> PathBuf {
|
||||
@@ -278,7 +318,7 @@ async fn fingerprint_game_dir(game_path: &Path) -> eyre::Result<GameFingerprint>
|
||||
}
|
||||
|
||||
pub fn is_ignored_game_root_name(name: &str) -> bool {
|
||||
name == LIBRARY_INDEX_DIR
|
||||
name == LEGACY_LIBRARY_INDEX_DIR
|
||||
}
|
||||
|
||||
fn is_reserved_transient_name(name: &str) -> bool {
|
||||
@@ -286,7 +326,7 @@ fn is_reserved_transient_name(name: &str) -> bool {
|
||||
|| name == VERSION_TMP_FILE
|
||||
|| name == VERSION_DISCARDED_FILE
|
||||
|| name == INTENT_LOG_FILE
|
||||
|| name == LIBRARY_INDEX_DIR
|
||||
|| name == LEGACY_LIBRARY_INDEX_DIR
|
||||
}
|
||||
|
||||
fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool {
|
||||
@@ -462,7 +502,7 @@ struct IndexUpdate {
|
||||
async fn update_index_for_game(
|
||||
game_root: &Path,
|
||||
game_id: &str,
|
||||
catalog: &HashSet<String>,
|
||||
catalog: &GameCatalog,
|
||||
index: &mut LibraryIndex,
|
||||
) -> eyre::Result<IndexUpdate> {
|
||||
if !catalog.contains(game_id) {
|
||||
@@ -550,9 +590,11 @@ fn scan_from_index(index: &LibraryIndex) -> LocalLibraryScan {
|
||||
/// Scans the local game directory and returns summaries plus a game database.
|
||||
pub async fn scan_local_library(
|
||||
game_dir: impl AsRef<Path>,
|
||||
catalog: &HashSet<String>,
|
||||
state_dir: impl AsRef<Path>,
|
||||
catalog: &GameCatalog,
|
||||
) -> eyre::Result<LocalLibraryScan> {
|
||||
let game_path = game_dir.as_ref();
|
||||
let state_path = state_dir.as_ref();
|
||||
|
||||
let metadata = match tokio::fs::metadata(game_path).await {
|
||||
Ok(metadata) => metadata,
|
||||
@@ -577,7 +619,7 @@ pub async fn scan_local_library(
|
||||
}
|
||||
|
||||
let _index_guard = LIBRARY_INDEX_LOCK.lock().await;
|
||||
let index_path = library_index_path(game_path);
|
||||
let index_path = library_index_path(state_path);
|
||||
let mut index = load_library_index(&index_path).await;
|
||||
let mut seen_ids = HashSet::new();
|
||||
let mut summaries = HashMap::new();
|
||||
@@ -636,12 +678,14 @@ pub async fn scan_local_library(
|
||||
/// Rescans a single game root through the cached index and returns full library state.
|
||||
pub async fn rescan_local_game(
|
||||
game_dir: impl AsRef<Path>,
|
||||
catalog: &HashSet<String>,
|
||||
state_dir: impl AsRef<Path>,
|
||||
catalog: &GameCatalog,
|
||||
game_id: &str,
|
||||
) -> eyre::Result<LocalLibraryScan> {
|
||||
let game_path = game_dir.as_ref();
|
||||
let state_path = state_dir.as_ref();
|
||||
let _index_guard = LIBRARY_INDEX_LOCK.lock().await;
|
||||
let index_path = library_index_path(game_path);
|
||||
let index_path = library_index_path(state_path);
|
||||
let mut index = load_library_index(&index_path).await;
|
||||
|
||||
let update = update_index_for_game(game_path, game_id, catalog, &mut index).await?;
|
||||
@@ -672,10 +716,7 @@ pub async fn get_game_file_descriptions(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::Path,
|
||||
};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
use lanspread_proto::Availability;
|
||||
|
||||
@@ -765,7 +806,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn scan_uses_version_ini_and_local_dir_as_independent_state() {
|
||||
let temp = TempDir::new("lanspread-local-games");
|
||||
let catalog = HashSet::from([
|
||||
let state = TempDir::new("lanspread-local-games-state");
|
||||
let catalog = GameCatalog::from_ids([
|
||||
"ready".to_string(),
|
||||
"local-only".to_string(),
|
||||
"eti-only".to_string(),
|
||||
@@ -783,7 +825,7 @@ mod tests {
|
||||
b"20250101",
|
||||
);
|
||||
|
||||
let scan = scan_local_library(temp.path(), &catalog)
|
||||
let scan = scan_local_library(temp.path(), state.path(), &catalog)
|
||||
.await
|
||||
.expect("scan should succeed");
|
||||
|
||||
@@ -818,11 +860,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn rescan_promotes_installed_only_game_to_ready_when_sentinel_appears() {
|
||||
let temp = TempDir::new("lanspread-local-games");
|
||||
let catalog = HashSet::from(["game".to_string()]);
|
||||
let state = TempDir::new("lanspread-local-games-state");
|
||||
let catalog = GameCatalog::from_ids(["game".to_string()]);
|
||||
std::fs::create_dir_all(temp.path().join("game").join("local"))
|
||||
.expect("local install dir should be created");
|
||||
|
||||
let first_scan = scan_local_library(temp.path(), &catalog)
|
||||
let first_scan = scan_local_library(temp.path(), state.path(), &catalog)
|
||||
.await
|
||||
.expect("initial scan should succeed");
|
||||
let local_only = first_scan
|
||||
@@ -835,7 +878,7 @@ mod tests {
|
||||
|
||||
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
|
||||
|
||||
let rescan = rescan_local_game(temp.path(), &catalog, "game")
|
||||
let rescan = rescan_local_game(temp.path(), state.path(), &catalog, "game")
|
||||
.await
|
||||
.expect("rescan should succeed");
|
||||
let ready = rescan
|
||||
@@ -851,11 +894,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn concurrent_rescans_preserve_both_index_updates() {
|
||||
let temp = TempDir::new("lanspread-local-games-concurrent");
|
||||
let catalog = HashSet::from(["game-a".to_string(), "game-b".to_string()]);
|
||||
let state = TempDir::new("lanspread-local-games-state");
|
||||
let catalog = GameCatalog::from_ids(["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");
|
||||
|
||||
let initial = scan_local_library(temp.path(), &catalog)
|
||||
let initial = scan_local_library(temp.path(), state.path(), &catalog)
|
||||
.await
|
||||
.expect("initial scan should succeed");
|
||||
assert_eq!(initial.revision, 1);
|
||||
@@ -864,13 +908,13 @@ mod tests {
|
||||
write_file(&temp.path().join("game-b").join("game-b.eti"), b"archive-b");
|
||||
|
||||
let (scan_a, scan_b) = tokio::join!(
|
||||
rescan_local_game(temp.path(), &catalog, "game-a"),
|
||||
rescan_local_game(temp.path(), &catalog, "game-b")
|
||||
rescan_local_game(temp.path(), state.path(), &catalog, "game-a"),
|
||||
rescan_local_game(temp.path(), state.path(), &catalog, "game-b")
|
||||
);
|
||||
scan_a.expect("game-a rescan should succeed");
|
||||
scan_b.expect("game-b rescan should succeed");
|
||||
|
||||
let index = load_library_index(&library_index_path(temp.path())).await;
|
||||
let index = load_library_index(&library_index_path(state.path())).await;
|
||||
assert_eq!(index.revision, 3);
|
||||
let game_a = index
|
||||
.games
|
||||
@@ -896,7 +940,7 @@ mod tests {
|
||||
let game_root = temp.path().join("game");
|
||||
write_file(&game_root.join("version.ini"), b"20250101");
|
||||
|
||||
let catalog = HashSet::from(["game".to_string()]);
|
||||
let catalog = GameCatalog::from_ids(["game".to_string()]);
|
||||
let no_operations = HashMap::new();
|
||||
assert!(local_download_available(temp.path(), "game", &no_operations, &catalog).await);
|
||||
|
||||
@@ -904,8 +948,29 @@ mod tests {
|
||||
assert!(!local_download_available(temp.path(), "game", &active_operations, &catalog).await);
|
||||
|
||||
assert!(
|
||||
!local_download_available(temp.path(), "game", &no_operations, &HashSet::new()).await
|
||||
!local_download_available(temp.path(), "game", &no_operations, &GameCatalog::empty())
|
||||
.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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
use std::{
|
||||
io::ErrorKind,
|
||||
path::{Path, PathBuf},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use futures::{StreamExt as _, stream};
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
|
||||
use crate::{
|
||||
install::intent::{
|
||||
InstallIntent,
|
||||
LEGACY_INTENT_FILE,
|
||||
LEGACY_INTENT_TMP_FILE,
|
||||
intent_path,
|
||||
write_intent,
|
||||
},
|
||||
local_games::{is_ignored_game_root_name, legacy_library_index_path},
|
||||
state_paths::{local_library_index_path, setup_done_path},
|
||||
};
|
||||
|
||||
const LEGACY_LIBRARY_INDEX_DIR: &str = ".lanspread";
|
||||
const LEGACY_FIRST_START_DONE_FILE: &str = ".softlan_first_start_done";
|
||||
const LEGACY_SOFTLAN_INSTALL_MARKER: &str = ".softlan_game_installed";
|
||||
const MIGRATION_CONCURRENCY: usize = 16;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize)]
|
||||
pub struct MigrationReport {
|
||||
pub games_checked: usize,
|
||||
pub library_index_migrated: bool,
|
||||
pub install_intents_migrated: usize,
|
||||
pub setup_markers_migrated: usize,
|
||||
pub legacy_files_deleted: usize,
|
||||
pub unknown_softlan_files: usize,
|
||||
pub failures: usize,
|
||||
}
|
||||
|
||||
impl MigrationReport {
|
||||
fn merge(&mut self, other: Self) {
|
||||
self.games_checked += other.games_checked;
|
||||
self.library_index_migrated |= other.library_index_migrated;
|
||||
self.install_intents_migrated += other.install_intents_migrated;
|
||||
self.setup_markers_migrated += other.setup_markers_migrated;
|
||||
self.legacy_files_deleted += other.legacy_files_deleted;
|
||||
self.unknown_softlan_files += other.unknown_softlan_files;
|
||||
self.failures += other.failures;
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrates legacy app-owned files out of the configured game directory.
|
||||
///
|
||||
/// This is intentionally separate from normal operation: callers should run it
|
||||
/// before starting the peer runtime for a game directory.
|
||||
pub async fn migrate_legacy_state(game_dir: &Path, state_dir: &Path) -> MigrationReport {
|
||||
let started = Instant::now();
|
||||
let mut report = MigrationReport::default();
|
||||
|
||||
report.merge(migrate_library_index(game_dir, state_dir).await);
|
||||
|
||||
let game_roots = match collect_game_roots(game_dir).await {
|
||||
Ok(game_roots) => game_roots,
|
||||
Err(err) => {
|
||||
if err.kind() != ErrorKind::NotFound {
|
||||
log::warn!(
|
||||
"Failed to enumerate game roots for legacy state migration in {}: {err}",
|
||||
game_dir.display()
|
||||
);
|
||||
report.failures += 1;
|
||||
}
|
||||
log_migration_report(&report, started);
|
||||
return report;
|
||||
}
|
||||
};
|
||||
|
||||
let game_reports = stream::iter(game_roots)
|
||||
.map(|(id, root)| async move { migrate_game_root(state_dir, id, root).await })
|
||||
.buffer_unordered(MIGRATION_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
for game_report in game_reports {
|
||||
report.merge(game_report);
|
||||
}
|
||||
|
||||
log_migration_report(&report, started);
|
||||
report
|
||||
}
|
||||
|
||||
async fn collect_game_roots(game_dir: &Path) -> std::io::Result<Vec<(String, PathBuf)>> {
|
||||
let mut roots = Vec::new();
|
||||
let mut entries = tokio::fs::read_dir(game_dir).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
if !entry.file_type().await?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(id) = entry.file_name().to_str().map(ToOwned::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
if is_ignored_game_root_name(&id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
roots.push((id, entry.path()));
|
||||
}
|
||||
Ok(roots)
|
||||
}
|
||||
|
||||
async fn migrate_library_index(game_dir: &Path, state_dir: &Path) -> MigrationReport {
|
||||
let mut report = MigrationReport::default();
|
||||
let legacy_path = legacy_library_index_path(game_dir);
|
||||
let target_path = local_library_index_path(state_dir);
|
||||
|
||||
match migrate_raw_file(&legacy_path, &target_path).await {
|
||||
Ok(MigrationOutcome::Migrated) => {
|
||||
report.library_index_migrated = true;
|
||||
report.legacy_files_deleted += 1;
|
||||
}
|
||||
Ok(MigrationOutcome::TargetAlreadyExists) => {
|
||||
report.legacy_files_deleted += 1;
|
||||
}
|
||||
Ok(MigrationOutcome::SourceMissing) => {}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to migrate legacy library index {} to {}: {err}",
|
||||
legacy_path.display(),
|
||||
target_path.display()
|
||||
);
|
||||
report.failures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
report.merge(delete_if_exists(&library_index_tmp_path(&legacy_path)).await);
|
||||
report.merge(remove_empty_legacy_library_dir(game_dir).await);
|
||||
report
|
||||
}
|
||||
|
||||
async fn migrate_game_root(state_dir: &Path, id: String, root: PathBuf) -> MigrationReport {
|
||||
let mut report = MigrationReport {
|
||||
games_checked: 1,
|
||||
..MigrationReport::default()
|
||||
};
|
||||
|
||||
report.merge(migrate_install_intent(state_dir, &id, &root).await);
|
||||
report.merge(delete_if_exists(&root.join(LEGACY_INTENT_TMP_FILE)).await);
|
||||
report.merge(migrate_setup_marker(state_dir, &id, &root).await);
|
||||
report.merge(delete_if_exists(&root.join(LEGACY_SOFTLAN_INSTALL_MARKER)).await);
|
||||
report.merge(note_unknown_softlan_files(&root).await);
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
async fn migrate_install_intent(state_dir: &Path, id: &str, root: &Path) -> MigrationReport {
|
||||
let mut report = MigrationReport::default();
|
||||
let legacy_path = root.join(LEGACY_INTENT_FILE);
|
||||
let target_path = intent_path(state_dir, id);
|
||||
|
||||
match path_exists(&legacy_path).await {
|
||||
Ok(false) => return report,
|
||||
Ok(true) => {}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to inspect legacy install intent {}: {err}",
|
||||
legacy_path.display()
|
||||
);
|
||||
report.failures += 1;
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
match path_exists(&target_path).await {
|
||||
Ok(true) => {
|
||||
report.merge(delete_file(&legacy_path).await);
|
||||
return report;
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to inspect app-state install intent {}: {err}",
|
||||
target_path.display()
|
||||
);
|
||||
report.failures += 1;
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
let data = match tokio::fs::read_to_string(&legacy_path).await {
|
||||
Ok(data) => data,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to read legacy install intent {}: {err}",
|
||||
legacy_path.display()
|
||||
);
|
||||
report.failures += 1;
|
||||
return report;
|
||||
}
|
||||
};
|
||||
|
||||
let intent = match serde_json::from_str::<InstallIntent>(&data) {
|
||||
Ok(intent) if intent.is_current_for(id) => intent,
|
||||
Ok(intent) => {
|
||||
log::warn!(
|
||||
"Leaving legacy install intent {} in place because it belongs to id {} schema {}",
|
||||
legacy_path.display(),
|
||||
intent.id,
|
||||
intent.schema_version
|
||||
);
|
||||
report.failures += 1;
|
||||
return report;
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Leaving corrupt legacy install intent {} in place: {err}",
|
||||
legacy_path.display()
|
||||
);
|
||||
report.failures += 1;
|
||||
return report;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = write_intent(state_dir, id, &intent).await {
|
||||
log::warn!(
|
||||
"Failed to write migrated install intent {}: {err}",
|
||||
target_path.display()
|
||||
);
|
||||
report.failures += 1;
|
||||
return report;
|
||||
}
|
||||
|
||||
report.install_intents_migrated += 1;
|
||||
report.merge(delete_file(&legacy_path).await);
|
||||
report
|
||||
}
|
||||
|
||||
async fn migrate_setup_marker(state_dir: &Path, id: &str, root: &Path) -> MigrationReport {
|
||||
let mut report = MigrationReport::default();
|
||||
let legacy_path = root.join("local").join(LEGACY_FIRST_START_DONE_FILE);
|
||||
let target_path = setup_done_path(state_dir, id);
|
||||
|
||||
match migrate_empty_marker(&legacy_path, &target_path).await {
|
||||
Ok(MigrationOutcome::Migrated) => {
|
||||
report.setup_markers_migrated += 1;
|
||||
report.legacy_files_deleted += 1;
|
||||
}
|
||||
Ok(MigrationOutcome::TargetAlreadyExists) => {
|
||||
report.legacy_files_deleted += 1;
|
||||
}
|
||||
Ok(MigrationOutcome::SourceMissing) => {}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to migrate legacy setup marker {} to {}: {err}",
|
||||
legacy_path.display(),
|
||||
target_path.display()
|
||||
);
|
||||
report.failures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
async fn note_unknown_softlan_files(root: &Path) -> MigrationReport {
|
||||
let mut report = MigrationReport::default();
|
||||
report.unknown_softlan_files += count_unknown_softlan_files(root).await;
|
||||
report.unknown_softlan_files += count_unknown_softlan_files(&root.join("local")).await;
|
||||
report
|
||||
}
|
||||
|
||||
async fn count_unknown_softlan_files(dir: &Path) -> usize {
|
||||
let mut count = 0;
|
||||
let mut entries = match tokio::fs::read_dir(dir).await {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => return 0,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to inspect {} for legacy .softlan files: {err}",
|
||||
dir.display()
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
if !name.starts_with(".softlan_")
|
||||
|| name == LEGACY_SOFTLAN_INSTALL_MARKER
|
||||
|| name == LEGACY_FIRST_START_DONE_FILE
|
||||
{
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
log::info!(
|
||||
"Leaving unknown legacy .softlan file in place: {}",
|
||||
entry.path().display()
|
||||
);
|
||||
}
|
||||
|
||||
count
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum MigrationOutcome {
|
||||
SourceMissing,
|
||||
TargetAlreadyExists,
|
||||
Migrated,
|
||||
}
|
||||
|
||||
async fn migrate_raw_file(
|
||||
legacy_path: &Path,
|
||||
target_path: &Path,
|
||||
) -> std::io::Result<MigrationOutcome> {
|
||||
if !path_exists(legacy_path).await? {
|
||||
return Ok(MigrationOutcome::SourceMissing);
|
||||
}
|
||||
|
||||
if path_exists(target_path).await? {
|
||||
remove_file_if_exists(legacy_path).await?;
|
||||
return Ok(MigrationOutcome::TargetAlreadyExists);
|
||||
}
|
||||
|
||||
let data = tokio::fs::read(legacy_path).await?;
|
||||
write_bytes_atomically(target_path, &data).await?;
|
||||
remove_file_if_exists(legacy_path).await?;
|
||||
Ok(MigrationOutcome::Migrated)
|
||||
}
|
||||
|
||||
async fn migrate_empty_marker(
|
||||
legacy_path: &Path,
|
||||
target_path: &Path,
|
||||
) -> std::io::Result<MigrationOutcome> {
|
||||
if !path_exists(legacy_path).await? {
|
||||
return Ok(MigrationOutcome::SourceMissing);
|
||||
}
|
||||
|
||||
if path_exists(target_path).await? {
|
||||
remove_file_if_exists(legacy_path).await?;
|
||||
return Ok(MigrationOutcome::TargetAlreadyExists);
|
||||
}
|
||||
|
||||
if let Some(parent) = target_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
tokio::fs::File::create(target_path)
|
||||
.await?
|
||||
.sync_all()
|
||||
.await?;
|
||||
remove_file_if_exists(legacy_path).await?;
|
||||
Ok(MigrationOutcome::Migrated)
|
||||
}
|
||||
|
||||
async fn write_bytes_atomically(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
let tmp_path = library_index_tmp_path(path);
|
||||
let mut file = tokio::fs::File::create(&tmp_path).await?;
|
||||
file.write_all(data).await?;
|
||||
file.sync_all().await?;
|
||||
drop(file);
|
||||
|
||||
tokio::fs::rename(&tmp_path, path).await?;
|
||||
sync_parent_dir(path)
|
||||
}
|
||||
|
||||
fn library_index_tmp_path(path: &Path) -> PathBuf {
|
||||
let Some(file_name) = path.file_name() else {
|
||||
return path.with_extension("tmp");
|
||||
};
|
||||
|
||||
let mut tmp_name = file_name.to_os_string();
|
||||
tmp_name.push(".tmp");
|
||||
path.with_file_name(tmp_name)
|
||||
}
|
||||
|
||||
async fn path_exists(path: &Path) -> std::io::Result<bool> {
|
||||
match tokio::fs::metadata(path).await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_if_exists(path: &Path) -> MigrationReport {
|
||||
match remove_file_if_exists(path).await {
|
||||
Ok(true) => MigrationReport {
|
||||
legacy_files_deleted: 1,
|
||||
..MigrationReport::default()
|
||||
},
|
||||
Ok(false) => MigrationReport::default(),
|
||||
Err(err) => {
|
||||
log::warn!("Failed to delete legacy file {}: {err}", path.display());
|
||||
MigrationReport {
|
||||
failures: 1,
|
||||
..MigrationReport::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_file(path: &Path) -> MigrationReport {
|
||||
match remove_file_if_exists(path).await {
|
||||
Ok(true) => MigrationReport {
|
||||
legacy_files_deleted: 1,
|
||||
..MigrationReport::default()
|
||||
},
|
||||
Ok(false) => MigrationReport::default(),
|
||||
Err(err) => {
|
||||
log::warn!("Failed to delete legacy file {}: {err}", path.display());
|
||||
MigrationReport {
|
||||
failures: 1,
|
||||
..MigrationReport::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_file_if_exists(path: &Path) -> std::io::Result<bool> {
|
||||
if !path_exists(path).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
match tokio::fs::remove_file(path).await {
|
||||
Ok(()) => Ok(true),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_empty_legacy_library_dir(game_dir: &Path) -> MigrationReport {
|
||||
let path = game_dir.join(LEGACY_LIBRARY_INDEX_DIR);
|
||||
let exists = match path_exists(&path).await {
|
||||
Ok(exists) => exists,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to inspect legacy library index directory {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
return MigrationReport {
|
||||
failures: 1,
|
||||
..MigrationReport::default()
|
||||
};
|
||||
}
|
||||
};
|
||||
if !exists {
|
||||
return MigrationReport::default();
|
||||
}
|
||||
|
||||
match tokio::fs::remove_dir(&path).await {
|
||||
Ok(()) => MigrationReport {
|
||||
legacy_files_deleted: 1,
|
||||
..MigrationReport::default()
|
||||
},
|
||||
Err(err)
|
||||
if err.kind() == ErrorKind::NotFound || err.kind() == ErrorKind::DirectoryNotEmpty =>
|
||||
{
|
||||
MigrationReport::default()
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to remove empty legacy library index directory {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
MigrationReport {
|
||||
failures: 1,
|
||||
..MigrationReport::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn log_migration_report(report: &MigrationReport, started: Instant) {
|
||||
log::info!(
|
||||
"Legacy state migration finished in {:?}: games_checked={}, library_index_migrated={}, \
|
||||
install_intents_migrated={}, setup_markers_migrated={}, legacy_files_deleted={}, \
|
||||
unknown_softlan_files={}, failures={}",
|
||||
started.elapsed(),
|
||||
report.games_checked,
|
||||
report.library_index_migrated,
|
||||
report.install_intents_migrated,
|
||||
report.setup_markers_migrated,
|
||||
report.legacy_files_deleted,
|
||||
report.unknown_softlan_files,
|
||||
report.failures
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::File::open(parent)?.sync_all()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
install::intent::{InstallIntentState, read_intent},
|
||||
test_support::TempDir,
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migrates_legacy_library_index_to_app_state() {
|
||||
let games = TempDir::new("lanspread-migration-games");
|
||||
let state = TempDir::new("lanspread-migration-state");
|
||||
let legacy_path = legacy_library_index_path(games.path());
|
||||
let target_path = local_library_index_path(state.path());
|
||||
let legacy_tmp_path = library_index_tmp_path(&legacy_path);
|
||||
|
||||
write_file(&legacy_path, br#"{"revision":7,"games":{}}"#);
|
||||
write_file(&legacy_tmp_path, b"tmp");
|
||||
|
||||
let report = migrate_legacy_state(games.path(), state.path()).await;
|
||||
|
||||
assert!(report.library_index_migrated);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&target_path).expect("index should migrate"),
|
||||
r#"{"revision":7,"games":{}}"#
|
||||
);
|
||||
assert!(!legacy_path.exists());
|
||||
assert!(!legacy_tmp_path.exists());
|
||||
assert!(!games.path().join(LEGACY_LIBRARY_INDEX_DIR).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migrates_per_game_intent_and_setup_marker() {
|
||||
let games = TempDir::new("lanspread-migration-games");
|
||||
let state = TempDir::new("lanspread-migration-state");
|
||||
let root = games.path().join("game");
|
||||
let intent = InstallIntent::new(
|
||||
"game",
|
||||
InstallIntentState::Updating,
|
||||
Some("20250101".to_string()),
|
||||
);
|
||||
let legacy_intent = root.join(LEGACY_INTENT_FILE);
|
||||
let legacy_tmp = root.join(LEGACY_INTENT_TMP_FILE);
|
||||
let legacy_setup = root.join("local").join(LEGACY_FIRST_START_DONE_FILE);
|
||||
let legacy_marker = root.join(LEGACY_SOFTLAN_INSTALL_MARKER);
|
||||
|
||||
write_file(
|
||||
&legacy_intent,
|
||||
&serde_json::to_vec_pretty(&intent).expect("intent should serialize"),
|
||||
);
|
||||
write_file(&legacy_tmp, b"tmp");
|
||||
write_file(&legacy_setup, b"");
|
||||
write_file(&legacy_marker, b"");
|
||||
|
||||
let report = migrate_legacy_state(games.path(), state.path()).await;
|
||||
|
||||
assert_eq!(report.install_intents_migrated, 1);
|
||||
assert_eq!(report.setup_markers_migrated, 1);
|
||||
let migrated_intent = read_intent(state.path(), "game").await;
|
||||
assert_eq!(migrated_intent.state, InstallIntentState::Updating);
|
||||
assert_eq!(migrated_intent.eti_version.as_deref(), Some("20250101"));
|
||||
assert!(setup_done_path(state.path(), "game").is_file());
|
||||
assert!(!legacy_intent.exists());
|
||||
assert!(!legacy_tmp.exists());
|
||||
assert!(!legacy_setup.exists());
|
||||
assert!(!legacy_marker.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_state_wins_over_legacy_per_game_state() {
|
||||
let games = TempDir::new("lanspread-migration-games");
|
||||
let state = TempDir::new("lanspread-migration-state");
|
||||
let root = games.path().join("game");
|
||||
let app_intent = InstallIntent::none("game", Some("app".to_string()));
|
||||
let legacy_intent = InstallIntent::new(
|
||||
"game",
|
||||
InstallIntentState::Installing,
|
||||
Some("legacy".to_string()),
|
||||
);
|
||||
let legacy_intent_path = root.join(LEGACY_INTENT_FILE);
|
||||
let legacy_setup = root.join("local").join(LEGACY_FIRST_START_DONE_FILE);
|
||||
|
||||
write_intent(state.path(), "game", &app_intent)
|
||||
.await
|
||||
.expect("app-state intent should be written");
|
||||
write_file(
|
||||
&legacy_intent_path,
|
||||
&serde_json::to_vec_pretty(&legacy_intent).expect("intent should serialize"),
|
||||
);
|
||||
write_file(&setup_done_path(state.path(), "game"), b"");
|
||||
write_file(&legacy_setup, b"");
|
||||
|
||||
let report = migrate_legacy_state(games.path(), state.path()).await;
|
||||
|
||||
assert_eq!(report.install_intents_migrated, 0);
|
||||
assert_eq!(report.setup_markers_migrated, 0);
|
||||
let intent = read_intent(state.path(), "game").await;
|
||||
assert_eq!(intent.state, InstallIntentState::None);
|
||||
assert_eq!(intent.eti_version.as_deref(), Some("app"));
|
||||
assert!(!legacy_intent_path.exists());
|
||||
assert!(!legacy_setup.exists());
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use bytes::Bytes;
|
||||
use lanspread_db::db::GameFileDescription;
|
||||
use lanspread_utils::maybe_addr;
|
||||
use s2n_quic::{
|
||||
application,
|
||||
connection,
|
||||
stream::{Error as StreamError, SendStream},
|
||||
};
|
||||
@@ -14,12 +15,24 @@ 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());
|
||||
|
||||
@@ -45,13 +58,34 @@ 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 = file.read(&mut buf[..read_len]).await?;
|
||||
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?
|
||||
}
|
||||
};
|
||||
if bytes_read == 0 {
|
||||
if !expect_exact {
|
||||
transfer_complete = true;
|
||||
@@ -59,7 +93,19 @@ async fn stream_file_bytes(
|
||||
break;
|
||||
}
|
||||
|
||||
tx.send(Bytes::copy_from_slice(&buf[..bytes_read])).await?;
|
||||
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?;
|
||||
}
|
||||
}
|
||||
remaining = remaining.saturating_sub(bytes_read as u64);
|
||||
total_bytes += bytes_read as u64;
|
||||
|
||||
@@ -97,12 +143,21 @@ async fn stream_file_bytes(
|
||||
validated_path.display()
|
||||
);
|
||||
|
||||
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}");
|
||||
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()),
|
||||
}
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -121,8 +176,18 @@ 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).await {
|
||||
if let Err(e) = stream_file_bytes(
|
||||
tx,
|
||||
game_dir,
|
||||
&game_file_desc.relative_path,
|
||||
0,
|
||||
None,
|
||||
cancel_token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let remote_addr = maybe_addr!(tx.connection().remote_addr());
|
||||
log::error!(
|
||||
"{remote_addr} failed to stream file {}: {e}",
|
||||
@@ -138,8 +203,18 @@ 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)).await {
|
||||
if let Err(e) = stream_file_bytes(
|
||||
tx,
|
||||
game_dir,
|
||||
relative_path,
|
||||
offset,
|
||||
Some(length),
|
||||
cancel_token,
|
||||
)
|
||||
.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, GameFileDescription};
|
||||
use lanspread_db::db::{Availability, Game, GameCatalog, GameFileDescription};
|
||||
use lanspread_proto::{GameSummary, LibraryDelta, LibrarySnapshot};
|
||||
|
||||
use crate::library::compute_library_digest;
|
||||
@@ -357,6 +357,54 @@ 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> {
|
||||
@@ -451,6 +499,24 @@ 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> {
|
||||
@@ -514,11 +580,33 @@ 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) -> Vec<GameFileDescription> {
|
||||
pub fn aggregated_game_files(
|
||||
&self,
|
||||
game_id: &str,
|
||||
expected_version: Option<&str>,
|
||||
) -> Vec<GameFileDescription> {
|
||||
let mut seen: HashMap<String, GameFileDescription> = HashMap::new();
|
||||
for (_, files) in self.latest_game_files_for(game_id) {
|
||||
for (_, files) in self.expected_version_game_files_for(game_id, expected_version) {
|
||||
for file in files {
|
||||
seen.entry(file.relative_path.clone()).or_insert(file);
|
||||
}
|
||||
@@ -559,8 +647,9 @@ impl PeerGameDB {
|
||||
pub fn validate_file_sizes_majority(
|
||||
&self,
|
||||
game_id: &str,
|
||||
expected_version: Option<&str>,
|
||||
) -> eyre::Result<MajorityValidationResult> {
|
||||
let game_files = self.latest_game_files_for(game_id);
|
||||
let game_files = self.expected_version_game_files_for(game_id, expected_version);
|
||||
if game_files.is_empty() {
|
||||
return Ok((Vec::new(), Vec::new(), HashMap::new()));
|
||||
}
|
||||
@@ -813,6 +902,14 @@ 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())
|
||||
@@ -925,6 +1022,41 @@ 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);
|
||||
@@ -979,7 +1111,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_uses_latest_version_file_metadata() {
|
||||
fn validation_uses_expected_version_file_metadata() {
|
||||
let old_addr = addr(12003);
|
||||
let new_addr = addr(12004);
|
||||
let mut db = PeerGameDB::new();
|
||||
@@ -1010,21 +1142,21 @@ mod tests {
|
||||
],
|
||||
);
|
||||
|
||||
let aggregated = db.aggregated_game_files("game");
|
||||
let aggregated = db.aggregated_game_files("game", Some("20250101"));
|
||||
let archive = aggregated
|
||||
.iter()
|
||||
.find(|desc| desc.relative_path == "game/archive.eti")
|
||||
.expect("latest archive should be present");
|
||||
.expect("expected-version archive should be present");
|
||||
assert_eq!(archive.size, 20);
|
||||
|
||||
let (validated, peers, file_peer_map) = db
|
||||
.validate_file_sizes_majority("game")
|
||||
.validate_file_sizes_majority("game", Some("20250101"))
|
||||
.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("latest archive should validate");
|
||||
.expect("expected-version archive should validate");
|
||||
assert_eq!(archive.size, 20);
|
||||
assert_eq!(file_peer_map.get("game/archive.eti"), Some(&vec![new_addr]));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use lanspread_proto::{Hello, HelloAck, PROTOCOL_VERSION};
|
||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
|
||||
@@ -22,6 +23,7 @@ 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 {
|
||||
@@ -32,6 +34,7 @@ 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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +45,7 @@ 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,7 +125,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.tx_notify_ui).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -156,7 +160,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.tx_notify_ui).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
|
||||
|
||||
build_hello_ack(ctx).await
|
||||
}
|
||||
@@ -201,12 +205,13 @@ async fn after_peer_library_recorded(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
collections::HashMap,
|
||||
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};
|
||||
@@ -242,6 +247,7 @@ mod tests {
|
||||
local_library: Arc::new(RwLock::new(LocalLibraryState::empty())),
|
||||
peer_game_db,
|
||||
tx_notify_ui,
|
||||
catalog: Arc::new(RwLock::new(GameCatalog::empty())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,14 +307,19 @@ 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(),
|
||||
PathBuf::new(),
|
||||
PathBuf::new(),
|
||||
Arc::new(NoopUnpacker),
|
||||
CancellationToken::new(),
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(HashSet::new())),
|
||||
Arc::new(RwLock::new(catalog)),
|
||||
Arc::new(RwLock::new(HashMap::new())),
|
||||
Arc::new(crate::NoopStreamInstallProvider),
|
||||
);
|
||||
*ctx.local_peer_addr.write().await = Some(addr([127, 0, 0, 1], 4000));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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};
|
||||
|
||||
@@ -18,6 +19,7 @@ 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,
|
||||
@@ -40,6 +42,7 @@ pub async fn run_ping_service(
|
||||
|
||||
ping_idle_peers(
|
||||
&peer_game_db,
|
||||
&catalog,
|
||||
&active_operations,
|
||||
&active_downloads,
|
||||
&tx_notify_ui,
|
||||
@@ -50,6 +53,7 @@ pub async fn run_ping_service(
|
||||
|
||||
prune_stale_peers(
|
||||
&peer_game_db,
|
||||
&catalog,
|
||||
&active_operations,
|
||||
&active_downloads,
|
||||
&tx_notify_ui,
|
||||
@@ -60,6 +64,7 @@ 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>,
|
||||
@@ -75,6 +80,7 @@ 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();
|
||||
@@ -93,6 +99,7 @@ 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,
|
||||
@@ -105,6 +112,7 @@ 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,
|
||||
@@ -120,6 +128,7 @@ 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>,
|
||||
@@ -137,7 +146,7 @@ async fn prune_stale_peers(
|
||||
}
|
||||
|
||||
if removed_any {
|
||||
events::emit_peer_game_list(peer_game_db, tx_notify_ui).await;
|
||||
events::emit_peer_game_list(peer_game_db, catalog, tx_notify_ui).await;
|
||||
handle_active_downloads_without_peers(
|
||||
peer_game_db,
|
||||
active_operations,
|
||||
@@ -150,6 +159,7 @@ 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>,
|
||||
@@ -157,7 +167,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, tx_notify_ui).await;
|
||||
events::emit_peer_game_list(peer_game_db, catalog, tx_notify_ui).await;
|
||||
handle_active_downloads_without_peers(
|
||||
peer_game_db,
|
||||
active_operations,
|
||||
|
||||
@@ -277,7 +277,7 @@ async fn run_gated_rescan(
|
||||
|
||||
let game_dir = ctx.game_dir.read().await.clone();
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
match rescan_local_game(&game_dir, &catalog, &id).await {
|
||||
match rescan_local_game(&game_dir, ctx.state_dir.as_ref(), &catalog, &id).await {
|
||||
Ok(scan) => update_and_announce_games(&ctx, &tx_notify_ui, scan).await,
|
||||
Err(err) => log::error!("Failed to rescan local game {id}: {err}"),
|
||||
}
|
||||
@@ -293,7 +293,7 @@ async fn run_gated_rescan(
|
||||
async fn run_fallback_scan(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
|
||||
let game_dir = ctx.game_dir.read().await.clone();
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
match scan_local_library(&game_dir, &catalog).await {
|
||||
match scan_local_library(&game_dir, ctx.state_dir.as_ref(), &catalog).await {
|
||||
Ok(scan) => update_and_announce_games(ctx, tx_notify_ui, scan).await,
|
||||
Err(err) => log::error!("Failed to scan local games directory: {err}"),
|
||||
}
|
||||
@@ -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,15 +373,18 @@ mod tests {
|
||||
std::fs::write(path, bytes).expect("file should be written");
|
||||
}
|
||||
|
||||
fn test_ctx(game_dir: PathBuf, catalog: HashSet<String>) -> Ctx {
|
||||
fn test_ctx(game_dir: PathBuf, catalog: GameCatalog) -> Ctx {
|
||||
Ctx::new(
|
||||
Arc::new(RwLock::new(PeerGameDB::new())),
|
||||
"peer".to_string(),
|
||||
game_dir,
|
||||
game_dir.clone(),
|
||||
game_dir.join(".test-state"),
|
||||
Arc::new(NoopUnpacker),
|
||||
CancellationToken::new(),
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(catalog)),
|
||||
Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||
Arc::new(crate::NoopStreamInstallProvider),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -444,7 +447,7 @@ mod tests {
|
||||
let temp = TempDir::new("lanspread-local-monitor");
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
HashSet::from(["game".to_string()]),
|
||||
GameCatalog::from_ids(["game".to_string()]),
|
||||
);
|
||||
ctx.active_operations
|
||||
.write()
|
||||
@@ -479,7 +482,7 @@ mod tests {
|
||||
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
HashSet::from(["game".to_string()]),
|
||||
GameCatalog::from_ids(["game".to_string()]),
|
||||
);
|
||||
let gate = RescanGate::default();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
@@ -514,7 +517,7 @@ mod tests {
|
||||
write_file(&game_root.join("version.ini"), b"20250101");
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
HashSet::from(["game".to_string()]),
|
||||
GameCatalog::from_ids(["game".to_string()]),
|
||||
);
|
||||
let gate = RescanGate::default();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
@@ -550,7 +553,7 @@ mod tests {
|
||||
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
HashSet::from(["game".to_string()]),
|
||||
GameCatalog::from_ids(["game".to_string()]),
|
||||
);
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
@@ -574,7 +577,7 @@ mod tests {
|
||||
);
|
||||
let ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
HashSet::from(["game".to_string()]),
|
||||
GameCatalog::from_ids(["game".to_string()]),
|
||||
);
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ use crate::{
|
||||
context::PeerCtx,
|
||||
error::PeerError,
|
||||
events,
|
||||
local_games::{get_game_file_descriptions, is_local_dir_name, local_download_available},
|
||||
local_games::{get_game_file_descriptions, is_local_dir_name, local_download_matches_catalog},
|
||||
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>;
|
||||
@@ -99,6 +100,9 @@ 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
|
||||
@@ -162,7 +166,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.tx_notify_ui).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
|
||||
} else {
|
||||
let addr = {
|
||||
let db = ctx.peer_game_db.read().await;
|
||||
@@ -209,7 +213,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_available(game_dir, game_id, &active_operations, &catalog).await
|
||||
local_download_matches_catalog(game_dir, game_id, &active_operations, &catalog).await
|
||||
}
|
||||
|
||||
async fn can_dispatch_file_transfer(
|
||||
@@ -218,10 +222,23 @@ async fn can_dispatch_file_transfer(
|
||||
game_id: &str,
|
||||
relative_path: &str,
|
||||
) -> bool {
|
||||
!path_points_inside_local(game_id, relative_path)
|
||||
relative_path_belongs_to_game(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());
|
||||
@@ -232,6 +249,67 @@ 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,
|
||||
@@ -242,6 +320,14 @@ 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 {
|
||||
@@ -249,11 +335,13 @@ 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).await;
|
||||
send_game_file_data(&desc, &mut tx, &game_dir, cancel_token).await;
|
||||
drop(guard);
|
||||
FramedWrite::new(tx, LengthDelimitedCodec::new())
|
||||
}
|
||||
|
||||
@@ -269,37 +357,99 @@ 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).await;
|
||||
send_game_file_chunk(
|
||||
&game_id,
|
||||
&relative_path,
|
||||
offset,
|
||||
length,
|
||||
&mut tx,
|
||||
&game_dir,
|
||||
cancel_token,
|
||||
)
|
||||
.await;
|
||||
drop(guard);
|
||||
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.tx_notify_ui).await;
|
||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &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};
|
||||
|
||||
@@ -327,16 +477,19 @@ mod tests {
|
||||
std::fs::write(path, bytes).expect("file should be written");
|
||||
}
|
||||
|
||||
fn test_ctx(game_dir: PathBuf, catalog: HashSet<String>) -> PeerCtx {
|
||||
fn test_ctx(game_dir: PathBuf, catalog: GameCatalog) -> PeerCtx {
|
||||
let (tx_notify_ui, _rx) = mpsc::unbounded_channel();
|
||||
Ctx::new(
|
||||
Arc::new(RwLock::new(PeerGameDB::new())),
|
||||
"peer".to_string(),
|
||||
game_dir,
|
||||
game_dir.clone(),
|
||||
game_dir.join(".test-state"),
|
||||
Arc::new(NoopUnpacker),
|
||||
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)
|
||||
}
|
||||
@@ -350,6 +503,19 @@ 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");
|
||||
@@ -359,17 +525,19 @@ 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 ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
HashSet::from([
|
||||
"ready".to_string(),
|
||||
"active".to_string(),
|
||||
"missing-sentinel".to_string(),
|
||||
]),
|
||||
);
|
||||
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);
|
||||
ctx.active_operations
|
||||
.write()
|
||||
.await
|
||||
@@ -387,6 +555,10 @@ 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"
|
||||
@@ -402,23 +574,28 @@ 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 ctx = test_ctx(
|
||||
temp.path().to_path_buf(),
|
||||
HashSet::from([
|
||||
"ready".to_string(),
|
||||
"active".to_string(),
|
||||
"missing-sentinel".to_string(),
|
||||
]),
|
||||
);
|
||||
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);
|
||||
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,
|
||||
@@ -431,6 +608,15 @@ 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,6 +11,7 @@ use std::{
|
||||
};
|
||||
|
||||
use futures::FutureExt as _;
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use tokio::sync::{
|
||||
RwLock,
|
||||
mpsc::{UnboundedReceiver, UnboundedSender},
|
||||
@@ -22,6 +23,7 @@ use crate::{
|
||||
PeerCommand,
|
||||
PeerEvent,
|
||||
PeerRuntimeComponent,
|
||||
StreamInstallProvider,
|
||||
Unpacker,
|
||||
context::Ctx,
|
||||
events,
|
||||
@@ -82,8 +84,11 @@ pub(crate) fn spawn_peer_runtime(
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
peer_id: String,
|
||||
game_dir: PathBuf,
|
||||
state_dir: PathBuf,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
catalog: Arc<RwLock<std::collections::HashSet<String>>>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
active_outbound_transfers: crate::context::OutboundTransfers,
|
||||
stream_install_provider: Arc<dyn StreamInstallProvider>,
|
||||
) -> PeerRuntimeHandle {
|
||||
let shutdown = CancellationToken::new();
|
||||
let task_tracker = TaskTracker::new();
|
||||
@@ -98,10 +103,13 @@ pub(crate) fn spawn_peer_runtime(
|
||||
peer_game_db,
|
||||
peer_id,
|
||||
game_dir,
|
||||
state_dir,
|
||||
unpacker,
|
||||
runtime_shutdown.clone(),
|
||||
runtime_tracker.clone(),
|
||||
catalog,
|
||||
active_outbound_transfers,
|
||||
stream_install_provider,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -188,6 +196,7 @@ 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();
|
||||
@@ -205,6 +214,7 @@ 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();
|
||||
@@ -213,6 +223,7 @@ 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,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const PEER_ID_FILE: &str = "peer_id";
|
||||
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 {
|
||||
return dir.to_path_buf();
|
||||
}
|
||||
|
||||
if let Some(dir) = std::env::var_os("LANSPREAD_STATE_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
|
||||
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
|
||||
return PathBuf::from(home).join(".lanspread");
|
||||
}
|
||||
|
||||
std::env::temp_dir().join("lanspread")
|
||||
}
|
||||
|
||||
pub(crate) fn peer_id_path(state_dir: &Path) -> PathBuf {
|
||||
state_dir.join(PEER_ID_FILE)
|
||||
}
|
||||
|
||||
pub(crate) fn local_library_index_path(state_dir: &Path) -> PathBuf {
|
||||
state_dir
|
||||
.join(LOCAL_LIBRARY_DIR)
|
||||
.join(LOCAL_LIBRARY_INDEX_FILE)
|
||||
}
|
||||
|
||||
pub(crate) fn game_state_dir(state_dir: &Path, game_id: &str) -> PathBuf {
|
||||
state_dir.join(GAMES_DIR).join(game_id)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,958 @@
|
||||
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,13 +3,9 @@ name = "lanspread-proto"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
[lib]
|
||||
doctest = false
|
||||
test = false
|
||||
|
||||
[dependencies]
|
||||
# local
|
||||
@@ -21,6 +17,10 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[lib]
|
||||
test = false
|
||||
doctest = false
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -4,7 +4,7 @@ use bytes::Bytes;
|
||||
use lanspread_db::db::{Game, GameFileDescription};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const PROTOCOL_VERSION: u32 = 4;
|
||||
pub const PROTOCOL_VERSION: u32 = 5;
|
||||
|
||||
pub use lanspread_db::db::Availability;
|
||||
|
||||
@@ -67,6 +67,9 @@ pub enum Request {
|
||||
offset: u64,
|
||||
length: u64,
|
||||
},
|
||||
StreamInstall {
|
||||
game_id: String,
|
||||
},
|
||||
Hello(Hello),
|
||||
LibraryDelta {
|
||||
peer_id: String,
|
||||
@@ -94,6 +97,41 @@ 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;
|
||||
@@ -145,3 +183,62 @@ 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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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.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:@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:typescript@^6.0.3": "6.0.3",
|
||||
"npm:vite@^8.0.13": "8.0.13"
|
||||
"npm:vite@^8.0.16": "8.0.16"
|
||||
},
|
||||
"npm": {
|
||||
"@emnapi/core@1.10.0": {
|
||||
@@ -42,71 +42,71 @@
|
||||
"@tybys/wasm-util"
|
||||
]
|
||||
},
|
||||
"@oxc-project/types@0.130.0": {
|
||||
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="
|
||||
"@oxc-project/types@0.133.0": {
|
||||
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="
|
||||
},
|
||||
"@rolldown/binding-android-arm64@1.0.1": {
|
||||
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
|
||||
"@rolldown/binding-android-arm64@1.0.3": {
|
||||
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
|
||||
"os": ["android"],
|
||||
"cpu": ["arm64"]
|
||||
},
|
||||
"@rolldown/binding-darwin-arm64@1.0.1": {
|
||||
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
|
||||
"@rolldown/binding-darwin-arm64@1.0.3": {
|
||||
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
|
||||
"os": ["darwin"],
|
||||
"cpu": ["arm64"]
|
||||
},
|
||||
"@rolldown/binding-darwin-x64@1.0.1": {
|
||||
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
|
||||
"@rolldown/binding-darwin-x64@1.0.3": {
|
||||
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
|
||||
"os": ["darwin"],
|
||||
"cpu": ["x64"]
|
||||
},
|
||||
"@rolldown/binding-freebsd-x64@1.0.1": {
|
||||
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
|
||||
"@rolldown/binding-freebsd-x64@1.0.3": {
|
||||
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
|
||||
"os": ["freebsd"],
|
||||
"cpu": ["x64"]
|
||||
},
|
||||
"@rolldown/binding-linux-arm-gnueabihf@1.0.1": {
|
||||
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
|
||||
"@rolldown/binding-linux-arm-gnueabihf@1.0.3": {
|
||||
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm"]
|
||||
},
|
||||
"@rolldown/binding-linux-arm64-gnu@1.0.1": {
|
||||
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
|
||||
"@rolldown/binding-linux-arm64-gnu@1.0.3": {
|
||||
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"]
|
||||
},
|
||||
"@rolldown/binding-linux-arm64-musl@1.0.1": {
|
||||
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
|
||||
"@rolldown/binding-linux-arm64-musl@1.0.3": {
|
||||
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"]
|
||||
},
|
||||
"@rolldown/binding-linux-ppc64-gnu@1.0.1": {
|
||||
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
|
||||
"@rolldown/binding-linux-ppc64-gnu@1.0.3": {
|
||||
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["ppc64"]
|
||||
},
|
||||
"@rolldown/binding-linux-s390x-gnu@1.0.1": {
|
||||
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
|
||||
"@rolldown/binding-linux-s390x-gnu@1.0.3": {
|
||||
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["s390x"]
|
||||
},
|
||||
"@rolldown/binding-linux-x64-gnu@1.0.1": {
|
||||
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
|
||||
"@rolldown/binding-linux-x64-gnu@1.0.3": {
|
||||
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"]
|
||||
},
|
||||
"@rolldown/binding-linux-x64-musl@1.0.1": {
|
||||
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
|
||||
"@rolldown/binding-linux-x64-musl@1.0.3": {
|
||||
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"]
|
||||
},
|
||||
"@rolldown/binding-openharmony-arm64@1.0.1": {
|
||||
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
|
||||
"@rolldown/binding-openharmony-arm64@1.0.3": {
|
||||
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
|
||||
"os": ["openharmony"],
|
||||
"cpu": ["arm64"]
|
||||
},
|
||||
"@rolldown/binding-wasm32-wasi@1.0.1": {
|
||||
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
|
||||
"@rolldown/binding-wasm32-wasi@1.0.3": {
|
||||
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
|
||||
"dependencies": [
|
||||
"@emnapi/core",
|
||||
"@emnapi/runtime",
|
||||
@@ -114,13 +114,13 @@
|
||||
],
|
||||
"cpu": ["wasm32"]
|
||||
},
|
||||
"@rolldown/binding-win32-arm64-msvc@1.0.1": {
|
||||
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
|
||||
"@rolldown/binding-win32-arm64-msvc@1.0.3": {
|
||||
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
|
||||
"os": ["win32"],
|
||||
"cpu": ["arm64"]
|
||||
},
|
||||
"@rolldown/binding-win32-x64-msvc@1.0.1": {
|
||||
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
|
||||
"@rolldown/binding-win32-x64-msvc@1.0.3": {
|
||||
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
|
||||
"os": ["win32"],
|
||||
"cpu": ["x64"]
|
||||
},
|
||||
@@ -226,19 +226,19 @@
|
||||
"tslib"
|
||||
]
|
||||
},
|
||||
"@types/react-dom@19.2.3_@types+react@19.2.14": {
|
||||
"@types/react-dom@19.2.3_@types+react@19.2.17": {
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dependencies": [
|
||||
"@types/react"
|
||||
]
|
||||
},
|
||||
"@types/react@19.2.14": {
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"@types/react@19.2.17": {
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"dependencies": [
|
||||
"csstype"
|
||||
]
|
||||
},
|
||||
"@vitejs/plugin-react@6.0.2_vite@8.0.13": {
|
||||
"@vitejs/plugin-react@6.0.2_vite@8.0.16": {
|
||||
"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.14": {
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"postcss@8.5.15": {
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dependencies": [
|
||||
"nanoid",
|
||||
"picocolors",
|
||||
"source-map-js"
|
||||
]
|
||||
},
|
||||
"react-dom@19.2.6_react@19.2.6": {
|
||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||
"react-dom@19.2.7_react@19.2.7": {
|
||||
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
||||
"dependencies": [
|
||||
"react",
|
||||
"scheduler"
|
||||
]
|
||||
},
|
||||
"react@19.2.6": {
|
||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="
|
||||
"react@19.2.7": {
|
||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="
|
||||
},
|
||||
"rolldown@1.0.1": {
|
||||
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
|
||||
"rolldown@1.0.3": {
|
||||
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||
"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.16": {
|
||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||
"tinyglobby@0.2.17": {
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dependencies": [
|
||||
"fdir",
|
||||
"picomatch"
|
||||
@@ -412,8 +412,8 @@
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"bin": true
|
||||
},
|
||||
"vite@8.0.13": {
|
||||
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
|
||||
"vite@8.0.16": {
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"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.14",
|
||||
"npm:@types/react@^19.2.17",
|
||||
"npm:@vitejs/plugin-react@^6.0.2",
|
||||
"npm:react-dom@^19.2.6",
|
||||
"npm:react@^19.2.6",
|
||||
"npm:react-dom@^19.2.7",
|
||||
"npm:react@^19.2.7",
|
||||
"npm:typescript@^6.0.3",
|
||||
"npm:vite@^8.0.13"
|
||||
"npm:vite@^8.0.16"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,17 +12,17 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-store": "^2.4.3",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.13",
|
||||
"vite": "^8.0.16",
|
||||
"@tauri-apps/cli": "^2.11.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,28 +8,19 @@ 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 }
|
||||
@@ -37,13 +28,27 @@ eyre = { workspace = true }
|
||||
log = { workspace = true }
|
||||
mimalloc = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tauri = { workspace = true }
|
||||
tauri-plugin-log = { workspace = true }
|
||||
tauri-plugin-shell = { workspace = true }
|
||||
tauri-plugin-dialog = { workspace = true }
|
||||
tauri-plugin-shell = { 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"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main-logs",
|
||||
"description": "Capability for the main-logs window",
|
||||
"windows": [
|
||||
"main-logs"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
]
|
||||
}
|
||||
@@ -1,26 +1,31 @@
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::fs::File;
|
||||
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,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
sync::{Arc, Mutex, OnceLock},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use eyre::bail;
|
||||
use lanspread_compat::eti::get_games;
|
||||
use lanspread_db::db::{Availability, Game, GameDB, GameFileDescription};
|
||||
use lanspread_db::db::{Availability, Game, GameCatalog, GameDB, GameFileDescription};
|
||||
use lanspread_peer::{
|
||||
ActiveOperation,
|
||||
ActiveOperationKind,
|
||||
ExternalUnrarStreamProvider,
|
||||
NoopStreamInstallProvider,
|
||||
PeerCommand,
|
||||
PeerEvent,
|
||||
PeerGameDB,
|
||||
PeerRuntimeHandle,
|
||||
PeerStartOptions,
|
||||
StreamInstallProvider,
|
||||
UnpackFuture,
|
||||
Unpacker,
|
||||
start_peer,
|
||||
migrate_legacy_state,
|
||||
start_peer_with_options,
|
||||
};
|
||||
use tauri::{AppHandle, Emitter as _, Manager};
|
||||
use tauri_plugin_shell::{ShellExt, process::Command};
|
||||
@@ -28,9 +33,51 @@ 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 {
|
||||
@@ -40,8 +87,18 @@ struct LanSpreadState {
|
||||
active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
|
||||
games_folder: Arc<RwLock<String>>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
|
||||
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>);
|
||||
@@ -63,11 +120,19 @@ struct UiActiveOperation {
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
struct GamesListPayload {
|
||||
games: Vec<Game>,
|
||||
games: Vec<LauncherGame>,
|
||||
active_operations: Vec<UiActiveOperation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
struct LauncherGame {
|
||||
#[serde(flatten)]
|
||||
game: Game,
|
||||
can_host_server: bool,
|
||||
active_outbound_transfers: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
||||
struct UnpackLogEntry {
|
||||
archive: String,
|
||||
destination: String,
|
||||
@@ -79,11 +144,29 @@ 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 = 100;
|
||||
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> {
|
||||
@@ -102,8 +185,43 @@ async fn get_unpack_logs(
|
||||
Ok(state.inner().unpack_logs.read().await.clone())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const FIRST_START_DONE_FILE: &str = ".softlan_first_start_done";
|
||||
#[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))]
|
||||
const GAME_START_SCRIPT: &str = "game_start.cmd";
|
||||
const SERVER_START_SCRIPT: &str = "server_start.cmd";
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
const DEFAULT_LANGUAGE: &str = "en";
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
const DEFAULT_USERNAME: &str = "Commander";
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
const MAX_USERNAME_CHARS: usize = 24;
|
||||
|
||||
#[tauri::command]
|
||||
async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<()> {
|
||||
@@ -124,7 +242,12 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<bool> {
|
||||
async fn install_game(
|
||||
id: String,
|
||||
language: String,
|
||||
username: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<bool> {
|
||||
if state
|
||||
.inner()
|
||||
.active_operations
|
||||
@@ -150,11 +273,12 @@ async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> ta
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let _ = (language, username);
|
||||
let handled = if let Some(peer_ctrl) = peer_ctrl {
|
||||
let command = if !downloaded {
|
||||
PeerCommand::GetGame(id)
|
||||
PeerCommand::GetGame(id.clone())
|
||||
} else if !installed {
|
||||
PeerCommand::InstallGame { id }
|
||||
PeerCommand::InstallGame { id: id.clone() }
|
||||
} else {
|
||||
log::info!("Game is already installed: {id}");
|
||||
return Ok(false);
|
||||
@@ -162,6 +286,7 @@ async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> ta
|
||||
|
||||
if let Err(e) = peer_ctrl.send(command) {
|
||||
log::error!("Failed to send message to peer: {e:?}");
|
||||
return Ok(false);
|
||||
}
|
||||
true
|
||||
} else {
|
||||
@@ -173,7 +298,62 @@ async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> ta
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<bool> {
|
||||
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> {
|
||||
if state
|
||||
.inner()
|
||||
.active_operations
|
||||
@@ -189,8 +369,9 @@ async fn update_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tau
|
||||
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 {
|
||||
if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id }) {
|
||||
if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id: id.clone() }) {
|
||||
log::error!("Failed to send message to peer: {e:?}");
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -381,6 +562,86 @@ fn is_single_component_game_id(id: &str) -> bool {
|
||||
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
struct LaunchSettings {
|
||||
language: String,
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn launch_settings(language: &str, username: &str) -> LaunchSettings {
|
||||
LaunchSettings {
|
||||
language: sanitize_language(language),
|
||||
username: sanitize_username(username),
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
"de" => "de".to_string(),
|
||||
"en" => "en".to_string(),
|
||||
_ => DEFAULT_LANGUAGE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn sanitize_username(username: &str) -> String {
|
||||
let cleaned = username
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() && *c != '"' && *c != '%')
|
||||
.take(MAX_USERNAME_CHARS)
|
||||
.collect::<String>();
|
||||
|
||||
if cleaned.is_empty() {
|
||||
DEFAULT_USERNAME.to_string()
|
||||
} else {
|
||||
cleaned
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn script_params(script_path: &Path, id: &str, settings: &LaunchSettings) -> String {
|
||||
script_params_with_mode("/c", script_path, id, settings)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn server_script_params(script_path: &Path, id: &str, settings: &LaunchSettings) -> String {
|
||||
script_params_with_mode("/k", script_path, id, settings)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn script_params_with_mode(
|
||||
cmd_mode: &str,
|
||||
script_path: &Path,
|
||||
id: &str,
|
||||
settings: &LaunchSettings,
|
||||
) -> String {
|
||||
format!(
|
||||
r#"/d /s {cmd_mode} ""{}" "local" "{}" "{}" "{}"""#,
|
||||
script_path.display(),
|
||||
id,
|
||||
settings.language,
|
||||
settings.username,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<usize> {
|
||||
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
|
||||
@@ -418,7 +679,12 @@ async fn get_game_thumbnail(
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_as_admin(file: &str, params: &str, dir: &str) -> bool {
|
||||
fn run_as_admin(
|
||||
file: &str,
|
||||
params: &str,
|
||||
dir: &str,
|
||||
show_cmd: windows::Win32::UI::WindowsAndMessaging::SHOW_WINDOW_CMD,
|
||||
) -> bool {
|
||||
use std::{ffi::OsStr, os::windows::ffi::OsStrExt};
|
||||
|
||||
use windows::{Win32::UI::Shell::ShellExecuteW, core::PCWSTR};
|
||||
@@ -435,7 +701,7 @@ fn run_as_admin(file: &str, params: &str, dir: &str) -> bool {
|
||||
PCWSTR::from_raw(file_wide.as_ptr()),
|
||||
PCWSTR::from_raw(params_wide.as_ptr()),
|
||||
PCWSTR::from_raw(dir_wide.as_ptr()),
|
||||
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
||||
show_cmd,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -445,8 +711,16 @@ fn run_as_admin(file: &str, params: &str, dir: &str) -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn run_game_windows(
|
||||
id: String,
|
||||
language: String,
|
||||
username: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<()> {
|
||||
if !is_single_component_game_id(&id) {
|
||||
log::warn!("Ignoring run request for invalid game id: {id}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let settings = launch_settings(&language, &username);
|
||||
let games_folder_lock = state.inner().games_folder.clone();
|
||||
let games_folder = {
|
||||
let guard = games_folder_lock.read().await;
|
||||
@@ -461,11 +735,15 @@ async fn run_game_windows(
|
||||
|
||||
let game_path = games_folder.join(id.clone());
|
||||
|
||||
let game_setup_bin = game_path.join("game_setup.cmd");
|
||||
let game_start_bin = game_path.join("game_start.cmd");
|
||||
let game_setup_bin = game_path.join(GAME_SETUP_SCRIPT);
|
||||
let game_start_bin = game_path.join(GAME_START_SCRIPT);
|
||||
let Some(state_dir) = state.inner().state_dir.get().cloned() else {
|
||||
log::error!("app state directory is not initialized; cannot run game");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let first_start_done_file = game_path.join("local").join(FIRST_START_DONE_FILE);
|
||||
if !first_start_done_file.exists() && game_setup_bin.exists() {
|
||||
let setup_done_file = lanspread_peer::setup_done_path(&state_dir, &id);
|
||||
if !setup_done_file.exists() && game_setup_bin.exists() {
|
||||
if !local_install_is_present(&game_path) {
|
||||
log::warn!(
|
||||
"local install is missing for {}; skipping game_setup",
|
||||
@@ -476,62 +754,176 @@ async fn run_game_windows(
|
||||
|
||||
let result = run_as_admin(
|
||||
"cmd.exe",
|
||||
&format!(
|
||||
r#"/c "{} local {} de playername""#,
|
||||
game_setup_bin.display(),
|
||||
&id
|
||||
),
|
||||
&script_params(&game_setup_bin, &id, &settings),
|
||||
&game_path.display().to_string(),
|
||||
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
||||
);
|
||||
|
||||
if !result {
|
||||
log::error!("failed to run game_setup.cmd");
|
||||
log::error!("failed to run {GAME_SETUP_SCRIPT}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Err(e) = File::create(&first_start_done_file) {
|
||||
if let Some(parent) = setup_done_file.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||
{
|
||||
log::error!(
|
||||
"failed to create first-start marker {}: {e}",
|
||||
first_start_done_file.display()
|
||||
"failed to create setup marker directory {}: {e}",
|
||||
parent.display()
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(e) = std::fs::File::create(&setup_done_file) {
|
||||
log::error!(
|
||||
"failed to create setup marker {}: {e}",
|
||||
setup_done_file.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
apply_launch_settings(&state_dir, &game_path, &id, &language, &username).await;
|
||||
|
||||
if game_start_bin.exists() {
|
||||
let result = run_as_admin(
|
||||
"cmd.exe",
|
||||
&format!(
|
||||
r#"/c "{} local {} de playername""#,
|
||||
game_start_bin.display(),
|
||||
&id
|
||||
),
|
||||
&script_params(&game_start_bin, &id, &settings),
|
||||
&game_path.display().to_string(),
|
||||
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
||||
);
|
||||
|
||||
if !result {
|
||||
log::error!("failed to run game_start.cmd");
|
||||
log::error!("failed to run {GAME_START_SCRIPT}");
|
||||
}
|
||||
}
|
||||
|
||||
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, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<()> {
|
||||
async fn run_game(
|
||||
id: String,
|
||||
language: String,
|
||||
username: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<()> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
run_game_windows(id, state).await?;
|
||||
run_game_windows(id, language, username, state).await?;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = state;
|
||||
let _ = (state, language, username);
|
||||
log::error!("run_game not implemented for this platform: id={id}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn start_server_windows(
|
||||
id: String,
|
||||
language: String,
|
||||
username: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<bool> {
|
||||
if !is_single_component_game_id(&id) {
|
||||
log::warn!("Ignoring server start request for invalid game id: {id}");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let settings = launch_settings(&language, &username);
|
||||
let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone());
|
||||
if !games_folder.exists() {
|
||||
log::error!("games_folder {} does not exist", games_folder.display());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let game_path = games_folder.join(id.clone());
|
||||
if !local_install_is_present(&game_path) {
|
||||
log::warn!(
|
||||
"local install is missing for {}; skipping {SERVER_START_SCRIPT}",
|
||||
game_path.display()
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let server_start_bin = game_path.join(SERVER_START_SCRIPT);
|
||||
if !server_start_bin.is_file() {
|
||||
log::warn!(
|
||||
"server start script is missing for {}: {}",
|
||||
id,
|
||||
server_start_bin.display()
|
||||
);
|
||||
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),
|
||||
&game_path.display().to_string(),
|
||||
windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL,
|
||||
);
|
||||
|
||||
if !result {
|
||||
log::error!("failed to run {SERVER_START_SCRIPT}");
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_server(
|
||||
id: String,
|
||||
language: String,
|
||||
username: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<bool> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
start_server_windows(id, language, username, state).await
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = (state, language, username);
|
||||
log::error!("start_server not implemented for this platform: id={id}");
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn local_install_is_present(game_path: &Path) -> bool {
|
||||
game_path.join("local").is_dir()
|
||||
@@ -581,6 +973,24 @@ 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);
|
||||
@@ -592,19 +1002,31 @@ async fn emit_games_list(app_handle: &AppHandle) {
|
||||
|
||||
let games_db_lock = state.games.clone();
|
||||
let game_db = games_db_lock.read().await;
|
||||
let games_folder = state.games_folder.read().await.clone();
|
||||
|
||||
if game_db.games.is_empty() {
|
||||
log::debug!("Game database empty; skipping emit");
|
||||
return;
|
||||
}
|
||||
|
||||
let active_transfers = state.active_outbound_transfers.read().await;
|
||||
|
||||
let games_to_emit = game_db
|
||||
.all_games()
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect::<Vec<Game>>();
|
||||
.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,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<LauncherGame>>();
|
||||
|
||||
drop(game_db);
|
||||
drop(active_transfers);
|
||||
|
||||
let active_operations = {
|
||||
let active_operations = state.active_operations.read().await;
|
||||
@@ -623,6 +1045,17 @@ async fn emit_games_list(app_handle: &AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
fn game_can_host_server(games_folder: &str, game: &Game) -> bool {
|
||||
if !game.installed || games_folder.is_empty() || !is_single_component_game_id(&game.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
PathBuf::from(games_folder)
|
||||
.join(&game.id)
|
||||
.join(SERVER_START_SCRIPT)
|
||||
.is_file()
|
||||
}
|
||||
|
||||
fn ui_active_operations_from_map(
|
||||
active_operations: &HashMap<String, UiOperationKind>,
|
||||
) -> Vec<UiActiveOperation> {
|
||||
@@ -660,6 +1093,11 @@ fn ui_operation_from_peer(operation: ActiveOperationKind) -> UiOperationKind {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn game_directory_exists(path: String) -> bool {
|
||||
PathBuf::from(path).is_dir()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> tauri::Result<()> {
|
||||
log::info!("update_game_directory: {path}");
|
||||
@@ -689,6 +1127,21 @@ async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> ta
|
||||
}
|
||||
|
||||
let path_changed = current_path != path;
|
||||
let Some(state_dir) = state.state_dir.get().cloned() else {
|
||||
log::error!("app state directory is not initialized; cannot update game directory");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if path_changed || state.peer_ctrl.read().await.is_none() {
|
||||
let migration = migrate_legacy_state(&games_folder, &state_dir).await;
|
||||
if migration.failures > 0 {
|
||||
log::warn!(
|
||||
"Legacy state migration completed with {} failure(s)",
|
||||
migration.failures
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
*state.games_folder.write().await = path;
|
||||
|
||||
ensure_bundled_game_db_loaded(&app_handle).await;
|
||||
@@ -712,36 +1165,7 @@ async fn update_game_db(games: Vec<Game>, app: AppHandle) {
|
||||
|
||||
{
|
||||
let mut game_db = state.games.write().await;
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
}
|
||||
apply_peer_remote_games(&mut game_db, games);
|
||||
}
|
||||
|
||||
emit_games_list(&app).await;
|
||||
@@ -904,8 +1328,8 @@ async fn run_unrar_sidecar(
|
||||
}
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
let stdout = clean_terminal_log(&String::from_utf8_lossy(&out.stdout));
|
||||
let stderr = clean_terminal_log(&String::from_utf8_lossy(&out.stderr));
|
||||
let status_code = out.status.code();
|
||||
let success = out.status.success();
|
||||
|
||||
@@ -962,20 +1386,476 @@ async fn record_unpack_failure(
|
||||
|
||||
async fn record_unpack_log(app_handle: &AppHandle, entry: UnpackLogEntry) {
|
||||
let state = app_handle.state::<LanSpreadState>();
|
||||
{
|
||||
let mut entry = entry;
|
||||
clean_unpack_log_entry(&mut entry);
|
||||
let logs = {
|
||||
let mut logs = state.inner().unpack_logs.write().await;
|
||||
logs.push(entry);
|
||||
if logs.len() > MAX_UNPACK_LOGS {
|
||||
let overflow = logs.len() - MAX_UNPACK_LOGS;
|
||||
logs.drain(..overflow);
|
||||
}
|
||||
}
|
||||
trim_unpack_logs(&mut logs);
|
||||
logs.clone()
|
||||
};
|
||||
|
||||
persist_unpack_logs(app_handle, &logs).await;
|
||||
|
||||
if let Err(err) = app_handle.emit("unpack-logs-updated", ()) {
|
||||
log::warn!("Failed to emit unpack-logs-updated event: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_unpack_logs(logs: &mut Vec<UnpackLogEntry>) {
|
||||
if logs.len() > MAX_UNPACK_LOGS {
|
||||
let overflow = logs.len() - MAX_UNPACK_LOGS;
|
||||
logs.drain(..overflow);
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_unpack_log_entry(entry: &mut UnpackLogEntry) {
|
||||
let stdout = clean_terminal_log(&entry.stdout);
|
||||
let stderr = clean_terminal_log(&entry.stderr);
|
||||
entry.stdout = stdout;
|
||||
entry.stderr = stderr;
|
||||
}
|
||||
|
||||
fn clean_terminal_log(input: &str) -> String {
|
||||
let mut output = String::new();
|
||||
let mut line = String::new();
|
||||
let mut chars = input.chars().peekable();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'\r' if chars.peek() == Some(&'\n') => {
|
||||
let _ = chars.next();
|
||||
output.push_str(&line);
|
||||
output.push('\n');
|
||||
line.clear();
|
||||
}
|
||||
'\r' => {
|
||||
line.clear();
|
||||
}
|
||||
'\n' => {
|
||||
output.push_str(&line);
|
||||
output.push('\n');
|
||||
line.clear();
|
||||
}
|
||||
'\u{8}' => {
|
||||
let _ = line.pop();
|
||||
}
|
||||
'\t' => line.push(ch),
|
||||
ch if ch.is_control() => {}
|
||||
ch => line.push(ch),
|
||||
}
|
||||
}
|
||||
|
||||
output.push_str(&line);
|
||||
output
|
||||
}
|
||||
|
||||
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) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
|
||||
Err(err) => {
|
||||
log::warn!("Failed to read unpack logs from {}: {err}", path.display());
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let mut logs = match serde_json::from_str::<Vec<UnpackLogEntry>>(&contents) {
|
||||
Ok(logs) => logs,
|
||||
Err(err) => {
|
||||
log::warn!("Failed to parse unpack logs from {}: {err}", path.display());
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
logs.iter_mut().for_each(clean_unpack_log_entry);
|
||||
trim_unpack_logs(&mut logs);
|
||||
logs
|
||||
}
|
||||
|
||||
async fn persist_unpack_logs(app_handle: &AppHandle, logs: &[UnpackLogEntry]) {
|
||||
let state = app_handle.state::<LanSpreadState>();
|
||||
let Some(state_dir) = state.state_dir.get().cloned() else {
|
||||
log::warn!("Cannot persist unpack logs before app state directory is initialized");
|
||||
return;
|
||||
};
|
||||
let path = unpack_logs_path(&state_dir);
|
||||
let contents = match serde_json::to_vec_pretty(logs) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to serialize unpack logs for {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = tokio::fs::write(&path, contents).await {
|
||||
log::warn!("Failed to persist unpack logs to {}: {err}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn now_millis() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -1015,7 +1895,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 = game_db.games.keys().cloned().collect::<HashSet<_>>();
|
||||
let catalog = GameCatalog::from_game_db(&game_db);
|
||||
*state.games.write().await = game_db;
|
||||
*state.catalog.write().await = catalog;
|
||||
}
|
||||
@@ -1032,16 +1912,26 @@ async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(state_dir) = state.state_dir.get().cloned() else {
|
||||
log::error!("app state directory is not initialized; cannot start peer");
|
||||
return;
|
||||
};
|
||||
let tx_peer_event = app_handle.state::<PeerEventTx>().inner().0.clone();
|
||||
let unpacker = Arc::new(SidecarUnpacker {
|
||||
app_handle: app_handle.clone(),
|
||||
});
|
||||
match start_peer(
|
||||
let stream_install_provider = stream_install_provider_for_app(app_handle);
|
||||
match start_peer_with_options(
|
||||
games_folder.to_path_buf(),
|
||||
tx_peer_event,
|
||||
state.peer_game_db.clone(),
|
||||
unpacker,
|
||||
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) => {
|
||||
let sender = handle.sender();
|
||||
@@ -1058,6 +1948,22 @@ 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}");
|
||||
@@ -1078,6 +1984,44 @@ 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 {
|
||||
@@ -1104,6 +2048,10 @@ 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,
|
||||
@@ -1304,7 +2252,7 @@ async fn handle_got_game_files(
|
||||
file_descriptions,
|
||||
})
|
||||
{
|
||||
log::error!("Failed to send PeerCommand::DownloadGameFiles: {e}");
|
||||
log::error!("Failed to continue queued game transfer: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1322,6 +2270,20 @@ fn handle_download_finished(app_handle: &AppHandle, id: String) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unpack_log_fixture(index: usize) -> UnpackLogEntry {
|
||||
let timestamp = u64::try_from(index).unwrap_or(u64::MAX);
|
||||
UnpackLogEntry {
|
||||
archive: format!("archive-{index}.rar"),
|
||||
destination: format!("destination-{index}"),
|
||||
status_code: Some(0),
|
||||
stdout: format!("stdout {index}"),
|
||||
stderr: String::new(),
|
||||
started_at_ms: timestamp,
|
||||
finished_at_ms: timestamp,
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn game_fixture(id: &str, name: &str) -> Game {
|
||||
Game {
|
||||
id: id.to_string(),
|
||||
@@ -1342,6 +2304,139 @@ 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";
|
||||
|
||||
assert_eq!(clean_terminal_log(input), "Extracting foo OK\nAll done\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_log_cleanup_applies_backspaces() {
|
||||
assert_eq!(clean_terminal_log("abc\u{8}\u{8}de\n"), "ade\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_log_cleanup_removes_other_controls() {
|
||||
assert_eq!(clean_terminal_log("a\u{7}b\tc"), "ab\tc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpack_log_retention_keeps_last_twenty_entries() {
|
||||
let mut logs = (0..25).map(unpack_log_fixture).collect::<Vec<_>>();
|
||||
|
||||
trim_unpack_logs(&mut logs);
|
||||
|
||||
assert_eq!(logs.len(), MAX_UNPACK_LOGS);
|
||||
assert_eq!(
|
||||
logs.first().map(|entry| entry.archive.as_str()),
|
||||
Some("archive-5.rar")
|
||||
);
|
||||
assert_eq!(
|
||||
logs.last().map(|entry| entry.archive.as_str()),
|
||||
Some("archive-24.rar")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpack_logs_load_from_app_state_dir_and_apply_retention() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"lanspread-unpack-logs-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 logs = (0..25).map(unpack_log_fixture).collect::<Vec<_>>();
|
||||
std::fs::write(
|
||||
unpack_logs_path(&root),
|
||||
serde_json::to_vec(&logs).expect("logs should serialize"),
|
||||
)
|
||||
.expect("logs should be written");
|
||||
|
||||
let loaded = load_unpack_logs(&root);
|
||||
|
||||
assert_eq!(loaded.len(), MAX_UNPACK_LOGS);
|
||||
assert_eq!(
|
||||
loaded.first().map(|entry| entry.archive.as_str()),
|
||||
Some("archive-5.rar")
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.last().map(|entry| entry.archive.as_str()),
|
||||
Some("archive-24.rar")
|
||||
);
|
||||
|
||||
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([
|
||||
@@ -1430,6 +2525,32 @@ 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"));
|
||||
@@ -1440,6 +2561,102 @@ mod tests {
|
||||
assert!(!is_single_component_game_id("/game"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_settings_sanitize_script_arguments() {
|
||||
assert_eq!(
|
||||
launch_settings("DE", " Alice \"Ace\"%PATH%\n "),
|
||||
LaunchSettings {
|
||||
language: "de".to_string(),
|
||||
username: "Alice AcePATH".to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
launch_settings("fr", ""),
|
||||
LaunchSettings {
|
||||
language: DEFAULT_LANGUAGE.to_string(),
|
||||
username: DEFAULT_USERNAME.to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[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(
|
||||
Path::new("C:/Games/My Game")
|
||||
.join(GAME_START_SCRIPT)
|
||||
.as_path(),
|
||||
"my-game",
|
||||
&LaunchSettings {
|
||||
language: "en".to_string(),
|
||||
username: "Alice".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
start_params,
|
||||
r#"/d /s /c ""C:/Games/My Game/game_start.cmd" "local" "my-game" "en" "Alice"""#
|
||||
);
|
||||
|
||||
let server_params = server_script_params(
|
||||
Path::new("C:/Games/My Game")
|
||||
.join(SERVER_START_SCRIPT)
|
||||
.as_path(),
|
||||
"my-game",
|
||||
&LaunchSettings {
|
||||
language: "en".to_string(),
|
||||
username: "Alice".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
server_params,
|
||||
r#"/d /s /k ""C:/Games/My Game/server_start.cmd" "local" "my-game" "en" "Alice"""#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_host_capability_requires_installed_game_with_script() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"lanspread-server-test-{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_nanos()
|
||||
));
|
||||
let game_root = root.join("game");
|
||||
std::fs::create_dir_all(&game_root).expect("game root should be created");
|
||||
std::fs::write(game_root.join(SERVER_START_SCRIPT), b"").expect("script should be written");
|
||||
|
||||
let mut game = game_fixture("game", "Game");
|
||||
assert!(!game_can_host_server(
|
||||
root.to_string_lossy().as_ref(),
|
||||
&game
|
||||
));
|
||||
|
||||
game.installed = true;
|
||||
assert!(game_can_host_server(root.to_string_lossy().as_ref(), &game));
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_local_snapshot_replaces_local_state_without_overwriting_catalog_metadata() {
|
||||
let mut alpha = game_fixture("alpha", "Catalog Alpha");
|
||||
@@ -1481,31 +2698,61 @@ 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,
|
||||
update_game_directory,
|
||||
update_game,
|
||||
uninstall_game,
|
||||
@@ -1514,11 +2761,27 @@ pub fn run() {
|
||||
open_game_files,
|
||||
get_peer_count,
|
||||
get_game_thumbnail,
|
||||
get_unpack_logs
|
||||
get_unpack_logs,
|
||||
get_main_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;
|
||||
});
|
||||
if state.state_dir.set(state_dir).is_err() {
|
||||
log::warn!("app state directory was already initialized");
|
||||
}
|
||||
spawn_peer_event_loop(app.handle().clone(), rx_peer_event);
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
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
|
||||
* unpack-logs companion window. The URL query string disambiguates the two so
|
||||
* companion log windows. The URL query string disambiguates the views so
|
||||
* a single Vite build serves both.
|
||||
*/
|
||||
const App = () => (isUnpackLogsView() ? <UnpackLogsWindow /> : <MainWindow />);
|
||||
const App = () => {
|
||||
if (isMainLogsView()) return <MainLogsWindow />;
|
||||
if (isUnpackLogsView()) return <UnpackLogsWindow />;
|
||||
return <MainWindow />;
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -33,6 +33,9 @@ const formatLogTime = (timestampMs: number): string => {
|
||||
return new Date(timestampMs).toLocaleString();
|
||||
};
|
||||
|
||||
const logSortTime = (entry: UnpackLogEntry): number =>
|
||||
entry.finished_at_ms > 0 ? entry.finished_at_ms : entry.started_at_ms;
|
||||
|
||||
const basename = (path: string): string => {
|
||||
const segments = path.split(/[\\/]/);
|
||||
return segments[segments.length - 1] || path;
|
||||
@@ -97,6 +100,10 @@ export const UnpackLogsWindow = () => {
|
||||
|
||||
out.push({ entry, originalIndex, stdoutLines, stderrLines, matchCount });
|
||||
});
|
||||
out.sort((a, b) => {
|
||||
const timestampDelta = logSortTime(b.entry) - logSortTime(a.entry);
|
||||
return timestampDelta !== 0 ? timestampDelta : b.originalIndex - a.originalIndex;
|
||||
});
|
||||
return out;
|
||||
}, [logs, errorsOnly, regex]);
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ const progressStats = (game: Game) => {
|
||||
total,
|
||||
speed,
|
||||
eta: etaSeconds,
|
||||
activePeerCount: progress?.active_peer_count ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -52,6 +53,8 @@ export const DownloadProgress = ({ game, size = 'md', full = false, onCancel }:
|
||||
};
|
||||
|
||||
if (size === 'lg') {
|
||||
const peerUnit = stats.activePeerCount === 1 ? 'peer' : 'peers';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
@@ -75,6 +78,18 @@ export const DownloadProgress = ({ game, size = 'md', full = false, onCancel }:
|
||||
</span>
|
||||
<span className="dl-sep">·</span>
|
||||
<span className="dl-speed">{formatDownloadSpeed(stats.speed)}</span>
|
||||
{stats.activePeerCount > 0 && (
|
||||
<>
|
||||
<span className="dl-sep dl-sep-peers">·</span>
|
||||
<span
|
||||
className="dl-peers"
|
||||
title={`Downloading from ${stats.activePeerCount} ${peerUnit} on the LAN`}
|
||||
>
|
||||
<Icon.users />
|
||||
<span>{stats.activePeerCount}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="dl-sep dl-sep-eta">·</span>
|
||||
<span className="dl-eta">{formatDownloadEta(stats.eta)} left</span>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,15 @@ export const Icon = {
|
||||
<path d="M4 2.5v11l10-5.5z" />
|
||||
</svg>
|
||||
),
|
||||
server: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
|
||||
<rect x="2" y="3" width="12" height="4.5" rx="1" />
|
||||
<rect x="2" y="8.5" width="12" height="4.5" rx="1" />
|
||||
<circle cx="4.6" cy="5.25" r=".55" fill="currentColor" stroke="none" />
|
||||
<circle cx="4.6" cy="10.75" r=".55" fill="currentColor" stroke="none" />
|
||||
<path d="M7 5.25h4.5M7 10.75h4.5" />
|
||||
</svg>
|
||||
),
|
||||
install: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={1.7} {...baseStroke} {...p}>
|
||||
<path d="M8 2v8" />
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { Game } from '../lib/types';
|
||||
import { deriveState } from '../lib/gameState';
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
installed: 'Installed',
|
||||
local: 'Local',
|
||||
downloading: 'Downloading',
|
||||
busy: 'Working',
|
||||
none: '',
|
||||
};
|
||||
import { deriveState, stateChipLabel } from '../lib/gameState';
|
||||
|
||||
interface Props {
|
||||
game: Game;
|
||||
@@ -17,7 +9,7 @@ interface Props {
|
||||
|
||||
export const StateChip = ({ game, showNone = false }: Props) => {
|
||||
const state = deriveState(game);
|
||||
const label = LABELS[state] ?? '';
|
||||
const label = stateChipLabel(game);
|
||||
if (!label && !showNone) return null;
|
||||
return (
|
||||
<div className="state-chip" data-state={state}>
|
||||
|
||||
@@ -7,11 +7,7 @@ interface Props {
|
||||
export const NoDirectoryState = ({ onChooseDirectory }: Props) => (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon"><Icon.folder /></div>
|
||||
<h2 className="empty-state-title">Pick a game directory</h2>
|
||||
<p className="empty-state-hint">
|
||||
SoftLAN scans the folder you point it at for installable game bundles
|
||||
and tracks what your peers on the LAN have available.
|
||||
</p>
|
||||
<h2 className="empty-state-title">Please select a game folder</h2>
|
||||
<button type="button" className="ghost-btn" onClick={onChooseDirectory}>
|
||||
<Icon.folder />
|
||||
<span>Choose folder</span>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -42,6 +43,14 @@ 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
|
||||
@@ -66,8 +75,8 @@ export const GameCard = ({
|
||||
<div className="card-meta">
|
||||
{metaSeparator(formatBytes(game.size), game.genre || null)}
|
||||
</div>
|
||||
<div className={`card-status${game.status_level === 'error' ? ' is-error' : ''}`}>
|
||||
{game.status_message ?? ''}
|
||||
<div className={`card-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
|
||||
{statusMessage}
|
||||
</div>
|
||||
<ActionButton
|
||||
game={game}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { StateChip } from '../StateChip';
|
||||
import { ActionButton } from '../ActionButton';
|
||||
|
||||
import { Game, InstallStatus } from '../../lib/types';
|
||||
import { deriveState, isInProgress } from '../../lib/gameState';
|
||||
import { canStreamInstall, gameStatusLabel, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
|
||||
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
|
||||
|
||||
interface Props {
|
||||
@@ -13,9 +13,11 @@ 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;
|
||||
onStartServer: (game: Game) => void;
|
||||
onViewFiles: (game: Game) => void;
|
||||
}
|
||||
|
||||
@@ -27,33 +29,41 @@ 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,
|
||||
onStartServer,
|
||||
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.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">
|
||||
@@ -90,22 +100,22 @@ export const GameDetailModal = ({
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Version</div>
|
||||
<div className="meta-value meta-mono">
|
||||
{formatEtiVersion(game.local_version ?? game.eti_game_version)}
|
||||
{formatEtiVersion(game.eti_game_version ?? game.local_version)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Status</div>
|
||||
<div className="meta-value">{statusLabelFor(game)}</div>
|
||||
<div className="meta-value">{gameStatusLabel(game)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{game.description && (
|
||||
<p className="modal-desc">{game.description}</p>
|
||||
{description && (
|
||||
<p className="modal-desc">{description}</p>
|
||||
)}
|
||||
|
||||
{game.status_message && (
|
||||
<p className={`modal-status${game.status_level === 'error' ? ' is-error' : ''}`}>
|
||||
{game.status_message}
|
||||
{statusMessage && (
|
||||
<p className={`modal-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
|
||||
{statusMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -116,6 +126,27 @@ 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"
|
||||
className="act-btn act-lg act-server"
|
||||
onClick={() => onStartServer(game)}
|
||||
>
|
||||
<Icon.server />
|
||||
<span className="act-label">Start Server</span>
|
||||
</button>
|
||||
)}
|
||||
{game.installed && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,15 +8,21 @@ import {
|
||||
ASPECT_OPTIONS,
|
||||
BG_OPTIONS,
|
||||
DENSITY_OPTIONS,
|
||||
UISettings,
|
||||
LANGUAGE_OPTIONS,
|
||||
type UISettings,
|
||||
} from '../../hooks/useSettings';
|
||||
|
||||
interface Props {
|
||||
settings: UISettings;
|
||||
gameDir: string;
|
||||
hasGameDirectory: boolean;
|
||||
onPickDirectory: () => void;
|
||||
onChange: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const buildNr = import.meta.env.VITE_LANSPREAD_BUILD_NR;
|
||||
|
||||
interface RowProps {
|
||||
label: string;
|
||||
hint: string;
|
||||
@@ -33,7 +39,57 @@ const Row = ({ label, hint, children }: RowProps) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
|
||||
interface TextInputProps {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
maxLength: number;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const SettingsTextInput = ({ value, placeholder, maxLength, onChange }: TextInputProps) => (
|
||||
<div className="settings-text">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
spellCheck={false}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface GameFolderFieldProps {
|
||||
path: string;
|
||||
isValid: boolean;
|
||||
onPickDirectory: () => void;
|
||||
}
|
||||
|
||||
const GameFolderField = ({ path, isValid, onPickDirectory }: GameFolderFieldProps) => (
|
||||
<div className={`folder-field ${isValid ? 'is-set' : 'is-unset'}`}>
|
||||
<Icon.folder className="folder-field-icon" aria-hidden="true" />
|
||||
<div className="folder-field-path" title={isValid ? path : 'No folder selected'}>
|
||||
{isValid ? <bdi>{path}</bdi> : <span className="folder-field-empty">Not set</span>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="folder-field-btn"
|
||||
aria-label={isValid ? 'Change game folder' : 'Choose game folder'}
|
||||
onClick={onPickDirectory}
|
||||
>
|
||||
{isValid ? 'Change…' : 'Choose…'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SettingsDialog = ({
|
||||
settings,
|
||||
gameDir,
|
||||
hasGameDirectory,
|
||||
onPickDirectory,
|
||||
onChange,
|
||||
onClose,
|
||||
}: Props) => (
|
||||
<Modal onClose={onClose} className="settings-modal">
|
||||
<div className="settings-head">
|
||||
<h2>Settings</h2>
|
||||
@@ -48,6 +104,25 @@ export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
|
||||
</div>
|
||||
|
||||
<div className="settings-body">
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">Profile</div>
|
||||
<Row label="Username" hint="Shown to other players on the LAN">
|
||||
<SettingsTextInput
|
||||
value={settings.username}
|
||||
placeholder="Enter a username"
|
||||
maxLength={24}
|
||||
onChange={(v) => onChange('username', v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Language" hint="Interface language">
|
||||
<SegmentedRadio
|
||||
value={settings.language}
|
||||
options={LANGUAGE_OPTIONS}
|
||||
onChange={(v) => onChange('language', v)}
|
||||
/>
|
||||
</Row>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">Appearance</div>
|
||||
<Row label="Accent color" hint="Used for primary actions and highlights">
|
||||
@@ -68,6 +143,13 @@ export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">Library</div>
|
||||
<Row label="Game folder" hint="Parent directory where games are downloaded and installed">
|
||||
<GameFolderField
|
||||
path={gameDir}
|
||||
isValid={hasGameDirectory}
|
||||
onPickDirectory={onPickDirectory}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Grid density" hint="How tightly cards are packed">
|
||||
<SegmentedRadio
|
||||
value={settings.density}
|
||||
@@ -86,6 +168,7 @@ export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
|
||||
</div>
|
||||
|
||||
<div className="settings-foot">
|
||||
<div className="settings-build-nr">Build-Nr: {buildNr}</div>
|
||||
<button type="button" className="settings-done" onClick={onClose}>Done</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Icon } from '../Icon';
|
||||
import { truncatePath } from '../../lib/format';
|
||||
|
||||
interface Props {
|
||||
path: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const DirectoryButton = ({ path, onClick }: Props) => (
|
||||
<button className="dirbtn" type="button" title={path || 'Choose a game directory'} onClick={onClick}>
|
||||
<Icon.folder />
|
||||
<span className="dirbtn-label">Game directory</span>
|
||||
<span className="dirbtn-path">
|
||||
{path ? truncatePath(path) : 'choose…'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -8,15 +8,18 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search input with a `/` keyboard shortcut for focus. Ignores the shortcut
|
||||
* when the user is already typing into another input or textarea.
|
||||
* Search input with `/` and Ctrl+F keyboard shortcuts for focus. Ignores
|
||||
* shortcuts when the user is already typing into another input or textarea.
|
||||
*/
|
||||
export const SearchField = ({ value, onChange }: Props) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const clearClassName = value ? 'search-clear' : 'search-clear is-hidden';
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== '/') return;
|
||||
const isFindShortcut = e.ctrlKey && !e.altKey && !e.shiftKey
|
||||
&& e.key.toLowerCase() === 'f';
|
||||
if (e.key !== '/' && !isFindShortcut) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
||||
e.preventDefault();
|
||||
@@ -49,19 +52,19 @@ export const SearchField = ({ value, onChange }: Props) => {
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
className="search-clear"
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
onChange('');
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Icon.clearCircle />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={clearClassName}
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
aria-hidden={value ? undefined : true}
|
||||
disabled={!value}
|
||||
onClick={() => {
|
||||
onChange('');
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Icon.clearCircle />
|
||||
</button>
|
||||
<span className="search-kbd">/</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Brand } from '../Brand';
|
||||
import { SegmentedFilters } from './SegmentedFilters';
|
||||
import { SearchField } from './SearchField';
|
||||
import { SortMenu } from './SortMenu';
|
||||
import { DirectoryButton } from './DirectoryButton';
|
||||
import { KebabMenu, KebabItem } from './KebabMenu';
|
||||
|
||||
import { FilterCounts } from '../../lib/gameState';
|
||||
@@ -17,8 +16,6 @@ interface Props {
|
||||
setQuery: (value: string) => void;
|
||||
sort: GameSort;
|
||||
setSort: (value: GameSort) => void;
|
||||
gameDir: string;
|
||||
onPickDirectory: () => void;
|
||||
kebabItems: ReadonlyArray<KebabItem>;
|
||||
}
|
||||
|
||||
@@ -31,16 +28,25 @@ export const TopBar = ({
|
||||
setQuery,
|
||||
sort,
|
||||
setSort,
|
||||
gameDir,
|
||||
onPickDirectory,
|
||||
kebabItems,
|
||||
}: Props) => (
|
||||
<header className="topbar">
|
||||
<Brand peerCount={peerCount} />
|
||||
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} />
|
||||
<SearchField value={query} onChange={setQuery} />
|
||||
<SortMenu value={sort} onChange={setSort} />
|
||||
<DirectoryButton path={gameDir} onClick={onPickDirectory} />
|
||||
<KebabMenu items={kebabItems} />
|
||||
<div className="topbar-left">
|
||||
<Brand peerCount={peerCount} />
|
||||
<div className="topbar-left-trail">
|
||||
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar-center">
|
||||
<SearchField value={query} onChange={setQuery} />
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
<div className="topbar-right-lead">
|
||||
<SortMenu value={sort} onChange={setSort} />
|
||||
</div>
|
||||
<div className="topbar-right-tail">
|
||||
<KebabMenu items={kebabItems} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ask } from '@tauri-apps/plugin-dialog';
|
||||
|
||||
import { UseGamesResult } from './useGames';
|
||||
import { type UseGamesResult } from './useGames';
|
||||
import { type UISettings } from './useSettings';
|
||||
|
||||
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>;
|
||||
@@ -19,18 +23,41 @@ export interface GameActions {
|
||||
* are marked as "checking peers" until the backend emits an authoritative
|
||||
* operation snapshot; cancellation waits for the backend to clear that snapshot.
|
||||
*/
|
||||
export const useGameActions = (games: UseGamesResult): GameActions => {
|
||||
export const useGameActions = (
|
||||
games: UseGamesResult,
|
||||
settings: Pick<UISettings, 'language' | 'username'>,
|
||||
): GameActions => {
|
||||
const play = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('run_game', { id });
|
||||
await invoke('run_game', {
|
||||
id,
|
||||
language: settings.language,
|
||||
username: settings.username,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('run_game failed:', err);
|
||||
}
|
||||
}, []);
|
||||
}, [settings.language, settings.username]);
|
||||
|
||||
const startServer = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('start_server', {
|
||||
id,
|
||||
language: settings.language,
|
||||
username: settings.username,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('start_server failed:', err);
|
||||
}
|
||||
}, [settings.language, settings.username]);
|
||||
|
||||
const install = useCallback(async (id: string) => {
|
||||
try {
|
||||
const success = await invoke<boolean>('install_game', { id });
|
||||
const success = await invoke<boolean>('install_game', {
|
||||
id,
|
||||
language: settings.language,
|
||||
username: settings.username,
|
||||
});
|
||||
if (!success) return;
|
||||
|
||||
const game = games.games.find(item => item.id === id);
|
||||
@@ -40,16 +67,37 @@ export const useGameActions = (games: UseGamesResult): GameActions => {
|
||||
} 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]);
|
||||
|
||||
const update = useCallback(async (id: string) => {
|
||||
try {
|
||||
const success = await invoke<boolean>('update_game', { id });
|
||||
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]);
|
||||
}, [games, settings.language, settings.username]);
|
||||
|
||||
const uninstall = useCallback(async (id: string) => {
|
||||
try {
|
||||
@@ -61,11 +109,19 @@ export const useGameActions = (games: UseGamesResult): GameActions => {
|
||||
|
||||
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 {
|
||||
@@ -83,5 +139,15 @@ export const useGameActions = (games: UseGamesResult): GameActions => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { play, install, update, uninstall, removeDownload, cancelDownload, viewFiles };
|
||||
return {
|
||||
play,
|
||||
startServer,
|
||||
install,
|
||||
streamInstall,
|
||||
update,
|
||||
uninstall,
|
||||
removeDownload,
|
||||
cancelDownload,
|
||||
viewFiles,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { GAME_DIR_KEY, SETTINGS_FILE, SETTINGS_FILE_OPTIONS } from '../lib/store
|
||||
*/
|
||||
export const useGameDirectory = () => {
|
||||
const [gameDir, setGameDir] = useState('');
|
||||
const [gameDirExists, setGameDirExists] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -30,7 +31,11 @@ export const useGameDirectory = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameDir) return;
|
||||
if (!gameDir.trim()) {
|
||||
setGameDirExists(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const sync = async () => {
|
||||
try {
|
||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||
@@ -38,19 +43,50 @@ export const useGameDirectory = () => {
|
||||
} catch (err) {
|
||||
console.error('Failed to persist game directory:', err);
|
||||
}
|
||||
|
||||
let exists = false;
|
||||
try {
|
||||
exists = await invoke<boolean>('game_directory_exists', { path: gameDir });
|
||||
} catch (err) {
|
||||
console.error('Failed to validate game directory:', err);
|
||||
}
|
||||
if (cancelled) return;
|
||||
setGameDirExists(exists);
|
||||
if (!exists) return;
|
||||
|
||||
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
||||
console.error('Failed to push game directory to backend:', err),
|
||||
);
|
||||
};
|
||||
void sync();
|
||||
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
||||
console.error('Failed to push game directory to backend:', err),
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [gameDir]);
|
||||
|
||||
const hasGameDirectory = gameDir.trim() !== '' && gameDirExists;
|
||||
|
||||
const rescan = useCallback(() => {
|
||||
if (!gameDir) return;
|
||||
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
||||
console.error('Failed to rescan game directory:', err),
|
||||
);
|
||||
if (!gameDir.trim()) {
|
||||
setGameDirExists(false);
|
||||
return;
|
||||
}
|
||||
const sync = async () => {
|
||||
let exists = false;
|
||||
try {
|
||||
exists = await invoke<boolean>('game_directory_exists', { path: gameDir });
|
||||
} catch (err) {
|
||||
console.error('Failed to validate game directory:', err);
|
||||
}
|
||||
setGameDirExists(exists);
|
||||
if (!exists) return;
|
||||
|
||||
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
||||
console.error('Failed to rescan game directory:', err),
|
||||
);
|
||||
};
|
||||
void sync();
|
||||
}, [gameDir]);
|
||||
|
||||
return { gameDir, setGameDir, rescan };
|
||||
return { gameDir, gameDirExists, hasGameDirectory, setGameDir, rescan };
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { load } from '@tauri-apps/plugin-store';
|
||||
|
||||
import { GameFilter, GameSort } from '../lib/types';
|
||||
import { type GameFilter, type GameSort, type LauncherLanguage } from '../lib/types';
|
||||
import { SETTINGS_FILE, SETTINGS_FILE_OPTIONS, UI_SETTINGS_KEY } from '../lib/store';
|
||||
|
||||
export type Density = 'compact' | 'normal' | 'large';
|
||||
@@ -15,10 +15,14 @@ export interface UISettings {
|
||||
aspect: CoverAspect;
|
||||
sort: GameSort;
|
||||
filter: GameFilter;
|
||||
username: string;
|
||||
language: LauncherLanguage;
|
||||
}
|
||||
|
||||
type StoredGameSort = GameSort | 'size';
|
||||
type StoredUISettings = Partial<Omit<UISettings, 'sort'> & { sort: StoredGameSort }>;
|
||||
const DEFAULT_USERNAME = 'Commander';
|
||||
const MAX_USERNAME_LENGTH = 24;
|
||||
|
||||
export const ACCENT_OPTIONS = [
|
||||
{ value: '#3b82f6', label: 'Blue' },
|
||||
@@ -47,6 +51,18 @@ export const ASPECT_OPTIONS: ReadonlyArray<{ value: CoverAspect; label: string }
|
||||
{ value: 'banner', label: 'Banner' },
|
||||
];
|
||||
|
||||
export const LANGUAGE_OPTIONS: ReadonlyArray<{ value: LauncherLanguage; label: string }> = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
];
|
||||
|
||||
const defaultLanguage = (): LauncherLanguage => {
|
||||
if (typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('de')) {
|
||||
return 'de';
|
||||
}
|
||||
return 'en';
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: UISettings = {
|
||||
accent: '#3b82f6',
|
||||
bg: 'gradient',
|
||||
@@ -54,6 +70,8 @@ export const DEFAULT_SETTINGS: UISettings = {
|
||||
aspect: 'square',
|
||||
sort: 'status',
|
||||
filter: 'local',
|
||||
username: DEFAULT_USERNAME,
|
||||
language: defaultLanguage(),
|
||||
};
|
||||
|
||||
const sanitize = (raw: StoredUISettings | undefined): UISettings => ({
|
||||
@@ -63,12 +81,23 @@ const sanitize = (raw: StoredUISettings | undefined): UISettings => ({
|
||||
aspect: raw?.aspect ?? DEFAULT_SETTINGS.aspect,
|
||||
sort: sanitizeSort(raw?.sort),
|
||||
filter: raw?.filter ?? DEFAULT_SETTINGS.filter,
|
||||
username: sanitizeUsername(raw?.username),
|
||||
language: sanitizeLanguage(raw?.language),
|
||||
});
|
||||
|
||||
const sanitizeSort = (sort: StoredGameSort | undefined): GameSort => (
|
||||
sort === 'size' ? 'sizeDesc' : sort ?? DEFAULT_SETTINGS.sort
|
||||
);
|
||||
|
||||
const sanitizeLanguage = (language: LauncherLanguage | undefined): LauncherLanguage => (
|
||||
language === 'de' || language === 'en' ? language : DEFAULT_SETTINGS.language
|
||||
);
|
||||
|
||||
const sanitizeUsername = (username: string | undefined): string => {
|
||||
const trimmed = username?.trim() ?? '';
|
||||
return trimmed ? Array.from(trimmed).slice(0, MAX_USERNAME_LENGTH).join('') : DEFAULT_USERNAME;
|
||||
};
|
||||
|
||||
export interface UseSettings {
|
||||
settings: UISettings;
|
||||
set: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
||||
|
||||
@@ -19,10 +19,6 @@ export const formatEtiVersion = (raw: string | undefined): string => {
|
||||
return raw;
|
||||
};
|
||||
|
||||
/** Truncate a path with a leading ellipsis when it exceeds the limit. */
|
||||
export const truncatePath = (path: string, max = 36): string =>
|
||||
path.length > max ? `…${path.slice(-(max - 1))}` : path;
|
||||
|
||||
export const formatPlayers = (max?: number): string => {
|
||||
if (!max || max <= 0) return '—';
|
||||
return max === 1 ? '1' : `1–${max}`;
|
||||
|
||||
@@ -82,25 +82,73 @@ 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;
|
||||
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;
|
||||
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;
|
||||
};
|
||||
|
||||
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';
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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,12 +21,13 @@ export enum ActiveOperationKind {
|
||||
RemovingDownload = 'RemovingDownload',
|
||||
}
|
||||
|
||||
export type StatusLevel = 'info' | 'error';
|
||||
export type StatusLevel = 'info' | 'warning' | 'error';
|
||||
|
||||
export interface DownloadProgress {
|
||||
downloaded_bytes: number;
|
||||
total_bytes: number;
|
||||
bytes_per_second: number;
|
||||
active_peer_count: number;
|
||||
}
|
||||
|
||||
export interface DownloadProgressPayload extends DownloadProgress {
|
||||
@@ -57,6 +58,8 @@ export interface Game {
|
||||
status_level?: StatusLevel;
|
||||
download_progress?: DownloadProgress;
|
||||
peer_count: number;
|
||||
can_host_server?: boolean;
|
||||
active_outbound_transfers?: number;
|
||||
}
|
||||
|
||||
export interface ActiveOperation {
|
||||
@@ -77,3 +80,6 @@ export type GameSort = 'az' | 'sizeDesc' | 'sizeAsc' | 'status';
|
||||
|
||||
/** Visual state of a card. Derived from backend operation status and local flags. */
|
||||
export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'busy';
|
||||
|
||||
/** Two-character language code passed through to game scripts. */
|
||||
export type LauncherLanguage = 'en' | 'de';
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
flex-direction: column;
|
||||
background: var(--bg-0);
|
||||
color: var(--t-1);
|
||||
container-type: inline-size;
|
||||
container-name: launcher;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
@@ -56,7 +58,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Top bar */
|
||||
/* Top bar — three visual zones with search at the geometric center */
|
||||
.topbar {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
@@ -64,12 +66,75 @@
|
||||
-webkit-backdrop-filter: blur(20px) saturate(140%);
|
||||
backdrop-filter: blur(20px) saturate(140%);
|
||||
border-bottom: 1px solid var(--bd-1);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
column-gap: 16px;
|
||||
padding: 14px 24px;
|
||||
min-height: 64px;
|
||||
}
|
||||
.topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 14px 24px;
|
||||
flex-wrap: nowrap;
|
||||
min-height: 64px;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar-left-trail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar-center .search {
|
||||
flex: 0 1 360px;
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar-right-lead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar-right-tail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Below ~1100px of launcher width the geometric centering stops reading —
|
||||
collapse the three zones into a single left-to-right flowing row. */
|
||||
@container launcher (max-width: 1100px) {
|
||||
.topbar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 16px;
|
||||
}
|
||||
.topbar-left,
|
||||
.topbar-center,
|
||||
.topbar-right {
|
||||
justify-content: flex-start;
|
||||
flex: 0 0 auto;
|
||||
gap: 12px;
|
||||
}
|
||||
.topbar-center {
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
.topbar-center .search {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Brand */
|
||||
@@ -239,6 +304,10 @@
|
||||
color: var(--t-1);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.search-clear.is-hidden {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.search-kbd {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
@@ -325,46 +394,6 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Directory button */
|
||||
.dirbtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--bd-1);
|
||||
border-radius: 8px;
|
||||
color: var(--t-2);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
max-width: 360px;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
color 0.15s;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.dirbtn:hover {
|
||||
border-color: var(--bd-2);
|
||||
color: var(--t-1);
|
||||
}
|
||||
.dirbtn-label {
|
||||
color: var(--t-1);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dirbtn-path {
|
||||
color: var(--t-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Kebab menu */
|
||||
.kebab {
|
||||
position: relative;
|
||||
@@ -710,6 +739,12 @@
|
||||
.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;
|
||||
@@ -807,6 +842,20 @@
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: var(--bd-3);
|
||||
}
|
||||
.act-server {
|
||||
color: var(--t-1);
|
||||
background: color-mix(in srgb, var(--accent) 14%, rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.act-server:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent) 22%, rgba(255, 255, 255, 0.04));
|
||||
border-color: color-mix(in srgb, var(--accent) 75%, transparent);
|
||||
filter: none;
|
||||
}
|
||||
.act-server svg {
|
||||
color: var(--accent);
|
||||
}
|
||||
.act-busy {
|
||||
color: var(--t-1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
@@ -1045,6 +1094,18 @@
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl-lg-secondary .dl-peers {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--t-1);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl-lg-secondary .dl-peers svg {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.dl-lg-secondary .dl-eta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -1054,12 +1115,18 @@
|
||||
.dl-sep {
|
||||
opacity: 0.45;
|
||||
}
|
||||
@container (max-width: 380px) {
|
||||
@container (max-width: 320px) {
|
||||
.dl-lg-secondary .dl-eta,
|
||||
.dl-lg-secondary .dl-sep-eta {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@container (max-width: 240px) {
|
||||
.dl-lg-secondary .dl-peers,
|
||||
.dl-lg-secondary .dl-sep-peers {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.dl-lg-pct {
|
||||
grid-area: pct;
|
||||
color: var(--t-1);
|
||||
@@ -1322,6 +1389,16 @@
|
||||
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;
|
||||
@@ -1436,11 +1513,18 @@
|
||||
}
|
||||
.settings-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 22px 18px;
|
||||
border-top: 1px solid var(--bd-1);
|
||||
gap: 10px;
|
||||
}
|
||||
.settings-build-nr {
|
||||
min-width: 0;
|
||||
color: var(--t-3);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.settings-done {
|
||||
height: 36px;
|
||||
padding: 0 22px;
|
||||
@@ -1457,6 +1541,123 @@
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* Settings: text input */
|
||||
.settings-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 220px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--bd-1);
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.settings-text:focus-within {
|
||||
background: var(--bg-2);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
.settings-text input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--t-1);
|
||||
font: inherit;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.settings-text input::placeholder {
|
||||
color: var(--t-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Settings: game-folder field */
|
||||
.folder-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 340px;
|
||||
height: 36px;
|
||||
padding: 0 4px 0 12px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--bd-1);
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
.folder-field:hover {
|
||||
border-color: var(--bd-2);
|
||||
}
|
||||
.folder-field.is-unset {
|
||||
border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-1));
|
||||
background: color-mix(in srgb, var(--danger) 6%, var(--bg-3));
|
||||
}
|
||||
.folder-field.is-unset:hover {
|
||||
border-color: color-mix(in srgb, var(--danger) 55%, var(--bd-2));
|
||||
}
|
||||
.folder-field-icon {
|
||||
color: var(--t-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.folder-field.is-unset .folder-field-icon {
|
||||
color: #f87171;
|
||||
}
|
||||
.folder-field-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--t-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
.folder-field-empty {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #f87171;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.folder-field-btn {
|
||||
flex-shrink: 0;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--t-1);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
.folder-field-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.folder-field.is-unset .folder-field-btn {
|
||||
background: color-mix(in srgb, var(--accent) 85%, transparent);
|
||||
color: #fff;
|
||||
}
|
||||
.folder-field.is-unset .folder-field-btn:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Settings: color swatches */
|
||||
.swatch-row {
|
||||
display: inline-flex;
|
||||
@@ -1570,6 +1771,9 @@
|
||||
margin: 0 0 20px;
|
||||
max-width: 44ch;
|
||||
}
|
||||
.empty-state-title + .ghost-btn {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.empty-state .ghost-btn {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_LANSPREAD_BUILD_NR: string;
|
||||
}
|
||||
|
||||
@@ -43,24 +43,50 @@ 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, setGameDir, rescan } = useGameDirectory();
|
||||
const { gameDir, hasGameDirectory, setGameDir, rescan } = useGameDirectory();
|
||||
const games = useGames(rescan);
|
||||
const actions = useGameActions(games);
|
||||
const actions = useGameActions(games, settings);
|
||||
const thumbnails = useThumbnails();
|
||||
|
||||
const [openGameId, setOpenGameId] = useState<string | null>(null);
|
||||
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const counts = useMemo(() => countByFilter(games.games), [games.games]);
|
||||
const visibleGames = useMemo(
|
||||
() => hasGameDirectory ? games.games : [],
|
||||
[games.games, hasGameDirectory],
|
||||
);
|
||||
const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]);
|
||||
|
||||
// Query is local UI state (no need to persist).
|
||||
const [query, setQuery] = useState('');
|
||||
const filteredGames = useMemo(
|
||||
() => applyFilterAndSort(games.games, settings.filter, settings.sort, query),
|
||||
[games.games, settings.filter, settings.sort, query],
|
||||
() => applyFilterAndSort(visibleGames, settings.filter, settings.sort, query),
|
||||
[visibleGames, settings.filter, settings.sort, query],
|
||||
);
|
||||
|
||||
const openGame = useMemo<Game | null>(
|
||||
@@ -104,6 +130,7 @@ 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]);
|
||||
|
||||
@@ -116,25 +143,23 @@ export const MainWindow = () => {
|
||||
|
||||
return (
|
||||
<div className={className} style={rootStyle}>
|
||||
{gameDir ? (
|
||||
<>
|
||||
<TopBar
|
||||
peerCount={games.totalPeerCount}
|
||||
filter={settings.filter}
|
||||
setFilter={(v) => setSetting('filter', v)}
|
||||
counts={counts}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
sort={settings.sort}
|
||||
setSort={(v) => setSetting('sort', v)}
|
||||
gameDir={gameDir}
|
||||
onPickDirectory={() => void pickDirectory()}
|
||||
kebabItems={kebabItems}
|
||||
/>
|
||||
<main className="grid-wrap">
|
||||
<TopBar
|
||||
peerCount={games.totalPeerCount}
|
||||
filter={settings.filter}
|
||||
setFilter={(v) => setSetting('filter', v)}
|
||||
counts={counts}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
sort={settings.sort}
|
||||
setSort={(v) => setSetting('sort', v)}
|
||||
kebabItems={kebabItems}
|
||||
/>
|
||||
<main className="grid-wrap">
|
||||
{hasGameDirectory ? (
|
||||
<>
|
||||
<ResultsBar shown={filteredGames.length} total={counts.all} />
|
||||
{filteredGames.length === 0 ? (
|
||||
games.games.length === 0 ? (
|
||||
visibleGames.length === 0 ? (
|
||||
<EmptyResultsState
|
||||
title="Scanning for games"
|
||||
hint="Looking for game bundles in your selected directory…"
|
||||
@@ -155,13 +180,11 @@ export const MainWindow = () => {
|
||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
) : (
|
||||
<main className="grid-wrap">
|
||||
</>
|
||||
) : (
|
||||
<NoDirectoryState onChooseDirectory={() => void pickDirectory()} />
|
||||
</main>
|
||||
)}
|
||||
)}
|
||||
</main>
|
||||
|
||||
{openGame && (
|
||||
<GameDetailModal
|
||||
@@ -169,9 +192,11 @@ 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)}
|
||||
onStartServer={(g) => actions.startServer(g.id)}
|
||||
onViewFiles={(g) => actions.viewFiles(g.id)}
|
||||
/>
|
||||
)}
|
||||
@@ -187,6 +212,9 @@ export const MainWindow = () => {
|
||||
{settingsOpen && (
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
gameDir={gameDir}
|
||||
hasGameDirectory={hasGameDirectory}
|
||||
onPickDirectory={() => void pickDirectory()}
|
||||
onChange={setSetting}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
actionLabel,
|
||||
activeStatusById,
|
||||
applyFilterAndSort,
|
||||
canStreamInstall,
|
||||
countByFilter,
|
||||
deriveState,
|
||||
downloadProgressPercent,
|
||||
@@ -10,7 +11,9 @@ import {
|
||||
formatDownloadEta,
|
||||
formatDownloadSpeed,
|
||||
formatDownloadSpeedShort,
|
||||
gameStatusLabel,
|
||||
mergeGameUpdate,
|
||||
stateChipLabel,
|
||||
} from '../src/lib/gameState.ts';
|
||||
import {
|
||||
ActiveOperationKind,
|
||||
@@ -99,6 +102,7 @@ Deno.test('download progress is preserved only while actively downloading', () =
|
||||
downloaded_bytes: 50,
|
||||
total_bytes: 100,
|
||||
bytes_per_second: 12_500_000,
|
||||
active_peer_count: 2,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -114,6 +118,11 @@ Deno.test('download progress is preserved only while actively downloading', () =
|
||||
50,
|
||||
'active download snapshot should keep progress',
|
||||
);
|
||||
assertEquals(
|
||||
stillDownloading.download_progress?.active_peer_count,
|
||||
2,
|
||||
'active download snapshot should keep live peer count',
|
||||
);
|
||||
assertEquals(
|
||||
settled.download_progress,
|
||||
undefined,
|
||||
@@ -128,6 +137,7 @@ Deno.test('downloading action label includes current speed', () => {
|
||||
downloaded_bytes: 50,
|
||||
total_bytes: 100,
|
||||
bytes_per_second: 12_500_000,
|
||||
active_peer_count: 2,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -184,6 +194,7 @@ Deno.test('download progress formatting matches the progress-bar layouts', () =>
|
||||
downloaded_bytes: 12 * 1024 * 1024 * 1024,
|
||||
total_bytes: 35 * 1024 * 1024 * 1024,
|
||||
bytes_per_second: 49_400_000,
|
||||
active_peer_count: 3,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -201,3 +212,75 @@ 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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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');
|
||||
});
|
||||
@@ -1,12 +1,18 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// A timestamp keeps build numbers monotonic without a checked-in counter file.
|
||||
const buildNr = Date.now().toString();
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
"import.meta.env.VITE_LANSPREAD_BUILD_NR": JSON.stringify(buildNr),
|
||||
},
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
|
||||
@@ -3,14 +3,14 @@ name = "lanspread-utils"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
[lib]
|
||||
doctest = false
|
||||
test = false
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
|
||||
[lib]
|
||||
test = false
|
||||
doctest = false
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
+165
-33
@@ -32,6 +32,29 @@ The HTML mock includes two chrome variants — **A (single-row)** and **B (two-r
|
||||
|
||||
---
|
||||
|
||||
## Changes since v3
|
||||
|
||||
- **Game-folder button removed from the top bar.** Setting the games directory is a one-time action — it doesn't deserve permanent real estate in the chrome. The button is gone from both top-bar variants, freeing the right zone for the kebab menu alone (variant A) / the storage meter + kebab pair (variant B).
|
||||
- **Game folder moved into Settings → Library.** Now a row inside the Settings dialog, styled like the other Library rows. Two visual states (set / not-set) carry over from the old button — see "Settings dialog → Library → Game folder" below.
|
||||
- **Persisted setting renamed.** `gameFolderSet: boolean` → `gameFolder: string | null`. The actual path is now persisted, not just a "is it configured?" flag. Default is `null` (unset on first run; user must pick a folder before the library scans).
|
||||
|
||||
## Changes since v2
|
||||
|
||||
- **Top bar layout reorganized.** The single-row top bar is now structured as three visual zones (still one row on wide windows):
|
||||
- **Left:** brand mark + wordmark.
|
||||
- **Center (semantically the "search cluster"):** segmented filter pills · search field · sort menu. The **search field is positioned at the geometric center of the window** — filter pills sit immediately to its left, sort menu immediately to its right.
|
||||
- **Right:** kebab menu (game-folder configuration has moved into Settings — see v3 changes).
|
||||
- Below ~1100 px of launcher width (container query), the three zones collapse into a single left-to-right flowing row (no wrap, no centering). Implement via container query on the launcher root; viewport media query is acceptable if your codebase doesn't use container queries yet.
|
||||
- See "Top bar (variant A)" below for the full spec and rationale.
|
||||
|
||||
## Changes since v1
|
||||
|
||||
- **Settings → Profile section** added at the top of the dialog with two new persisted preferences: **Username** (text input) and **Language** (segmented `English` / `Deutsch`). See "Settings dialog" below for shape + persistence keys.
|
||||
- **Start Server** action added to the **game detail overlay**, next to **Play**, for installed games that support a dedicated server. Driven by a new `canHostServer: true` flag on the game record. See "Detail overlay → Actions row" and "Game data shape" for the full spec.
|
||||
- Grid cards are **unchanged** — Start Server only ever appears in the detail overlay.
|
||||
|
||||
---
|
||||
|
||||
## Screens / views
|
||||
|
||||
### 1. Main library (variant A — primary)
|
||||
@@ -40,21 +63,29 @@ The default screen. A grid of game cards over a dark, gradient-tinted background
|
||||
|
||||
**Layout (top-to-bottom):**
|
||||
|
||||
1. **Top bar** — single row, sticky, full width, 64px tall, semi-transparent dark with backdrop-blur. Background `rgba(10,14,19,0.65)` + `backdrop-filter: blur(20px) saturate(140%)`. Border-bottom `1px solid rgba(255,255,255,0.06)`. Contents, left-to-right with 18px gap and 24px horizontal padding:
|
||||
1. **Top bar** — single row, sticky, full width, 64px tall, semi-transparent dark with backdrop-blur. Background `rgba(10,14,19,0.65)` + `backdrop-filter: blur(20px) saturate(140%)`. Border-bottom `1px solid rgba(255,255,255,0.06)`. Padding `14px 24px`. **Layout:** a 3-column CSS grid — `grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr)` with `column-gap: 16px` — putting the search field in the middle (auto-sized) column so it sits at the **geometric center of the window** regardless of how wide the side groups are. The side columns are each `display: flex; justify-content: space-between` so their contents pin to the outer edge on one end and hug the search on the other.
|
||||
|
||||
- **Brand** — 28×28px rounded square in `--accent` (default `#3b82f6`) with the letter "S" in Bebas Neue 20px white. Next to it, the wordmark "SoftLAN" in 15px / 700 weight `--t-1` `#e6edf3`.
|
||||
- **Segmented filter pills** — pill-shaped container (`background var(--bg-2) #131b25`, `1px solid rgba(255,255,255,0.06)`, `border-radius: 999px`, `padding: 4px`). Three buttons:
|
||||
- `All Games` · count chip
|
||||
- `Local` · count chip
|
||||
- `Installed` · count chip
|
||||
- **Left zone (col 1, flex space-between):**
|
||||
- **Brand** (pinned far-left) — 28×28 px rounded square in `--accent` (default `#3b82f6`) with the letter "S" in Bebas Neue 20 px white. Next to it, the wordmark "SoftLAN" in 15 px / 700 weight `--t-1` `#e6edf3`.
|
||||
- **Segmented filter pills** (pinned right, hugging the search field) — pill-shaped container (`background var(--bg-2) #131b25`, `1px solid rgba(255,255,255,0.06)`, `border-radius: 999px`, `padding: 4px`). Three buttons:
|
||||
- `All Games` · count chip
|
||||
- `Local` · count chip
|
||||
- `Installed` · count chip
|
||||
|
||||
Active button has an animated pill thumb (background `var(--accent)`, transitions `left` and `width` with `cubic-bezier(.4,1.2,.5,1)` over 220ms), text becomes white, count-chip background goes `rgba(0,0,0,0.25)`. Inactive: text `var(--t-2) #9aa6b4`, count-chip background `rgba(255,255,255,0.08)`.
|
||||
Active button has an animated pill thumb (background `var(--accent)`, transitions `left` and `width` with `cubic-bezier(.4,1.2,.5,1)` over 220 ms), text becomes white, count-chip background goes `rgba(0,0,0,0.25)`. Inactive: text `var(--t-2) #9aa6b4`, count-chip background `rgba(255,255,255,0.08)`.
|
||||
|
||||
`Local` = installed *or* downloaded-but-not-yet-installed. `Installed` = installed only. `All Games` = everything available on the network.
|
||||
- **Search field** — 36px tall, min-width 320px (flex 0 1 380px). `background var(--bg-2)`, `1px solid var(--bd-1)`, `border-radius: 8px`, padding `0 12px`. Has a leading magnifying-glass icon (14×14, `currentColor`) and a trailing "/" kbd hint (`background rgba(255,255,255,0.06)`, `border-radius: 4px`, font `11px ui-monospace`). On focus: border becomes `color-mix(in srgb, var(--accent) 60%, var(--bd-2))`, background `var(--bg-1)`, ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent)`. The "/" key shortcut should focus the search.
|
||||
- **Sort menu** — 36px button, same surface style as search. Label `Sort: <bold value>` plus 13px sort-bars icon and 11px chevron. Click reveals dropdown menu below. Options: `Name (A–Z)`, `Size (largest)`, `Recently Played`, `Status`.
|
||||
- **Game directory button** — 36px button, max-width 360px. Folder icon, "Game directory" label (600 weight `--t-1`), then the current path in `ui-monospace` 11.5px `--t-3` truncated with leading ellipsis when long (e.g. `…s/Desktop/eti_games_AFTER_LAN_2025`).
|
||||
- **Kebab menu** (`⋮`) — 36×36 button with same surface. Menu items: `Settings` (opens Settings dialog — see below), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`.
|
||||
`Local` = installed *or* downloaded-but-not-yet-installed. `Installed` = installed only. `All Games` = everything available on the network.
|
||||
|
||||
The filter is grouped semantically with the search — it scopes what the user is searching, so it belongs at the search field's left shoulder.
|
||||
|
||||
- **Center zone (col 2, search alone):**
|
||||
- **Search field** — 36 px tall, `flex: 0 1 360px` (caps at 360 px wide so it can't elbow into the side zones). `background var(--bg-2)`, `1px solid var(--bd-1)`, `border-radius: 8px`, padding `0 12px`. Leading magnifying-glass icon (14×14, `currentColor`) and a trailing "/" kbd hint (`background rgba(255,255,255,0.06)`, `border-radius: 4px`, font `11px ui-monospace`). On focus: border `color-mix(in srgb, var(--accent) 60%, var(--bd-2))`, background `var(--bg-1)`, ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent)`. The `/` key shortcut should focus the search.
|
||||
|
||||
- **Right zone (col 3, flex space-between with two sub-groups):**
|
||||
- **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: <bold value>` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (A–Z)`, `Size (largest)`, `Recently Played`, `Status`. This is the only thing on the *left* side of the right zone — it's part of the search cluster, so it hugs the search.
|
||||
- **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface as search. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`. This is the only "app-level" control left in the top bar; the game-folder picker has moved into Settings.
|
||||
|
||||
**Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet.
|
||||
|
||||
2. **Results bar** — 18px top padding inside the scroll wrapper, 24px horizontal. Flex row with space-between:
|
||||
- Left: `Showing <strong>N</strong> of M games` in 12.5px `var(--t-2)` (strong is `var(--t-1)`).
|
||||
@@ -88,12 +119,26 @@ Opens when the user **clicks anywhere on a game card except the action button**.
|
||||
2. **Body** — 22px top, 26px bottom, 28px horizontal:
|
||||
- **Meta grid** — 4-column CSS grid, 12px gap. Each cell: `padding 10px 12px`, `background rgba(255,255,255,0.025)`, `1px solid var(--bd-1)`, `border-radius: 8px`. Cells (in order): `Size` (e.g. 8.2 GB), `Players` (icon + range), `Version` (mono, e.g. 2018.04.12), `Status` (Installed / Local / Not downloaded).
|
||||
- **Description** — 14px / 1.55 line-height, `var(--t-2)`, `text-wrap: pretty`, `max-width: 64ch`.
|
||||
- **Actions row** — flex row, 10px gap, 4px top padding:
|
||||
- Primary action button (44px tall, see "Action button" below — Play / Install / Download depending on state)
|
||||
- If `state === 'installed'`: ghost-button **Uninstall** — 44px, `background rgba(255,255,255,0.04)`, `1px solid var(--bd-2)`, `border-radius: 8px`, text `#f87171`, trash icon. On hover: bg `rgba(239,68,68,0.10)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`.
|
||||
- If `state === 'local'`: ghost-button **Delete from disk** (same danger styling).
|
||||
- Spacer (`flex: 1`).
|
||||
- Ghost-button **View files** (neutral) — opens system file manager at the game folder.
|
||||
- **Actions row** — flex row, 10px gap, 4px top padding. Order, left → right:
|
||||
1. **Primary action button** (44px tall, see "Action button" below — Play / Install / Download depending on state).
|
||||
2. **Start Server** — *only* when `game.canHostServer === true` **and** `state === 'installed'`. Same 44px height as Play, but visually a peer secondary action (see "Start Server button" below). Triggers a Tauri command that spawns the game's dedicated-server executable in headless mode against the local LAN (port + server config out of scope here — leave a `startServer(gameId)` IPC stub).
|
||||
3. If `state === 'installed'`: ghost-button **Uninstall** — 44px, `background rgba(255,255,255,0.04)`, `1px solid var(--bd-2)`, `border-radius: 8px`, text `#f87171`, trash icon. On hover: bg `rgba(239,68,68,0.10)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`.
|
||||
4. If `state === 'local'`: ghost-button **Delete from disk** (same danger styling).
|
||||
5. If `state === 'downloading'`: ghost-button **Cancel** (same danger styling).
|
||||
6. Spacer (`flex: 1`).
|
||||
7. Ghost-button **View files** (neutral) — opens system file manager at the game folder.
|
||||
|
||||
#### Start Server button
|
||||
|
||||
A secondary-but-equal action that sits next to **Play**. The intent is to read as a host-action ("I want to put this game on the LAN") without competing with the green Play button for the player's primary attention.
|
||||
|
||||
- Same shape and height as Play: 44px tall, `border-radius: 8px`, `font 14px / 600`, 8px gap between icon and label, padding `0 22px`.
|
||||
- Surface: `background: color-mix(in srgb, var(--accent) 14%, rgba(255,255,255,0.04))`, `border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent)`, `box-shadow: inset 0 1px 0 rgba(255,255,255,0.06)`. Text in `--t-1`.
|
||||
- **Icon** in `--accent`: a small server-rack glyph (two stacked rounded rectangles each with an LED dot and a hint of wiring). 13×13. SVG in `components.jsx → Icon.server`.
|
||||
- Hover: `background: color-mix(in srgb, var(--accent) 22%, ...)`, border darkens to `color-mix(... 75%, transparent)`. Active: `transform: scale(0.98)` (shared with `.act-btn`).
|
||||
- A future *running* state (live indicator dot + "Server running" label + click-to-stop) is **not** in this round — flag as a follow-up when wiring the real spawn.
|
||||
|
||||
The button is purposefully **not** present on game cards in the grid — hosting a server is intentional and benefits from the context of the detail overlay (player count, version, etc.). Don't add it to cards.
|
||||
|
||||
---
|
||||
|
||||
@@ -108,10 +153,20 @@ Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim tr
|
||||
│ Settings [×] │ ← head: 22 28 18, 1px bottom border
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ APPEARANCE │ ← section title: 10.5px / 700 / 0.12em / uppercase / --t-3
|
||||
│ PROFILE │ ← section title (new): 10.5px / 700 / 0.12em / uppercase / --t-3
|
||||
│ │
|
||||
│ Accent color │ ← row label: 14px / 600 / --t-1
|
||||
│ Used for primary actions and highlights │ ← row hint: 12px / --t-3
|
||||
│ Username │ ← row label: 14px / 600 / --t-1
|
||||
│ Shown to other players on the LAN │ ← row hint: 12px / --t-3
|
||||
│ [ Enter a username ] │ ← text input (220×36)
|
||||
│ │
|
||||
│ Language │
|
||||
│ Interface language │
|
||||
│ [English│Deutsch] │ ← segmented radio (new)
|
||||
│ │
|
||||
│ APPEARANCE │
|
||||
│ │
|
||||
│ Accent color │
|
||||
│ Used for primary actions and highlights │
|
||||
│ ⬤⬤⬤⬤⬤⬤ │ ← 6 swatches, right-aligned
|
||||
│ │
|
||||
│ Background │
|
||||
@@ -120,6 +175,11 @@ Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim tr
|
||||
│ │
|
||||
│ LIBRARY │
|
||||
│ │
|
||||
│ Game folder │
|
||||
│ Parent directory where games are │
|
||||
│ downloaded and installed │
|
||||
│ [📁 /home/pfs/…/eti_games [Change…]] │ ← folder field (340×36)
|
||||
│ │
|
||||
│ Grid density │
|
||||
│ How tightly cards are packed │
|
||||
│ [Compact│Normal│Large]│
|
||||
@@ -138,6 +198,15 @@ Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim tr
|
||||
- Left (`settings-row-info`): label (14px / 600 / `--t-1`) + hint (3px-top, 12px / `--t-3`)
|
||||
- Right (`settings-row-control`): the control
|
||||
|
||||
**Profile section** (new in this round). Two rows, rendered **above** Appearance — it's the most personal/identity-shaped setting so it's the first thing the user sees in Settings.
|
||||
|
||||
- **Username** — `<input type="text">` wrapped in a styled container: 220px wide, 36px tall, `background var(--bg-3)`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 0 12px`. Input itself is transparent/borderless, `font 13.5px / 600`, color `--t-1`, placeholder `"Enter a username"` in `--t-3` / 500. `maxLength={24}`, `spellCheck={false}`. On focus the container gets `background var(--bg-2)`, border `var(--accent)`, and an accent focus ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent)`.
|
||||
- **Language** — same segmented-radio control as Background / Density / Cover aspect, with two options: `English` (value `'en'`) and `Deutsch` (value `'de'`). Active option gets the accent fill, same as the other segmented radios.
|
||||
|
||||
**Library section.** Three rows: **Game folder** (new in v3 — moved out of the top bar), **Grid density**, **Cover aspect**.
|
||||
|
||||
- **Game folder** — see "Game-folder field" below. The first row in the section because it's the only setting users *must* configure for the launcher to work; density and aspect are pure preference.
|
||||
|
||||
**Color swatch picker:** flex row of 8px-gapped buttons. Each swatch is 32×32, `border-radius: 9px`, no border. Inside, a 100% × 100% rounded-8 colored dot with inset shadow `0 0 0 1px rgba(255,255,255,0.08)`. Hover: dot scales 1.06. **Active**: dot has ring `box-shadow: 0 0 0 2px var(--bg-2), 0 0 0 4px <swatch-color>` and shows a centered white check icon with drop-shadow `0 1px 2px rgba(0,0,0,0.5)`.
|
||||
|
||||
Six accent options: Blue `#3b82f6`, Cyan `#22d3ee`, Violet `#a855f7`, Green `#22c55e`, Amber `#f59e0b`, Red `#ef4444`.
|
||||
@@ -147,10 +216,46 @@ Six accent options: Blue `#3b82f6`, Cyan `#22d3ee`, Violet `#a855f7`, Green `#22
|
||||
**Done button:** filled button in `--accent`, 36px tall, 13.5px / 600. Closes the dialog.
|
||||
|
||||
Persisted settings (write through to local storage / Tauri config):
|
||||
- `username`: string, max 24 chars. Default `"Commander"` (placeholder — feel free to default to the OS username on first run). Used as the network identity for LAN sessions; the hint copy *"Shown to other players on the LAN"* tells the user what it does.
|
||||
- `language`: `'en'` | `'de'`. Default `'en'`. Drives an i18n layer (introduce one if it doesn't exist yet — `react-i18next` or similar). Initial copy is English-only in the mock; German translations need to be added as part of implementation. Recommend detecting the OS locale on first run and defaulting to `'de'` if the system language starts with `de`.
|
||||
- `accent`: one of the six hex values above. Default `#3b82f6`.
|
||||
- `bg`: `flat` | `gradient` | `animated`. Default `gradient`.
|
||||
- `density`: `compact` | `normal` | `large`. Default `normal`.
|
||||
- `aspect`: `box` | `square` | `banner`. Default `box`.
|
||||
- `gameFolder`: `string | null`. Absolute path to the parent directory where games are downloaded and installed. Default `null` (unset on first run). See "Game-folder field" below.
|
||||
|
||||
---
|
||||
|
||||
## Game-folder field
|
||||
|
||||
A settings row inside the **Library** section of the Settings dialog. Exposes the user's currently-configured game folder (the parent directory under which all per-game subfolders live).
|
||||
|
||||
**Why it lives in Settings now:** users set this once at install time and basically never touch it again. A permanent top-bar button burned high-attention chrome on a control nobody used after day one. Settings is where one-time configuration belongs.
|
||||
|
||||
Two visual states, driven by whether `settings.gameFolder` resolves to an accessible directory:
|
||||
|
||||
| State | Trigger | Path display | Border | Button label |
|
||||
|---|---|---|---|---|
|
||||
| **Set & valid** | path is configured and exists on disk | full path in mono, truncated head-first | default `--bd-1` | `Change…` (neutral pill) |
|
||||
| **Not set / invalid** | path is `null`/empty, or path is set but the directory no longer exists | `Not set` in red | tinted red (`color-mix(in srgb, var(--danger) 35%, var(--bd-1))`) + faint red bg tint | `Choose…` (accent-filled pill) |
|
||||
|
||||
"Invalid" is intentionally collapsed into the same visual state as "not set" — the user's job is identical (open the picker and pick a folder), so we don't differentiate. If we later need a distinct "missing" state (e.g. to show the *last known* path so the user can re-attach an external drive), introduce a third state then; for now, keep it simple.
|
||||
|
||||
**Anatomy:** `inline-flex`, `width: 340px`, `height: 36px`, `padding: 0 4px 0 12px`, `gap: 8px`. `background: var(--bg-3)`, `border-radius: 8px`. Children, left to right:
|
||||
|
||||
1. **Folder icon** — `Icon.folder` from `components.jsx`, 14×14, `var(--t-3)` (set state) or `#f87171` (unset state).
|
||||
2. **Path display** — `flex: 1`, mono `12px / ui-monospace`, `--t-1`, single line, `overflow: hidden; text-overflow: ellipsis`. **`direction: rtl` + `unicode-bidi: plaintext`** so truncation happens from the head and the leaf folder (the part the user actually cares about) stays visible. When unset: shows the word `Not set` in 12.5 px / 600 / `#f87171` instead.
|
||||
3. **Action button** — 28 px tall pill, `border-radius: 6px`, `padding: 0 12px`, `font 12.5px / 600`. Set state: neutral `rgba(255,255,255,0.06)` bg, label `Change…`. Unset state: `var(--accent)` fill at 85% alpha, white text, label `Choose…` (so the call-to-action reads stronger when the path needs picking). Click → native folder picker via Tauri; on selection, write through to `settings.gameFolder` and rescan library.
|
||||
|
||||
**Hover:** border darkens to `--bd-2` (set state) or to `color-mix(in srgb, var(--danger) 55%, var(--bd-2))` (unset state). The inner button has its own hover (background opacity bumps).
|
||||
|
||||
**Accessibility:** the path itself is selectable text inside the field; the action button carries `aria-label="Change game folder"` / `"Choose game folder"`. The full path is also exposed via `title` on the path-display element so it's reachable on hover when truncated.
|
||||
|
||||
**Why no inline path on the previous top-bar button anymore?** Original design squeezed the full path into a top-bar button as truncated mono. It rarely showed the meaningful part of the path on real-world configurations, ate horizontal space, and competed with the actual primary controls (filter / search / sort) for the top bar's attention budget. In the new home (Settings), the field has all the width it needs to show a useful prefix of the path while still keeping the leaf visible — and it's only on screen when the user is actively reconfiguring.
|
||||
|
||||
**Data:** the component takes `value: string | null` and an `onChange(next: string)` callback. `null` (or empty/whitespace string) renders the unset state; any non-empty string renders the set state. The `onChange` callback should fire only on successful picker confirmation (not on cancel). In production, derive `value` from your settings store; if you want to additionally validate existence, do the `fs.metadata` check in the store / a hook and pass `null` when the directory is missing.
|
||||
|
||||
**Dev preview:** the prototype's Tweaks panel exposes a `Game folder` **text field** (under the *Library* section) that writes directly to `t.gameFolder`. Type any string to simulate the set state; clear it to simulate the unset state. This is dev-only — in the real app the value comes from the settings store via the picker, **not** from a free-form text input. Don't ship the Tweaks panel.
|
||||
|
||||
---
|
||||
|
||||
@@ -251,20 +356,22 @@ grid-template-areas:
|
||||
```
|
||||
|
||||
- **Primary row** (`.dl-lg-primary`, top-left) — pulse dot + the uppercase live label `DOWNLOADING` in `color-mix(in srgb, var(--accent) 80%, white)`, 13px / 600, `letter-spacing: 0.02em`. This is the only place the word "Downloading" appears in the component.
|
||||
- **Secondary row** (`.dl-lg-secondary`, bottom-left) — the live stats. 12px, three groups separated by `·` (0.45 opacity):
|
||||
- **Secondary row** (`.dl-lg-secondary`, bottom-left) — the live stats. 12px, four groups separated by `·` (0.45 opacity):
|
||||
1. `<strong>11.4 GB</strong> / 35 GB` (`var(--t-1)` strong + `var(--t-2)` rest)
|
||||
2. `47.6 MB/s` (`var(--t-1)`)
|
||||
3. `8 min left` (`var(--t-2)`)
|
||||
3. `[users-icon] 5` — `.dl-peers`, inline-flex with 4px gap, icon at 0.7 opacity, count in `var(--t-1)` 600 tabular-nums. Hidden entirely when `game.peers` is falsy. Communicates this is a LAN swarm transfer; the full sentence lives in the `title` tooltip.
|
||||
4. `8 min left` (`var(--t-2)`)
|
||||
- **pct column** — large percentage, 20px / 700, `letter-spacing: -0.01em`, `var(--t-1)`. `%` glyph at 12px / 600 / 0.55 opacity.
|
||||
- **cancel column** — 28×28 square, `1px solid var(--bd-2)`, `border-radius: 6px`, X icon. Hover: bg `rgba(239,68,68,0.12)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`. Cancelling reverts the game to its prior state (`local` if any data was kept, `none` otherwise) — dev decides the underlying behavior.
|
||||
|
||||
**Graceful degradation in narrow modals:**
|
||||
|
||||
```css
|
||||
@container (max-width: 380px) { .dl-lg-secondary .dl-eta, .dl-lg-secondary .dl-sep-eta { display: none; } }
|
||||
@container (max-width: 320px) { .dl-lg-secondary .dl-eta, .dl-lg-secondary .dl-sep-eta { display: none; } }
|
||||
@container (max-width: 240px) { .dl-lg-secondary .dl-peers, .dl-lg-secondary .dl-sep-peers { display: none; } }
|
||||
```
|
||||
|
||||
The ETA drops first if the modal narrows; bytes + speed stay (they're the actionable numbers). The pct/cancel column never collapses.
|
||||
ETA drops first, then peers; bytes + speed always stay (they're the actionable numbers). The pct/cancel column never collapses.
|
||||
|
||||
### Number formatting
|
||||
|
||||
@@ -287,10 +394,11 @@ type Game = {
|
||||
state: 'installed' | 'local' | 'downloading' | 'none';
|
||||
progress?: number; // 0–1, only when state === 'downloading'
|
||||
speed?: number; // current throughput in MB/s
|
||||
peers?: number; // number of LAN peers currently seeding
|
||||
};
|
||||
```
|
||||
|
||||
In the real app, `progress` and `speed` come from the download worker (Tauri command emitting events). The mock's `useLiveDownload(game)` hook (in `components.jsx`) is just a placeholder — 600ms `setInterval` advancing `progress` proportional to `speed`, with `speed` smoothed via a low-pass filter and small random drift so the number doesn't look fake. Replace with a `useEffect` that subscribes to your real progress events; the rendering layer needs nothing else.
|
||||
In the real app, `progress`, `speed`, and `peers` come from the download worker (Tauri command emitting events). The mock's `useLiveDownload(game)` hook (in `components.jsx`) is just a placeholder — 600ms `setInterval` advancing `progress` proportional to `speed`, with `speed` smoothed via a low-pass filter and small random drift so the number doesn't look fake. `peers` is read straight off the game object (static in the mock); in production, push updates as peers join/leave the swarm — the `.dl-peers` chip re-renders silently. Replace the hook with a `useEffect` that subscribes to your real progress events; the rendering layer needs nothing else.
|
||||
|
||||
Filter changes:
|
||||
- `Local` filter includes `installed` + `local` + `downloading` (in-flight downloads belong on the Local tab — you're managing them).
|
||||
@@ -329,8 +437,8 @@ Implement only if you decide variant A doesn't work after building.
|
||||
- **Click filter tab / segmented pill** → change filter.
|
||||
- **Click sort button** → opens dropdown; click an option → re-sort grid; clicking outside the menu closes it.
|
||||
- **Hover game card** → lift + accent border glow + cover image scale 1.03.
|
||||
- **Click "Game directory" button** → open native folder picker via Tauri; on selection, rescan library.
|
||||
- **Click "Settings"** in kebab → open Settings dialog. Changes apply live and persist immediately (no Apply button — Done just closes).
|
||||
- **Click "Change…" / "Choose…" in the Settings → Library → Game folder row** → open native folder picker via Tauri; on selection, write to `settings.gameFolder` and rescan library. The field indicates whether a valid folder is currently configured (mono path + neutral `Change…`) or not (red `Not set` + accent-filled `Choose…`) — see "Game-folder field" above.
|
||||
- **Click "Unpack logs"** in kebab → opens a logs viewer (separate window or modal — out of scope for this design).
|
||||
- **Click "Refresh library"** in kebab → re-runs the library scan.
|
||||
- **Esc** → closes any open modal (detail overlay, Settings).
|
||||
@@ -360,12 +468,16 @@ type Game = {
|
||||
state: 'installed' | 'local' | 'downloading' | 'none';
|
||||
progress?: number; // 0–1 — present only when state === 'downloading'
|
||||
speed?: number; // MB/s — present only when state === 'downloading'
|
||||
peers?: number; // LAN peers currently seeding
|
||||
players: string; // e.g. "2–32"
|
||||
tags: string[];
|
||||
cover: { c1: string; c2: string; accent: string; mood?: string };
|
||||
canHostServer?: boolean; // true if the game ships with a dedicated-server binary
|
||||
};
|
||||
```
|
||||
|
||||
**Server-capable games** in the mock catalog (`canHostServer: true`): BF1942, BF2, CoD2, CoD4, CoD:UO, CS 1.6, CS:Source, Cube 2/Sauerbraten, Doom 3, L4D2, Minecraft, Quake III, TF2, UT2004. RTS / social-deduction / co-op-only-P2P games (AoE II HD, RA3, Generals ZH, Among Us, Portal 2, StarCraft, Warcraft III, AvP, 8-Bit Armies, BlazeRush) are not flagged — they host in-game. In production the flag should come from the same per-game manifest that drives titles / sizes / cover art. Wire each entry to whatever launch command the dedicated server uses (`hldsexec`, `srcds`, `minecraft_server.jar`, etc.); the IPC stub looks like `startServer(gameId)` returning a handle or process id.
|
||||
|
||||
**UI state:**
|
||||
```ts
|
||||
type LauncherUI = {
|
||||
@@ -377,7 +489,20 @@ type LauncherUI = {
|
||||
};
|
||||
```
|
||||
|
||||
**Persisted settings:** see Settings dialog section. Persist via Tauri's plugin-store or a local JSON file in app data dir.
|
||||
**Persisted settings** (mirror of Settings dialog state):
|
||||
```ts
|
||||
type LauncherSettings = {
|
||||
username: string;
|
||||
language: 'en' | 'de';
|
||||
accent: string; // hex from the curated 6-color palette
|
||||
bg: 'flat' | 'gradient' | 'animated';
|
||||
density: 'compact' | 'normal' | 'large';
|
||||
aspect: 'box' | 'square' | 'banner';
|
||||
gameFolder: string | null; // v3: moved out of top bar, persists actual path
|
||||
};
|
||||
```
|
||||
|
||||
Persist via Tauri's plugin-store or a local JSON file in app data dir. Changes from the Settings dialog should write through immediately (no Apply button).
|
||||
|
||||
**Storage figures:** computed by summing game sizes per state, plus free-space query via Tauri.
|
||||
|
||||
@@ -445,7 +570,7 @@ Cover art in the design files is **stylized placeholder art** — generated enti
|
||||
|
||||
In the production app, the launcher should ideally use real cover-art when available (fetch from IGDB / Steam / local game folder) and fall back to the placeholder generator for games without art. The placeholder generator is in `design_reference/components.jsx → GameCover`.
|
||||
|
||||
The icon set (search, play, install, download, folder, kebab, sort, users, close, check, chevron, trash) is in `design_reference/components.jsx → Icon`. They are 12-14px inline SVGs using `currentColor`. Reuse as-is or substitute with the codebase's existing icon library at the same visual weight.
|
||||
The icon set (search, play, **server**, install, download, folder, kebab, sort, users, close, check, chevron, trash) is in `design_reference/components.jsx → Icon`. They are 12-14px inline SVGs using `currentColor`. Reuse as-is or substitute with the codebase's existing icon library at the same visual weight. The `server` glyph is new in this round — two stacked rounded rectangles with LED dots, used only on the Start Server button.
|
||||
|
||||
Fonts to load:
|
||||
```html
|
||||
@@ -465,15 +590,20 @@ design_reference/
|
||||
├── data.jsx ← mock GAMES array + filter/sort helpers + STORAGE mock
|
||||
├── components.jsx ← Icon, GameCover, StateChip, ActionButton, GameCard,
|
||||
│ SegmentedFilters, UnderlineFilters, SearchField,
|
||||
│ SortMenu, StorageMeter, DirectoryButton, KebabMenu,
|
||||
│ GameDetailModal, SettingsDialog
|
||||
│ SortMenu, StorageMeter, KebabMenu,
|
||||
│ GameDetailModal, SettingsDialog (incl. GameFolderField)
|
||||
└── launcher.jsx ← <Launcher> component composing chrome + grid + modals
|
||||
```
|
||||
|
||||
To preview the design in a browser:
|
||||
1. Open `SoftLAN Launcher.html` in a static-server (e.g. `python -m http.server` from the folder).
|
||||
2. You'll see a design canvas with all variants (A, B, C, D, E) side-by-side. Click an artboard's expand button to view it full-screen.
|
||||
3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you live-change accent / density / aspect / background. In the production app these live in the Settings dialog (variant E).
|
||||
2. You'll see a design canvas with all variants (A, B, C, D, E, F) side-by-side. Click an artboard's expand button to view it full-screen.
|
||||
- **A / B** — chrome variants (A is the chosen direction)
|
||||
- **C** — detail overlay for an installed, server-capable game (Counter-Strike 1.6) → shows **Play + Start Server + Uninstall**
|
||||
- **D** — detail overlay for a downloaded-but-not-installed game (CoD 4) → shows **Install + Delete from disk**
|
||||
- **E** — detail overlay for a downloading game (AvP) → shows the live progress component + **Cancel**
|
||||
- **F** — Settings dialog open, with the new **Profile** section at the top
|
||||
3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you live-change every persisted setting (username / language / accent / background / density / aspect / game folder). In the production app these all live in the Settings dialog.
|
||||
|
||||
---
|
||||
|
||||
@@ -484,3 +614,5 @@ To preview the design in a browser:
|
||||
- **Error state on action** — if a Download / Install fails, show inline error on the affected card (red border + retry button), and a toast.
|
||||
- **Progress state** — designed. See "Download progress" section above. The action-button slot is swapped for a live `DownloadProgress` component (card + modal variants with container-query fallback for narrow tiles). Wire it to your real progress events; the rendering layer is dev-ready.
|
||||
- **Keyboard arrow nav** — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal.
|
||||
- **"Server running" state** — once Start Server actually spawns a process, the button should switch to a *running* state (live indicator dot + "Server running" label + click-to-stop). Not designed this round — flag for follow-up alongside whatever server-status panel the app grows.
|
||||
- **German translations** — the language toggle is wired in Settings, but the catalog of translated UI strings hasn't been compiled. Stand up `react-i18next` (or equivalent) and seed `en.json` from the existing copy; `de.json` is a translation task for whoever owns localization.
|
||||
|
||||
@@ -35,14 +35,17 @@ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||
"accent": "#3b82f6",
|
||||
"density": "normal",
|
||||
"aspect": "square",
|
||||
"bg": "gradient"
|
||||
"bg": "gradient",
|
||||
"username": "ddidderr",
|
||||
"language": "en",
|
||||
"gameFolder": "\/some\/folder\/to\/games"
|
||||
}/*EDITMODE-END*/;
|
||||
|
||||
const ACCENTS = ['#3b82f6', '#22d3ee', '#a855f7', '#22c55e', '#f59e0b', '#ef4444'];
|
||||
|
||||
function App() {
|
||||
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||
const heroGame = GAMES.find(g => g.id === 'ra3'); // installed → modal shows Play + Uninstall
|
||||
const heroGame = GAMES.find(g => g.id === 'cs'); // installed + canHostServer → shows Play + Start Server + Uninstall
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
@@ -62,7 +65,7 @@ function App() {
|
||||
|
||||
<DCSection id="detail" title="Game detail overlay"
|
||||
subtitle="Opens when you click a card. Full description, metadata, primary action + secondary actions (incl. uninstall).">
|
||||
<DCArtboard id="detail-modal" label="C · Detail overlay (installed game)" width={1340} height={840}>
|
||||
<DCArtboard id="detail-modal" label="C · Detail overlay (installed, can host server)" width={1340} height={840}>
|
||||
<Launcher variant="single" tweaks={t} setTweak={setTweak}
|
||||
initialFilter="installed" initialSort="az"
|
||||
initialOpenGame={heroGame}/>
|
||||
@@ -90,6 +93,13 @@ function App() {
|
||||
</DesignCanvas>
|
||||
|
||||
<TweaksPanel>
|
||||
<TweakSection label="Profile"/>
|
||||
<TweakText label="Username" value={t.username}
|
||||
onChange={(v) => setTweak('username', v)}/>
|
||||
<TweakRadio label="Language" value={t.language}
|
||||
options={[{value: 'en', label: 'English'}, {value: 'de', label: 'Deutsch'}]}
|
||||
onChange={(v) => setTweak('language', v)}/>
|
||||
|
||||
<TweakSection label="Theme"/>
|
||||
<TweakColor label="Accent" value={t.accent} options={ACCENTS}
|
||||
onChange={(v) => setTweak('accent', v)}/>
|
||||
@@ -104,6 +114,10 @@ function App() {
|
||||
<TweakRadio label="Cover aspect" value={t.aspect}
|
||||
options={['box', 'square', 'banner']}
|
||||
onChange={(v) => setTweak('aspect', v)}/>
|
||||
|
||||
<TweakSection label="Library"/>
|
||||
<TweakText label="Game folder" value={t.gameFolder}
|
||||
onChange={(v) => setTweak('gameFolder', v)}/>
|
||||
</TweaksPanel>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ const { useState, useMemo, useRef, useEffect } = React;
|
||||
const Icon = {
|
||||
search: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="7" cy="7" r="5"/><path d="m13.5 13.5-3-3"/></svg>,
|
||||
play: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" {...p}><path d="M4 2.5v11l10-5.5z"/></svg>,
|
||||
server: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><rect x="2" y="3" width="12" height="4.5" rx="1"/><rect x="2" y="8.5" width="12" height="4.5" rx="1"/><circle cx="4.6" cy="5.25" r=".55" fill="currentColor" stroke="none"/><circle cx="4.6" cy="10.75" r=".55" fill="currentColor" stroke="none"/><path d="M7 5.25h4.5M7 10.75h4.5"/></svg>,
|
||||
install:(p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M8 2v8"/><path d="m4.5 7 3.5 3.5L11.5 7"/><path d="M2.5 12.5h11"/></svg>,
|
||||
download:(p)=> <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M8 2v8"/><path d="m4.5 7 3.5 3.5L11.5 7"/><path d="M2.5 13.5h11"/></svg>,
|
||||
folder: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" {...p}><path d="M1.75 3.75v8.5a1 1 0 0 0 1 1h10.5a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7.5L6 2.75H2.75a1 1 0 0 0-1 1z"/></svg>,
|
||||
@@ -159,6 +160,15 @@ function DownloadProgress({ game, accent, size = 'md', full = false }) {
|
||||
</span>
|
||||
<span className="dl-sep">·</span>
|
||||
<span className="dl-speed">{fmtSpeed(speed)}</span>
|
||||
{game.peers > 0 && (
|
||||
<React.Fragment>
|
||||
<span className="dl-sep dl-sep-peers">·</span>
|
||||
<span className="dl-peers" title={`Downloading from ${game.peers} ${game.peers === 1 ? 'peer' : 'peers'} on the LAN`}>
|
||||
<Icon.users/>
|
||||
<span>{game.peers}</span>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
)}
|
||||
<span className="dl-sep dl-sep-eta">·</span>
|
||||
<span className="dl-eta">{fmtEta(etaSec)} left</span>
|
||||
</div>
|
||||
@@ -344,12 +354,17 @@ function StorageMeter({ accent, compact = false }) {
|
||||
// Directory button (shows path)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
function DirectoryButton({ path }) {
|
||||
const short = path.length > 36 ? '…' + path.slice(-34) : path;
|
||||
const isSet = !!(path && path.trim());
|
||||
const label = isSet ? 'Game folder' : 'Set game folder';
|
||||
const tooltip = isSet ? path : 'Please select a game folder';
|
||||
return (
|
||||
<button className="dirbtn" title={path}>
|
||||
<button
|
||||
className={`dirbtn ${isSet ? 'dirbtn-set' : 'dirbtn-unset'}`}
|
||||
title={tooltip}
|
||||
aria-label={isSet ? `Game folder: ${path}` : 'Set game folder'}
|
||||
>
|
||||
<Icon.folder/>
|
||||
<span className="dirbtn-label">Game directory</span>
|
||||
<span className="dirbtn-path">{short}</span>
|
||||
<span className="dirbtn-label">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -426,6 +441,13 @@ function GameDetailModal({ game, accent, onClose }) {
|
||||
<p className="modal-desc">{game.desc}</p>
|
||||
<div className="modal-actions">
|
||||
<ActionButton state={game.state} accent={accent} size="lg" game={game}/>
|
||||
{game.canHostServer && game.state === 'installed' && (
|
||||
<button className="act-btn act-lg act-server"
|
||||
style={{ '--accent': accent }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<Icon.server/><span>Start Server</span>
|
||||
</button>
|
||||
)}
|
||||
{game.state === 'installed' && (
|
||||
<button className="ghost-btn ghost-danger"><Icon.trash/><span>Uninstall</span></button>
|
||||
)}
|
||||
@@ -459,8 +481,22 @@ const SETTING_OPTIONS = {
|
||||
bg: [{ value: 'flat', label: 'Flat' }, { value: 'gradient', label: 'Gradient' }, { value: 'animated', label: 'Animated' }],
|
||||
density: [{ value: 'compact', label: 'Compact' }, { value: 'normal', label: 'Normal' }, { value: 'large', label: 'Large' }],
|
||||
aspect: [{ value: 'box', label: 'Box-art' }, { value: 'square', label: 'Square' }, { value: 'banner', label: 'Banner' }],
|
||||
language: [{ value: 'en', label: 'English' }, { value: 'de', label: 'Deutsch' }],
|
||||
};
|
||||
|
||||
function SettingsTextInput({ value, placeholder, maxLength = 24, onChange, accent }) {
|
||||
return (
|
||||
<div className="settings-text" style={{ '--accent': accent }}>
|
||||
<input type="text"
|
||||
value={value || ''}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
spellCheck={false}
|
||||
onChange={(e) => onChange(e.target.value)}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsRow({ label, hint, children }) {
|
||||
return (
|
||||
<div className="settings-row">
|
||||
@@ -488,6 +524,31 @@ function SegmentedRadio({ value, options, onChange, accent }) {
|
||||
);
|
||||
}
|
||||
|
||||
function GameFolderField({ value, onChange, accent }) {
|
||||
const isSet = !!(value && value.trim());
|
||||
const handleChange = () => {
|
||||
// In production: open native folder picker via Tauri.
|
||||
// For the prototype, prompt for a path so the field is exercisable.
|
||||
const next = window.prompt('Game folder path (leave empty to clear)', value || '');
|
||||
if (next == null) return;
|
||||
onChange(next.trim());
|
||||
};
|
||||
return (
|
||||
<div className={`folder-field ${isSet ? 'is-set' : 'is-unset'}`}
|
||||
style={{ '--accent': accent }}>
|
||||
<span className="folder-field-icon" aria-hidden="true"><Icon.folder/></span>
|
||||
<div className="folder-field-path" title={isSet ? value : 'No folder selected'}>
|
||||
{isSet
|
||||
? <bdi>{value}</bdi>
|
||||
: <span className="folder-field-empty">Not set</span>}
|
||||
</div>
|
||||
<button type="button" className="folder-field-btn" onClick={handleChange}>
|
||||
{isSet ? 'Change\u2026' : 'Choose\u2026'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorSwatchPicker({ value, options, onChange }) {
|
||||
return (
|
||||
<div className="swatch-row">
|
||||
@@ -515,6 +576,21 @@ function SettingsDialog({ settings, onChange, onClose }) {
|
||||
<button className="modal-close settings-close" onClick={onClose} aria-label="Close"><Icon.close/></button>
|
||||
</div>
|
||||
<div className="settings-body">
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-title">Profile</div>
|
||||
<SettingsRow label="Username" hint="Shown to other players on the LAN">
|
||||
<SettingsTextInput value={settings.username}
|
||||
placeholder="Enter a username"
|
||||
onChange={(v) => onChange('username', v)}
|
||||
accent={settings.accent}/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Language" hint="Interface language">
|
||||
<SegmentedRadio value={settings.language || 'en'}
|
||||
options={SETTING_OPTIONS.language}
|
||||
onChange={(v) => onChange('language', v)}
|
||||
accent={settings.accent}/>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-title">Appearance</div>
|
||||
<SettingsRow label="Accent color" hint="Used for primary actions and highlights">
|
||||
@@ -531,6 +607,11 @@ function SettingsDialog({ settings, onChange, onClose }) {
|
||||
</div>
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-title">Library</div>
|
||||
<SettingsRow label="Game folder" hint="Parent directory where games are downloaded and installed">
|
||||
<GameFolderField value={settings.gameFolder}
|
||||
onChange={(v) => onChange('gameFolder', v)}
|
||||
accent={settings.accent}/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Grid density" hint="How tightly cards are packed">
|
||||
<SegmentedRadio value={settings.density}
|
||||
options={SETTING_OPTIONS.density}
|
||||
|
||||
@@ -18,7 +18,7 @@ const GAMES = [
|
||||
{
|
||||
id: 'avp', title: 'Aliens vs. Predator', size: 35.0, version: '2019.10.01',
|
||||
desc: "Three campaigns, three nightmares. Be the alien stalking the dark, the predator hunting both, or the marine just trying to make it home with a working flashlight.",
|
||||
state: 'downloading', progress: 0.32, speed: 49.4, players: '2–16', tags: ['FPS', 'Horror', 'Multiplayer'],
|
||||
state: 'downloading', progress: 0.32, speed: 49.4, peers: 5, players: '2–16', tags: ['FPS', 'Horror', 'Multiplayer'],
|
||||
cover: { c1: '#064e3b', c2: '#020617', accent: '#34d399', mood: 'dark' },
|
||||
},
|
||||
{
|
||||
@@ -28,13 +28,13 @@ const GAMES = [
|
||||
cover: { c1: '#ef4444', c2: '#1e3a8a', accent: '#fef08a', mood: 'playful' },
|
||||
},
|
||||
{
|
||||
id: 'bf1942', title: 'Battlefield 1942', size: 7.8, version: '2016.01.30',
|
||||
id: 'bf1942', title: 'Battlefield 1942', size: 7.8, version: '2016.01.30', canHostServer: true,
|
||||
desc: "The original Battlefield. WWII on land, sea, and air across 16 maps. The mod scene basically reinvented PC gaming on top of this engine.",
|
||||
state: 'installed', players: '2–64', tags: ['FPS', 'Vehicles', 'LAN'],
|
||||
cover: { c1: '#92400e', c2: '#1c1917', accent: '#facc15', mood: 'war' },
|
||||
},
|
||||
{
|
||||
id: 'bf2', title: 'Battlefield 2 Complete', size: 8.0, version: '2021.12.27',
|
||||
id: 'bf2', title: 'Battlefield 2 Complete', size: 8.0, version: '2021.12.27', canHostServer: true,
|
||||
desc: "Modern combat with commander mode, squads, and the kind of jet-vs-jet duels you tell stories about for a decade.",
|
||||
state: 'local', players: '2–64', tags: ['FPS', 'Vehicles', 'Tactical'],
|
||||
cover: { c1: '#3f3f46', c2: '#0a0a0a', accent: '#22d3ee', mood: 'tactical' },
|
||||
@@ -46,19 +46,19 @@ const GAMES = [
|
||||
cover: { c1: '#f97316', c2: '#7c2d12', accent: '#fde047', mood: 'arcade' },
|
||||
},
|
||||
{
|
||||
id: 'cod2', title: 'Call of Duty 2', size: 7.0, version: '2016.09.22',
|
||||
id: 'cod2', title: 'Call of Duty 2', size: 7.0, version: '2016.09.22', canHostServer: true,
|
||||
desc: "WWII shooter — Russian, British and American campaigns, plus the multiplayer that defined LAN parties for years.",
|
||||
state: 'installed', players: '2–32', tags: ['FPS', 'War'],
|
||||
cover: { c1: '#57534e', c2: '#1c1917', accent: '#fbbf24', mood: 'war' },
|
||||
},
|
||||
{
|
||||
id: 'cod4mw', title: 'Call of Duty 4: Modern Warfare', size: 13.0, version: '2016.09.21',
|
||||
id: 'cod4mw', title: 'Call of Duty 4: Modern Warfare', size: 13.0, version: '2016.09.21', canHostServer: true,
|
||||
desc: "The shooter that flipped the genre to modern combat and minted a generation of esports careers. All Ghillied Up still holds up.",
|
||||
state: 'local', players: '2–32', tags: ['FPS', 'Modern'],
|
||||
cover: { c1: '#525252', c2: '#0a0a0a', accent: '#84cc16', mood: 'tactical' },
|
||||
},
|
||||
{
|
||||
id: 'coduo', title: 'Call of Duty: United Offensive', size: 3.8, version: '2018.09.08',
|
||||
id: 'coduo', title: 'Call of Duty: United Offensive', size: 3.8, version: '2018.09.08', canHostServer: true,
|
||||
desc: "Expansion to the original CoD. Battle of the Bulge, Sicily, Kursk. Adds tanks, B-17 sequences, and the flamethrower nobody asked for but everybody loved.",
|
||||
state: 'none', players: '2–32', tags: ['FPS', 'Expansion'],
|
||||
cover: { c1: '#78716c', c2: '#292524', accent: '#fb923c', mood: 'war' },
|
||||
@@ -76,37 +76,37 @@ const GAMES = [
|
||||
cover: { c1: '#a16207', c2: '#422006', accent: '#facc15', mood: 'war' },
|
||||
},
|
||||
{
|
||||
id: 'cs', title: 'Counter-Strike 1.6', size: 0.7, version: '2014.01.21',
|
||||
id: 'cs', title: 'Counter-Strike 1.6', size: 0.7, version: '2014.01.21', canHostServer: true,
|
||||
desc: "The 1.6 build still everyone insists was the peak. Terrorists vs Counter-Terrorists, AWP camping, de_dust2.",
|
||||
state: 'installed', players: '2–32', tags: ['FPS', 'Competitive', 'LAN'],
|
||||
cover: { c1: '#1e40af', c2: '#0c1f3a', accent: '#fbbf24', mood: 'tactical' },
|
||||
},
|
||||
{
|
||||
id: 'css', title: 'Counter-Strike: Source', size: 4.3, version: '2014.10.23',
|
||||
id: 'css', title: 'Counter-Strike: Source', size: 4.3, version: '2014.10.23', canHostServer: true,
|
||||
desc: "CS reborn on the Source engine. Same maps, same rules, with physics that lets the molotovs work properly.",
|
||||
state: 'installed', players: '2–32', tags: ['FPS', 'Competitive'],
|
||||
cover: { c1: '#1d4ed8', c2: '#0c1f3a', accent: '#f59e0b', mood: 'tactical' },
|
||||
},
|
||||
{
|
||||
id: 'cube2', title: 'Cube 2: Sauerbraten', size: 0.4, version: '2013.09.20',
|
||||
id: 'cube2', title: 'Cube 2: Sauerbraten', size: 0.4, version: '2013.09.20', canHostServer: true,
|
||||
desc: "Open-source arena FPS with in-game level editing. Fast, free, and one of those things every LAN party always had a copy of.",
|
||||
state: 'none', players: '2–16', tags: ['FPS', 'Open Source'],
|
||||
cover: { c1: '#dc2626', c2: '#7f1d1d', accent: '#f1f5f9', mood: 'arcade' },
|
||||
},
|
||||
{
|
||||
id: 'doom3', title: 'Doom 3', size: 2.2, version: '2012.01.31',
|
||||
id: 'doom3', title: 'Doom 3', size: 2.2, version: '2012.01.31', canHostServer: true,
|
||||
desc: "Sci-fi horror reboot of the franchise. Mars Research Facility, demonic incursion, the shotgun that started a flashlight debate.",
|
||||
state: 'local', players: '2–16', tags: ['FPS', 'Horror'],
|
||||
cover: { c1: '#7f1d1d', c2: '#000000', accent: '#f97316', mood: 'dark' },
|
||||
},
|
||||
{
|
||||
id: 'l4d2', title: 'Left 4 Dead 2', size: 6.5, version: '2020.08.15',
|
||||
id: 'l4d2', title: 'Left 4 Dead 2', size: 6.5, version: '2020.08.15', canHostServer: true,
|
||||
desc: "Co-op zombie survival with the AI Director rewriting every campaign run. Bring four friends or four strangers; the chainsaw works the same.",
|
||||
state: 'installed', players: '1–8', tags: ['Co-op', 'FPS', 'Horror'],
|
||||
cover: { c1: '#15803d', c2: '#052e16', accent: '#fef08a', mood: 'horror' },
|
||||
},
|
||||
{
|
||||
id: 'minecraft', title: 'Minecraft', size: 1.0, version: '2024.03.01',
|
||||
id: 'minecraft', title: 'Minecraft', size: 1.0, version: '2024.03.01', canHostServer: true,
|
||||
desc: "Infinite voxel sandbox. Build, mine, survive, get blown up by a creeper. The LAN button is right there.",
|
||||
state: 'installed', players: '2–100', tags: ['Sandbox', 'Survival', 'LAN'],
|
||||
cover: { c1: '#15803d', c2: '#7c2d12', accent: '#fde68a', mood: 'sandbox' },
|
||||
@@ -118,9 +118,9 @@ const GAMES = [
|
||||
cover: { c1: '#ea580c', c2: '#1e293b', accent: '#22d3ee', mood: 'tech' },
|
||||
},
|
||||
{
|
||||
id: 'quake3', title: 'Quake III Arena', size: 0.5, version: '2010.08.15',
|
||||
id: 'quake3', title: 'Quake III Arena', size: 0.5, version: '2010.08.15', canHostServer: true,
|
||||
desc: "Arena FPS in its most distilled form. Rocket jumps, rail-gun duels, and a netcode that's still the benchmark.",
|
||||
state: 'downloading', progress: 0.71, speed: 12.8, players: '2–16', tags: ['FPS', 'Arena', 'LAN'],
|
||||
state: 'downloading', progress: 0.71, speed: 12.8, peers: 3, players: '2–16', tags: ['FPS', 'Arena', 'LAN'],
|
||||
cover: { c1: '#7f1d1d', c2: '#0a0a0a', accent: '#fbbf24', mood: 'dark' },
|
||||
},
|
||||
{
|
||||
@@ -130,13 +130,13 @@ const GAMES = [
|
||||
cover: { c1: '#1e3a8a', c2: '#172554', accent: '#22d3ee', mood: 'scifi' },
|
||||
},
|
||||
{
|
||||
id: 'tf2', title: 'Team Fortress 2', size: 22.0, version: '2023.06.18',
|
||||
id: 'tf2', title: 'Team Fortress 2', size: 22.0, version: '2023.06.18', canHostServer: true,
|
||||
desc: "Class-based shooter with nine archetypes, absurd hats, and a meta with more history than most actual sports.",
|
||||
state: 'local', players: '2–32', tags: ['FPS', 'Class-based'],
|
||||
cover: { c1: '#b45309', c2: '#7c2d12', accent: '#fbbf24', mood: 'cartoon' },
|
||||
},
|
||||
{
|
||||
id: 'ut2k4', title: 'Unreal Tournament 2004', size: 5.2, version: '2012.06.01',
|
||||
id: 'ut2k4', title: 'Unreal Tournament 2004', size: 5.2, version: '2012.06.01', canHostServer: true,
|
||||
desc: "Arena shooter at maximum velocity. Onslaught, Assault, Bombing Run — vehicles, jump boots, the announcer screaming HEADSHOT.",
|
||||
state: 'none', players: '2–32', tags: ['FPS', 'Arena'],
|
||||
cover: { c1: '#854d0e', c2: '#422006', accent: '#fde047', mood: 'arena' },
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// launcher.jsx — composes top bar + grid into a complete launcher screen
|
||||
// Comes in two chrome variants: 'single' (one-row) and 'two' (two-row).
|
||||
|
||||
const DIR_PATH = '/home/pfs/Desktop/eti_games_AFTER_LAN_2025';
|
||||
|
||||
function applyFilterAndSort(games, filter, sort, query) {
|
||||
let g = filterGames(games, filter);
|
||||
if (query.trim()) {
|
||||
@@ -51,15 +49,26 @@ function Launcher({
|
||||
style={{ '--accent': accent }}>
|
||||
{variant === 'single' ? (
|
||||
<header className="topbar topbar-single">
|
||||
<div className="brand">
|
||||
<div className="brand-mark" style={{ background: accent }}>S</div>
|
||||
<div className="brand-name">SoftLAN</div>
|
||||
<div className="topbar-left">
|
||||
<div className="brand">
|
||||
<div className="brand-mark" style={{ background: accent }}>S</div>
|
||||
<div className="brand-name">SoftLAN</div>
|
||||
</div>
|
||||
<div className="topbar-left-trail">
|
||||
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} accent={accent}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar-center">
|
||||
<SearchField value={query} onChange={setQuery} accent={accent} wide/>
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
<div className="topbar-right-lead">
|
||||
<SortMenu value={sort} onChange={setSort} accent={accent}/>
|
||||
</div>
|
||||
<div className="topbar-right-trail">
|
||||
<KebabMenu items={menuItems}/>
|
||||
</div>
|
||||
</div>
|
||||
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} accent={accent}/>
|
||||
<SearchField value={query} onChange={setQuery} accent={accent} wide/>
|
||||
<SortMenu value={sort} onChange={setSort} accent={accent}/>
|
||||
<DirectoryButton path={DIR_PATH}/>
|
||||
<KebabMenu items={menuItems}/>
|
||||
</header>
|
||||
) : (
|
||||
<header className="topbar topbar-two">
|
||||
@@ -68,7 +77,6 @@ function Launcher({
|
||||
<div className="brand-mark" style={{ background: accent }}>S</div>
|
||||
<div className="brand-name">SoftLAN <span className="brand-name-soft">Launcher</span></div>
|
||||
</div>
|
||||
<DirectoryButton path={DIR_PATH}/>
|
||||
<div className="topbar-row1-right">
|
||||
<StorageMeter accent={accent}/>
|
||||
<KebabMenu items={menuItems}/>
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
color: var(--t-1);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
container-type: inline-size;
|
||||
container-name: launcher;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
@@ -77,13 +79,70 @@
|
||||
border-bottom: 1px solid var(--bd-1);
|
||||
}
|
||||
|
||||
/* Variant 1: single row */
|
||||
/* Variant 1: single row — three visual zones with search at geometric center */
|
||||
.topbar-single {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
column-gap: 16px;
|
||||
padding: 14px 24px;
|
||||
}
|
||||
.topbar-single .topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 14px 24px;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar-single .topbar-left-trail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar-single .topbar-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar-single .topbar-center .search { flex: 0 1 360px; min-width: 0; }
|
||||
.topbar-single .topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar-single .topbar-right-lead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar-single .topbar-right-trail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* When the launcher gets narrow, the three-zone centering breaks down —
|
||||
collapse to a single left-to-right flowing row. */
|
||||
@container launcher (max-width: 1100px) {
|
||||
.topbar-single {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 16px;
|
||||
}
|
||||
.topbar-single .topbar-left,
|
||||
.topbar-single .topbar-center,
|
||||
.topbar-single .topbar-right,
|
||||
.topbar-single .topbar-right-trail {
|
||||
justify-content: flex-start;
|
||||
flex: 0 0 auto;
|
||||
gap: 12px;
|
||||
}
|
||||
.topbar-single .topbar-center { flex: 1 1 200px; }
|
||||
.topbar-single .topbar-center .search { flex: 1 1 auto; }
|
||||
}
|
||||
|
||||
/* Variant 2: two row */
|
||||
@@ -307,29 +366,27 @@
|
||||
|
||||
/* ─── Directory button ─── */
|
||||
.dirbtn {
|
||||
position: relative;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
height: 36px; padding: 0 12px;
|
||||
height: 36px; padding: 0 14px 0 12px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--bd-1);
|
||||
border-radius: 8px;
|
||||
color: var(--t-2);
|
||||
font: inherit; font-size: 12.5px;
|
||||
color: var(--t-1);
|
||||
font: inherit; font-size: 12.5px; font-weight: 600;
|
||||
cursor: pointer;
|
||||
max-width: 360px;
|
||||
transition: border-color .15s, color .15s;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.dirbtn:hover { border-color: var(--bd-2); color: var(--t-1); }
|
||||
.dirbtn-label { color: var(--t-1); font-weight: 600; flex-shrink: 0; }
|
||||
.dirbtn-path {
|
||||
color: var(--t-3);
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 11.5px;
|
||||
transition: border-color .15s, color .15s, background .15s;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
.dirbtn:hover { border-color: var(--bd-2); }
|
||||
.dirbtn-label { line-height: 1; }
|
||||
.dirbtn-unset {
|
||||
border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-1));
|
||||
}
|
||||
.dirbtn-unset:hover {
|
||||
border-color: color-mix(in srgb, var(--danger) 55%, var(--bd-2));
|
||||
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
|
||||
}
|
||||
|
||||
/* ─── Kebab menu ─── */
|
||||
@@ -630,6 +687,22 @@
|
||||
}
|
||||
.act-download:hover { background: rgba(255,255,255,0.12); border-color: var(--bd-3); }
|
||||
|
||||
/* Start Server — secondary "primary" action sitting next to Play.
|
||||
Uses the accent as a tinted fill + border so it reads as host-action
|
||||
without competing with the green Play button. */
|
||||
.act-server {
|
||||
color: var(--t-1);
|
||||
background: color-mix(in srgb, var(--accent) 14%, rgba(255,255,255,0.04));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.06);
|
||||
}
|
||||
.act-server:hover {
|
||||
background: color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.04));
|
||||
border-color: color-mix(in srgb, var(--accent) 75%, transparent);
|
||||
filter: none;
|
||||
}
|
||||
.act-server svg { color: var(--accent); }
|
||||
|
||||
/* ─── Download progress (in place of action button when state === 'downloading') ─── */
|
||||
.dl {
|
||||
position: relative;
|
||||
@@ -784,14 +857,25 @@
|
||||
}
|
||||
.dl-lg-secondary .dl-bytes .dl-of { color: var(--t-2); font-weight: 500; }
|
||||
.dl-lg-secondary .dl-speed { color: var(--t-1); font-weight: 600; white-space: nowrap; }
|
||||
.dl-lg-secondary .dl-peers {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
color: var(--t-1); font-weight: 600; font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl-lg-secondary .dl-peers svg { opacity: 0.7; }
|
||||
.dl-lg-secondary .dl-eta { white-space: nowrap; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dl-sep { opacity: 0.45; }
|
||||
|
||||
/* Gracefully drop the ETA when the modal is narrow */
|
||||
@container (max-width: 380px) {
|
||||
@container (max-width: 320px) {
|
||||
.dl-lg-secondary .dl-eta,
|
||||
.dl-lg-secondary .dl-sep-eta { display: none; }
|
||||
}
|
||||
/* Even narrower: drop peers too, keep size + speed */
|
||||
@container (max-width: 240px) {
|
||||
.dl-lg-secondary .dl-peers,
|
||||
.dl-lg-secondary .dl-sep-peers { display: none; }
|
||||
}
|
||||
|
||||
.dl-lg-pct {
|
||||
grid-area: pct;
|
||||
@@ -1124,3 +1208,106 @@
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px -2px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.18);
|
||||
}
|
||||
|
||||
/* ─── Settings: text input ─── */
|
||||
.settings-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--bd-1);
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
height: 36px;
|
||||
width: 220px;
|
||||
transition: border-color .15s, box-shadow .15s, background .15s;
|
||||
}
|
||||
.settings-text:focus-within {
|
||||
background: var(--bg-2);
|
||||
border-color: var(--accent, #3b82f6);
|
||||
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent, #3b82f6) 22%, transparent);
|
||||
}
|
||||
.settings-text input {
|
||||
flex: 1; min-width: 0;
|
||||
background: transparent;
|
||||
border: 0; outline: 0;
|
||||
color: var(--t-1);
|
||||
font: inherit;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1px;
|
||||
}
|
||||
.settings-text input::placeholder {
|
||||
color: var(--t-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── Settings: game-folder field ─── */
|
||||
.folder-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 340px;
|
||||
height: 36px;
|
||||
padding: 0 4px 0 12px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--bd-1);
|
||||
border-radius: 8px;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.folder-field:hover { border-color: var(--bd-2); }
|
||||
.folder-field.is-unset {
|
||||
border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-1));
|
||||
background: color-mix(in srgb, var(--danger) 6%, var(--bg-3));
|
||||
}
|
||||
.folder-field.is-unset:hover {
|
||||
border-color: color-mix(in srgb, var(--danger) 55%, var(--bd-2));
|
||||
}
|
||||
.folder-field-icon {
|
||||
display: inline-flex;
|
||||
color: var(--t-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.folder-field.is-unset .folder-field-icon { color: #f87171; }
|
||||
.folder-field-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--t-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
direction: rtl; /* truncate from the head so the leaf folder stays visible */
|
||||
text-align: left;
|
||||
unicode-bidi: plaintext; /* keep character order intact */
|
||||
}
|
||||
.folder-field-empty {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #f87171;
|
||||
letter-spacing: 0.1px;
|
||||
}
|
||||
.folder-field-btn {
|
||||
flex-shrink: 0;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: var(--t-1);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.folder-field-btn:hover { background: rgba(255,255,255,0.12); }
|
||||
.folder-field.is-unset .folder-field-btn {
|
||||
background: color-mix(in srgb, var(--accent, #3b82f6) 85%, transparent);
|
||||
color: #fff;
|
||||
}
|
||||
.folder-field.is-unset .folder-field-btn:hover {
|
||||
background: var(--accent, #3b82f6);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ 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
|
||||
|
||||
@@ -13,6 +19,7 @@ bundle:
|
||||
|
||||
fmt:
|
||||
cargo +nightly fmt
|
||||
tombi format
|
||||
just --fmt
|
||||
|
||||
_fix:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
# cargo-vet audits file
|
||||
|
||||
[[audits.windows-link]]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
# cargo-vet config file
|
||||
|
||||
[cargo-vet]
|
||||
|
||||
Reference in New Issue
Block a user