Compare commits

..

2 Commits

Author SHA1 Message Date
ddidderr 8c8079fe19 feat: pass profile settings to launch scripts
Add launcher profile settings for username and language, then thread those
values into the Windows script launch path. The game setup, game start, and
server start scripts now share the same argument shape:

- game path: local
- game id
- language: en or de
- player name

Expose a local can_host_server flag in the games payload by checking for
server_start.cmd in an installed game's root directory. The detail modal uses
that flag to show Start Server only for installed games with the script, and
the new start_server command invokes server_start.cmd with the same sanitized
settings used by game_setup.cmd and game_start.cmd.

Test Plan:
- just fmt
- just test
- just frontend-test
- just build
- just clippy
- git diff --check

Refs: design/README.md
2026-05-21 09:40:23 +02:00
ddidderr 993ab25bbf docs: update launcher design for profile and server actions
Document the profile settings added to the launcher design and the new
Start Server detail action. The settings contract now includes a persisted
username and language choice, and the game detail overlay shows Start Server
only for installed games that can host a dedicated server.

The reference mock now includes the matching Profile controls, a server icon,
server-capable sample catalog entries, and the updated detail/settings
artboards so implementation can follow the selected design direction.

Test Plan:
- git diff --cached --check

Refs: design/README.md
2026-05-21 09:23:05 +02:00
102 changed files with 1495 additions and 8615 deletions
Generated
+501 -253
View File
@@ -8,6 +8,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"version_check",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -38,6 +49,23 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_log-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
[[package]]
name = "android_logger"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
dependencies = [
"android_log-sys",
"env_filter",
"log",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -53,6 +81,12 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "atk"
version = "0.18.2"
@@ -93,9 +127,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.1"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
@@ -155,13 +189,25 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.0"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
dependencies = [
"serde_core",
]
[[package]]
name = "bitvec"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -181,10 +227,34 @@ dependencies = [
]
[[package]]
name = "brotli"
version = "8.0.3"
name = "borsh"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a"
dependencies = [
"borsh-derive",
"bytes",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59"
dependencies = [
"once_cell",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "brotli"
version = "8.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -193,9 +263,9 @@ dependencies = [
[[package]]
name = "brotli-decompressor"
version = "5.0.1"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -212,9 +282,43 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.20.3"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byte-unit"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d"
dependencies = [
"rust_decimal",
"schemars 1.2.1",
"serde",
"utf8-width",
]
[[package]]
name = "bytecheck"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
dependencies = [
"bytecheck_derive",
"ptr_meta",
"simdutf8",
]
[[package]]
name = "bytecheck_derive"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "bytemuck"
@@ -243,7 +347,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"cairo-sys-rs",
"glib",
"libc",
@@ -306,9 +410,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.63"
version = "1.2.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -349,6 +453,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
@@ -362,9 +472,9 @@ dependencies = [
[[package]]
name = "chrono"
version = "0.4.45"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"num-traits",
@@ -432,7 +542,7 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"core-foundation",
"core-graphics-types",
"foreign-types",
@@ -445,7 +555,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"core-foundation",
"libc",
]
@@ -689,7 +799,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"block2",
"libc",
"objc2",
@@ -697,9 +807,9 @@ dependencies = [
[[package]]
name = "displaydoc"
version = "0.2.6"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
@@ -803,9 +913,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.16.0"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
dependencies = [
"serde",
]
@@ -839,6 +949,16 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "env_filter"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
dependencies = [
"log",
"regex",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -902,6 +1022,15 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fern"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
dependencies = [
"log",
]
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -939,17 +1068,6 @@ dependencies = [
"spin",
]
[[package]]
name = "flume"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
dependencies = [
"futures-core",
"futures-sink",
"spin",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1019,6 +1137,12 @@ dependencies = [
"libc",
]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futures"
version = "0.3.32"
@@ -1323,7 +1447,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"futures-channel",
"futures-core",
"futures-executor",
@@ -1444,25 +1568,19 @@ name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
"foldhash 0.1.5",
]
[[package]]
@@ -1478,11 +1596,11 @@ dependencies = [
[[package]]
name = "hashlink"
version = "0.11.1"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.16.1",
"hashbrown 0.15.5",
]
[[package]]
@@ -1521,9 +1639,9 @@ dependencies = [
[[package]]
name = "http"
version = "1.4.2"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
"bytes",
"itoa",
@@ -1560,9 +1678,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hyper"
version = "1.10.1"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
dependencies = [
"atomic-waker",
"bytes",
@@ -1800,11 +1918,11 @@ dependencies = [
[[package]]
name = "inotify"
version = "0.11.2"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1"
checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"inotify-sys",
"libc",
]
@@ -1937,12 +2055,13 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.100"
version = "0.3.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -1974,16 +2093,16 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"serde",
"unicode-segmentation",
]
[[package]]
name = "kqueue"
version = "1.2.0"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
@@ -1995,7 +2114,7 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"libc",
]
@@ -2034,7 +2153,6 @@ name = "lanspread-peer"
version = "0.1.0"
dependencies = [
"bytes",
"crc32fast",
"eyre",
"futures",
"gethostname",
@@ -2091,18 +2209,13 @@ 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",
]
@@ -2180,27 +2293,27 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
checksum = "2d1eacfa31c33ec25e873c136ba5669f00f9866d0688bea7be4d3f7e43067df6"
dependencies = [
"cc",
]
[[package]]
name = "libredox"
version = "0.1.17"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.37.0"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
@@ -2230,9 +2343,12 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.32"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
dependencies = [
"value-bag",
]
[[package]]
name = "markup5ever"
@@ -2247,12 +2363,12 @@ dependencies = [
[[package]]
name = "mdns-sd"
version = "0.20.0"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "892f96f6d2ebe1ea641279f986ac52a2a6bac71e8f743bb258315cfe2bd7e88e"
checksum = "18148fee27e99e76dbf6e137f27727113d31f766e578d1b93a93c3615fca7081"
dependencies = [
"fastrand",
"flume 0.11.1",
"flume",
"if-addrs",
"log",
"mio",
@@ -2262,9 +2378,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.8.1"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memoffset"
@@ -2277,9 +2393,9 @@ dependencies = [
[[package]]
name = "mimalloc"
version = "0.1.52"
version = "0.1.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
checksum = "b3627c4272df786b9260cabaa46aec1d59c93ede723d4c3ef646c503816b0640"
dependencies = [
"libmimalloc-sys",
]
@@ -2302,9 +2418,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.1"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"log",
@@ -2314,9 +2430,9 @@ dependencies = [
[[package]]
name = "muda"
version = "0.19.2"
version = "0.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c"
checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb"
dependencies = [
"crossbeam-channel",
"dpi",
@@ -2339,7 +2455,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"jni-sys 0.3.1",
"log",
"ndk-sys",
@@ -2369,7 +2485,7 @@ version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"fsevent-sys",
"inotify",
"kqueue",
@@ -2387,23 +2503,14 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [
"bitflags 2.13.0",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"bitflags 2.11.1",
]
[[package]]
name = "num-conv"
version = "0.2.2"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]]
name = "num-integer"
@@ -2481,7 +2588,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"block2",
"objc2",
"objc2-core-foundation",
@@ -2494,7 +2601,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"objc2",
"objc2-foundation",
]
@@ -2515,7 +2622,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"dispatch2",
"objc2",
]
@@ -2526,7 +2633,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"dispatch2",
"objc2",
"objc2-core-foundation",
@@ -2559,7 +2666,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -2586,7 +2693,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"block2",
"libc",
"objc2",
@@ -2599,7 +2706,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"objc2",
"objc2-core-foundation",
]
@@ -2610,7 +2717,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
@@ -2622,7 +2729,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"block2",
"objc2",
"objc2-cloud-kit",
@@ -2653,7 +2760,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"block2",
"objc2",
"objc2-app-kit",
@@ -2858,7 +2965,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"crc32fast",
"fdeflate",
"flate2",
@@ -2931,7 +3038,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.12+spec-1.1.0",
"toml_edit 0.25.11+spec-1.1.0",
]
[[package]]
@@ -2967,6 +3074,26 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "ptr_meta"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
dependencies = [
"ptr_meta_derive",
]
[[package]]
name = "ptr_meta_derive"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "quick-xml"
version = "0.39.4"
@@ -2997,6 +3124,12 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.7.3"
@@ -3005,11 +3138,22 @@ checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
dependencies = [
"getrandom 0.1.16",
"libc",
"rand_chacha",
"rand_chacha 0.2.2",
"rand_core 0.5.1",
"rand_hc",
]
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.10.1"
@@ -3031,6 +3175,16 @@ dependencies = [
"rand_core 0.5.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
name = "rand_core"
version = "0.5.1"
@@ -3040,6 +3194,15 @@ dependencies = [
"getrandom 0.1.16",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.10.1"
@@ -3067,7 +3230,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
]
[[package]]
@@ -3103,9 +3266,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.12.4"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
@@ -3126,15 +3289,24 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.8.11"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rend"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
dependencies = [
"bytecheck",
]
[[package]]
name = "reqwest"
version = "0.13.4"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -3202,6 +3374,52 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rkyv"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
dependencies = [
"bitvec",
"bytecheck",
"bytes",
"hashbrown 0.12.3",
"ptr_meta",
"rend",
"rkyv_derive",
"seahash",
"tinyvec",
"uuid",
]
[[package]]
name = "rkyv_derive"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "rust_decimal"
version = "1.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995"
dependencies = [
"arrayvec",
"borsh",
"bytes",
"num-traits",
"rand 0.8.6",
"rkyv",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -3223,7 +3441,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"errno",
"libc",
"linux-raw-sys",
@@ -3273,10 +3491,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "s2n-codec"
version = "0.82.0"
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a650d3f187901f3519ec8a1fe7da3faccc0b2fb40f350eda2c7851fdf2bda0f6"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "s2n-codec"
version = "0.80.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80c081add6c80a35614d55ac1ecac3b0d609de48c7d17c643e9bab0e84e27539"
dependencies = [
"byteorder",
"bytes",
@@ -3285,9 +3509,9 @@ dependencies = [
[[package]]
name = "s2n-quic"
version = "1.82.0"
version = "1.80.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c27c34127facefcd3e5530c4de5739a62cd4a593710b1194dacbd8e884b6be92"
checksum = "d1949f3735d6c7003b74c0519afcddf7800bdc14cd65b03930710511802b4032"
dependencies = [
"bytes",
"cfg-if",
@@ -3309,9 +3533,9 @@ dependencies = [
[[package]]
name = "s2n-quic-core"
version = "0.82.0"
version = "0.80.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79fbc3f06797d985363f74de105d18554b5a272b924b166d73a6564943da1230"
checksum = "bf79f1e410296ae7bf17a0e877d3ce30cb589c73faf6ea31e9ee61aad486197d"
dependencies = [
"atomic-waker",
"byteorder",
@@ -3331,9 +3555,9 @@ dependencies = [
[[package]]
name = "s2n-quic-crypto"
version = "0.82.0"
version = "0.80.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e58ea5aa39eecc29559d1e1bb4a5d55a747fa7b80cff5a3400c57489510644e3"
checksum = "9a6c42fa0e4a5305121fd0d55662614287283b2d54aeae8adecefe72d8d36b55"
dependencies = [
"aws-lc-rs",
"cfg-if",
@@ -3345,9 +3569,9 @@ dependencies = [
[[package]]
name = "s2n-quic-platform"
version = "0.82.0"
version = "0.80.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4eebb6007139cfffdf3d473d39f01a214032c339432a6293b16b0f7b25343f40"
checksum = "a161996e9c20557626704b883f8845d04133c0a27b176d1a1ee401932423d2f2"
dependencies = [
"cfg-if",
"futures",
@@ -3360,9 +3584,9 @@ dependencies = [
[[package]]
name = "s2n-quic-rustls"
version = "0.82.0"
version = "0.80.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb0084afa65eefae2c37d9ab44118a14dfc5bb78dbf997c0f5176f7cf8d2e633"
checksum = "68e0dffd40e31fb305e68ab9884a74b1c94949b3e258d02fb6e3a1f1c4305ba0"
dependencies = [
"bytes",
"rustls",
@@ -3374,9 +3598,9 @@ dependencies = [
[[package]]
name = "s2n-quic-tls"
version = "0.82.0"
version = "0.80.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91150b25ce824ffea581b449ad04acf9b4aef2fa68a46f667cdc9cc6f7b87823"
checksum = "c085a4e5085a30f796270f383e6b9707e81c3abbdbbf9123ad7577628cf26d4a"
dependencies = [
"bytes",
"errno",
@@ -3389,9 +3613,9 @@ dependencies = [
[[package]]
name = "s2n-quic-tls-default"
version = "0.82.0"
version = "0.80.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1f5ae64863972facee778dc80a24317e613f035296631f267b71f225e569c22"
checksum = "ac276a124e18b980cc8f8483a41a26f42564216f759f7f5822a8c4ffb8af8f04"
dependencies = [
"s2n-quic-rustls",
"s2n-quic-tls",
@@ -3399,9 +3623,9 @@ dependencies = [
[[package]]
name = "s2n-quic-transport"
version = "0.82.0"
version = "0.80.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b82fca53ce1734cc1d1dca96cc9ceb65ed528f27cb43b7de865215b6cf17908"
checksum = "2b6c77ed7cf87296886620917ba760ddb1e042446faa7be876887725c89dab63"
dependencies = [
"bytes",
"futures-channel",
@@ -3417,9 +3641,9 @@ dependencies = [
[[package]]
name = "s2n-tls"
version = "0.3.37"
version = "0.3.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60ada8adc59c848686744ff0ca93f1d1edc565a69d96a58a26abd84a521a5a0"
checksum = "5d22c36f207d0bb6a38272d5d1fa1cd99094335c70db1bfef54e0b8b672ae26f"
dependencies = [
"errno",
"hex",
@@ -3430,9 +3654,9 @@ dependencies = [
[[package]]
name = "s2n-tls-sys"
version = "0.3.37"
version = "0.3.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6960ccb00e47fe4b641f9c7b769515a4f89cb65459ca7afbed20fa39aac0b1b"
checksum = "bcf9e3b136cdd2c99f0cee2811d80b9f6bc44b5fd48c172857de32babb506f5e"
dependencies = [
"aws-lc-rs",
"cc",
@@ -3505,13 +3729,19 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "selectors"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"cssparser",
"derive_more",
"log",
@@ -3589,9 +3819,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.150"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
@@ -3630,10 +3860,22 @@ dependencies = [
]
[[package]]
name = "serde_with"
version = "3.21.0"
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
dependencies = [
"base64 0.22.1",
"bs58",
@@ -3651,9 +3893,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.21.0"
version = "3.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
dependencies = [
"darling",
"proc-macro2",
@@ -3703,15 +3945,6 @@ dependencies = [
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shared_child"
version = "1.1.1"
@@ -3725,9 +3958,9 @@ dependencies = [
[[package]]
name = "shlex"
version = "2.0.1"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "sigchld"
@@ -3766,6 +3999,12 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
@@ -3797,9 +4036,9 @@ dependencies = [
[[package]]
name = "socket2"
version = "0.6.4"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
@@ -3864,9 +4103,9 @@ dependencies = [
[[package]]
name = "sqlx"
version = "0.9.0"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
dependencies = [
"sqlx-core",
"sqlx-macros",
@@ -3875,13 +4114,12 @@ dependencies = [
[[package]]
name = "sqlx-core"
version = "0.9.0"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64 0.22.1",
"bytes",
"cfg-if",
"crc",
"crossbeam-queue",
"either",
@@ -3890,11 +4128,12 @@ dependencies = [
"futures-intrusive",
"futures-io",
"futures-util",
"hashbrown 0.16.1",
"hashbrown 0.15.5",
"hashlink",
"indexmap 2.14.0",
"log",
"memchr",
"once_cell",
"percent-encoding",
"serde",
"sha2",
@@ -3908,9 +4147,9 @@ dependencies = [
[[package]]
name = "sqlx-macros"
version = "0.9.0"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
dependencies = [
"proc-macro2",
"quote",
@@ -3921,15 +4160,15 @@ dependencies = [
[[package]]
name = "sqlx-macros-core"
version = "0.9.0"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
dependencies = [
"cfg-if",
"dotenvy",
"either",
"heck 0.5.0",
"hex",
"once_cell",
"proc-macro2",
"quote",
"serde",
@@ -3944,13 +4183,12 @@ dependencies = [
[[package]]
name = "sqlx-sqlite"
version = "0.9.0"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
dependencies = [
"atoi",
"flume 0.12.0",
"form_urlencoded",
"flume",
"futures-channel",
"futures-core",
"futures-executor",
@@ -3960,6 +4198,7 @@ dependencies = [
"log",
"percent-encoding",
"serde",
"serde_urlencoded",
"sqlx-core",
"thiserror 2.0.18",
"tracing",
@@ -4047,6 +4286,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
@@ -4096,11 +4336,11 @@ dependencies = [
[[package]]
name = "tao"
version = "0.35.3"
version = "0.35.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"block2",
"core-foundation",
"core-graphics",
@@ -4145,6 +4385,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -4322,6 +4568,28 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-log"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
dependencies = [
"android_logger",
"byte-unit",
"fern",
"log",
"objc2",
"objc2-foundation",
"serde",
"serde_json",
"serde_repr",
"swift-rs",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.3.5"
@@ -4509,15 +4777,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.47"
@@ -4724,9 +4983,9 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
version = "0.25.11+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
dependencies = [
"indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0",
@@ -4766,11 +5025,11 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.6.11"
version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"bytes",
"futures-util",
"http",
@@ -4824,32 +5083,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"nu-ansi-term",
"sharded-slab",
"smallvec",
"thread_local",
"tracing-core",
"tracing-log",
]
[[package]]
@@ -4888,9 +5121,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.20.1"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "unic-char-property"
@@ -4941,9 +5174,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-xid"
@@ -4994,6 +5227,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-width"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -5002,9 +5241,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.23.3"
version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -5013,10 +5252,10 @@ dependencies = [
]
[[package]]
name = "valuable"
version = "0.1.1"
name = "value-bag"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
[[package]]
name = "vcpkg"
@@ -5107,9 +5346,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.123"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
dependencies = [
"cfg-if",
"once_cell",
@@ -5120,9 +5359,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.73"
version = "0.4.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf"
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -5130,9 +5369,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.123"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -5140,9 +5379,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.123"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -5153,9 +5392,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.123"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
dependencies = [
"unicode-ident",
]
@@ -5201,7 +5440,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.11.1",
"hashbrown 0.15.5",
"indexmap 2.14.0",
"semver",
@@ -5209,9 +5448,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.100"
version = "0.3.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -5892,7 +6131,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.13.0",
"bitflags 2.11.1",
"indexmap 2.14.0",
"log",
"serde",
@@ -5972,6 +6211,15 @@ dependencies = [
"x11-dl",
]
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]]
name = "x11"
version = "2.21.0"
@@ -5995,9 +6243,9 @@ dependencies = [
[[package]]
name = "yoke"
version = "0.8.3"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
dependencies = [
"stable_deref_trait",
"yoke-derive",
@@ -6018,18 +6266,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.52"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.52"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
+13 -23
View File
@@ -14,49 +14,39 @@ members = [
[workspace.dependencies]
base64 = "0.22"
bytes = { version = "1", features = ["serde"] }
crc32fast = "1"
eyre = "0.6"
futures = "0.3"
gethostname = "1"
if-addrs = "0.15"
log = "0.4"
mdns-sd = "0.20"
mdns-sd = "0.19"
mimalloc = { version = "0.1", features = ["secure"] }
notify = "8"
s2n-quic = { version = "1", features = ["provider-event-tracing"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = {
version = "0.9",
default-features = false,
features = [
"derive",
"runtime-tokio",
"sqlite",
]
}
sqlx = { version = "0.8", default-features = false, features = [
"derive",
"runtime-tokio",
"sqlite",
] }
strum = { version = "0.28", features = ["derive"] }
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-log = "2"
tauri-plugin-shell = "2"
tauri-plugin-store = "2"
time = { version = "0.3", features = ["local-offset"] }
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["codec", "rt"] }
tracing = "0.1"
tracing-log = "0.2"
tracing-subscriber = "0.3"
uuid = { version = "1", features = ["v7"] }
walkdir = "2"
windows = {
version = "0.62",
features = [
"Win32",
"Win32_UI",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
]
}
windows = { version = "0.62", features = [
"Win32",
"Win32_UI",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
[profile.release]
debug = true
+3 -4
View File
@@ -5,10 +5,9 @@
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 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.
- 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.
- 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
-66
View File
@@ -1,66 +0,0 @@
# Streamed Install Next Steps
Id 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 Id harden the pieces that decide whether this is
product-ready.
1. **Done — Move from CLI-only to real app integration**
The GUI now has an explicit “Low disk install” action in the game detail
modal for remote-only games. The Tauri backend queues that path through
`stream_install_game`, injects the shared external `unrar` stream provider,
and hands fetched file details to `StreamInstallGame` instead of the normal
download command.
2. **Done — Replace per-file `unrar p` with a final archive provider**
The shared external `unrar` stream provider now runs `unrar lt` once for the
archive metadata and one sequential `unrar p` pass per archive for payload
bytes. It frames directories, file starts, file chunks, and file ends from
the technical listing, so CLI and GUI callers use one purpose-built provider
instead of a per-file extraction loop.
3. **Done — Handle solid archives deliberately**
The provider exposes the RAR `solid` flag in `ArchiveBegin` and always uses
one sequential payload pass per archive, which is the safe path for solid
archives. S41 now verifies a real solid RAR fixture through the Docker
peer-cli flow, including local-only final state, absent root archive/sentinel,
byte count, and extracted payload SHA-256 hashes.
4. **Done — Decide the integrity model**
Streamed installs intentionally verify against sender archive metadata for
now: each file must match the RAR-advertised size and CRC32. That catches
transport corruption, truncation, and provider bugs, but does not claim
malicious-peer protection. Trusted content remains a separate catalog schema
step: add catalog-owned archive or extracted-file SHA-256 hashes, then verify
those at the receiver before commit.
5. **Done — Upgrade retry/resume semantics**
Streamed install attempts now use the same majority-validated peer set as
normal downloads, and each failed attempt rolls back its staging transaction
before trying the next peer. S42 pins the policy: retry the whole stream from
another validated peer, keep no partial files across attempts, and do not add
byte-offset resume until there is a strong reason.
6. **Done — Expand scenario coverage**
S43-S47 cover the remaining streamed-install edges: already-installed
rejection, corrupt archive rollback, sender disconnect mid-stream, receiver
cancel mid-stream, and multi-archive `.eti` roots streamed in sorted order.
The peer-cli harness now exposes `cancel-download` so cancellation scenarios
exercise the same runtime path as the GUI.
7. **Done — Clean product semantics**
The UI now keeps streamed installs in the installed visual state while making
the sharing limitation explicit: cards show `Not shareable`, and the detail
modal status shows `Installed, not shareable`. Downloaded-and-installed games
keep the normal `Installed` label.
The remaining production-readiness step is additive: move from sender-owned RAR
metadata to catalog-owned archive or extracted-file hashes, then verify those
at the receiver before committing the streamed install.
+24 -203
View File
@@ -22,57 +22,48 @@ for deterministic local runs; mDNS/macvlan remains an environment smoke path.
| S12 | Transfer serving gates | A peer has a non-catalog, missing-sentinel, active-operation, or `local/` path request. | The serving peer declines metadata/data; covered by unit tests where timing is too small for a stable CLI race test. |
| S13 | Exact transferred-file equality | Repeat small and large downloads, then compare every transferred regular file against its source with SHA-256 manifests. | Source and receiver manifests match exactly for each transferred file; no extra or missing files appear in the downloaded game root. |
| S14 | Large multi-peer chunked download | `fixture-alpha/alienswarm` contains a renamed RAR `.eti` larger than 100 MB. A second peer downloads it, then a third peer downloads `alienswarm` from both peers. | The third peer's downloaded files match the source by SHA-256; `download-chunk-finished` events show the large `.eti` chunks coming from both peers with byte counts balanced within one chunk. |
| S15 | Catalog-version skew | Three peers advertise the same catalog game ID. Peers A and B have stale `version.ini` values; peer C has the catalog's expected version. An empty client connects to all three and downloads the game with `install=false`. | `list-games` shows one row for the game with `peer_count=1` and the catalog `eti_game_version`. The `got-game-files` descriptor set and transfer source are peer C only; no chunks come from A or B. The receiver's `version.ini` and SHA-256 manifest match C exactly. |
| S16 | Catalog-version fanout with stale peers present | Peer A has a stale version of a game. Peers B and C both advertise the catalog version with matching file manifests; use a large file when proving chunk split. | The aggregated row counts only catalog-version ready peers. Large-file chunks may split between B and C; peer A is not listed as downloadable and contributes no manifest vote or file chunks. |
| S17 | Catalog-version conflict rejection | Peer A has a stale version. Peers B and C both advertise the catalog version, but their file sizes conflict. | Validation considers only the catalog-version peers, so A cannot rescue the majority. The download fails with `download-failed`, and no committed target `version.ini` remains. |
| S15 | Three-way version skew | Three peers advertise the same catalog game ID. Peer A has `version.ini=20250101`, peer B has `version.ini=20250201`, and peer C has `version.ini=20250301`; each version has distinguishable file contents. An empty client connects to all three and downloads the game with `install=false`. | `list-games` shows one row for the game with `peer_count=3` and `eti_game_version=20250301`. The `got-game-files` descriptor set and transfer source are peer C's newest version only; no chunks come from A or B. The receiver's `version.ini` and SHA-256 manifest match C exactly. |
| S16 | Latest-version fanout with stale peers present | Peer A has an older version of a game. Peers B and C both advertise the same newest version with matching file manifests; use a large file when proving chunk split. | The aggregated row still counts all ready peers, but eligible transfer peers are only B and C. Large-file chunks may split between B and C; peer A contributes no manifest majority vote and no file chunks. |
| S17 | Latest-version conflict rejection | Peer A has an older version. Peers B and C both advertise the newest version, but their latest-version file sizes conflict. | Validation considers only the latest-version peers, so A cannot rescue the majority. The download fails with `download-failed`, and no committed target `version.ini` remains. |
| S18 | Mid-download source drop with redundancy | Client downloads a large shared game from two ready peers, then one source is killed after the download has begun. | Failed chunks are retried against the surviving source; the download finishes, no `download-failed` is emitted, and the receiver's files match the source by diff or SHA-256. |
| S19 | Mid-download sole-source drop | Client downloads a large game from one source, then that source is killed after the download has begun. | The download emits `download-failed`; no committed target `version.ini` remains; any partial payload is not advertised as ready; active operation state clears so a retry is possible. |
| S20 | Receiver write failure | Client downloads a large game into a constrained `/games` filesystem. | The download fails deterministically, no committed `version.ini` is advertised, and active operation state clears so the peer can retry later. |
| S21 | Add-game propagation | Two connected peers are running; one peer gains a new catalog game root through a completed download or an external drop. | The other peer receives a library update without reconnecting, and `list-games` shows the new remote game under the existing peer. |
| S22 | Remove-game propagation | Two connected peers are running; one peer loses a previously advertised game root. | The other peer receives a library update without dropping the peer, and `list-games` no longer shows that remote game. |
| S23 | Version bump propagation | Two connected peers are running; one peer's ready game root starts with a stale `version.ini`, then changes to the catalog version. | The other peer receives a library update without reconnecting; the stale row is absent before the change, then the catalog-version game appears as downloadable. |
| S23 | Version bump propagation | Two connected peers are running; one peer's ready game root gets a newer `version.ini`. | The other peer receives a library update without reconnecting, and the aggregated row reflects the newer `eti_game_version`. |
| S24 | Two clients pull from one source | Two empty clients connect to the same source and download the same large game concurrently. | Both downloads finish, both receivers match the source by diff or SHA-256, and the source remains responsive. |
| S25 | One client downloads two games concurrently | One client connected to a source issues two different `download` commands without waiting for the first to finish. | Both operations may run in parallel; both eventually finish, each game reaches the requested install state, and each transferred root matches its source. |
| S26 | Same-game duplicate download rejection | A client starts downloading a game, then issues a second `download` command for the same game while the first operation is active. | The second request is rejected deterministically as an operation-in-progress condition; the first download is not corrupted and still reaches its documented final state. |
| S27 | Self-connect rejection | A peer sends `connect` to its own advertised listener address. | The command fails cleanly, no self-peer entry is created, and the peer remains responsive. |
| S28 | Address change without identity change | A known peer is rediscovered with the same peer ID and a different listener address while its library is still known. | The peer record updates in place to the new address, the existing library stays attached to that peer ID, and no duplicate peer entry appears. This is covered with a deterministic unit-level check until the CLI can rebind a live listener without restart. |
| S29 | Empty-library peer participates | A peer with no games connects into the mesh. | Other peers list it as a peer with zero games; it can receive a download, advertise the new game without restart, and become a source. |
| S30 | 5+ peer mesh aggregation | Five peers advertise partially overlapping catalog games with a mix of unique and shared catalog-version games; a sixth client connects to all five. | The client shows one row per game ID, correct catalog-version ready-source `peer_count`, catalog `eti_game_version`, no duplicates, and no self entries. |
| S30 | 5+ peer mesh aggregation | Five peers advertise partially overlapping catalog games with a mix of unique games, shared games, and differing versions; a sixth client connects to all five. | The client shows one row per game ID, correct ready-source `peer_count`, latest `eti_game_version`, no duplicates, and no self entries. |
| S31 | Bootstrapped peer becomes source in same session | An empty client downloads a game from a source, the original source shuts down, then a fresh third peer downloads the same game from the bootstrapped client. | The third peer's files match the original source by diff or SHA-256, proving downloaded files become servable without restart. |
| S32 | Reinstall after uninstall | A downloaded game is installed, uninstalled, then installed again without another download. | `local/` is recreated from preserved root files, no transfer events occur during reinstall, and the game returns to `installed=true`. |
| S33 | Install after external root mutation | A downloaded game root is externally mutated before `install` is issued. | The CLI fixture installer installs from the current root bytes. The resulting `local/fixture-payload.txt` must match the mutated archive bytes exactly. |
| S34 | Many-small-files game without `.eti` | A catalog game root contains `version.ini` plus many small regular files and no archive. | Download with `install=false` transfers every file, chunk events are coherent for small files, and source/receiver manifests match exactly. |
| S35 | Unknown game ID from remote peer | A remote peer advertises a game ID that is not in the receiver's catalog. | The receiver does not list the unknown game as downloadable, download attempts fail deterministically, and no local files are created. |
| S36 | Catalog singleton beats stale majority | Five peers advertise one game; one peer has the catalog version and four peers have stale versions. | `list-games` reports `peer_count=1` and the catalog `eti_game_version`; all descriptors and chunks come from the singleton catalog-version peer, while stale peers remain hidden and contribute zero bytes. |
| S36 | Latest singleton beats stale majority | Five peers advertise one game; one peer has `20260501`, four peers have `20250101`. | `list-games` reports `eti_game_version=20260501`; all descriptors and chunks come from the singleton latest peer; stale peers contribute zero bytes. |
| S37 | Single-source download throughput | A source peer advertises a temporary catalog game with one sparse `2 GiB` `.eti`; an empty client downloads it with `install=false`. | The client emits `download-finished` with throughput measurements (`bytes`, `duration_ms`, `mib_per_s`, `mbit_per_s`), and the downloaded archive size matches the source. |
| S38 | First-play launch-setting stamping | `fixture-persona/css` ships a real RAR `.eti` whose tree buries a CRLF `SmartSteamEmu.ini` with a stub `PersonaName` line under `engine/bin/win64/steam_settings/`, plus a stub `account_name.txt` and `language.txt` under `profiles/local/`. A peer installs `css` (with `--unrar`), then sends `play css` with a username and language, then `play css` again. | After install the marker `games/css/launch_settings_applied` is absent and the stub files are intact under `local/`. The first `play` returns `already_applied=false` with `account_name_written`, `language_written`, and `persona_name_written` all true; the deep `SmartSteamEmu.ini` `PersonaName` value becomes the username with its `\r\n` ending and sibling lines preserved, `account_name.txt` becomes the username, `language.txt` becomes the passed language, and the marker now exists. A second `play` returns `already_applied=true`, rewrites nothing, and leaves the files untouched even if their values were reset externally. |
| S39 | Streamed install without keeping archive payload | Empty client connects to `fixture-bravo`, then sends `stream-install cnctw`. The source has real RAR `.eti` payload entries under `bin/` and `data/`; the receiver uses the container-bundled `unrar` stream provider. | Client emits `download-begin`, streamed `download-chunk-finished`, `download-finished`, `install-begin`, and `install-finished`. Local `cnctw` is `downloaded=false`, `installed=true`, `availability=LocalOnly`; root `version.ini` and `.eti` are absent; `local/bin/cnctw-payload.bin` and `local/data/cnctw-assets.dat` match `unrar p` output by SHA-256; the source reports no active outbound transfer for `cnctw` after completion. |
| S40 | Streamed install receiver is not a peer source | After S39, a third peer connects only to the streamed-install receiver. | The third peer may see the receiver's local-only summary in peer snapshots, but `list-games` remote aggregation does not expose `cnctw` as downloadable, `peer_count` remains zero/absent, and attempting `download cnctw` fails with no local files created. |
| S41 | Solid archive streamed install | Empty client connects to a peer serving `fixture-solid/cnctw`, whose `.eti` is a real solid RAR archive. The receiver uses the container-bundled `unrar` stream provider. | The fixture is verified as solid with `unrar lt`; streamed install finishes with `downloaded=false`, `installed=true`, `availability=LocalOnly`; root archive and `version.ini` are absent; streamed byte count equals the extracted solid entries; local payload SHA-256 hashes match `unrar p` output. |
| S42 | Streamed install whole-stream retry | Empty client connects to two peers serving the same catalog-version `cnctw`: one broken source whose `--unrar` path is missing, followed by one good source. | The broken source sorts before the good source in retry order, contributes zero chunks, and the good source completes a fresh whole-stream attempt. The final state is local-only installed, no root archive/sentinel, no `.local.installing`, byte count matches the extracted entries, and payload hashes match the good source. |
| S43 | Already-installed streamed install rejection | A client first stream-installs `cnctw`, then attempts `stream-install cnctw` again. | The second request emits `download-failed`, does not emit a new success event, leaves the existing local-only install intact, and clears active operations. |
| S44 | Corrupt archive streamed install rollback | A source advertises catalog-version `cnctw`, but its root `.eti` is replaced with invalid bytes before the client requests `stream-install cnctw`. | The stream emits `download-failed`, does not emit download/install success, clears active operations, and leaves no `local/`, `.local.installing`, root archive, or root `version.ini` on the receiver. |
| S45 | Sender disconnect during streamed install | A source serves large catalog-version `alienswarm`; after the client receives the first streamed chunk, the source container is killed. | The operation reaches a terminal failure/peers-gone event, emits no download/install success, clears active operations, and rolls back local/staging state. |
| S46 | Receiver cancel during streamed install | A client starts streaming large catalog-version `alienswarm`, receives the first chunk, then sends `cancel-download alienswarm`. | The receiver cancels without emitting download/install success or a user-visible download failure, clears active operations, and rolls back local/staging state. |
| S47 | Multi-archive streamed install order | A source serves `fixture-multi/cnctw` with two root `.eti` archives named to require sorted processing. | Streamed chunk paths arrive in root archive sort order, both payloads install under `local/`, the receiver is local-only installed, and no root archives or sentinel are committed. |
## Version-Skew Contract
Use S15-S17 to pin down what happens when several peers have the same game ID
but only some match the local catalog version:
Use S15-S17 to pin down what "newer" means when several peers have the same
game ID:
- The receiver's catalog is authoritative. A remote root whose `version.ini`
does not match the catalog's expected version for that game ID is not
downloadable.
- Version comparison uses the eight-digit `version.ini` string, so use sortable
`YYYYMMDD` values in manual fixtures.
- `list-games` aggregates by game ID. The game appears once; `peer_count`
counts only ready peers with that ID and the catalog version.
- The aggregated `eti_game_version` must be the catalog version.
counts all ready peers with that ID, including peers that only have older
versions.
- The aggregated `eti_game_version` must be the newest ready version.
- The descriptor set emitted to the download path, file-size validation, and
transfer planning are catalog-version-only. Stale peers must not supply
download descriptors, majority votes, or chunks.
- If exactly one peer has the catalog version, that peer is the only transfer
source. If several peers match the catalog version, validation and chunk
fanout happen among that catalog-version set only.
transfer planning are latest-only. Older-version peers may be queried by a
generic detail request, but their descriptors must not supply download
descriptors, majority votes, or chunks once a newer version exists.
- If exactly one peer has the latest version, that peer is the only transfer
source. If several peers tie on the latest version, validation and chunk
fanout happen among that latest-version set only.
- Capture proof with the `list-games` row, `got-game-files` descriptors,
`download-chunk-finished` source addresses, and source/receiver SHA-256
manifests.
@@ -86,7 +77,7 @@ GUI:
payload files may remain, but they must not be advertised as a ready local
game and must not leave an active operation stuck.
- Source failure during a redundant download should retry failed chunks against
another validated source for the same catalog-version file.
another validated source for the same latest-version file.
- Live local library changes are observable by connected peers through library
deltas; reconnect is not required for add, remove, or version-bump cases.
- Same-game operations are single-flight. A duplicate download request while a
@@ -95,183 +86,13 @@ GUI:
are not downloadable.
For a manual run, prefer a catalog game ID already served by the fixture lab,
such as `cnc4`, then create temporary `just peer-cli-run` game roots where some
peers match the catalog version and others deliberately use stale
`version.ini` contents. The existing alpha/bravo/charlie fixtures cover
duplicate-source and shared-game cases; S15-S17 add the focused skew cases.
## First-Play Launch-Setting Contract
Use S38 to pin down how launcher settings are stamped into an installed game:
- Stamping happens on the first `play`, not during install/update. The install
transaction only clears the `games/<id>/launch_settings_applied` marker so the
next play reapplies settings to a freshly (re)created `local/`.
- The first play stamps the username into the first `account_name.txt` and the
first `SmartSteamEmu.ini` `PersonaName` line, and the language into the first
`language.txt`, searching the whole `local/` tree. The matched `PersonaName`
line keeps its existing line ending (`\n` or `\r\n`).
- The marker records only that we *tried*: it is written unconditionally after
the first play, so a game with none of these files is still marked done.
- S38 needs a real archive expanded with `--unrar`; the Docker matrix image now
carries the Linux sidecar for streamed-install coverage, while the peer
crate's `launch_settings` unit tests cover the rewrite, line-ending, and
marker logic deterministically.
## Streamed Install Archive Contract
Use S39-S41 to pin down low-disk streamed installs:
- The stream provider performs one archive metadata pass and one payload pass
per `.eti`, then frames entry boundaries for the receiver.
- Non-solid and solid archives both install into `local/` without committing a
root archive or root `version.ini`, so the receiver is installed but not a
downloadable source.
- Streamed install integrity is currently sender archive integrity: size and
RAR CRC32 must match the sender's archive metadata. The SHA-256 checks in the
scenarios prove the Docker/provider path matches the source fixture; they are
not catalog-owned trust anchors.
- S41 verifies the fixture is actually solid inside the source container, so
solid handling stays covered by the same Docker harness as the existing
streamed-install scenarios.
- S42 verifies retry/resume semantics: failed streamed attempts roll back their
staging directory and retry the whole stream from another validated peer.
There is no byte-offset resume contract.
- S43-S47 cover the remaining streamed-install failure and archive-shape edges:
already-installed rejection, corrupt archive rollback, sender disconnect,
receiver cancel, and multi-archive root sorting.
such as `cnc4`, then create temporary `just peer-cli-run` game roots with
different `version.ini` contents. The existing alpha/bravo/charlie fixtures
cover duplicate-source and shared-game cases, but not the three-version skew
until a dedicated fixture or temporary games root is prepared.
## Run Log
### 2026-06-07 - Catalog-Version Matrix Alignment (S1-S47)
- Code under test aligned checked-in fixture `version.ini` sentinels with the
catalog, made `run_extended_scenarios.py` stamp generated fixture games with
catalog versions by default, updated S15-S17/S23/S30/S36/S37 to assert
catalog-authoritative aggregation, and wired S38 into the executable matrix.
- Gates before Docker: `python3 -m py_compile
crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` passed.
- Targeted rebuilt-image runner:
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S3 S8 S14 S15 S16 S17 S21 S22 S23 S24 S29 S30 S31 S34 S36 S37 S39 S40 S41 S42 S43 S44 S45 S46 S47 --build-image`
passed.
- S38 standalone runner:
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S38`
passed, proving the real-RAR `css` fixture installs with the container
`/usr/local/bin/unrar` sidecar and stamps launch settings only once.
- Full matrix runner:
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
passed for S1-S47 against the rebuilt `lanspread-peer-cli:dev` image.
- The final full-run highlights included S3 aggregation, S15-S17
catalog-version skew/fanout/conflict, S23 stale-to-catalog propagation, S30
mesh aggregation, S36 catalog singleton over stale majority, S37 throughput,
S38 first-play stamping, and S39-S47 streamed-install coverage.
### 2026-06-07 - Streamed Install Edge Coverage (S43-S47)
- Code under test added `cancel-download` to `lanspread-peer-cli`, added the
tiny `fixture-multi/cnctw` two-archive fixture, and added S43-S47 in
`run_extended_scenarios.py`.
- Gates before Docker: `just fmt` and `python3 -m py_compile
crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` passed.
- Runner:
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S43 S44 S45 S46 S47 --build-image`
passed against the rebuilt `lanspread-peer-cli:dev` image.
- S43 stream-installed `cnctw`, retried `stream-install cnctw`, observed
`download-failed`, and verified the existing local-only install stayed intact.
- S44 replaced the source `cnctw.eti` with invalid bytes. The receiver emitted
`download-failed`, cleared active operations, and left no `local/`,
`.local.installing`, root archive, or root `version.ini`.
- S45 killed the sole `alienswarm` source after the first streamed chunk. The
receiver ended with `download-failed`, emitted no success, cleared active
operations, and rolled back local/staging state.
- S46 cancelled `alienswarm` on the receiver after the first streamed chunk.
The receiver emitted no success and no user-visible `download-failed`, cleared
active operations, and rolled back local/staging state.
- S47 streamed `fixture-multi/cnctw` and observed chunk paths in sorted root
archive order: `cnctw/.local.installing/order/first.txt`, then
`cnctw/.local.installing/order/second.txt`.
### 2026-06-07 - Streamed Install Whole-Stream Retry (S42)
- Code under test added S42 in `run_extended_scenarios.py`.
- Gates before Docker: `python3 -m py_compile
crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` passed.
- Runner:
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S42`
passed against the current `lanspread-peer-cli:dev` image.
- S42 started a broken source with `--unrar /missing-unrar` and a good source
with the same catalog-version `cnctw` metadata. The broken source sorted first
(`10.66.0.2:32897`) and the good source second (`10.66.0.3:34092`).
- The broken source contributed zero chunks; the good source completed the fresh
whole-stream attempt with `3145728` streamed file bytes.
- The final client state was `downloaded=false`, `installed=true`,
`availability=LocalOnly`, with no root `version.ini`, no root `cnctw.eti`,
and no `.local.installing` staging directory. Payload SHA-256 hashes matched
the good source's `unrar p` output.
### 2026-06-07 - Solid Streamed Install Coverage (S41)
- Code under test added `fixture-solid/cnctw`, a real solid RAR `.eti`, plus
S41 in `run_extended_scenarios.py`.
- Gates before Docker: `just fmt`, `git diff --check`, and
`python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
passed.
- Runner:
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S41 --build-image`
passed against the rebuilt `lanspread-peer-cli:dev` image.
- S41 verified the source archive with `unrar lt -cfg-` inside the source
container; the archive reported `Details: RAR 5, solid`.
- The streamed install finished with `downloaded=false`, `installed=true`,
`availability=LocalOnly`, no root `version.ini`, and no root `cnctw.eti`.
- The client received `118` streamed file bytes, matching the extracted solid
entries. Payload SHA-256 hashes matched `unrar p` output:
`88764c9a6c9b5b846b4323cf7725cb7fd70766ddd7fba4168332804a839fa193`
(`bin/cnctw-solid-payload.bin`) and
`44afc308269b2381b7c707a056dd8d9d393274108ac4d880237fa6772c861d7a`
(`data/cnctw-solid-assets.dat`).
### 2026-06-07 - Streamed Install Prototype (S39-S40)
- Code under test added `stream-install` to `lanspread-peer-cli`, a peer
`StreamInstallGame` command, streamed install frames over QUIC, and an
injected `unrar lt`/`unrar p` provider for archive-derived bytes.
- Gates before Docker: `just fmt` and
`RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test` passed for the
workspace.
- Runner:
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S39 S40 --build-image`
passed against the rebuilt `lanspread-peer-cli:dev` image.
- S39 streamed a catalog-version-adjusted `cnctw` fixture from a real RAR
`.eti` into the receiver's `local/` only. The receiver had
`downloaded=false`, `installed=true`, `availability=LocalOnly`, no root
`version.ini`, no root `.eti`, and payload SHA-256 hashes
`82f4da22dc042166def2a5ee2eca19fc9e52785f99838e86c32167cb342e2588`
(`bin/cnctw-payload.bin`) and
`abf833a06c74ea9f17d505c2684186491898ce906405e0f098f0deac19476b06`
(`data/cnctw-assets.dat`) matching `unrar p`.
- S40 connected an observer only to that streamed-install receiver. The
observer saw the receiver's `cnctw` summary as local-only, remote aggregation
hid it as a downloadable source, and `download cnctw` failed with
`no peers have game cnctw`.
### 2026-05-28 - First-Play Launch-Setting Stamping (S38)
- Code under test moved the `account_name.txt`/`language.txt` overwrite out of
the install transaction and into a single first-play step (shared with the new
`SmartSteamEmu.ini` `PersonaName` rewrite) gated by the
`games/<id>/launch_settings_applied` marker.
- `just test` passed the whole workspace, including the new
`lanspread_peer::launch_settings` unit tests and
`install::transaction::install_resets_launch_settings_marker`.
- S38 host run: built `crates/lanspread-peer-cli/fixtures/fixture-persona/css`
with a stored RAR `.eti` (verified by `unrar t`) burying a CRLF
`SmartSteamEmu.ini` plus stub `account_name.txt`/`language.txt`. A host peer
installed `css` with `--unrar /usr/bin/unrar`, then `play css` stamped the
username into the deep `PersonaName` line (CRLF preserved, sibling lines
intact) and `account_name.txt`, the language into `language.txt`, and created
the marker. A second `play css` returned `already_applied=true` and rewrote
nothing even after the value was reset externally.
### 2026-05-19 - Snapshot Status Fix Docker Matrix Pass
- Code under test included `5c4976d` (`fix(peer): settle local state before
+30 -32
View File
@@ -1,48 +1,46 @@
# lanspread
Peer-to-peer game library sharing for LAN parties. Peers discover each other on
the local network via mDNS, exchange library metadata over QUIC, and let users
browse and download games from each other. Ships as a Tauri desktop app.
## Description
## Build / install
Peer-to-peer game library sharing for LAN parties.
Install Rust, Deno, and `just` first, then bootstrap the project:
- Peers let users browse and download games from each other
- they discover each other on the local network via mDNS
- they exchange library metadata over QUIC
Ships as a Tauri desktop app.
## Development
### Prerequisites
```bash
just setup
# install Tauri CLI
cargo install tauri-cli
# install Deno with a package manager or from https://deno.land/
```
That installs the Tauri CLI with `cargo install tauri-cli` and installs the
Deno/npm dependencies from `crates/lanspread-tauri-deno-ts`.
Run the desktop app in development mode:
```bash
just run
```
Build without bundling:
### Build or Run
```bash
# build
just build
# run
just run
# test
just test
```
Create production bundles:
### Scripted peer harness
`crates/lanspread-peer-cli` runs the peer runtime without the GUI and speaks
JSONL on stdin/stdout. It is intended for automated multi-peer smoke tests.
```bash
just bundle
just peer-cli-build
just peer-cli-image
just peer-cli-run alpha
```
## Important just commands
- `just setup` - install the Tauri CLI and frontend dependencies.
- `just run` - run the Tauri app in dev mode.
- `just build` - build the app without bundling.
- `just bundle` - create production bundles.
- `just fmt` - format Rust, TOML, and the justfile.
- `just clippy` - lint the Rust workspace.
- `just test` - run workspace tests.
- `just frontend-test` - run frontend tests.
- `just peer-cli-build` - build the JSONL peer test harness.
- `just peer-cli-image` - build the peer harness Docker image.
- `just peer-cli-run NAME` - run one peer harness container.
+14 -14
View File
@@ -3,23 +3,23 @@ name = "lanspread-compat"
version = "0.1.0"
edition = "2024"
[lib]
doctest = false
test = false
[dependencies]
# local
lanspread-db = { path = "../lanspread-db" }
eyre = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }
tracing = { workspace = true }
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lints.rust]
unsafe_code = "forbid"
[dependencies]
# local
lanspread-db = { path = "../lanspread-db" }
eyre = { workspace = true }
sqlx = { workspace = true }
serde = { workspace = true }
tracing = { workspace = true }
[lib]
test = false
doctest = false
+2 -2
View File
@@ -57,14 +57,14 @@ impl From<EtiGame> for Game {
release_year: eti_game.game_release,
publisher: eti_game.game_publisher,
max_players: eti_game.game_maxplayers,
version: eti_game.game_version.clone(),
version: eti_game.game_version,
genre: eti_game.genre_de,
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
size: (eti_game.game_size * 1024.0 * 1024.0 * 1024.0) as u64,
downloaded: false,
installed: false,
availability: Availability::LocalOnly,
eti_game_version: Some(eti_game.game_version),
eti_game_version: None,
local_version: None,
peer_count: 0, // ETI games start with 0 peers until peer system discovers them
}
+9 -9
View File
@@ -3,8 +3,13 @@ name = "lanspread-db"
version = "0.1.0"
edition = "2024"
[lib]
doctest = false
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[dependencies]
eyre = { workspace = true }
@@ -12,10 +17,5 @@ serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lints.rust]
unsafe_code = "forbid"
[lib]
doctest = false
+1 -55
View File
@@ -78,7 +78,7 @@ pub struct Game {
/// Backend-reported availability state for this game's local or peer summary.
#[serde(default)]
pub availability: Availability,
/// Authoritative ETI game version from the bundled game.db (YYYYMMDD format).
/// ETI game version from version.ini (YYYYMMDD format) (server)
pub eti_game_version: Option<String>,
/// Local game version from version.ini (YYYYMMDD format)
pub local_version: Option<String>,
@@ -198,60 +198,6 @@ impl Default for GameDB {
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GameCatalog {
expected_versions: HashMap<String, Option<String>>,
}
impl GameCatalog {
#[must_use]
pub fn empty() -> Self {
Self {
expected_versions: HashMap::new(),
}
}
#[must_use]
pub fn from_game_db(game_db: &GameDB) -> Self {
Self {
expected_versions: game_db
.games
.values()
.map(|game| (game.id.clone(), game.eti_game_version.clone()))
.collect(),
}
}
#[must_use]
pub fn from_ids(ids: impl IntoIterator<Item = String>) -> Self {
Self {
expected_versions: ids.into_iter().map(|id| (id, None)).collect(),
}
}
pub fn insert(&mut self, id: String, expected_version: Option<String>) {
self.expected_versions.insert(id, expected_version);
}
#[must_use]
pub fn contains<S>(&self, id: S) -> bool
where
S: AsRef<str>,
{
self.expected_versions.contains_key(id.as_ref())
}
#[must_use]
pub fn expected_version<S>(&self, id: S) -> Option<&str>
where
S: AsRef<str>,
{
self.expected_versions
.get(id.as_ref())
.and_then(Option::as_deref)
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct GameFileDescription {
pub game_id: String,
+10 -10
View File
@@ -3,19 +3,19 @@ name = "lanspread-mdns"
version = "0.1.0"
edition = "2024"
[lib]
doctest = false
test = false
[dependencies]
eyre = { workspace = true }
log = { workspace = true }
mdns-sd = { workspace = true }
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lints.rust]
unsafe_code = "forbid"
[dependencies]
eyre = { workspace = true }
log = { workspace = true }
mdns-sd = { workspace = true }
[lib]
test = false
doctest = false
+12 -12
View File
@@ -3,12 +3,14 @@ name = "lanspread-peer-cli"
version = "0.1.0"
edition = "2024"
[lib]
doctest = false
[lints.rust]
unsafe_code = "forbid"
[[bin]]
name = "lanspread-peer-cli"
path = "src/main.rs"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
needless_pass_by_value = "allow"
[dependencies]
lanspread-compat = { path = "../lanspread-compat" }
@@ -20,11 +22,9 @@ serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
[lints.clippy]
needless_pass_by_value = "allow"
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lib]
doctest = false
[lints.rust]
unsafe_code = "forbid"
[[bin]]
name = "lanspread-peer-cli"
path = "src/main.rs"
+2 -4
View File
@@ -4,16 +4,14 @@ WORKDIR /work
COPY . .
RUN cargo build --release -p lanspread-peer-cli
FROM debian:trixie-slim
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates libstdc++6 \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /work/target/release/lanspread-peer-cli /usr/local/bin/lanspread-peer-cli
COPY crates/lanspread-tauri-deno-ts/src-tauri/game.db /app/game.db
COPY crates/lanspread-tauri-deno-ts/src-tauri/binaries/unrar-x86_64-unknown-linux-gnu /usr/local/bin/unrar
RUN chmod +x /usr/local/bin/unrar
ENTRYPOINT ["lanspread-peer-cli"]
CMD ["--games-dir", "/games", "--state-dir", "/state", "--catalog-db", "/app/game.db"]
-4
View File
@@ -42,7 +42,3 @@ echoed back on the result or error line.
{"id":"u1","cmd":"uninstall","game_id":"fixture-one"}
{"id":"q1","cmd":"shutdown"}
```
The `status` result includes receiver-side `active_operations` and
sender-side `active_outbound_transfers` counts by game ID, which the scenario
runner uses to verify transfer lifecycle cleanup.
@@ -1 +1 @@
20190317
20250101
@@ -1 +1 @@
20160130
20250103
@@ -1 +1 @@
20200721
20250102
@@ -1 +1 @@
20210416
20250201
@@ -1 +1 @@
20170204
20250202
@@ -1 +1 @@
20160128
20250203
@@ -1 +1 @@
20200721
20250102
@@ -1 +1 @@
20170204
20250202
@@ -1 +1 @@
20160920
20250301
@@ -1 +1 @@
20200315
20250302
@@ -1 +1 @@
20200907
20250303
@@ -1 +0,0 @@
20160128
@@ -1 +0,0 @@
20240623
@@ -1 +0,0 @@
20160128
@@ -1,15 +1,13 @@
#!/usr/bin/env python3
"""Run the peer-cli scenarios S1-S47 through Docker."""
"""Run the peer-cli scenarios S1-S36 through Docker."""
from __future__ import annotations
import argparse
import hashlib
import ipaddress
import json
import os
import queue
import shlex
import shutil
import subprocess
import sys
@@ -28,20 +26,7 @@ CONTAINER_PREFIX = "lanspread-peer-cli-ext"
CATALOG_DB = "/app/game.db"
FIXTURES = REPO / "crates" / "lanspread-peer-cli" / "fixtures"
CHUNK_SIZE = 128 * 1024 * 1024
CATALOG_VERSIONS = {
"alienswarm": "20190317",
"bf1942": "20160130",
"bfbc2": "20210416",
"cnc4": "20170204",
"cnctw": "20160128",
"cod5": "20160920",
"cod6": "20200315",
"coh": "20200907",
"css": "20240623",
"ggoo": "20200721",
}
PERF_GAME_ID = "bf1942"
PERF_GAME_VERSION = CATALOG_VERSIONS[PERF_GAME_ID]
PERF_GAME_SIZE = 2 * 1024 * 1024 * 1024
IGNORED_DIFF_NAMES = {".lanspread", ".lanspread.json", "local"}
@@ -318,8 +303,8 @@ class Runner:
("S13", self.s13_exact_transfer_equality),
("S14", self.s14_large_multi_peer_chunking),
("S15", self.s15_three_way_version_skew),
("S16", self.s16_catalog_fanout_with_stale),
("S17", self.s17_catalog_conflict_rejection),
("S16", self.s16_latest_fanout_with_stale),
("S17", self.s17_latest_conflict_rejection),
("S18", self.s18_redundant_source_drop),
("S19", self.s19_sole_source_drop),
("S20", self.s20_receiver_write_failure),
@@ -338,18 +323,8 @@ class Runner:
("S33", self.s33_install_after_mutation),
("S34", self.s34_many_small_files),
("S35", self.s35_unknown_game_filtered),
("S36", self.s36_catalog_singleton),
("S36", self.s36_latest_singleton),
("S37", self.s37_single_source_download_throughput),
("S38", self.s38_first_play_launch_settings),
("S39", self.s39_streamed_install_local_only),
("S40", self.s40_streamed_receiver_not_source),
("S41", self.s41_solid_archive_streamed_install),
("S42", self.s42_streamed_install_retries_next_source),
("S43", self.s43_streamed_install_rejects_installed_game),
("S44", self.s44_corrupt_stream_rolls_back),
("S45", self.s45_sender_disconnect_mid_stream),
("S46", self.s46_receiver_cancel_mid_stream),
("S47", self.s47_multi_archive_streams_in_sorted_order),
]
for scenario_id, scenario in scenarios:
@@ -534,20 +509,20 @@ class Runner:
def s8_ambiguous_metadata_rejection(self) -> str:
dir_a = self.fixture_root / "s8-a"
dir_b = self.fixture_root / "s8-b"
copy_game("ggoo", dir_a)
copy_game("ggoo", dir_b)
copy_game("ggoo", dir_a, version="20260101")
copy_game("ggoo", dir_b, version="20260101")
with (dir_b / "ggoo" / "ggoo.eti").open("ab") as handle:
handle.write(b"conflict")
peer_a = self.peer("s8-a", games_dir=dir_a)
peer_b = self.peer("s8-b", games_dir=dir_b)
client = self.peer("s8-client")
connect_many(client, [peer_a, peer_b])
wait_remote_game(client, "ggoo", peer_count=2, version=CATALOG_VERSIONS["ggoo"])
wait_remote_game(client, "ggoo", peer_count=2, version="20260101")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": "ggoo", "install": False})
client.wait_for(event_is("download-failed", "ggoo"), timeout=30, description="ggoo failed", waiter=waiter)
assert_not_exists(client.host_games_dir / "ggoo" / "version.ini")
return "conflicting catalog-version ggoo file sizes emitted download-failed and left no version.ini"
return "conflicting latest ggoo file sizes emitted download-failed and left no version.ini"
def s9_missing_game(self) -> str:
client = self.peer("s9-client")
@@ -617,37 +592,30 @@ class Runner:
return "small bfbc2 and large alienswarm transfers both diffed cleanly against sources"
def s14_large_multi_peer_chunking(self) -> str:
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)
alpha = self.peer("s14-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
stage = self.peer("s14-stage")
connect_many(stage, [alpha])
waiter = LineWaiter(len(stage.output))
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)
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")
client = self.peer("s14-client")
connect_many(client, [alpha, stage])
wait_remote_game(client, game_id, peer_count=2, version=PERF_GAME_VERSION)
wait_remote_game(client, "alienswarm", peer_count=2)
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": game_id, "install": False})
client.wait_for(event_is("download-finished", game_id), timeout=90, description="client finish", waiter=waiter)
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")
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")
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"{game_id} downloaded from two sources, diff matched, chunk totals={totals}"
return f"alienswarm downloaded from two sources, diff matched, chunk totals={totals}"
def s15_three_way_version_skew(self) -> str:
specs = [
("s15-a", "20150101"),
("s15-b", "20160101"),
("s15-c", CATALOG_VERSIONS["cnc4"]),
]
specs = [("s15-a", "20250101"), ("s15-b", "20250201"), ("s15-c", "20250301")]
peers = []
for name, version in specs:
game_dir = self.fixture_root / name
@@ -655,19 +623,19 @@ class Runner:
peers.append(self.peer(name, games_dir=game_dir))
client = self.peer("s15-client")
connect_many(client, peers)
wait_remote_game(client, "cnc4", peer_count=1, version=CATALOG_VERSIONS["cnc4"])
wait_remote_game(client, "cnc4", peer_count=3, version="20250301")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": "cnc4", "install": False})
client.wait_for(event_is("download-finished", "cnc4"), timeout=60, description="cnc4 finish", waiter=waiter)
assert_only_chunk_sources(client, "cnc4", {peers[2].ready_addr})
diff_game_dirs(peers[2].host_games_dir / "cnc4", client.host_games_dir / "cnc4")
return "three-way skew exposed only the catalog-version peer and receiver diffed cleanly"
return "three-way skew selected only 20250301 peer and receiver diffed cleanly"
def s16_catalog_fanout_with_stale(self) -> str:
def s16_latest_fanout_with_stale(self) -> str:
specs = [
("s16-a", "20180101"),
("s16-b", CATALOG_VERSIONS["alienswarm"]),
("s16-c", CATALOG_VERSIONS["alienswarm"]),
("s16-a", "20250101"),
("s16-b", "20250301"),
("s16-c", "20250301"),
]
peers = []
for name, version in specs:
@@ -676,7 +644,7 @@ class Runner:
peers.append(self.peer(name, games_dir=game_dir))
client = self.peer("s16-client")
connect_many(client, peers)
wait_remote_game(client, "alienswarm", peer_count=2, version=CATALOG_VERSIONS["alienswarm"])
wait_remote_game(client, "alienswarm", peer_count=3, version="20250301")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": "alienswarm", "install": False})
client.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="alienswarm finish", waiter=waiter)
@@ -685,13 +653,13 @@ class Runner:
if peers[0].ready_addr in totals:
raise ScenarioError(f"stale peer contributed chunks: {totals}")
diff_game_dirs(peers[1].host_games_dir / "alienswarm", client.host_games_dir / "alienswarm")
return f"catalog-version B/C peers served alienswarm while stale A contributed zero; totals={totals}"
return f"latest B/C peers served alienswarm while stale A contributed zero; totals={totals}"
def s17_catalog_conflict_rejection(self) -> str:
def s17_latest_conflict_rejection(self) -> str:
specs = [
("s17-a", "20150101", False),
("s17-b", CATALOG_VERSIONS["cnc4"], False),
("s17-c", CATALOG_VERSIONS["cnc4"], True),
("s17-a", "20250101", False),
("s17-b", "20250301", False),
("s17-c", "20250301", True),
]
peers = []
for name, version, conflict in specs:
@@ -703,12 +671,12 @@ class Runner:
peers.append(self.peer(name, games_dir=game_dir))
client = self.peer("s17-client")
connect_many(client, peers)
wait_remote_game(client, "cnc4", peer_count=2, version=CATALOG_VERSIONS["cnc4"])
wait_remote_game(client, "cnc4", peer_count=3, version="20250301")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": "cnc4", "install": False})
client.wait_for(event_is("download-failed", "cnc4"), timeout=30, description="cnc4 failed", waiter=waiter)
assert_not_exists(client.host_games_dir / "cnc4" / "version.ini")
return "catalog-version file conflict failed download and left no committed version.ini"
return "latest-version file conflict failed download and left no committed version.ini"
def s18_redundant_source_drop(self) -> str:
source_a_dir = self.fixture_root / "s18-a"
@@ -791,13 +759,13 @@ class Runner:
def s23_version_bump_propagation(self) -> str:
alpha = self.peer("s23-alpha")
bravo_dir = self.fixture_root / "s23-bravo"
copy_game("cnc4", bravo_dir, version="20160101")
copy_game("cnc4", bravo_dir, version="20250101")
bravo = self.peer("s23-bravo", games_dir=bravo_dir)
connect_many(alpha, [bravo])
wait_remote_absent(alpha, "cnc4", timeout=5)
(bravo_dir / "cnc4" / "version.ini").write_text(CATALOG_VERSIONS["cnc4"], encoding="utf-8")
wait_remote_game(alpha, "cnc4", peer_count=1, version=CATALOG_VERSIONS["cnc4"])
return "alpha observed stale cnc4 become catalog-version downloadable without reconnect"
wait_remote_game(alpha, "cnc4", peer_count=1, version="20250101")
(bravo_dir / "cnc4" / "version.ini").write_text("20260501", encoding="utf-8")
wait_remote_game(alpha, "cnc4", peer_count=1, version="20260501")
return "alpha observed cnc4 eti_game_version change 20250101 -> 20260501 without reconnect"
def s24_two_clients_one_source(self) -> str:
source = self.peer("s24-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
@@ -894,11 +862,11 @@ class Runner:
def s30_mesh_aggregation(self) -> str:
dirs = []
specs = [
("s30-a", [("ggoo", CATALOG_VERSIONS["ggoo"]), ("bf1942", CATALOG_VERSIONS["bf1942"])]),
("s30-b", [("ggoo", CATALOG_VERSIONS["ggoo"]), ("cnc4", CATALOG_VERSIONS["cnc4"])]),
("s30-c", [("cnc4", CATALOG_VERSIONS["cnc4"]), ("cod5", CATALOG_VERSIONS["cod5"])]),
("s30-d", [("cnctw", CATALOG_VERSIONS["cnctw"]), ("coh", CATALOG_VERSIONS["coh"])]),
("s30-e", [("cnctw", CATALOG_VERSIONS["cnctw"]), ("bf1942", CATALOG_VERSIONS["bf1942"])]),
("s30-a", [("ggoo", "20250101"), ("bf1942", "20250101")]),
("s30-b", [("ggoo", "20250101"), ("cnc4", "20250101")]),
("s30-c", [("cnc4", "20250301"), ("cod5", "20250101")]),
("s30-d", [("cnctw", "20250101"), ("coh", "20250101")]),
("s30-e", [("cnctw", "20250201"), ("bf1942", "20250201")]),
]
peers = []
for name, games in specs:
@@ -910,12 +878,12 @@ class Runner:
client = self.peer("s30-client")
connect_many(client, peers)
expected = {
"ggoo": (2, CATALOG_VERSIONS["ggoo"]),
"bf1942": (2, CATALOG_VERSIONS["bf1942"]),
"cnc4": (2, CATALOG_VERSIONS["cnc4"]),
"cod5": (1, CATALOG_VERSIONS["cod5"]),
"cnctw": (2, CATALOG_VERSIONS["cnctw"]),
"coh": (1, CATALOG_VERSIONS["coh"]),
"ggoo": (2, "20250101"),
"bf1942": (2, "20250201"),
"cnc4": (2, "20250301"),
"cod5": (1, "20250101"),
"cnctw": (2, "20250201"),
"coh": (1, "20250101"),
}
for game_id, (peer_count, version) in expected.items():
wait_remote_game(client, game_id, peer_count=peer_count, version=version)
@@ -925,7 +893,7 @@ class Runner:
raise ScenarioError(f"duplicate game rows: {ids}")
if any(peer["peer_id"] == client.peer_id for peer in client.list_peers()):
raise ScenarioError("client listed itself as a peer")
return f"client aggregated {len(expected)} IDs from 5 peers with expected peer_count/catalog versions"
return f"client aggregated {len(expected)} IDs from 5 peers with expected peer_count/latest versions"
def s31_bootstrapped_peer_source(self) -> str:
source = self.peer("s31-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
@@ -1021,34 +989,34 @@ class Runner:
assert_not_exists(client.host_games_dir / "mystery-game")
return f"unknown game absent from list-games; download errored '{err['error']}'; no local files"
def s36_catalog_singleton(self) -> str:
def s36_latest_singleton(self) -> str:
peers = []
for index in range(5):
game_dir = self.fixture_root / f"s36-{index}"
version = CATALOG_VERSIONS["cnc4"] if index == 0 else "20160101"
version = "20260501" if index == 0 else "20250101"
copy_game("cnc4", game_dir, version=version)
peers.append(self.peer(f"s36-{index}", games_dir=game_dir))
client = self.peer("s36-client")
connect_many(client, peers)
wait_remote_game(client, "cnc4", peer_count=1, version=CATALOG_VERSIONS["cnc4"])
wait_remote_game(client, "cnc4", peer_count=5, version="20260501")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": "cnc4", "install": False})
got = client.wait_for(event_is("got-game-files", "cnc4"), timeout=20, description="got game files", waiter=waiter)
client.wait_for(event_is("download-finished", "cnc4"), timeout=60, description="download finish", waiter=waiter)
catalog_addr = peers[0].ready_addr
if catalog_addr is None:
raise ScenarioError("catalog-version peer had no ready addr")
latest_addr = peers[0].ready_addr
if latest_addr is None:
raise ScenarioError("latest peer had no ready addr")
for item in client.output:
if item.get("type") != "event" or item.get("event") != "download-chunk-finished":
continue
data = item["data"]
if data.get("game_id") == "cnc4" and data.get("peer_addr") != catalog_addr:
if data.get("game_id") == "cnc4" and data.get("peer_addr") != latest_addr:
raise ScenarioError(f"stale peer contributed chunk: {data}")
diff_game_dirs(peers[0].host_games_dir / "cnc4", client.host_games_dir / "cnc4")
descs = got["data"]["file_descriptions"]
if not descs:
raise ScenarioError("got-game-files had no descriptors")
return "client reported singleton catalog-version peer; stale peers stayed hidden and sent no chunks; diff matched"
return "client reported latest 20260501 with peer_count=5; only singleton latest peer sent chunks; diff matched"
def s37_single_source_download_throughput(self) -> str:
source_dir = self.fixture_root / "s37-source"
@@ -1056,7 +1024,7 @@ class Runner:
source = self.peer("s37-source", games_dir=source_dir)
client = self.peer("s37-client")
connect_many(client, [source])
wait_remote_game(client, PERF_GAME_ID, peer_count=1, version=PERF_GAME_VERSION)
wait_remote_game(client, PERF_GAME_ID, peer_count=1, version="20260520")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": PERF_GAME_ID, "install": False})
@@ -1075,7 +1043,7 @@ class Runner:
throughput = finished.get("data", {}).get("throughput")
if not throughput:
raise ScenarioError(f"download-finished did not include throughput: {finished}")
expected_bytes = PERF_GAME_SIZE + len(PERF_GAME_VERSION)
expected_bytes = PERF_GAME_SIZE + len("20260520")
if int(throughput["bytes"]) != expected_bytes:
raise ScenarioError(
f"throughput byte count mismatch: {throughput['bytes']} != {expected_bytes}"
@@ -1089,535 +1057,6 @@ class Runner:
f"{throughput['chunks']} chunks"
)
def s38_first_play_launch_settings(self) -> str:
client_dir = self.fixture_root / "s38-client"
copy_game("css", client_dir)
client = self.peer(
"s38-client",
games_dir=client_dir,
extra_args=["--unrar", "/usr/local/bin/unrar"],
)
waiter = LineWaiter(len(client.output))
client.send({"cmd": "install", "game_id": "css"})
client.wait_for(
event_is("install-finished", "css"),
timeout=30,
description="css install",
waiter=waiter,
)
wait_local_game(client, "css", downloaded=True, installed=True)
marker = client.host_state_dir / "games" / "css" / "launch_settings_applied"
if marker.exists():
raise ScenarioError("launch settings marker existed before first play")
local_root = client.host_games_dir / "css" / "local"
account_file = local_root / "profiles" / "local" / "account_name.txt"
language_file = local_root / "profiles" / "local" / "language.txt"
ini_file = (
local_root
/ "engine"
/ "bin"
/ "win64"
/ "steam_settings"
/ "SmartSteamEmu.ini"
)
for path in [account_file, language_file, ini_file]:
if not path.is_file():
raise ScenarioError(f"expected installed launch settings file: {path}")
if b"PersonaName = stubplayer\r\n" not in ini_file.read_bytes():
raise ScenarioError("installed SmartSteamEmu.ini did not preserve CRLF stub PersonaName")
first = client.send(
{
"cmd": "play",
"game_id": "css",
"username": "Lan Hero",
"language": "german",
}
)["data"]["outcome"]
expected_first = {
"already_applied": False,
"account_name_written": True,
"language_written": True,
"persona_name_written": True,
}
if first != expected_first:
raise ScenarioError(f"unexpected first play outcome: {first}")
if not marker.is_file():
raise ScenarioError("launch settings marker was not written after first play")
if account_file.read_text(encoding="utf-8") != "Lan Hero":
raise ScenarioError("account_name.txt was not stamped with username")
if language_file.read_text(encoding="utf-8") != "german":
raise ScenarioError("language.txt was not stamped with language")
stamped_ini = ini_file.read_bytes()
if b"PersonaName = Lan Hero\r\n" not in stamped_ini:
raise ScenarioError("PersonaName was not stamped with CRLF preserved")
if b"AppId = 240\r\n" not in stamped_ini or b"Language = english\r\n" not in stamped_ini:
raise ScenarioError("SmartSteamEmu.ini sibling lines were not preserved")
client.docker_exec(
"sh",
"-c",
"printf resetaccount > /games/css/local/profiles/local/account_name.txt",
)
client.docker_exec(
"sh",
"-c",
"printf resetlang > /games/css/local/profiles/local/language.txt",
)
client.docker_exec(
"sh",
"-c",
"printf '[Settings]\\r\\nAppId = 240\\r\\n"
"PersonaName = resetplayer\\r\\nLanguage = english\\r\\n' > "
"/games/css/local/engine/bin/win64/steam_settings/SmartSteamEmu.ini",
)
second = client.send(
{
"cmd": "play",
"game_id": "css",
"username": "Second User",
"language": "french",
}
)["data"]["outcome"]
expected_second = {
"already_applied": True,
"account_name_written": False,
"language_written": False,
"persona_name_written": False,
}
if second != expected_second:
raise ScenarioError(f"unexpected second play outcome: {second}")
if account_file.read_text(encoding="utf-8") != "resetaccount":
raise ScenarioError("second play rewrote account_name.txt despite marker")
if language_file.read_text(encoding="utf-8") != "resetlang":
raise ScenarioError("second play rewrote language.txt despite marker")
if b"PersonaName = resetplayer\r\n" not in ini_file.read_bytes():
raise ScenarioError("second play rewrote PersonaName despite marker")
return "css first play stamped launch settings once; second play respected the marker"
def stream_install_cnctw(self, prefix: str) -> tuple[Peer, Peer]:
source_dir = self.fixture_root / f"{prefix}-bravo"
copy_game("cnctw", source_dir, version="20160128")
source = self.peer(f"{prefix}-bravo", games_dir=source_dir)
client = self.peer(f"{prefix}-client")
connect_many(client, [source])
wait_remote_game(client, "cnctw", peer_count=1)
waiter = LineWaiter(len(client.output))
client.send({"cmd": "stream-install", "game_id": "cnctw"})
client.wait_for(
event_is("download-begin", "cnctw"),
timeout=20,
description="stream begin cnctw",
waiter=waiter,
)
client.wait_for(
event_is("download-finished", "cnctw"),
timeout=60,
description="stream finish cnctw",
waiter=waiter,
)
client.wait_for(
event_is("install-finished", "cnctw"),
timeout=30,
description="stream install cnctw",
waiter=waiter,
)
return source, client
def s39_streamed_install_local_only(self) -> str:
source, client = self.stream_install_cnctw("s39")
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
assert_game_state(
game,
downloaded=False,
installed=True,
availability="LocalOnly",
)
game_root = client.host_games_dir / "cnctw"
assert_not_exists(game_root / "version.ini")
assert_not_exists(game_root / "cnctw.eti")
expected = {
"bin/cnctw-payload.bin": unrar_entry_sha256(
source, "cnctw", "bin/cnctw-payload.bin"
),
"data/cnctw-assets.dat": unrar_entry_sha256(
source, "cnctw", "data/cnctw-assets.dat"
),
}
actual = {
rel: sha256_file(game_root / "local" / rel)
for rel in expected
}
if actual != expected:
raise ScenarioError(f"streamed local payload hashes mismatched: {actual} != {expected}")
streamed_bytes = sum(
int(item.get("data", {}).get("length", 0))
for item in client.output
if item.get("type") == "event"
and item.get("event") == "download-chunk-finished"
and item.get("data", {}).get("game_id") == "cnctw"
)
expected_bytes = 3 * 1024 * 1024
if streamed_bytes != expected_bytes:
raise ScenarioError(
f"streamed byte count mismatch: {streamed_bytes} != {expected_bytes}"
)
wait_no_outbound_transfer(source, "cnctw")
return (
"cnctw streamed into local/ only; root archive and version.ini absent; "
f"payload hashes={actual}; source outbound transfer drained"
)
def s40_streamed_receiver_not_source(self) -> str:
_source, receiver = self.stream_install_cnctw("s40")
observer = self.peer("s40-observer")
connect_many(observer, [receiver])
receiver_snapshot = wait_peer_has_game(observer, receiver.peer_id, "cnctw")
summary = next(
game
for game in receiver_snapshot.get("games", [])
if game.get("id") == "cnctw"
)
if summary.get("availability") != "LocalOnly" or summary.get("downloaded"):
raise ScenarioError(f"receiver did not advertise cnctw as local-only: {summary}")
wait_remote_absent(observer, "cnctw", timeout=5)
err = observer.send(
{"cmd": "download", "game_id": "cnctw", "install": False},
expect_error=True,
)
if "no peers have game cnctw" not in err["error"]:
raise ScenarioError(f"unexpected local-only download error: {err}")
assert_not_exists(observer.host_games_dir / "cnctw")
return (
"observer saw receiver's local-only cnctw snapshot, but remote aggregation hid it "
f"and download errored '{err['error']}'"
)
def s41_solid_archive_streamed_install(self) -> str:
source_dir = self.fixture_root / "s41-solid-source"
source_game = source_dir / "cnctw"
shutil.copytree(FIXTURES / "fixture-solid" / "cnctw", source_game)
source = self.peer("s41-solid-source", games_dir=source_dir)
assert_peer_rar_archive_solid(source, "cnctw")
client = self.peer("s41-solid-client")
connect_many(client, [source])
wait_remote_game(client, "cnctw", peer_count=1, version="20160128")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "stream-install", "game_id": "cnctw"})
client.wait_for(
event_is("download-finished", "cnctw"),
timeout=60,
description="solid stream finish cnctw",
waiter=waiter,
)
client.wait_for(
event_is("install-finished", "cnctw"),
timeout=30,
description="solid stream install cnctw",
waiter=waiter,
)
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
assert_game_state(
game,
downloaded=False,
installed=True,
availability="LocalOnly",
)
game_root = client.host_games_dir / "cnctw"
assert_not_exists(game_root / "version.ini")
assert_not_exists(game_root / "cnctw.eti")
expected = {
"bin/cnctw-solid-payload.bin": unrar_entry_sha256(
source, "cnctw", "bin/cnctw-solid-payload.bin"
),
"data/cnctw-solid-assets.dat": unrar_entry_sha256(
source, "cnctw", "data/cnctw-solid-assets.dat"
),
}
actual = {
rel: sha256_file(game_root / "local" / rel)
for rel in expected
}
if actual != expected:
raise ScenarioError(
f"solid streamed payload hashes mismatched: {actual} != {expected}"
)
streamed_bytes = sum(
int(item.get("data", {}).get("length", 0))
for item in client.output
if item.get("type") == "event"
and item.get("event") == "download-chunk-finished"
and item.get("data", {}).get("game_id") == "cnctw"
)
expected_bytes = sum((game_root / "local" / rel).stat().st_size for rel in expected)
if streamed_bytes != expected_bytes:
raise ScenarioError(
f"solid streamed byte count mismatch: {streamed_bytes} != {expected_bytes}"
)
return (
"solid cnctw archive streamed through one local-only install; "
f"payload hashes={actual}, bytes={streamed_bytes}"
)
def s42_streamed_install_retries_next_source(self) -> str:
bad_dir = self.fixture_root / "s42-bad-source"
good_dir = self.fixture_root / "s42-good-source"
copy_game("cnctw", bad_dir, version="20160128")
copy_game("cnctw", good_dir, version="20160128")
bad = self.peer(
"s42-bad-source",
games_dir=bad_dir,
extra_args=["--unrar", "/missing-unrar"],
)
good = self.peer("s42-good-source", games_dir=good_dir)
if socket_addr_sort_key(bad.ready_addr) > socket_addr_sort_key(good.ready_addr):
raise ScenarioError(
"S42 requires the broken source to sort before the good source; "
f"bad={bad.ready_addr}, good={good.ready_addr}"
)
client = self.peer("s42-client")
connect_many(client, [bad, good])
wait_remote_game(client, "cnctw", peer_count=2, version="20160128")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "stream-install", "game_id": "cnctw"})
client.wait_for(
event_is("download-finished", "cnctw"),
timeout=60,
description="retry stream finish cnctw",
waiter=waiter,
)
client.wait_for(
event_is("install-finished", "cnctw"),
timeout=30,
description="retry stream install cnctw",
waiter=waiter,
)
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
assert_game_state(
game,
downloaded=False,
installed=True,
availability="LocalOnly",
)
game_root = client.host_games_dir / "cnctw"
assert_not_exists(game_root / ".local.installing")
assert_not_exists(game_root / "version.ini")
assert_not_exists(game_root / "cnctw.eti")
assert_only_chunk_sources(client, "cnctw", {good.ready_addr})
expected = {
"bin/cnctw-payload.bin": unrar_entry_sha256(
good, "cnctw", "bin/cnctw-payload.bin"
),
"data/cnctw-assets.dat": unrar_entry_sha256(
good, "cnctw", "data/cnctw-assets.dat"
),
}
actual = {
rel: sha256_file(game_root / "local" / rel)
for rel in expected
}
if actual != expected:
raise ScenarioError(f"retry streamed payload hashes mismatched: {actual} != {expected}")
streamed_bytes = sum(
int(item.get("data", {}).get("length", 0))
for item in client.output
if item.get("type") == "event"
and item.get("event") == "download-chunk-finished"
and item.get("data", {}).get("game_id") == "cnctw"
)
expected_bytes = 3 * 1024 * 1024
if streamed_bytes != expected_bytes:
raise ScenarioError(
f"retry streamed byte count mismatch: {streamed_bytes} != {expected_bytes}"
)
return (
"broken first source failed without chunks, next source completed whole stream; "
f"good={good.ready_addr}, bad={bad.ready_addr}, bytes={streamed_bytes}"
)
def s43_streamed_install_rejects_installed_game(self) -> str:
_source, client = self.stream_install_cnctw("s43")
start = len(client.output)
waiter = LineWaiter(start)
client.send({"cmd": "stream-install", "game_id": "cnctw"})
client.wait_for(
event_is("download-failed", "cnctw"),
timeout=20,
description="already-installed stream rejection",
waiter=waiter,
)
assert_no_event_since(client, start, "install-finished", "cnctw")
assert_no_event_since(client, start, "download-finished", "cnctw")
wait_no_active(client, "cnctw")
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
assert_game_state(
game,
downloaded=False,
installed=True,
availability="LocalOnly",
)
return "already-installed cnctw rejected a second streamed install without state drift"
def s44_corrupt_stream_rolls_back(self) -> str:
source_dir = self.fixture_root / "s44-corrupt-source"
copy_game("cnctw", source_dir, version="20160128")
(source_dir / "cnctw" / "cnctw.eti").write_bytes(b"not a rar archive")
source = self.peer("s44-corrupt-source", games_dir=source_dir)
client = self.peer("s44-client")
connect_many(client, [source])
wait_remote_game(client, "cnctw", peer_count=1, version="20160128")
start = len(client.output)
waiter = LineWaiter(start)
client.send({"cmd": "stream-install", "game_id": "cnctw"})
client.wait_for(
event_is("download-failed", "cnctw"),
timeout=30,
description="corrupt stream failed",
waiter=waiter,
)
assert_no_event_since(client, start, "download-finished", "cnctw")
assert_no_event_since(client, start, "install-finished", "cnctw")
wait_no_active(client, "cnctw")
assert_failed_stream_left_no_local(client, "cnctw")
return "corrupt cnctw archive emitted download-failed and left no local install"
def s45_sender_disconnect_mid_stream(self) -> str:
source_dir = self.fixture_root / "s45-source"
copy_game("alienswarm", source_dir, version="20190317")
source = self.peer("s45-source", games_dir=source_dir)
client = self.peer("s45-client")
connect_many(client, [source])
wait_remote_game(client, "alienswarm", peer_count=1, version="20190317")
start = len(client.output)
waiter = LineWaiter(start)
client.send({"cmd": "stream-install", "game_id": "alienswarm"})
client.wait_for(
event_is("download-chunk-finished", "alienswarm"),
timeout=30,
description="first alienswarm stream chunk before source drop",
waiter=waiter,
)
source.kill()
terminal = client.wait_for(
event_name_in({"download-failed", "download-peers-gone"}, "alienswarm"),
timeout=60,
description="sender disconnect terminal event",
waiter=waiter,
)
assert_no_event_since(client, start, "download-finished", "alienswarm")
assert_no_event_since(client, start, "install-finished", "alienswarm")
wait_no_active(client, "alienswarm")
assert_failed_stream_left_no_local(client, "alienswarm")
return (
"sender disconnect after first alienswarm chunk rolled back stream; "
f"terminal={terminal['event']}"
)
def s46_receiver_cancel_mid_stream(self) -> str:
source_dir = self.fixture_root / "s46-source"
copy_game("alienswarm", source_dir, version="20190317")
source = self.peer("s46-source", games_dir=source_dir)
client = self.peer("s46-client")
connect_many(client, [source])
wait_remote_game(client, "alienswarm", peer_count=1, version="20190317")
start = len(client.output)
waiter = LineWaiter(start)
client.send({"cmd": "stream-install", "game_id": "alienswarm"})
client.wait_for(
event_is("download-chunk-finished", "alienswarm"),
timeout=30,
description="first alienswarm stream chunk before receiver cancel",
waiter=waiter,
)
client.send({"cmd": "cancel-download", "game_id": "alienswarm"})
wait_no_active(client, "alienswarm", timeout=60)
assert_no_event_since(client, start, "download-finished", "alienswarm")
assert_no_event_since(client, start, "download-failed", "alienswarm")
assert_no_event_since(client, start, "install-finished", "alienswarm")
assert_failed_stream_left_no_local(client, "alienswarm")
return "receiver cancel after first alienswarm chunk rolled back without failed event"
def s47_multi_archive_streams_in_sorted_order(self) -> str:
source_dir = self.fixture_root / "s47-source"
source_game = source_dir / "cnctw"
shutil.copytree(FIXTURES / "fixture-multi" / "cnctw", source_game)
source = self.peer("s47-source", games_dir=source_dir)
client = self.peer("s47-client")
connect_many(client, [source])
wait_remote_game(client, "cnctw", peer_count=1, version="20160128")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "stream-install", "game_id": "cnctw"})
client.wait_for(
event_is("download-finished", "cnctw"),
timeout=30,
description="multi-archive stream finish",
waiter=waiter,
)
client.wait_for(
event_is("install-finished", "cnctw"),
timeout=30,
description="multi-archive stream install",
waiter=waiter,
)
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
assert_game_state(
game,
downloaded=False,
installed=True,
availability="LocalOnly",
)
game_root = client.host_games_dir / "cnctw"
assert_not_exists(game_root / "version.ini")
assert_not_exists(game_root / "a-first.eti")
assert_not_exists(game_root / "z-second.eti")
chunk_paths = streamed_chunk_paths(client, "cnctw")
expected_paths = [
"cnctw/.local.installing/order/first.txt",
"cnctw/.local.installing/order/second.txt",
]
if chunk_paths != expected_paths:
raise ScenarioError(f"multi-archive stream order mismatch: {chunk_paths}")
first = (game_root / "local" / "order" / "first.txt").read_text(encoding="utf-8")
second = (game_root / "local" / "order" / "second.txt").read_text(encoding="utf-8")
if first != "first archive payload\n" or second != "second archive payload\n":
raise ScenarioError(f"multi-archive payload mismatch: {first!r}, {second!r}")
return f"multi-archive cnctw streamed in sorted order: {chunk_paths}"
def run(command: list[str], description: str) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
@@ -1686,7 +1125,6 @@ def copy_game(game_id: str, destination_games_dir: Path, *, version: str | None
shutil.rmtree(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(source, destination)
version = version if version is not None else CATALOG_VERSIONS.get(game_id)
if version is not None:
(destination / "version.ini").write_text(version, encoding="utf-8")
@@ -1723,62 +1161,19 @@ def create_many_small_game(root: Path) -> None:
for index in range(20):
child = root / f"file-{index:02}.bin"
child.write_bytes(hashlib.sha256(f"small-{index}".encode()).digest() * 8)
(root / "version.ini").write_text(CATALOG_VERSIONS.get(root.name, "20250101"), encoding="utf-8")
(root / "version.ini").write_text("20250101", encoding="utf-8")
def create_large_sparse_game(root: Path, *, size: int) -> None:
if root.exists():
shutil.rmtree(root)
root.mkdir(parents=True)
(root / "version.ini").write_text(PERF_GAME_VERSION, encoding="utf-8")
(root / "version.ini").write_text("20260520", encoding="utf-8")
archive = root / f"{root.name}.eti"
with archive.open("wb") as handle:
handle.truncate(size)
def sha256_file(path: Path) -> str:
hasher = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
hasher.update(chunk)
return hasher.hexdigest()
def unrar_entry_sha256(peer: Peer, game_id: str, relative_path: str) -> str:
command = (
f"unrar p -inul /games/{shlex.quote(game_id)}/{shlex.quote(game_id)}.eti "
f"{shlex.quote(relative_path)} | sha256sum"
)
output = peer.docker_exec("sh", "-c", command).stdout.strip()
if not output:
raise ScenarioError(f"empty sha256 output for {game_id}:{relative_path}")
return output.split()[0]
def assert_peer_rar_archive_solid(peer: Peer, game_id: str) -> None:
output = peer.docker_exec(
"unrar",
"lt",
"-cfg-",
f"/games/{game_id}/{game_id}.eti",
).stdout
for line in output.splitlines():
stripped = line.strip()
if stripped.startswith("Details:"):
if "solid" in stripped.lower():
return
raise ScenarioError(f"RAR archive is not solid: {game_id}")
raise ScenarioError(f"RAR archive details were not reported: {game_id}")
def socket_addr_sort_key(addr: str | None) -> tuple[int, int]:
if addr is None:
raise ScenarioError("cannot sort missing peer address")
host, port = addr.rsplit(":", 1)
host = host.removeprefix("[").removesuffix("]")
return (int(ipaddress.ip_address(host)), int(port))
def format_bytes(size: int) -> str:
return f"{size / 1024 / 1024 / 1024:.2f} GiB"
@@ -1855,32 +1250,6 @@ def wait_local_game(
)
def wait_no_active(peer: Peer, game_id: str, timeout: float = 20) -> None:
deadline = time.monotonic() + timeout
last_active: list[dict[str, Any]] = []
while time.monotonic() < deadline:
active = peer.status()["active_operations"]
last_active = active
if all(item["game_id"] != game_id for item in active):
return
time.sleep(0.4)
raise ScenarioError(f"{peer.name} still has active operation for {game_id}: {last_active}")
def wait_no_outbound_transfer(peer: Peer, game_id: str, timeout: float = 20) -> None:
deadline = time.monotonic() + timeout
last_active: dict[str, int] = {}
while time.monotonic() < deadline:
active = peer.status()["active_outbound_transfers"]
last_active = active
if active.get(game_id, 0) == 0:
return
time.sleep(0.4)
raise ScenarioError(
f"{peer.name} still has outbound transfer for {game_id}: {last_active}"
)
def assert_game_state(
game: dict[str, Any],
*,
@@ -1927,10 +1296,7 @@ def wait_peer_has_game(
def assert_local_absent(peer: Peer, game_id: str) -> None:
rows = peer.list_games()["local"]
if any(
row["id"] == game_id and (row.get("downloaded") or row.get("installed"))
for row in rows
):
if any(row["id"] == game_id and row.get("downloaded") for row in rows):
raise ScenarioError(f"{peer.name} advertises failed local {game_id}: {rows}")
@@ -1946,15 +1312,6 @@ def assert_not_exists(path: Path) -> None:
raise ScenarioError(f"expected path to be absent: {path}")
def assert_failed_stream_left_no_local(peer: Peer, game_id: str) -> None:
game_root = peer.host_games_dir / game_id
assert_local_absent(peer, game_id)
assert_not_exists(game_root / "local")
assert_not_exists(game_root / ".local.installing")
assert_not_exists(game_root / "version.ini")
assert_not_exists(game_root / f"{game_id}.eti")
def event_is(event: str, game_id: str | None = None) -> Callable[[dict[str, Any]], bool]:
def predicate(item: dict[str, Any]) -> bool:
if item.get("type") != "event" or item.get("event") != event:
@@ -1966,17 +1323,6 @@ def event_is(event: str, game_id: str | None = None) -> Callable[[dict[str, Any]
return predicate
def event_name_in(events: set[str], game_id: str | None = None) -> Callable[[dict[str, Any]], bool]:
def predicate(item: dict[str, Any]) -> bool:
if item.get("type") != "event" or item.get("event") not in events:
return False
if game_id is None:
return True
return item.get("data", {}).get("game_id") == game_id
return predicate
def assert_no_event(peer: Peer, waiter: LineWaiter, event: str, game_id: str) -> None:
for item in peer.output[waiter.seen :]:
if item.get("type") == "event" and item.get("event") == event:
@@ -1984,13 +1330,6 @@ def assert_no_event(peer: Peer, waiter: LineWaiter, event: str, game_id: str) ->
raise ScenarioError(f"unexpected {event} for {game_id}: {item}")
def assert_no_event_since(peer: Peer, start: int, event: str, game_id: str) -> None:
for item in peer.output[start:]:
if item.get("type") == "event" and item.get("event") == event:
if item.get("data", {}).get("game_id") == game_id:
raise ScenarioError(f"unexpected {event} for {game_id}: {item}")
def assert_only_chunk_sources(
peer: Peer,
game_id: str,
@@ -2016,16 +1355,6 @@ def assert_only_chunk_sources(
raise ScenarioError(f"no chunk events recorded for {game_id}")
def streamed_chunk_paths(peer: Peer, game_id: str) -> list[str]:
return [
item["data"]["relative_path"]
for item in peer.output
if item.get("type") == "event"
and item.get("event") == "download-chunk-finished"
and item.get("data", {}).get("game_id") == game_id
]
def chunk_totals(peer: Peer, game_id: str, relative_path: str) -> dict[str, int]:
totals: dict[str, int] = {}
for item in peer.output:
-54
View File
@@ -33,23 +33,12 @@ pub enum CliCommand {
game_id: String,
install_after_download: bool,
},
StreamInstall {
game_id: String,
},
CancelDownload {
game_id: String,
},
Install {
game_id: String,
},
Uninstall {
game_id: String,
},
Play {
game_id: String,
username: String,
language: Option<String>,
},
WaitPeers {
count: usize,
timeout: Duration,
@@ -69,11 +58,8 @@ impl CliCommand {
Self::ListGames => "list-games",
Self::SetGameDir { .. } => "set-game-dir",
Self::Download { .. } => "download",
Self::StreamInstall { .. } => "stream-install",
Self::CancelDownload { .. } => "cancel-download",
Self::Install { .. } => "install",
Self::Uninstall { .. } => "uninstall",
Self::Play { .. } => "play",
Self::WaitPeers { .. } => "wait-peers",
Self::Connect { .. } => "connect",
Self::Shutdown => "shutdown",
@@ -109,26 +95,12 @@ pub fn parse_command_value(value: &Value) -> eyre::Result<CommandEnvelope> {
game_id: game_id(object)?,
install_after_download: install_after_download(object)?,
},
"stream-install" => CliCommand::StreamInstall {
game_id: game_id(object)?,
},
"cancel-download" => CliCommand::CancelDownload {
game_id: game_id(object)?,
},
"install" => CliCommand::Install {
game_id: game_id(object)?,
},
"uninstall" => CliCommand::Uninstall {
game_id: game_id(object)?,
},
"play" => CliCommand::Play {
game_id: game_id(object)?,
username: required_str(object, "username")?,
language: object
.get("language")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
},
"wait-peers" => CliCommand::WaitPeers {
count: required_u64(object, "count")?
.try_into()
@@ -358,32 +330,6 @@ mod tests {
assert_eq!(parsed["data"]["peer_count"], 0);
}
#[test]
fn parses_stream_install_command() {
let parsed = parse_command_line(r#"{"cmd":"stream-install","game_id":"cnctw"}"#)
.expect("command should parse");
assert_eq!(
parsed.command,
CliCommand::StreamInstall {
game_id: "cnctw".to_string(),
}
);
}
#[test]
fn parses_cancel_download_command() {
let parsed = parse_command_line(r#"{"cmd":"cancel-download","game_id":"cnctw"}"#)
.expect("command should parse");
assert_eq!(
parsed.command,
CliCommand::CancelDownload {
game_id: "cnctw".to_string(),
}
);
}
#[tokio::test]
async fn fixture_unpacker_creates_install_payload() {
let temp = TempDir::new("lanspread-peer-cli-fixture");
+16 -98
View File
@@ -12,14 +12,11 @@ use std::{
use eyre::Context;
use lanspread_compat::eti::get_games;
use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
use lanspread_db::db::{Game, GameFileDescription};
use lanspread_peer::{
ActiveOperation,
ActiveOperationKind,
ExternalUnrarStreamProvider,
InstallOperation,
NoopStreamInstallProvider,
OutboundTransfers,
PeerCommand,
PeerEvent,
PeerGameDB,
@@ -27,14 +24,11 @@ use lanspread_peer::{
PeerRuntimeHandle,
PeerSnapshot,
PeerStartOptions,
StreamInstallProvider,
migrate_legacy_state,
start_peer_with_options,
};
use lanspread_peer_cli::{
CliCommand,
CommandEnvelope,
DEFAULT_FIXTURE_VERSION,
ExternalUnrarUnpacker,
FixtureSeed,
FixtureUnpacker,
@@ -119,11 +113,8 @@ struct DownloadMeasurement {
struct SharedState {
state: RwLock<CliState>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
catalog: Arc<RwLock<GameCatalog>>,
active_outbound_transfers: OutboundTransfers,
catalog: Arc<RwLock<HashSet<String>>>,
notify: Notify,
games_dir: PathBuf,
state_dir: PathBuf,
}
#[tokio::main]
@@ -134,21 +125,14 @@ 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 active_outbound_transfers: OutboundTransfers = Arc::new(RwLock::new(HashMap::new()));
let unrar_for_streaming = args.unrar.clone().or_else(default_unrar_program);
let unpacker: Arc<dyn lanspread_peer::Unpacker> = match args.unrar.clone() {
let unpacker: Arc<dyn lanspread_peer::Unpacker> = match args.unrar {
Some(path) => Arc::new(ExternalUnrarUnpacker::new(path)),
None => Arc::new(FixtureUnpacker),
};
let stream_install_provider: Arc<dyn StreamInstallProvider> = match unrar_for_streaming {
Some(path) => Arc::new(ExternalUnrarStreamProvider::new(path)),
None => Arc::new(NoopStreamInstallProvider),
};
let mut handle = start_peer_with_options(
args.games_dir.clone(),
@@ -158,8 +142,6 @@ async fn main() -> eyre::Result<()> {
catalog.clone(),
PeerStartOptions {
state_dir: Some(args.state_dir.clone()),
active_outbound_transfers: Some(active_outbound_transfers.clone()),
stream_install_provider: Some(stream_install_provider),
},
)?;
let sender = handle.sender();
@@ -168,10 +150,7 @@ async fn main() -> eyre::Result<()> {
state: RwLock::new(CliState::default()),
peer_game_db,
catalog: catalog.clone(),
active_outbound_transfers,
notify: Notify::new(),
games_dir: args.games_dir.clone(),
state_dir: args.state_dir.clone(),
});
let writer = JsonlWriter::new();
@@ -183,7 +162,6 @@ async fn main() -> eyre::Result<()> {
"name": args.name,
"games_dir": args.games_dir,
"state_dir": args.state_dir,
"migration": migration,
"fixtures": fixture_seeds,
}),
));
@@ -262,21 +240,6 @@ 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?;
@@ -285,11 +248,6 @@ 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?;
@@ -317,20 +275,12 @@ async fn handle_command(
async fn status(shared: &SharedState) -> eyre::Result<Value> {
let state = shared.state.read().await;
let peer_count = shared.peer_game_db.read().await.peer_snapshots().len();
let active_outbound_transfers = {
let active = shared.active_outbound_transfers.read().await;
active
.iter()
.map(|(game_id, transfers)| (game_id.clone(), transfers.len()))
.collect::<HashMap<_, _>>()
};
Ok(json!({
"local_peer": state.local_peer.clone(),
"peer_count": peer_count,
"local_games": state.local_games.len(),
"remote_games": state.remote_games.len(),
"active_operations": active_operations_json(&state.active_operations),
"active_outbound_transfers": active_outbound_transfers,
}))
}
@@ -341,8 +291,15 @@ async fn list_peers(shared: &SharedState) -> eyre::Result<Value> {
async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
let state = shared.state.read().await;
let catalog = shared.catalog.read().await;
let remote = shared.peer_game_db.read().await.get_catalog_games(&catalog);
let catalog = shared.catalog.read().await.clone();
let remote = shared
.peer_game_db
.read()
.await
.get_all_games()
.into_iter()
.filter(|game| catalog.contains(&game.id))
.collect::<Vec<_>>();
Ok(json!({
"local": state.local_games.clone(),
"remote": remote,
@@ -350,25 +307,6 @@ async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
}))
}
async fn play(
shared: &SharedState,
game_id: &str,
username: &str,
language: Option<&str>,
) -> eyre::Result<Value> {
ensure_catalog_game(shared, game_id).await?;
let game_root = shared.games_dir.join(game_id);
let outcome = lanspread_peer::apply_launch_settings_once(
&shared.state_dir,
&game_root,
game_id,
Some(username),
language,
)
.await?;
Ok(json!({ "game_id": game_id, "outcome": outcome }))
}
async fn ensure_catalog_game(shared: &SharedState, game_id: &str) -> eyre::Result<()> {
if shared.catalog.read().await.contains(game_id) {
return Ok(());
@@ -465,7 +403,6 @@ async fn event_loop(
}
}
#[allow(clippy::too_many_lines)]
async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'static str, Value) {
match event {
PeerEvent::LocalPeerReady { peer_id, addr } => {
@@ -490,7 +427,6 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
state.local_games.clone_from(&games);
("local-library-changed", json!({ "games": games }))
}
PeerEvent::OutboundTransferCountChanged => ("outbound-transfer-count-changed", json!({})),
PeerEvent::ActiveOperationsChanged { active_operations } => {
let mut state = shared.state.write().await;
state.active_operations.clone_from(&active_operations);
@@ -701,27 +637,18 @@ fn seed_fixtures(game_dir: &Path, fixtures: &[String]) -> eyre::Result<Vec<Fixtu
.collect()
}
async fn load_catalog(catalog_db: Option<&Path>, fixtures: &[FixtureSeed]) -> GameCatalog {
let mut catalog = GameCatalog::empty();
async fn load_catalog(catalog_db: Option<&Path>, fixtures: &[FixtureSeed]) -> HashSet<String> {
let mut catalog = HashSet::new();
if let Some(path) = catalog_db
&& path.exists()
{
match get_games(path).await {
Ok(games) => {
for game in games {
catalog.insert(game.game_id, Some(game.game_version));
}
}
Ok(games) => catalog.extend(games.into_iter().map(|game| game.game_id)),
Err(err) => eprintln!("failed to load catalog db {}: {err}", path.display()),
}
}
for seed in fixtures {
catalog.insert(
seed.game_id.clone(),
Some(DEFAULT_FIXTURE_VERSION.to_string()),
);
}
catalog.extend(fixtures.iter().map(|seed| seed.game_id.clone()));
catalog
}
@@ -765,15 +692,6 @@ fn default_catalog_db() -> Option<PathBuf> {
.find(|path| path.exists())
}
fn default_unrar_program() -> Option<PathBuf> {
[
PathBuf::from("/usr/local/bin/unrar"),
PathBuf::from("/usr/bin/unrar"),
]
.into_iter()
.find(|path| path.exists())
}
fn next_string(args: &mut impl Iterator<Item = OsString>, flag: &str) -> eyre::Result<String> {
args.next()
.ok_or_else(|| eyre::eyre!("{flag} requires a value"))?
+8 -30
View File
@@ -116,9 +116,6 @@ Downloaded and installed are independent predicates:
- `installed` is true when `<game_root>/local/` is a directory. The contents of
`local/` are user-owned and are skipped by manifests, fingerprints, and file
serving.
- Install and update transactions unpack into staging, then overwrite the first
discovered game-provided `account_name.txt` and `language.txt` files under
the staged tree from launcher settings before promoting it to `local/`.
Reserved per-game paths:
@@ -127,8 +124,7 @@ Reserved per-game paths:
- `.local.installing/` is extraction staging.
- `.local.backup/` holds the previous install while an update or uninstall is in
flight.
- `games/<game_id>/install_intent.json` in the configured state directory is the
atomic per-game intent log.
- `.lanspread.json` is the atomic per-game intent log.
- `.lanspread_owned` inside `.local.*` directories proves Lanspread ownership
when the current intent is `None`.
@@ -137,17 +133,11 @@ 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 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.
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.
### Result
@@ -166,18 +156,6 @@ Most scans become O(number of game dirs), with full recursion only when needed.
scratch sentinel files. `local/` and install transaction metadata are
preserved, so a cancelled update of an installed game settles as local-only.
### Streamed install integrity
- Low-disk streamed installs request archive-derived file bytes from one peer
and write them directly into the install transaction staging directory.
- The receiver verifies every streamed file against the sender archive's file
size and RAR CRC32 before the transaction may commit. This catches truncated
streams, transport corruption, and provider bugs.
- This is not malicious-peer protection: the peer controls both the archive
metadata and the streamed bytes. A trusted-content model needs catalog-owned
hashes, either for the root archives or for extracted files, and receiver-side
SHA-256 verification against those catalog values before commit.
## Fault tolerance rules
- Every peer is keyed by `peer_id`, not by IP address.
@@ -217,8 +195,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:
- Use the cached `local_library/index.json` file in the configured state
directory to store per-root fingerprints and computed manifests.
- Introduce a cached index file (e.g., `.lanspread/index.json`) that stores
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.
+6 -7
View File
@@ -3,8 +3,10 @@ name = "lanspread-peer"
version = "0.1.0"
edition = "2024"
[lib]
doctest = false
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[dependencies]
lanspread-db = { path = "../lanspread-db" }
@@ -14,7 +16,6 @@ lanspread-utils = { path = "../lanspread-utils" }
# external
bytes = { workspace = true }
crc32fast = { workspace = true }
eyre = { workspace = true }
futures = { workspace = true }
gethostname = { workspace = true }
@@ -30,7 +31,5 @@ tokio-util = { workspace = true }
uuid = { workspace = true }
walkdir = { workspace = true }
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lib]
doctest = false
+9 -34
View File
@@ -14,8 +14,8 @@ It is designed to run headless other crates (most notably
roots are announced or served.
- `PeerCommand` represents the small control surface exposed to the UI layer:
`ListGames`, `GetGame`, `FetchLatestFromPeers`, `DownloadGameFiles`,
`StreamInstallGame`, `InstallGame`, `UninstallGame`, `RemoveDownloadedGame`,
`CancelDownload`, `SetGameDir`, and `GetPeerCount`.
`InstallGame`, `UninstallGame`, `RemoveDownloadedGame`, `CancelDownload`,
`SetGameDir`, and `GetPeerCount`.
- `PeerEvent` enumerates everything the peer runtime reports back to the UI:
library snapshots, download/install/uninstall lifecycle updates, runtime
failures, and peer membership changes.
@@ -28,8 +28,8 @@ lifetime of the process:
1. **Server component** (`run_server_component`) listens for QUIC connections,
advertises via mDNS, and serves `Request::ListGames`, `Request::GetGame`,
`Request::GetGameFileData`, `Request::GetGameFileChunk`, and
`Request::StreamInstall` by reading from the local game directory.
`Request::GetGameFileData`, and `Request::GetGameFileChunk` by reading from
the local game directory.
2. **Discovery loop** (`run_peer_discovery`) uses the `lanspread-mdns`
helper to discover other peers. The blocking mDNS work is executed on a
dedicated thread via `tokio::task::spawn_blocking` so that the Tokio runtime
@@ -87,26 +87,6 @@ When the UI asks to download a game:
7. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished`
is emitted and the peer auto-runs the install transaction.
### Streamed Install Pipeline
Low-disk installs use `PeerCommand::StreamInstallGame` instead of the normal
archive download pipeline. The peer core owns the whole operation: it refreshes
file metadata from catalog-version peers, runs the same majority file-size
validation used by normal downloads, selects a validated peer list, and emits
the regular download/install lifecycle events while streaming archive-expanded
bytes directly into a `StreamedInstallTransaction`.
The sender-side `StreamInstallProvider` writes control and chunk frames through
a cancellable `StreamInstallFrameSink`. If the QUIC writer fails because the
receiver cancelled or disconnected, the sink wakes any producer blocked on the
bounded frame channel and lets the transfer guard drop normally.
Each failed peer attempt rolls back its staging directory before trying the next
validated peer. A transaction that created a previously missing game root
removes that root again when rollback leaves it empty. Once staging has been
renamed to `local/`, post-promote intent or launch-settings cleanup failures are
logged for startup recovery rather than reported as a failed install.
`PeerCommand::CancelDownload` cancels the tracked download token for an active
transfer. The transfer task remains responsible for clearing `active_operations`,
discarding partial payload files, and refreshing the settled local snapshot, so
@@ -117,21 +97,16 @@ truth for whether a download is still running.
Install, update, uninstall, downloaded-file removal, and startup recovery live
under `src/install/`.
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.
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.
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
+11 -29
View File
@@ -1,22 +1,17 @@
//! Shared context types for the peer system.
use std::{collections::HashMap, net::SocketAddr, path::PathBuf, sync::Arc};
use std::{
collections::{HashMap, HashSet},
net::SocketAddr,
path::PathBuf,
sync::Arc,
};
use lanspread_db::db::{GameCatalog, GameDB};
use lanspread_db::db::GameDB;
use tokio::sync::{RwLock, mpsc::UnboundedSender};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use crate::{
PeerEvent,
StreamInstallProvider,
Unpacker,
events,
library::LocalLibraryState,
peer_db::PeerGameDB,
};
/// Thread-safe map of active outbound file transfers grouped by game ID.
pub type OutboundTransfers = Arc<RwLock<HashMap<String, Vec<(u64, CancellationToken)>>>>;
use crate::{PeerEvent, Unpacker, events, library::LocalLibraryState, peer_db::PeerGameDB};
/// Mutating filesystem operation currently in flight for a game root.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -37,7 +32,6 @@ 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>>,
@@ -45,12 +39,10 @@ pub struct Ctx {
pub active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
pub active_downloads: Arc<RwLock<HashMap<String, CancellationToken>>>,
pub unpacker: Arc<dyn Unpacker>,
pub stream_install_provider: Arc<dyn StreamInstallProvider>,
pub catalog: Arc<RwLock<GameCatalog>>,
pub catalog: Arc<RwLock<HashSet<String>>>,
pub peer_id: Arc<String>,
pub shutdown: CancellationToken,
pub task_tracker: TaskTracker,
pub active_outbound_transfers: OutboundTransfers,
}
/// Context for peer connection handling.
@@ -62,13 +54,11 @@ pub struct PeerCtx {
pub local_peer_addr: Arc<RwLock<Option<SocketAddr>>>,
pub active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
pub peer_game_db: Arc<RwLock<PeerGameDB>>,
pub catalog: Arc<RwLock<GameCatalog>>,
pub catalog: Arc<RwLock<HashSet<String>>>,
pub peer_id: Arc<String>,
pub tx_notify_ui: tokio::sync::mpsc::UnboundedSender<PeerEvent>,
pub stream_install_provider: Arc<dyn StreamInstallProvider>,
pub shutdown: CancellationToken,
pub task_tracker: TaskTracker,
pub active_outbound_transfers: OutboundTransfers,
}
impl std::fmt::Debug for PeerCtx {
@@ -89,17 +79,13 @@ 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<GameCatalog>>,
active_outbound_transfers: OutboundTransfers,
stream_install_provider: Arc<dyn StreamInstallProvider>,
catalog: Arc<RwLock<HashSet<String>>>,
) -> Self {
Self {
game_dir: Arc::new(RwLock::new(game_dir)),
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,
@@ -107,12 +93,10 @@ impl Ctx {
active_operations: Arc::new(RwLock::new(HashMap::new())),
active_downloads: Arc::new(RwLock::new(HashMap::new())),
unpacker,
stream_install_provider,
catalog,
peer_id: Arc::new(peer_id),
shutdown,
task_tracker,
active_outbound_transfers,
}
}
@@ -131,10 +115,8 @@ impl Ctx {
catalog: self.catalog.clone(),
peer_id: self.peer_id.clone(),
tx_notify_ui,
stream_install_provider: self.stream_install_provider.clone(),
shutdown: self.shutdown.clone(),
task_tracker: self.task_tracker.clone(),
active_outbound_transfers: self.active_outbound_transfers.clone(),
}
}
}
@@ -43,14 +43,25 @@ 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)?;
let (version_desc, transfer_descs) =
extract_version_descriptor(game_id, game_file_descs, &tx_notify_ui)?;
let version_buffer = match VersionIniBuffer::new(&version_desc) {
Ok(buffer) => Arc::new(buffer),
Err(err) => return Err(err),
Err(err) => {
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
id: game_id.to_string(),
})?;
return Err(err);
}
};
let game_root = games_folder.join(game_id);
begin_version_ini_transaction(&game_root).await?;
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);
}
if cancel_token.is_cancelled() {
rollback_version_ini_transaction(&game_root).await;
discard_cancelled_download_best_effort(&games_folder, game_id).await;
@@ -62,6 +73,9 @@ pub async fn download_game_files(
discard_cancelled_download_best_effort(&games_folder, game_id).await;
eyre::bail!("download cancelled for game {game_id}");
}
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
id: game_id.to_string(),
})?;
return Err(err);
}
if cancel_token.is_cancelled() {
@@ -97,6 +111,10 @@ pub async fn download_game_files(
rollback_version_ini_transaction(&game_root).await;
if cancel_token.is_cancelled() {
discard_cancelled_download_best_effort(&games_folder, game_id).await;
} else {
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
id: game_id.to_string(),
})?;
}
return Err(err);
}
@@ -109,9 +127,15 @@ pub async fn download_game_files(
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(())
}
+12 -4
View File
@@ -1,8 +1,9 @@
use std::{collections::HashMap, net::SocketAddr};
use lanspread_db::db::GameFileDescription;
use tokio::sync::mpsc::UnboundedSender;
use crate::config::CHUNK_SIZE;
use crate::{PeerEvent, config::CHUNK_SIZE};
/// Represents a chunk of a file to be downloaded.
#[derive(Debug, Clone)]
@@ -33,6 +34,7 @@ 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();
@@ -45,6 +47,9 @@ 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()
@@ -291,6 +296,7 @@ mod tests {
#[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(),
@@ -307,24 +313,26 @@ mod tests {
];
let (version, transfer) =
extract_version_descriptor("game", nested_decoy).expect("only one root sentinel");
extract_version_descriptor("game", nested_decoy, &tx).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).is_err());
assert!(extract_version_descriptor("game", missing, &tx).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(),
@@ -339,6 +347,6 @@ mod tests {
size: 8,
},
];
assert!(extract_version_descriptor("game", multiple).is_err());
assert!(extract_version_descriptor("game", multiple, &tx).is_err());
}
}
@@ -5,6 +5,8 @@ use tokio::fs::OpenOptions;
use crate::{local_games::is_local_dir_name, path_validation::validate_game_file_path};
const INTENT_LOG_FILE: &str = ".lanspread.json";
const SOFTLAN_INSTALL_MARKER: &str = ".softlan_game_installed";
const SYNC_DIR: &str = ".sync";
/// Prepares storage for game files by creating directories and pre-allocating files.
@@ -97,7 +99,11 @@ pub(super) async fn discard_cancelled_download(
}
fn should_preserve_on_download_discard(name: &str) -> bool {
is_local_dir_name(name) || name.starts_with(".local.") || name == SYNC_DIR
is_local_dir_name(name)
|| name.starts_with(".local.")
|| name == INTENT_LOG_FILE
|| name == SOFTLAN_INSTALL_MARKER
|| name == SYNC_DIR
}
async fn remove_entry(path: &Path) -> eyre::Result<()> {
@@ -201,6 +207,7 @@ mod tests {
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(".lanspread.json"), b"{\"intent\":\"None\"}");
write_file(&root.join(".local.backup").join(".lanspread_owned"), b"");
discard_cancelled_download(temp.path(), "game")
@@ -214,6 +221,7 @@ mod tests {
.expect("local install should remain"),
b"user-data"
);
assert!(root.join(".lanspread.json").is_file());
assert!(root.join(".local.backup").is_dir());
}
}
+1 -6
View File
@@ -2,7 +2,6 @@
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
use lanspread_db::db::GameCatalog;
use tokio::sync::{RwLock, mpsc::UnboundedSender};
use crate::{
@@ -66,13 +65,9 @@ fn active_operation_kind(operation: OperationKind) -> ActiveOperationKind {
pub async fn emit_peer_game_list(
peer_game_db: &Arc<RwLock<PeerGameDB>>,
catalog: &Arc<RwLock<GameCatalog>>,
tx_notify_ui: &UnboundedSender<PeerEvent>,
) {
let games = {
let catalog = catalog.read().await;
peer_game_db.read().await.get_catalog_games(&catalog)
};
let games = { peer_game_db.read().await.get_all_games() };
send(tx_notify_ui, PeerEvent::ListGames(games));
}
+84 -700
View File
@@ -6,12 +6,10 @@ use std::{
net::SocketAddr,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use lanspread_db::db::{GameDB, GameFileDescription};
use tokio::sync::{RwLock, mpsc::UnboundedSender};
use tokio_util::sync::CancellationToken;
use crate::{
InstallOperation,
@@ -25,7 +23,7 @@ use crate::{
game_from_summary,
get_game_file_descriptions,
local_dir_is_directory,
local_download_matches_catalog,
local_download_available,
rescan_local_game,
scan_local_library,
version_ini_is_regular_file,
@@ -34,20 +32,16 @@ use crate::{
peer_db::PeerGameDB,
remote_peer::ensure_peer_id_for_addr,
services::{HandshakeCtx, perform_handshake_with_peer},
stream_install::receive_streamed_install,
};
// =============================================================================
// Command handlers
// =============================================================================
const OUTBOUND_TRANSFER_DRAIN_POLL_INTERVAL: Duration = Duration::from_millis(10);
const OUTBOUND_TRANSFER_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
/// Handles the `ListGames` command.
pub async fn handle_list_games_command(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
log::info!("ListGames command received");
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, tx_notify_ui).await;
events::emit_peer_game_list(&ctx.peer_game_db, tx_notify_ui).await;
}
/// Tries to serve a game from local files.
@@ -60,7 +54,7 @@ async fn try_serve_local_game(
let active_operations = ctx.active_operations.read().await;
let catalog = ctx.catalog.read().await;
if !local_download_matches_catalog(&game_dir, id, &active_operations, &catalog).await {
if !local_download_available(&game_dir, id, &active_operations, &catalog).await {
return false;
}
drop(active_operations);
@@ -96,10 +90,9 @@ pub(crate) async fn handle_get_game_command(
}
log::info!("Requesting game from peers: {id}");
let expected_version = catalog_expected_version(ctx, &id).await;
let peers = {
let peer_game_db = ctx.peer_game_db.read().await;
source.select_peers(&peer_game_db, &id, expected_version.as_deref())
source.select_peers(&peer_game_db, &id)
};
if peers.is_empty() {
log::warn!("No peers have game {id}");
@@ -114,7 +107,6 @@ pub(crate) async fn handle_get_game_command(
ctx.task_tracker.spawn(fetch_game_details_from_peers(
peers,
id,
expected_version,
peer_game_db,
tx_notify_ui,
|peer_addr, game_id, peer_game_db| async move {
@@ -134,16 +126,10 @@ impl GameDetailSource {
matches!(self, Self::LocalOrPeers)
}
fn select_peers(
self,
peer_game_db: &PeerGameDB,
id: &str,
expected_version: Option<&str>,
) -> Vec<SocketAddr> {
fn select_peers(self, peer_game_db: &PeerGameDB, id: &str) -> Vec<SocketAddr> {
match self {
Self::LocalOrPeers | Self::LatestPeersOnly => {
peer_game_db.peers_with_expected_version(id, expected_version)
}
Self::LocalOrPeers => peer_game_db.peers_with_game(id),
Self::LatestPeersOnly => peer_game_db.peers_with_latest_version(id),
}
}
}
@@ -168,7 +154,6 @@ async fn request_game_details_and_update(
async fn fetch_game_details_from_peers<F, Fut>(
peers: Vec<SocketAddr>,
id: String,
expected_version: Option<String>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
tx_notify_ui: UnboundedSender<PeerEvent>,
mut fetch_details: F,
@@ -190,12 +175,7 @@ async fn fetch_game_details_from_peers<F, Fut>(
}
if fetched_any {
let aggregated_files = {
peer_game_db
.read()
.await
.aggregated_game_files(&id, expected_version.as_deref())
};
let aggregated_files = { peer_game_db.read().await.aggregated_game_files(&id) };
if let Err(e) = tx_notify_ui.send(PeerEvent::GotGameFiles {
id: id.clone(),
@@ -230,7 +210,6 @@ pub async fn handle_download_game_files_command(
}
let games_folder = { ctx.game_dir.read().await.clone() };
let expected_version = catalog_expected_version(ctx, &id).await;
// Use majority validation to get trusted file descriptions and peer whitelist
let (validated_descriptions, peer_whitelist, file_peer_map) = {
@@ -238,7 +217,7 @@ pub async fn handle_download_game_files_command(
.peer_game_db
.read()
.await
.validate_file_sizes_majority(&id, expected_version.as_deref())
.validate_file_sizes_majority(&id)
{
Ok((files, peers, file_peer_map)) => {
log::info!(
@@ -281,7 +260,7 @@ pub async fn handle_download_game_files_command(
let local_dl_available = {
let active_operations = ctx.active_operations.read().await;
let catalog = ctx.catalog.read().await;
local_download_matches_catalog(&games_folder, &id, &active_operations, &catalog).await
local_download_available(&games_folder, &id, &active_operations, &catalog).await
};
if peer_whitelist.is_empty() {
@@ -310,17 +289,9 @@ pub async fn handle_download_game_files_command(
return;
}
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
BeginOperationResult::Started => {}
BeginOperationResult::AlreadyActive => {
log::warn!("Operation for {id} already in progress; ignoring new download request");
return;
}
BeginOperationResult::DrainTimedOut => {
log::error!("Timed out waiting for outbound transfers before downloading {id}");
send_download_failed(tx_notify_ui, &id);
return;
}
if !begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
log::warn!("Operation for {id} already in progress; ignoring new download request");
return;
}
let active_operations = ctx.active_operations.clone();
@@ -350,7 +321,7 @@ pub async fn handle_download_game_files_command(
peer_whitelist,
file_peer_map,
tx_notify_ui_clone.clone(),
cancel_token.clone(),
cancel_token,
)
.await;
@@ -370,7 +341,6 @@ pub async fn handle_download_game_files_command(
}
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;
};
@@ -384,8 +354,6 @@ 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,
@@ -394,9 +362,7 @@ pub async fn handle_download_game_files_command(
)
.await;
} else {
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);
clear_active_download(&ctx_clone, &download_id).await;
}
} else {
if let Err(err) = refresh_local_game_for_ending_operation(
@@ -409,9 +375,8 @@ pub async fn handle_download_game_files_command(
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(
@@ -427,17 +392,7 @@ pub async fn handle_download_game_files_command(
}
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
download_state_guard.disarm();
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,
);
log::error!("Download failed for {download_id}: {e}");
}
}
});
@@ -452,60 +407,6 @@ 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,
@@ -546,284 +447,6 @@ 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();
@@ -837,20 +460,9 @@ async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEve
return;
};
match begin_operation(ctx, tx_notify_ui, &id, prepared.operation_kind).await {
BeginOperationResult::Started => {}
BeginOperationResult::AlreadyActive => {
log::warn!("Operation for {id} already in progress; ignoring install command");
return;
}
BeginOperationResult::DrainTimedOut => {
log::error!("Timed out waiting for outbound transfers before install/update of {id}");
events::send(
tx_notify_ui,
PeerEvent::InstallGameFailed { id: id.clone() },
);
return;
}
if !begin_operation(ctx, tx_notify_ui, &id, prepared.operation_kind).await {
log::warn!("Operation for {id} already in progress; ignoring install command");
return;
}
run_started_install_operation(ctx, tx_notify_ui, id, prepared).await;
@@ -926,13 +538,12 @@ async fn run_started_install_operation(
},
);
let state_dir = ctx.state_dir.as_ref();
match operation {
InstallOperation::Installing => {
install::install(&game_root, state_dir, &id, ctx.unpacker.clone()).await
install::install(&game_root, &id, ctx.unpacker.clone()).await
}
InstallOperation::Updating => {
install::update(&game_root, state_dir, &id, ctx.unpacker.clone()).await
install::update(&game_root, &id, ctx.unpacker.clone()).await
}
}
};
@@ -973,20 +584,9 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
return;
}
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Uninstalling).await {
BeginOperationResult::Started => {}
BeginOperationResult::AlreadyActive => {
log::warn!("Operation for {id} already in progress; ignoring uninstall command");
return;
}
BeginOperationResult::DrainTimedOut => {
log::error!("Timed out waiting for outbound transfers before uninstall of {id}");
events::send(
tx_notify_ui,
PeerEvent::UninstallGameFailed { id: id.clone() },
);
return;
}
if !begin_operation(ctx, tx_notify_ui, &id, OperationKind::Uninstalling).await {
log::warn!("Operation for {id} already in progress; ignoring uninstall command");
return;
}
let game_root = { ctx.game_dir.read().await.join(&id) };
@@ -1001,7 +601,7 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
PeerEvent::UninstallGameBegin { id: id.clone() },
);
install::uninstall(&game_root, ctx.state_dir.as_ref(), &id).await
install::uninstall(&game_root, &id).await
};
match result {
@@ -1046,20 +646,9 @@ async fn run_remove_downloaded_operation(
return;
}
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::RemovingDownload).await {
BeginOperationResult::Started => {}
BeginOperationResult::AlreadyActive => {
log::warn!("Operation for {id} already in progress; ignoring downloaded-file removal");
return;
}
BeginOperationResult::DrainTimedOut => {
log::error!("Timed out waiting for outbound transfers before removal of {id}");
events::send(
tx_notify_ui,
PeerEvent::RemoveDownloadedGameFailed { id: id.clone() },
);
return;
}
if !begin_operation(ctx, tx_notify_ui, &id, OperationKind::RemovingDownload).await {
log::warn!("Operation for {id} already in progress; ignoring downloaded-file removal");
return;
}
let game_dir = { ctx.game_dir.read().await.clone() };
@@ -1109,36 +698,12 @@ async fn run_remove_downloaded_operation(
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BeginOperationResult {
Started,
AlreadyActive,
DrainTimedOut,
}
async fn begin_operation(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
operation: OperationKind,
) -> BeginOperationResult {
begin_operation_with_drain_timeout(
ctx,
tx_notify_ui,
id,
operation,
OUTBOUND_TRANSFER_DRAIN_TIMEOUT,
)
.await
}
async fn begin_operation_with_drain_timeout(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
operation: OperationKind,
drain_timeout: Duration,
) -> BeginOperationResult {
) -> bool {
let started = {
let mut active_operations = ctx.active_operations.write().await;
match active_operations.entry(id.to_string()) {
@@ -1150,70 +715,11 @@ async fn begin_operation_with_drain_timeout(
}
};
if !started {
return BeginOperationResult::AlreadyActive;
if started {
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
}
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
if operation_requires_outbound_drain(operation)
&& !cancel_and_wait_for_outbound_transfers(ctx, id, drain_timeout).await
{
end_operation(ctx, tx_notify_ui, id).await;
return BeginOperationResult::DrainTimedOut;
}
BeginOperationResult::Started
}
fn operation_requires_outbound_drain(operation: OperationKind) -> bool {
operation == OperationKind::Updating || operation == OperationKind::RemovingDownload
}
async fn cancel_and_wait_for_outbound_transfers(
ctx: &Ctx,
id: &str,
drain_timeout: Duration,
) -> bool {
let mut tokens_to_cancel = Vec::new();
{
let active = ctx.active_outbound_transfers.read().await;
if let Some(transfers) = active.get(id) {
for (_, token) in transfers {
tokens_to_cancel.push(token.clone());
}
}
}
for token in tokens_to_cancel {
token.cancel();
}
let drained = tokio::time::timeout(drain_timeout, async {
loop {
let count = {
let active = ctx.active_outbound_transfers.read().await;
active.get(id).map_or(0, Vec::len)
};
if count == 0 {
break;
}
tokio::time::sleep(OUTBOUND_TRANSFER_DRAIN_POLL_INTERVAL).await;
}
})
.await
.is_ok();
if !drained {
let count = {
let active = ctx.active_outbound_transfers.read().await;
active.get(id).map_or(0, Vec::len)
};
log::error!(
"Timed out after {drain_timeout:?} waiting for {count} outbound transfer(s) to drain for {id}"
);
}
drained
started
}
async fn transition_download_to_install(
@@ -1261,31 +767,6 @@ 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;
@@ -1295,14 +776,6 @@ async fn catalog_contains(ctx: &Ctx, id: &str) -> bool {
ctx.catalog.read().await.contains(id)
}
async fn catalog_expected_version(ctx: &Ctx, id: &str) -> Option<String> {
ctx.catalog
.read()
.await
.expected_version(id)
.map(ToOwned::to_owned)
}
/// Handles the `SetGameDir` command.
pub async fn handle_set_game_dir_command(
ctx: &Ctx,
@@ -1372,7 +845,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, ctx.state_dir.as_ref(), &active_ids).await?;
install::recover_on_startup(&game_dir, &active_ids).await?;
scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir, event_policy).await
}
@@ -1397,7 +870,7 @@ 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, ctx.state_dir.as_ref(), &catalog).await?;
let scan = scan_local_library(game_dir, &catalog).await?;
update_and_announce_games_with_policy(ctx, tx_notify_ui, scan, event_policy, None).await;
Ok(())
}
@@ -1411,7 +884,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, ctx.state_dir.as_ref(), &catalog, id).await?;
let scan = rescan_local_game(&game_dir, &catalog, id).await?;
update_and_announce_games_with_policy(
ctx,
tx_notify_ui,
@@ -1493,8 +966,13 @@ async fn update_and_announce_games_with_policy(
active_operation_ids.remove(id);
}
if !active_operation_ids.is_empty() {
let previous = ctx.local_library.read().await.games.clone();
for id in &active_operation_ids {
summaries.remove(id);
if let Some(summary) = previous.get(id.as_str()) {
summaries.insert(id.clone(), summary.clone());
} else {
summaries.remove(id);
}
}
game_db = GameDB::from(summaries.values().map(game_from_summary).collect());
}
@@ -1548,14 +1026,13 @@ async fn update_and_announce_games_with_policy(
#[cfg(test)]
mod tests {
use std::{
collections::HashMap,
collections::HashSet,
net::SocketAddr,
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::Duration,
};
use lanspread_db::db::GameCatalog;
use lanspread_proto::{Availability, GameSummary};
use tokio::sync::mpsc;
use tokio_util::{sync::CancellationToken, task::TaskTracker};
@@ -1591,40 +1068,14 @@ mod tests {
Ctx::new(
Arc::new(RwLock::new(PeerGameDB::new())),
"peer".to_string(),
game_dir.clone(),
game_dir.join(".test-state"),
game_dir,
Arc::new(FakeUnpacker),
CancellationToken::new(),
TaskTracker::new(),
Arc::new(RwLock::new(GameCatalog::from_ids(["game".to_string()]))),
Arc::new(RwLock::new(HashMap::new())),
Arc::new(crate::NoopStreamInstallProvider),
Arc::new(RwLock::new(HashSet::from(["game".to_string()]))),
)
}
#[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
@@ -1703,7 +1154,7 @@ mod tests {
}
#[test]
fn update_source_selects_expected_ready_peer_manifest() {
fn update_source_selects_latest_ready_peer_manifest() {
let old_addr = addr(12_000);
let new_addr = addr(12_001);
let local_only_addr = addr(12_002);
@@ -1725,13 +1176,13 @@ mod tests {
);
assert_eq!(
GameDetailSource::LatestPeersOnly.select_peers(&db, "game", Some("20250101")),
GameDetailSource::LatestPeersOnly.select_peers(&db, "game"),
vec![new_addr]
);
}
#[tokio::test]
async fn update_fetch_emits_fresh_manifest_from_expected_peer() {
async fn update_fetch_emits_fresh_manifest_from_latest_peer() {
let old_addr = addr(12_010);
let new_addr = addr(12_011);
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
@@ -1750,40 +1201,33 @@ mod tests {
}
let peers = {
let db = peer_game_db.read().await;
GameDetailSource::LatestPeersOnly.select_peers(&db, "game", Some("20250101"))
GameDetailSource::LatestPeersOnly.select_peers(&db, "game")
};
let (tx, mut rx) = mpsc::unbounded_channel();
let fetched_peers = Arc::new(Mutex::new(Vec::new()));
fetch_game_details_from_peers(
peers,
"game".to_string(),
Some("20250101".to_string()),
peer_game_db.clone(),
tx,
{
fetch_game_details_from_peers(peers, "game".to_string(), peer_game_db.clone(), tx, {
let fetched_peers = fetched_peers.clone();
move |peer_addr, game_id, peer_game_db| {
let fetched_peers = fetched_peers.clone();
move |peer_addr, game_id, peer_game_db| {
let fetched_peers = fetched_peers.clone();
async move {
fetched_peers
.lock()
.expect("fetched peer list should not be poisoned")
.push(peer_addr);
let files = vec![
file_desc(&game_id, "game/version.ini", 8),
file_desc(&game_id, "game/new.eti", 11),
];
peer_game_db.write().await.update_peer_game_files(
&"new".to_string(),
&game_id,
files.clone(),
);
Ok(files)
}
async move {
fetched_peers
.lock()
.expect("fetched peer list should not be poisoned")
.push(peer_addr);
let files = vec![
file_desc(&game_id, "game/version.ini", 8),
file_desc(&game_id, "game/new.eti", 11),
];
peer_game_db.write().await.update_peer_game_files(
&"new".to_string(),
&game_id,
files.clone(),
);
Ok(files)
}
},
)
}
})
.await;
assert_eq!(
@@ -1804,7 +1248,7 @@ mod tests {
file_descriptions
.iter()
.any(|desc| desc.relative_path == "game/new.eti" && desc.size == 11),
"expected-version peer manifest should be emitted to the download path"
"latest peer manifest should be emitted to the download path"
);
}
@@ -1819,7 +1263,6 @@ mod tests {
fetch_game_details_from_peers(
vec![first_addr, second_addr],
"game".to_string(),
Some("20250101".to_string()),
peer_game_db,
tx.clone(),
{
@@ -1853,7 +1296,7 @@ mod tests {
#[tokio::test]
async fn update_request_skips_local_manifest_even_when_download_exists() {
let temp = TempDir::new("lanspread-handler-expected-peer");
let temp = TempDir::new("lanspread-handler-latest-peer");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20240101");
write_file(&root.join("game.eti"), b"old archive");
@@ -1876,37 +1319,23 @@ mod tests {
}
#[tokio::test]
async fn local_library_scan_hides_active_game_state() {
let temp = TempDir::new("lanspread-handler-active-hide");
async fn local_library_scan_freezes_active_game_state() {
let temp = TempDir::new("lanspread-handler-active-freeze");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("game.eti"), b"archive");
let ctx = test_ctx(temp.path().to_path_buf());
let (tx, mut rx) = mpsc::unbounded_channel();
let catalog = ctx.catalog.read().await.clone();
// 1. Initial scan: the game is ready and announced
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
.await
.expect("scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
panic!("expected LocalLibraryChanged");
};
assert_eq!(games.len(), 1);
assert_eq!(games[0].id, "game");
// 2. Set the game as active/in-progress and scan again
ctx.active_operations
.write()
.await
.insert("game".to_string(), OperationKind::Installing);
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
let (tx, mut rx) = mpsc::unbounded_channel();
let catalog = ctx.catalog.read().await.clone();
let scan = scan_local_library(temp.path(), &catalog)
.await
.expect("scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
@@ -1914,7 +1343,7 @@ mod tests {
};
assert!(
games.is_empty(),
"active game should be hidden/unannounced during operations"
"active game should keep its previous announced state"
);
}
@@ -1928,10 +1357,7 @@ mod tests {
let ctx = test_ctx(temp.path().to_path_buf());
let (tx, mut rx) = mpsc::unbounded_channel();
assert_eq!(
begin_operation(&ctx, &tx, "game", OperationKind::Updating).await,
BeginOperationResult::Started
);
assert!(begin_operation(&ctx, &tx, "game", OperationKind::Updating).await);
assert_active_update(
recv_event(&mut rx).await,
vec![ActiveOperation {
@@ -1941,48 +1367,6 @@ mod tests {
);
}
#[tokio::test]
async fn begin_operation_timeout_clears_active_operation_snapshot() {
let temp = TempDir::new("lanspread-handler-active-drain-timeout");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("game.eti"), b"archive");
let ctx = test_ctx(temp.path().to_path_buf());
let (tx, mut rx) = mpsc::unbounded_channel();
let token = CancellationToken::new();
ctx.active_outbound_transfers
.write()
.await
.insert("game".to_string(), vec![(1, token.clone())]);
assert_eq!(
begin_operation_with_drain_timeout(
&ctx,
&tx,
"game",
OperationKind::Updating,
Duration::from_millis(1),
)
.await,
BeginOperationResult::DrainTimedOut
);
assert!(token.is_cancelled());
assert_active_update(
recv_event(&mut rx).await,
vec![ActiveOperation {
id: "game".to_string(),
operation: ActiveOperationKind::Updating,
}],
);
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(
!ctx.active_operations.read().await.contains_key("game"),
"timed-out drain should not leave the operation stuck active"
);
}
#[tokio::test]
async fn unchanged_settled_scan_is_not_reemitted() {
let temp = TempDir::new("lanspread-handler-settled-unchanged");
@@ -1994,13 +1378,13 @@ mod tests {
let (tx, mut rx) = mpsc::unbounded_channel();
let catalog = ctx.catalog.read().await.clone();
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
let scan = scan_local_library(temp.path(), &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(), ctx.state_dir.as_ref(), &catalog)
let scan = scan_local_library(temp.path(), &catalog)
.await
.expect("second scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
@@ -2019,7 +1403,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(), ctx.state_dir.as_ref(), &catalog)
let scan = scan_local_library(temp.path(), &catalog)
.await
.expect("initial scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
@@ -2311,7 +1695,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(), ctx.state_dir.as_ref(), &catalog)
let scan = scan_local_library(temp.path(), &catalog)
.await
.expect("initial scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
@@ -2397,7 +1781,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(), ctx.state_dir.as_ref(), &catalog)
let scan = scan_local_library(current.path(), &catalog)
.await
.expect("initial scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
+19 -3
View File
@@ -1,13 +1,13 @@
use std::path::Path;
use std::path::{Path, PathBuf};
use uuid::Uuid;
use crate::state_paths::peer_id_path;
const PEER_ID_FILE: &str = "peer_id";
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: &Path) -> eyre::Result<String> {
pub fn load_or_create_peer_id(state_dir: Option<&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,3 +30,19 @@ 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)
}
+32 -49
View File
@@ -7,10 +7,8 @@ use serde::{Deserialize, Serialize};
use tokio::io::AsyncWriteExt;
const INTENT_SCHEMA_VERSION: u32 = 1;
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";
const INTENT_FILE: &str = ".lanspread.json";
const INTENT_TMP_FILE: &str = ".lanspread.json.tmp";
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum InstallIntentState {
@@ -43,22 +41,18 @@ 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(state_dir: &Path, id: &str) -> PathBuf {
crate::state_paths::game_state_dir(state_dir, id).join(INTENT_FILE)
pub fn intent_path(game_root: &Path) -> PathBuf {
game_root.join(INTENT_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 fn intent_tmp_path(game_root: &Path) -> PathBuf {
game_root.join(INTENT_TMP_FILE)
}
pub async fn read_intent(state_dir: &Path, id: &str) -> InstallIntent {
let path = intent_path(state_dir, id);
pub async fn read_intent(game_root: &Path, id: &str) -> InstallIntent {
let path = intent_path(game_root);
let data = match tokio::fs::read_to_string(&path).await {
Ok(data) => data,
Err(err) => {
@@ -70,7 +64,7 @@ pub async fn read_intent(state_dir: &Path, id: &str) -> InstallIntent {
};
match serde_json::from_str::<InstallIntent>(&data) {
Ok(intent) if intent.is_current_for(id) => intent,
Ok(intent) if intent.schema_version == INTENT_SCHEMA_VERSION && intent.id == id => intent,
Ok(intent) => {
log::warn!(
"Ignoring install intent {} with schema {} for id {}",
@@ -87,11 +81,10 @@ pub async fn read_intent(state_dir: &Path, id: &str) -> InstallIntent {
}
}
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);
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);
let data = serde_json::to_vec_pretty(intent)?;
let mut file = tokio::fs::File::create(&tmp_path).await?;
@@ -129,18 +122,6 @@ 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");
@@ -149,12 +130,12 @@ mod tests {
InstallIntentState::Updating,
Some("20240101".to_string()),
);
write_intent(temp.path(), "game", &previous)
write_intent(temp.path(), &previous)
.await
.expect("previous intent should be written");
tokio::fs::write(
intent_tmp_path(temp.path(), "game"),
intent_tmp_path(temp.path()),
serde_json::to_vec(&InstallIntent::new(
"game",
InstallIntentState::Installing,
@@ -173,12 +154,12 @@ mod tests {
#[tokio::test]
async fn schema_mismatch_is_treated_as_missing() {
let temp = TempDir::new("lanspread-intent");
write_raw_intent(
temp.path(),
"game",
tokio::fs::write(
intent_path(temp.path()),
r#"{"schema_version":2,"id":"game","recorded_at":0,"state":"Updating"}"#,
)
.await;
.await
.expect("intent should be written");
let recovered = read_intent(temp.path(), "game").await;
assert_eq!(recovered.state, InstallIntentState::None);
@@ -187,12 +168,12 @@ mod tests {
#[tokio::test]
async fn mismatched_id_is_treated_as_missing() {
let temp = TempDir::new("lanspread-intent");
write_raw_intent(
temp.path(),
"game",
tokio::fs::write(
intent_path(temp.path()),
r#"{"schema_version":1,"id":"other","recorded_at":0,"state":"Updating"}"#,
)
.await;
.await
.expect("intent should be written");
let recovered = read_intent(temp.path(), "game").await;
assert_eq!(recovered.state, InstallIntentState::None);
@@ -201,7 +182,9 @@ mod tests {
#[tokio::test]
async fn corrupt_intent_is_treated_as_missing() {
let temp = TempDir::new("lanspread-intent");
write_raw_intent(temp.path(), "game", b"not json").await;
tokio::fs::write(intent_path(temp.path()), b"not json")
.await
.expect("intent should be written");
let recovered = read_intent(temp.path(), "game").await;
assert_eq!(recovered.state, InstallIntentState::None);
@@ -210,21 +193,21 @@ mod tests {
#[tokio::test]
async fn old_manifest_hash_field_is_ignored_and_new_writes_omit_it() {
let temp = TempDir::new("lanspread-intent");
write_raw_intent(
temp.path(),
"game",
tokio::fs::write(
intent_path(temp.path()),
r#"{"schema_version":1,"id":"game","recorded_at":0,"state":"Updating","eti_version":"20240101","manifest_hash":42}"#,
)
.await;
.await
.expect("intent should be written");
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(), "game", &InstallIntent::none("game", None))
write_intent(temp.path(), &InstallIntent::none("game", None))
.await
.expect("intent should be written");
let written = tokio::fs::read_to_string(intent_path(temp.path(), "game"))
let written = tokio::fs::read_to_string(intent_path(temp.path()))
.await
.expect("intent should be readable");
assert!(
+2 -10
View File
@@ -1,16 +1,8 @@
pub(crate) mod intent;
mod intent;
mod remove;
mod transaction;
pub mod unpack;
pub use remove::remove_downloaded;
pub(crate) use transaction::root_eti_archives;
pub use transaction::{
StreamedInstallTransaction,
begin_streamed_install,
install,
recover_on_startup,
uninstall,
update,
};
pub use transaction::{install, recover_on_startup, uninstall, update};
pub use unpack::{UnpackFuture, Unpacker};
+48 -464
View File
@@ -11,7 +11,7 @@ use super::{
intent::{InstallIntent, InstallIntentState, read_intent, write_intent},
unpack::Unpacker,
};
use crate::{local_games::version_ini_is_regular_file, state_paths::launch_settings_applied_path};
use crate::local_games::version_ini_is_regular_file;
const LOCAL_DIR: &str = "local";
const INSTALLING_DIR: &str = ".local.installing";
@@ -33,154 +33,10 @@ struct InstallFsState {
backup: FsEntryState,
}
pub struct StreamedInstallTransaction {
game_root: PathBuf,
state_dir: PathBuf,
id: String,
staging: PathBuf,
eti_version: Option<String>,
created_game_root: bool,
}
impl StreamedInstallTransaction {
#[must_use]
pub fn staging_dir(&self) -> &Path {
&self.staging
}
pub async fn commit(self) -> eyre::Result<()> {
let local = local_dir(&self.game_root);
if let Err(err) = tokio::fs::rename(&self.staging, &local)
.await
.wrap_err_with(|| format!("failed to promote streamed install for {}", self.id))
{
if let Err(cleanup_err) = remove_dir_all_if_exists(&self.staging).await {
log::warn!(
"Failed to clean streamed install staging {}: {cleanup_err}",
self.staging.display()
);
}
if let Err(cleanup_err) =
remove_created_empty_game_root(&self.game_root, self.created_game_root).await
{
log::warn!(
"Failed to clean streamed install game root {}: {cleanup_err}",
self.game_root.display()
);
}
let _ = write_intent(
&self.state_dir,
&self.id,
&InstallIntent::none(&self.id, self.eti_version.clone()),
)
.await;
return Err(err);
}
if let Err(err) = reset_launch_settings_marker(&self.state_dir, &self.id).await {
log::error!(
"Streamed install for {} was promoted but launch-settings marker reset failed: {err}",
self.id
);
}
if let Err(err) = write_intent(
&self.state_dir,
&self.id,
&InstallIntent::none(&self.id, self.eti_version.clone()),
)
.await
{
log::error!(
"Streamed install for {} was promoted but intent cleanup failed: {err}",
self.id
);
}
Ok(())
}
pub async fn rollback(self) -> eyre::Result<()> {
let cleanup_result = async {
remove_dir_all_if_exists(&self.staging).await?;
remove_created_empty_game_root(&self.game_root, self.created_game_root).await
}
.await;
let intent_result = write_intent(
&self.state_dir,
&self.id,
&InstallIntent::none(&self.id, self.eti_version.clone()),
)
.await;
cleanup_result?;
intent_result
}
}
pub async fn begin_streamed_install(
game_root: &Path,
state_dir: &Path,
id: &str,
) -> eyre::Result<StreamedInstallTransaction> {
if path_is_dir(&local_dir(game_root)).await {
eyre::bail!("game {id} is already installed");
}
let created_game_root = !path_exists(game_root).await;
tokio::fs::create_dir_all(game_root).await?;
let eti_version = read_downloaded_version(game_root).await;
if let Err(err) = write_intent(
state_dir,
id,
&InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()),
)
.await
{
if let Err(cleanup_err) = remove_created_empty_game_root(game_root, created_game_root).await
{
log::warn!(
"Failed to clean streamed install game root {}: {cleanup_err}",
game_root.display()
);
}
return Err(err);
}
let staging = installing_dir(game_root);
if let Err(err) = prepare_owned_empty_dir(&staging).await {
let _ = write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await;
if let Err(cleanup_err) = remove_created_empty_game_root(game_root, created_game_root).await
{
log::warn!(
"Failed to clean streamed install game root {}: {cleanup_err}",
game_root.display()
);
}
return Err(err);
}
let staging = tokio::fs::canonicalize(&staging).await.unwrap_or(staging);
Ok(StreamedInstallTransaction {
game_root: game_root.to_path_buf(),
state_dir: state_dir.to_path_buf(),
id: id.to_string(),
staging,
eti_version,
created_game_root,
})
}
pub async fn install(
game_root: &Path,
state_dir: &Path,
id: &str,
unpacker: Arc<dyn Unpacker>,
) -> eyre::Result<()> {
pub async fn install(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
let eti_version = read_downloaded_version(game_root).await;
write_intent(
state_dir,
id,
game_root,
&InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()),
)
.await?;
@@ -188,8 +44,7 @@ pub async fn install(
let result = install_inner(game_root, id, unpacker).await;
match result {
Ok(()) => {
reset_launch_settings_marker(state_dir, id).await?;
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
Ok(())
}
Err(err) => {
@@ -199,22 +54,16 @@ pub async fn install(
installing_dir(game_root).display()
);
}
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
Err(err)
}
}
}
pub async fn update(
game_root: &Path,
state_dir: &Path,
id: &str,
unpacker: Arc<dyn Unpacker>,
) -> eyre::Result<()> {
pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
let eti_version = read_downloaded_version(game_root).await;
write_intent(
state_dir,
id,
game_root,
&InstallIntent::new(id, InstallIntentState::Updating, eti_version.clone()),
)
.await?;
@@ -222,8 +71,7 @@ pub async fn update(
let result = update_inner(game_root, id, unpacker).await;
match result {
Ok(()) => {
reset_launch_settings_marker(state_dir, id).await?;
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
write_intent(game_root, &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}",
@@ -234,7 +82,7 @@ pub async fn update(
}
Err(err) => {
let rollback = rollback_update(game_root).await;
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
if let Err(rollback_err) = rollback {
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
}
@@ -243,11 +91,10 @@ pub async fn update(
}
}
pub async fn uninstall(game_root: &Path, state_dir: &Path, id: &str) -> eyre::Result<()> {
pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
let eti_version = read_downloaded_version(game_root).await;
write_intent(
state_dir,
id,
game_root,
&InstallIntent::new(id, InstallIntentState::Uninstalling, eti_version.clone()),
)
.await?;
@@ -255,7 +102,7 @@ pub async fn uninstall(game_root: &Path, state_dir: &Path, id: &str) -> eyre::Re
let result = uninstall_inner(game_root).await;
match result {
Ok(()) => {
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
Ok(())
}
Err(err) => {
@@ -263,17 +110,13 @@ pub async fn uninstall(game_root: &Path, state_dir: &Path, id: &str) -> eyre::Re
if let Err(rollback_err) = rollback {
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
}
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
Err(err)
}
}
}
pub async fn recover_on_startup(
game_dir: &Path,
state_dir: &Path,
active_ids: &HashSet<String>,
) -> eyre::Result<()> {
pub async fn recover_on_startup(game_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 {
@@ -298,28 +141,22 @@ pub async fn recover_on_startup(
continue;
}
recover_game_root(&entry.path(), state_dir, &id).await?;
recover_game_root(&entry.path(), &id).await?;
}
Ok(())
}
pub async fn recover_game_root(game_root: &Path, state_dir: &Path, id: &str) -> eyre::Result<()> {
pub async fn recover_game_root(game_root: &Path, id: &str) -> eyre::Result<()> {
recover_download_transients(game_root).await?;
let intent = read_intent(state_dir, id).await;
let intent = read_intent(game_root, 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, 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?;
}
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?,
}
Ok(())
}
@@ -396,7 +233,7 @@ async fn unpack_archives(
Ok(())
}
pub(crate) async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
let mut entries = tokio::fs::read_dir(game_root).await?;
let mut archives = Vec::new();
while let Some(entry) = entries.next_entry().await? {
@@ -412,18 +249,6 @@ pub(crate) async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<Path
Ok(archives)
}
/// Drop the per-game launch-settings marker before committing install/update
/// success, so recovery can retry the reset before publishing a clean intent.
async fn reset_launch_settings_marker(state_dir: &Path, id: &str) -> eyre::Result<()> {
let marker = launch_settings_applied_path(state_dir, id);
remove_file_if_exists(&marker).await.wrap_err_with(|| {
format!(
"failed to reset launch-settings marker {}",
marker.display()
)
})
}
async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
sweep_owned_orphan(&installing_dir(game_root)).await?;
sweep_owned_orphan(&backup_dir(game_root)).await?;
@@ -432,12 +257,10 @@ 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,
..
@@ -445,29 +268,15 @@ async fn recover_installing(
{
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
}
if commit_landed {
reset_launch_settings_marker(state_dir, id).await?;
}
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
write_intent(game_root, &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,
@@ -492,12 +301,11 @@ async fn recover_updating(
} => remove_dir_all_if_exists(&backup_dir(game_root)).await?,
_ => {}
}
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
}
async fn recover_uninstalling(
game_root: &Path,
state_dir: &Path,
id: &str,
intent: InstallIntent,
fs: InstallFsState,
@@ -515,7 +323,7 @@ async fn recover_uninstalling(
} => uninstall_inner(game_root).await?,
_ => {}
}
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
}
async fn recover_download_transients(root: &Path) -> eyre::Result<()> {
@@ -608,10 +416,6 @@ 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(()),
@@ -627,38 +431,12 @@ 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)
}
@@ -752,123 +530,33 @@ 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, state.path(), "game", successful_unpacker())
install(&root, "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(state.path(), "game").await;
let intent = read_intent(&root, "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, state.path(), "game", unpacker.clone())
install(&root, "game", unpacker.clone())
.await
.expect("install should succeed");
@@ -885,46 +573,34 @@ 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,
state.path(),
"game",
Arc::new(FakeUnpacker::failing()),
)
.await
.expect_err("update should fail");
let err = update(&root, "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(state.path(), "game").await;
let intent = read_intent(&root, "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,
state.path(),
"game",
Arc::new(FakeUnpacker::commit_conflict()),
)
.await
.expect_err("update should fail at commit rename");
let err = update(&root, "game", Arc::new(FakeUnpacker::commit_conflict()))
.await
.expect_err("update should fail at commit rename");
assert!(
err.to_string().contains("failed to promote update"),
@@ -938,21 +614,19 @@ 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(state.path(), "game").await;
let intent = read_intent(&root, "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, state.path(), "game", successful_unpacker())
update(&root, "game", successful_unpacker())
.await
.expect("update should succeed");
@@ -960,21 +634,19 @@ mod tests {
assert!(!root.join("local").join("old.txt").exists());
assert!(!root.join(".local.installing").exists());
assert!(!root.join(".local.backup").exists());
assert!(!launch_settings_applied_path(state.path(), "game").exists());
let intent = read_intent(state.path(), "game").await;
let intent = read_intent(&root, "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, state.path(), "game")
uninstall(&root, "game")
.await
.expect("uninstall should succeed");
@@ -989,7 +661,6 @@ 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");
@@ -998,7 +669,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, state.path(), "game")
let _err = uninstall(&root, "game")
.await
.expect_err("uninstall should fail while deleting backup");
@@ -1026,7 +697,7 @@ mod tests {
b"locked"
);
assert!(!root.join(".local.backup").exists());
let intent = read_intent(state.path(), "game").await;
let intent = read_intent(&root, "game").await;
assert_eq!(intent.state, InstallIntentState::None);
}
@@ -1173,25 +844,23 @@ 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(
state.path(),
"game",
&root,
&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, state.path(), "game")
recover_game_root(&root, "game")
.await
.unwrap_or_else(|err| panic!("{} recovery should succeed: {err}", case.name));
assert_recovered_case(&root, &case);
let intent = read_intent(state.path(), "game").await;
let intent = read_intent(&root, "game").await;
assert_eq!(intent.state, InstallIntentState::None, "{}", case.name);
assert_eq!(
intent.eti_version.as_deref(),
@@ -1202,92 +871,13 @@ 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, state.path(), "game")
recover_game_root(&root, "game")
.await
.expect("recovery should succeed");
@@ -1297,12 +887,11 @@ 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, state.path(), "game")
recover_game_root(&root, "game")
.await
.expect("recovery should succeed");
@@ -1313,19 +902,14 @@ 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(),
state.path(),
&HashSet::from(["active".to_string()]),
)
.await
.expect("recovery should succeed");
recover_on_startup(temp.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());
@@ -1,502 +0,0 @@
//! One-shot launcher-setting application performed the first time a game is played.
//!
//! Some games ship per-user setting files somewhere under their installed
//! `local/` tree — an `account_name.txt`, a `language.txt`, and/or a
//! `SmartSteamEmu.ini` carrying a `PersonaName = ...` line. The first time the
//! user launches a game we stamp the launcher's configured username and language
//! into whichever of those files exist, then record a per-game marker so the
//! step never runs again.
//!
//! The marker only records that we *tried*: it is written unconditionally after
//! the first attempt, whether or not any file or matching line was found. Moving
//! this out of the install transaction means already-installed games are fixed
//! up on their next play rather than only on a fresh (re)install.
use std::{
ffi::OsStr,
io::ErrorKind,
path::{Path, PathBuf},
};
use eyre::WrapErr;
use crate::state_paths::launch_settings_applied_path;
const LOCAL_DIR: &str = "local";
const ACCOUNT_NAME_FILE: &str = "account_name.txt";
const LANGUAGE_FILE: &str = "language.txt";
const SMART_STEAM_EMU_INI: &str = "SmartSteamEmu.ini";
const PERSONA_NAME_KEY: &str = "PersonaName";
/// What the one-shot launcher-setting step did for a game.
///
/// These flags are an independent, observable report of each file's fate rather
/// than a state machine, so a plain record of bools is the clearest shape here.
#[allow(clippy::struct_excessive_bools)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
pub struct LaunchSettingsOutcome {
/// The marker already existed, so nothing was searched or changed.
pub already_applied: bool,
/// An `account_name.txt` was found and overwritten with the username.
pub account_name_written: bool,
/// A `language.txt` was found and overwritten with the language.
pub language_written: bool,
/// A `SmartSteamEmu.ini` `PersonaName` line was found and rewritten.
pub persona_name_written: bool,
}
/// Apply launcher settings once for `game_id`, then mark the attempt as done.
///
/// If the per-game marker already exists this is a no-op returning
/// `already_applied = true`. Otherwise it searches `<game_root>/local/` for the
/// known setting files, stamps `account_name` into the first `account_name.txt`
/// and first `SmartSteamEmu.ini` with a `PersonaName` line (preserving that
/// line's existing line ending) and `language` into the first `language.txt`,
/// and — whatever was or was not found — records the marker so the step never
/// runs again for this game.
pub async fn apply_launch_settings_once(
state_dir: &Path,
game_root: &Path,
game_id: &str,
account_name: Option<&str>,
language: Option<&str>,
) -> eyre::Result<LaunchSettingsOutcome> {
let marker = launch_settings_applied_path(state_dir, game_id);
if tokio::fs::try_exists(&marker).await.unwrap_or(false) {
return Ok(LaunchSettingsOutcome {
already_applied: true,
..LaunchSettingsOutcome::default()
});
}
let local_root = game_root.join(LOCAL_DIR);
let outcome = LaunchSettingsOutcome {
already_applied: false,
account_name_written: overwrite_first_file(&local_root, ACCOUNT_NAME_FILE, account_name)
.await?,
language_written: overwrite_first_file(&local_root, LANGUAGE_FILE, language).await?,
persona_name_written: rewrite_first_persona_name(&local_root, account_name).await?,
};
mark_applied(&marker).await?;
Ok(outcome)
}
/// Overwrite the first file named `file_name` under `root` with `value`.
///
/// Returns `false` without touching anything when `value` is `None` or no such
/// file exists.
async fn overwrite_first_file(
root: &Path,
file_name: &str,
value: Option<&str>,
) -> eyre::Result<bool> {
let Some(value) = value else {
return Ok(false);
};
let Some(path) = find_first_file(root, file_name).await? else {
return Ok(false);
};
tokio::fs::write(&path, value)
.await
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
Ok(true)
}
/// Rewrite the first `PersonaName` line found in any `SmartSteamEmu.ini` under `root`.
async fn rewrite_first_persona_name(root: &Path, persona_name: Option<&str>) -> eyre::Result<bool> {
let Some(persona_name) = persona_name else {
return Ok(false);
};
for path in find_files(root, SMART_STEAM_EMU_INI).await? {
let content = tokio::fs::read_to_string(&path)
.await
.wrap_err_with(|| format!("failed to read {}", path.display()))?;
let Some(rewritten) = rewrite_persona_name_content(&content, persona_name) else {
continue;
};
tokio::fs::write(&path, rewritten)
.await
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
return Ok(true);
}
Ok(false)
}
/// Find the first regular file named `file_name` anywhere under `root`.
///
/// A missing `root` (for example an uninstalled game with no `local/`) yields
/// `None`. Directories are visited in sorted order for deterministic results.
async fn find_first_file(root: &Path, file_name: &str) -> eyre::Result<Option<PathBuf>> {
let mut pending_dirs = vec![root.to_path_buf()];
while let Some(dir) = pending_dirs.pop() {
let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == ErrorKind::NotFound => continue,
Err(err) => {
return Err(err).wrap_err_with(|| format!("failed to read {}", dir.display()));
}
};
let mut child_dirs = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let file_type = entry.file_type().await?;
let path = entry.path();
if file_type.is_dir() {
child_dirs.push(path);
} else if file_type.is_file() && entry.file_name() == OsStr::new(file_name) {
return Ok(Some(path));
}
}
child_dirs.sort();
child_dirs.reverse();
pending_dirs.extend(child_dirs);
}
Ok(None)
}
/// Find every regular file named `file_name` anywhere under `root`.
///
/// A missing `root` yields an empty list. Directories are visited in sorted
/// order for deterministic results.
async fn find_files(root: &Path, file_name: &str) -> eyre::Result<Vec<PathBuf>> {
let mut matches = Vec::new();
let mut pending_dirs = vec![root.to_path_buf()];
while let Some(dir) = pending_dirs.pop() {
let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == ErrorKind::NotFound => continue,
Err(err) => {
return Err(err).wrap_err_with(|| format!("failed to read {}", dir.display()));
}
};
let mut child_dirs = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let file_type = entry.file_type().await?;
let path = entry.path();
if file_type.is_dir() {
child_dirs.push(path);
} else if file_type.is_file() && entry.file_name() == OsStr::new(file_name) {
matches.push(path);
}
}
child_dirs.sort();
child_dirs.reverse();
pending_dirs.extend(child_dirs);
}
Ok(matches)
}
/// Rewrite the first `PersonaName` line in `content`, preserving its line ending.
///
/// Returns `None` when no matching line exists, so the caller can skip writing.
fn rewrite_persona_name_content(content: &str, persona_name: &str) -> Option<String> {
let mut output = String::with_capacity(content.len() + persona_name.len());
let mut replaced = false;
for segment in content.split_inclusive('\n') {
if replaced {
output.push_str(segment);
continue;
}
let (body, ending) = split_trailing_newline(segment);
if let Some(new_body) = rewrite_persona_line(body, persona_name) {
output.push_str(&new_body);
output.push_str(ending);
replaced = true;
} else {
output.push_str(segment);
}
}
replaced.then_some(output)
}
/// Split a `split_inclusive('\n')` segment into its body and trailing newline.
fn split_trailing_newline(segment: &str) -> (&str, &str) {
if let Some(body) = segment.strip_suffix("\r\n") {
(body, "\r\n")
} else if let Some(body) = segment.strip_suffix('\n') {
(body, "\n")
} else {
(segment, "")
}
}
/// Rewrite a single line matching `^\s*PersonaName\s*=\s*...` to use `persona_name`.
///
/// Leading whitespace and the spacing around `=` are preserved; everything after
/// the separator's trailing whitespace is replaced with `persona_name`.
fn rewrite_persona_line(line: &str, persona_name: &str) -> Option<String> {
let leading_len = line.len() - line.trim_start().len();
let (leading, rest) = line.split_at(leading_len);
let rest = rest.strip_prefix(PERSONA_NAME_KEY)?;
let mid_len = rest.len() - rest.trim_start().len();
let (mid_ws, after_mid) = rest.split_at(mid_len);
let after_eq = after_mid.strip_prefix('=')?;
let post_len = after_eq.len() - after_eq.trim_start().len();
let post_ws = &after_eq[..post_len];
Some(format!(
"{leading}{PERSONA_NAME_KEY}{mid_ws}={post_ws}{persona_name}"
))
}
async fn mark_applied(marker: &Path) -> eyre::Result<()> {
if let Some(parent) = marker.parent() {
tokio::fs::create_dir_all(parent)
.await
.wrap_err_with(|| format!("failed to create {}", parent.display()))?;
}
tokio::fs::write(marker, [])
.await
.wrap_err_with(|| format!("failed to write {}", marker.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::TempDir;
#[test]
fn rewrites_simple_line_and_preserves_unix_ending() {
let content = "[User]\nPersonaName = stubname\nLanguage = english\n";
let rewritten =
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
assert_eq!(
rewritten,
"[User]\nPersonaName = realuser\nLanguage = english\n"
);
}
#[test]
fn preserves_crlf_line_ending() {
let content = "[User]\r\nPersonaName = stubname\r\nLanguage = english\r\n";
let rewritten =
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
assert_eq!(
rewritten,
"[User]\r\nPersonaName = realuser\r\nLanguage = english\r\n"
);
}
#[test]
fn preserves_leading_whitespace_and_separator_spacing() {
let content = "\tPersonaName=stubname\n";
let rewritten =
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
assert_eq!(rewritten, "\tPersonaName=realuser\n");
}
#[test]
fn rewrites_final_line_without_trailing_newline() {
let content = "PersonaName = stubname";
let rewritten =
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
assert_eq!(rewritten, "PersonaName = realuser");
}
#[test]
fn ignores_similar_keys() {
assert!(rewrite_persona_line("PersonaNameExtra = x", "realuser").is_none());
assert!(rewrite_persona_line("MyPersonaName = x", "realuser").is_none());
assert!(rewrite_persona_line("PersonaName foo", "realuser").is_none());
}
#[test]
fn returns_none_when_no_persona_line() {
let content = "[User]\nLanguage = english\n";
assert!(rewrite_persona_name_content(content, "realuser").is_none());
}
#[tokio::test]
async fn applies_username_to_both_files_then_marks_done() {
let state = TempDir::new("lanspread-launch-state");
let game = TempDir::new("lanspread-launch-game");
let root = game.path();
let account = root.join(LOCAL_DIR).join("profile").join(ACCOUNT_NAME_FILE);
let ini = root
.join(LOCAL_DIR)
.join("config")
.join("emu")
.join(SMART_STEAM_EMU_INI);
let language = root.join(LOCAL_DIR).join(LANGUAGE_FILE);
write_file(&account, b"stubname");
write_file(&ini, b"[User]\r\nPersonaName = stubname\r\n");
write_file(&language, b"english");
let outcome = apply_launch_settings_once(
state.path(),
root,
"game",
Some("realuser"),
Some("german"),
)
.await
.expect("apply should succeed");
assert_eq!(
outcome,
LaunchSettingsOutcome {
already_applied: false,
account_name_written: true,
language_written: true,
persona_name_written: true,
}
);
assert_eq!(
std::fs::read_to_string(&account).expect("account file should be readable"),
"realuser"
);
assert_eq!(
std::fs::read_to_string(&ini).expect("ini should be readable"),
"[User]\r\nPersonaName = realuser\r\n"
);
assert_eq!(
std::fs::read_to_string(&language).expect("language file should be readable"),
"german"
);
assert!(launch_settings_applied_path(state.path(), "game").is_file());
}
#[tokio::test]
async fn is_noop_once_marker_exists() {
let state = TempDir::new("lanspread-launch-state");
let game = TempDir::new("lanspread-launch-game");
let root = game.path();
let ini = root.join(LOCAL_DIR).join(SMART_STEAM_EMU_INI);
write_file(&ini, b"PersonaName = stubname\n");
let first = apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
.await
.expect("first apply should succeed");
assert!(first.persona_name_written);
assert!(!first.already_applied);
// Externally reset the value; a second apply must not touch it again.
write_file(&ini, b"PersonaName = stubname\n");
let second = apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
.await
.expect("second apply should succeed");
assert!(second.already_applied);
assert!(!second.persona_name_written);
assert_eq!(
std::fs::read_to_string(&ini).expect("ini should be readable"),
"PersonaName = stubname\n"
);
}
#[tokio::test]
async fn marks_done_even_when_nothing_found() {
let state = TempDir::new("lanspread-launch-state");
let game = TempDir::new("lanspread-launch-game");
let root = game.path();
write_file(
&root.join(LOCAL_DIR).join("readme.txt"),
b"no settings here",
);
let outcome = apply_launch_settings_once(
state.path(),
root,
"game",
Some("realuser"),
Some("german"),
)
.await
.expect("apply should succeed");
assert_eq!(outcome, LaunchSettingsOutcome::default());
assert!(launch_settings_applied_path(state.path(), "game").is_file());
}
#[tokio::test]
async fn marks_done_when_local_missing() {
let state = TempDir::new("lanspread-launch-state");
let game = TempDir::new("lanspread-launch-game");
let outcome = apply_launch_settings_once(
state.path(),
game.path(),
"game",
Some("realuser"),
Some("german"),
)
.await
.expect("apply should succeed");
assert_eq!(outcome, LaunchSettingsOutcome::default());
assert!(launch_settings_applied_path(state.path(), "game").is_file());
}
#[tokio::test]
async fn overwrites_first_account_file_in_sorted_order() {
let state = TempDir::new("lanspread-launch-state");
let game = TempDir::new("lanspread-launch-game");
let root = game.path();
let first = root.join(LOCAL_DIR).join("a").join(ACCOUNT_NAME_FILE);
let second = root.join(LOCAL_DIR).join("z").join(ACCOUNT_NAME_FILE);
write_file(&first, b"old-a");
write_file(&second, b"old-z");
apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
.await
.expect("apply should succeed");
assert_eq!(
std::fs::read_to_string(&first).expect("first account file should be readable"),
"realuser"
);
assert_eq!(
std::fs::read_to_string(&second).expect("second account file should be readable"),
"old-z"
);
}
#[tokio::test]
async fn searches_past_ini_files_without_persona_name() {
let state = TempDir::new("lanspread-launch-state");
let game = TempDir::new("lanspread-launch-game");
let root = game.path();
let first = root.join(LOCAL_DIR).join("a").join(SMART_STEAM_EMU_INI);
let second = root.join(LOCAL_DIR).join("b").join(SMART_STEAM_EMU_INI);
write_file(&first, b"[User]\nLanguage = english\n");
write_file(&second, b"PersonaName = stubname\n");
let outcome =
apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
.await
.expect("apply should succeed");
assert!(outcome.persona_name_written);
assert_eq!(
std::fs::read_to_string(&first).expect("first ini should be readable"),
"[User]\nLanguage = english\n"
);
assert_eq!(
std::fs::read_to_string(&second).expect("second ini should be readable"),
"PersonaName = realuser\n"
);
assert!(launch_settings_applied_path(state.path(), "game").is_file());
}
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("parent dir should be created");
}
std::fs::write(path, bytes).expect("file should be written");
}
}
+9 -71
View File
@@ -20,10 +20,8 @@ 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;
@@ -31,8 +29,6 @@ mod peer_db;
mod remote_peer;
mod services;
mod startup;
mod state_paths;
mod stream_install;
#[cfg(test)]
mod test_support;
@@ -40,13 +36,12 @@ mod test_support;
// Public re-exports
// =============================================================================
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
use std::{collections::HashSet, net::SocketAddr, path::PathBuf, sync::Arc};
pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT};
pub use error::PeerError;
pub use install::{UnpackFuture, Unpacker};
use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
pub use migration::{MigrationReport, migrate_legacy_state};
use lanspread_db::db::{Game, GameFileDescription};
pub use peer_db::{
MajorityValidationResult,
PeerGameDB,
@@ -61,6 +56,7 @@ use tokio::sync::{
};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
pub use crate::startup::PeerRuntimeHandle;
use crate::{
context::Ctx,
handlers::{
@@ -77,20 +73,6 @@ 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,
},
};
// =============================================================================
@@ -162,8 +144,6 @@ pub enum PeerEvent {
PeerCountUpdated(usize),
/// The local library contents changed after a scan.
LocalLibraryChanged { games: Vec<Game> },
/// The number of active outbound transfers changed.
OutboundTransferCountChanged,
/// The set of in-progress local operations changed.
ActiveOperationsChanged {
active_operations: Vec<ActiveOperation>,
@@ -252,8 +232,6 @@ 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.
@@ -271,29 +249,10 @@ pub enum PeerCommand {
}
/// Optional startup settings for non-GUI callers and tests.
#[derive(Clone, Default)]
#[derive(Clone, Debug, Default)]
pub struct PeerStartOptions {
/// Directory used for peer identity and other state.
pub state_dir: Option<PathBuf>,
pub active_outbound_transfers: Option<crate::context::OutboundTransfers>,
/// Provider used to stream archive entries for low-disk streamed installs.
pub stream_install_provider: Option<Arc<dyn StreamInstallProvider>>,
}
impl std::fmt::Debug for PeerStartOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PeerStartOptions")
.field("state_dir", &self.state_dir)
.field(
"active_outbound_transfers",
&self.active_outbound_transfers.as_ref().map(|_| "..."),
)
.field(
"stream_install_provider",
&self.stream_install_provider.as_ref().map(|_| "..."),
)
.finish()
}
}
// =============================================================================
@@ -318,7 +277,7 @@ pub fn start_peer(
tx_notify_ui: UnboundedSender<PeerEvent>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
unpacker: Arc<dyn Unpacker>,
catalog: Arc<RwLock<GameCatalog>>,
catalog: Arc<RwLock<HashSet<String>>>,
) -> eyre::Result<PeerRuntimeHandle> {
start_peer_with_options(
game_dir,
@@ -337,25 +296,16 @@ pub fn start_peer_with_options(
tx_notify_ui: UnboundedSender<PeerEvent>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
unpacker: Arc<dyn Unpacker>,
catalog: Arc<RwLock<GameCatalog>>,
catalog: Arc<RwLock<HashSet<String>>>,
options: PeerStartOptions,
) -> eyre::Result<PeerRuntimeHandle> {
let PeerStartOptions {
state_dir,
active_outbound_transfers,
stream_install_provider,
} = options;
let state_dir = resolve_state_dir(state_dir.as_deref());
let PeerStartOptions { state_dir } = options;
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)?;
let peer_id = identity::load_or_create_peer_id(state_dir.as_deref())?;
let (tx_control, rx_control) = tokio::sync::mpsc::unbounded_channel();
@@ -366,11 +316,8 @@ pub fn start_peer_with_options(
peer_game_db,
peer_id,
game_dir,
state_dir,
unpacker,
catalog,
active_outbound_transfers,
stream_install_provider,
))
}
@@ -382,25 +329,19 @@ 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<GameCatalog>>,
active_outbound_transfers: crate::context::OutboundTransfers,
stream_install_provider: Arc<dyn StreamInstallProvider>,
catalog: Arc<RwLock<HashSet<String>>>,
) -> eyre::Result<()> {
let ctx = Ctx::new(
peer_game_db,
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}");
@@ -474,9 +415,6 @@ 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;
}
+28 -93
View File
@@ -9,7 +9,7 @@ use std::{
time::{SystemTime, UNIX_EPOCH},
};
use lanspread_db::db::{Game, GameCatalog, GameDB, GameFileDescription};
use lanspread_db::db::{Game, GameDB, GameFileDescription};
use lanspread_proto::{Availability, GameSummary};
use serde::{Deserialize, Serialize};
use tokio::{io::AsyncWriteExt, sync::Mutex};
@@ -51,7 +51,7 @@ pub async fn local_download_available(
game_dir: &Path,
game_id: &str,
active_operations: &HashMap<String, OperationKind>,
catalog: &GameCatalog,
catalog: &HashSet<String>,
) -> bool {
if !catalog.contains(game_id) {
log::debug!("Not serving game {game_id} locally because it is not in the catalog");
@@ -67,45 +67,11 @@ 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 LEGACY_LIBRARY_INDEX_DIR: &str = ".lanspread";
const 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";
@@ -148,14 +114,8 @@ pub struct LocalLibraryScan {
pub revision: u64,
}
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_path(game_dir: &Path) -> PathBuf {
game_dir.join(LIBRARY_INDEX_DIR).join(LIBRARY_INDEX_FILE)
}
fn library_index_tmp_path(path: &Path) -> PathBuf {
@@ -318,7 +278,7 @@ async fn fingerprint_game_dir(game_path: &Path) -> eyre::Result<GameFingerprint>
}
pub fn is_ignored_game_root_name(name: &str) -> bool {
name == LEGACY_LIBRARY_INDEX_DIR
name == LIBRARY_INDEX_DIR
}
fn is_reserved_transient_name(name: &str) -> bool {
@@ -326,7 +286,7 @@ fn is_reserved_transient_name(name: &str) -> bool {
|| name == VERSION_TMP_FILE
|| name == VERSION_DISCARDED_FILE
|| name == INTENT_LOG_FILE
|| name == LEGACY_LIBRARY_INDEX_DIR
|| name == LIBRARY_INDEX_DIR
}
fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool {
@@ -502,7 +462,7 @@ struct IndexUpdate {
async fn update_index_for_game(
game_root: &Path,
game_id: &str,
catalog: &GameCatalog,
catalog: &HashSet<String>,
index: &mut LibraryIndex,
) -> eyre::Result<IndexUpdate> {
if !catalog.contains(game_id) {
@@ -590,11 +550,9 @@ 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>,
state_dir: impl AsRef<Path>,
catalog: &GameCatalog,
catalog: &HashSet<String>,
) -> eyre::Result<LocalLibraryScan> {
let game_path = game_dir.as_ref();
let state_path = state_dir.as_ref();
let metadata = match tokio::fs::metadata(game_path).await {
Ok(metadata) => metadata,
@@ -619,7 +577,7 @@ pub async fn scan_local_library(
}
let _index_guard = LIBRARY_INDEX_LOCK.lock().await;
let index_path = library_index_path(state_path);
let index_path = library_index_path(game_path);
let mut index = load_library_index(&index_path).await;
let mut seen_ids = HashSet::new();
let mut summaries = HashMap::new();
@@ -678,14 +636,12 @@ 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>,
state_dir: impl AsRef<Path>,
catalog: &GameCatalog,
catalog: &HashSet<String>,
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(state_path);
let index_path = library_index_path(game_path);
let mut index = load_library_index(&index_path).await;
let update = update_index_for_game(game_path, game_id, catalog, &mut index).await?;
@@ -716,7 +672,10 @@ pub async fn get_game_file_descriptions(
#[cfg(test)]
mod tests {
use std::{collections::HashMap, path::Path};
use std::{
collections::{HashMap, HashSet},
path::Path,
};
use lanspread_proto::Availability;
@@ -806,8 +765,7 @@ mod tests {
#[tokio::test]
async fn scan_uses_version_ini_and_local_dir_as_independent_state() {
let temp = TempDir::new("lanspread-local-games");
let state = TempDir::new("lanspread-local-games-state");
let catalog = GameCatalog::from_ids([
let catalog = HashSet::from([
"ready".to_string(),
"local-only".to_string(),
"eti-only".to_string(),
@@ -825,7 +783,7 @@ mod tests {
b"20250101",
);
let scan = scan_local_library(temp.path(), state.path(), &catalog)
let scan = scan_local_library(temp.path(), &catalog)
.await
.expect("scan should succeed");
@@ -860,12 +818,11 @@ mod tests {
#[tokio::test]
async fn rescan_promotes_installed_only_game_to_ready_when_sentinel_appears() {
let temp = TempDir::new("lanspread-local-games");
let state = TempDir::new("lanspread-local-games-state");
let catalog = GameCatalog::from_ids(["game".to_string()]);
let catalog = HashSet::from(["game".to_string()]);
std::fs::create_dir_all(temp.path().join("game").join("local"))
.expect("local install dir should be created");
let first_scan = scan_local_library(temp.path(), state.path(), &catalog)
let first_scan = scan_local_library(temp.path(), &catalog)
.await
.expect("initial scan should succeed");
let local_only = first_scan
@@ -878,7 +835,7 @@ mod tests {
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
let rescan = rescan_local_game(temp.path(), state.path(), &catalog, "game")
let rescan = rescan_local_game(temp.path(), &catalog, "game")
.await
.expect("rescan should succeed");
let ready = rescan
@@ -894,12 +851,11 @@ mod tests {
#[tokio::test]
async fn concurrent_rescans_preserve_both_index_updates() {
let temp = TempDir::new("lanspread-local-games-concurrent");
let state = TempDir::new("lanspread-local-games-state");
let catalog = GameCatalog::from_ids(["game-a".to_string(), "game-b".to_string()]);
let catalog = HashSet::from(["game-a".to_string(), "game-b".to_string()]);
write_file(&temp.path().join("game-a").join("version.ini"), b"20250101");
write_file(&temp.path().join("game-b").join("version.ini"), b"20250101");
let initial = scan_local_library(temp.path(), state.path(), &catalog)
let initial = scan_local_library(temp.path(), &catalog)
.await
.expect("initial scan should succeed");
assert_eq!(initial.revision, 1);
@@ -908,13 +864,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(), state.path(), &catalog, "game-a"),
rescan_local_game(temp.path(), state.path(), &catalog, "game-b")
rescan_local_game(temp.path(), &catalog, "game-a"),
rescan_local_game(temp.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(state.path())).await;
let index = load_library_index(&library_index_path(temp.path())).await;
assert_eq!(index.revision, 3);
let game_a = index
.games
@@ -940,7 +896,7 @@ mod tests {
let game_root = temp.path().join("game");
write_file(&game_root.join("version.ini"), b"20250101");
let catalog = GameCatalog::from_ids(["game".to_string()]);
let catalog = HashSet::from(["game".to_string()]);
let no_operations = HashMap::new();
assert!(local_download_available(temp.path(), "game", &no_operations, &catalog).await);
@@ -948,29 +904,8 @@ mod tests {
assert!(!local_download_available(temp.path(), "game", &active_operations, &catalog).await);
assert!(
!local_download_available(temp.path(), "game", &no_operations, &GameCatalog::empty())
.await
!local_download_available(temp.path(), "game", &no_operations, &HashSet::new()).await
);
assert!(!local_download_available(temp.path(), "missing", &no_operations, &catalog).await);
}
#[tokio::test]
async fn local_download_matches_catalog_requires_expected_version() {
let temp = TempDir::new("lanspread-local-games");
let game_root = temp.path().join("game");
write_file(&game_root.join("version.ini"), b"20260101");
let mut catalog = GameCatalog::empty();
catalog.insert("game".to_string(), Some("20250101".to_string()));
let no_operations = HashMap::new();
assert!(
!local_download_matches_catalog(temp.path(), "game", &no_operations, &catalog).await
);
catalog.insert("game".to_string(), Some("20260101".to_string()));
assert!(
local_download_matches_catalog(temp.path(), "game", &no_operations, &catalog).await
);
}
}
-612
View File
@@ -1,612 +0,0 @@
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());
}
}
+9 -84
View File
@@ -4,7 +4,6 @@ use bytes::Bytes;
use lanspread_db::db::GameFileDescription;
use lanspread_utils::maybe_addr;
use s2n_quic::{
application,
connection,
stream::{Error as StreamError, SendStream},
};
@@ -15,24 +14,12 @@ use tokio::{
use crate::{config::FILE_TRANSFER_BUFFER_SIZE, path_validation::validate_game_file_path};
fn cancel_send_stream(tx: &mut SendStream, remote_addr: impl std::fmt::Display, path: &Path) {
// Reset instead of finishing so truncated whole-file transfers cannot look like EOF.
if let Err(err) = tx.reset(application::Error::UNKNOWN) {
log::debug!(
"{remote_addr} failed to reset cancelled transfer for {}: {err}",
path.display()
);
}
}
#[allow(clippy::too_many_lines)]
async fn stream_file_bytes(
tx: &mut SendStream,
base_dir: &Path,
relative_path: &str,
offset: u64,
length: Option<u64>,
cancel_token: tokio_util::sync::CancellationToken,
) -> eyre::Result<()> {
let remote_addr = maybe_addr!(tx.connection().remote_addr());
@@ -58,34 +45,13 @@ async fn stream_file_bytes(
let mut buf = vec![0u8; FILE_TRANSFER_BUFFER_SIZE];
while remaining > 0 {
if cancel_token.is_cancelled() {
log::info!(
"{remote_addr} transfer cancelled for {}",
validated_path.display()
);
cancel_send_stream(tx, remote_addr, &validated_path);
return Err(eyre::eyre!("File transfer cancelled by user"));
}
let read_len = std::cmp::min(remaining, buf.len() as u64);
let read_len: usize = read_len.try_into().unwrap_or(usize::MAX);
if read_len == 0 {
break;
}
let bytes_read = tokio::select! {
() = cancel_token.cancelled() => {
log::info!(
"{remote_addr} transfer cancelled for {}",
validated_path.display()
);
cancel_send_stream(tx, remote_addr, &validated_path);
return Err(eyre::eyre!("File transfer cancelled by user"));
}
res = file.read(&mut buf[..read_len]) => {
res?
}
};
let bytes_read = file.read(&mut buf[..read_len]).await?;
if bytes_read == 0 {
if !expect_exact {
transfer_complete = true;
@@ -93,19 +59,7 @@ async fn stream_file_bytes(
break;
}
tokio::select! {
() = cancel_token.cancelled() => {
log::info!(
"{remote_addr} transfer cancelled for {}",
validated_path.display()
);
cancel_send_stream(tx, remote_addr, &validated_path);
return Err(eyre::eyre!("File transfer cancelled by user"));
}
res = tx.send(Bytes::copy_from_slice(&buf[..bytes_read])) => {
res?;
}
}
tx.send(Bytes::copy_from_slice(&buf[..bytes_read])).await?;
remaining = remaining.saturating_sub(bytes_read as u64);
total_bytes += bytes_read as u64;
@@ -143,21 +97,12 @@ async fn stream_file_bytes(
validated_path.display()
);
tokio::select! {
() = cancel_token.cancelled() => {
log::info!("{remote_addr} transfer cancelled while closing stream");
cancel_send_stream(tx, remote_addr, &validated_path);
return Err(eyre::eyre!("File transfer cancelled by user"));
}
res = tx.close() => {
match res {
Ok(()) => {}
Err(err) if transfer_complete && is_clean_remote_close(&err) => {
log::debug!("{remote_addr} closed stream after transfer completion: {err}");
}
Err(err) => return Err(err.into()),
}
match tx.close().await {
Ok(()) => {}
Err(err) if transfer_complete && is_clean_remote_close(&err) => {
log::debug!("{remote_addr} closed stream after transfer completion: {err}");
}
Err(err) => return Err(err.into()),
}
Ok(())
}
@@ -176,18 +121,8 @@ pub async fn send_game_file_data(
game_file_desc: &GameFileDescription,
tx: &mut SendStream,
game_dir: &Path,
cancel_token: tokio_util::sync::CancellationToken,
) {
if let Err(e) = stream_file_bytes(
tx,
game_dir,
&game_file_desc.relative_path,
0,
None,
cancel_token,
)
.await
{
if let Err(e) = stream_file_bytes(tx, game_dir, &game_file_desc.relative_path, 0, None).await {
let remote_addr = maybe_addr!(tx.connection().remote_addr());
log::error!(
"{remote_addr} failed to stream file {}: {e}",
@@ -203,18 +138,8 @@ pub async fn send_game_file_chunk(
length: u64,
tx: &mut SendStream,
game_dir: &Path,
cancel_token: tokio_util::sync::CancellationToken,
) {
if let Err(e) = stream_file_bytes(
tx,
game_dir,
relative_path,
offset,
Some(length),
cancel_token,
)
.await
{
if let Err(e) = stream_file_bytes(tx, game_dir, relative_path, offset, Some(length)).await {
let remote_addr = maybe_addr!(tx.connection().remote_addr());
log::error!(
"{remote_addr} failed to stream chunk {game_id}/{relative_path} offset {offset} length {length}: {e}"
+9 -141
View File
@@ -7,7 +7,7 @@ use std::{
time::{Duration, Instant},
};
use lanspread_db::db::{Availability, Game, GameCatalog, GameFileDescription};
use lanspread_db::db::{Availability, Game, GameFileDescription};
use lanspread_proto::{GameSummary, LibraryDelta, LibrarySnapshot};
use crate::library::compute_library_digest;
@@ -357,54 +357,6 @@ impl PeerGameDB {
games
}
/// Returns catalog games aggregated from peers that advertise the expected catalog version.
#[must_use]
pub fn get_catalog_games(&self, catalog: &GameCatalog) -> Vec<Game> {
let mut aggregated: HashMap<String, Game> = HashMap::new();
let mut peer_counts: HashMap<String, u32> = HashMap::new();
for peer in self.peers.values() {
for game in peer.games.values().filter(|game| {
catalog.contains(&game.id)
&& game_matches_expected_version(game, catalog.expected_version(&game.id))
}) {
*peer_counts.entry(game.id.clone()).or_insert(0) += 1;
}
}
for peer in self.peers.values() {
for game in peer.games.values().filter(|game| {
catalog.contains(&game.id)
&& game_matches_expected_version(game, catalog.expected_version(&game.id))
}) {
aggregated
.entry(game.id.clone())
.and_modify(|existing| {
existing.peer_count = *peer_counts.get(&game.id).unwrap_or(&0);
if game.size > existing.size {
existing.size = game.size;
}
existing.set_downloaded(true);
if game.installed {
existing.installed = true;
}
})
.or_insert_with(|| {
let mut game_clone = summary_to_game(game);
if let Some(expected_version) = catalog.expected_version(&game.id) {
game_clone.eti_game_version = Some(expected_version.to_string());
}
game_clone.peer_count = *peer_counts.get(&game.id).unwrap_or(&0);
game_clone
});
}
}
let mut games: Vec<Game> = aggregated.into_values().collect();
games.sort_by(|a, b| a.name.cmp(&b.name));
games
}
/// Returns the latest version of a game across all peers.
#[must_use]
pub fn get_latest_version_for_game(&self, game_id: &str) -> Option<String> {
@@ -499,24 +451,6 @@ impl PeerGameDB {
.collect()
}
/// Returns addresses of peers that have the expected catalog version of a game.
#[must_use]
pub fn peers_with_expected_version(
&self,
game_id: &str,
expected_version: Option<&str>,
) -> Vec<SocketAddr> {
self.peers
.iter()
.filter(|(_, peer)| {
peer.games
.get(game_id)
.is_some_and(|game| game_matches_expected_version(game, expected_version))
})
.map(|(_, peer)| peer.addr)
.collect()
}
/// Returns addresses of peers that have the latest version of a game.
#[must_use]
pub fn peers_with_latest_version(&self, game_id: &str) -> Vec<SocketAddr> {
@@ -580,33 +514,11 @@ impl PeerGameDB {
.collect()
}
/// Returns file descriptions from peers that advertise the expected catalog version.
#[must_use]
pub fn expected_version_game_files_for(
&self,
game_id: &str,
expected_version: Option<&str>,
) -> Vec<(SocketAddr, Vec<GameFileDescription>)> {
let expected_peers = self.peers_with_expected_version(game_id, expected_version);
if expected_peers.is_empty() {
return Vec::new();
}
self.game_files_for(game_id)
.into_iter()
.filter(|(addr, _)| expected_peers.contains(addr))
.collect()
}
/// Returns aggregated file descriptions for a game across all peers.
#[must_use]
pub fn aggregated_game_files(
&self,
game_id: &str,
expected_version: Option<&str>,
) -> Vec<GameFileDescription> {
pub fn aggregated_game_files(&self, game_id: &str) -> Vec<GameFileDescription> {
let mut seen: HashMap<String, GameFileDescription> = HashMap::new();
for (_, files) in self.expected_version_game_files_for(game_id, expected_version) {
for (_, files) in self.latest_game_files_for(game_id) {
for file in files {
seen.entry(file.relative_path.clone()).or_insert(file);
}
@@ -647,9 +559,8 @@ impl PeerGameDB {
pub fn validate_file_sizes_majority(
&self,
game_id: &str,
expected_version: Option<&str>,
) -> eyre::Result<MajorityValidationResult> {
let game_files = self.expected_version_game_files_for(game_id, expected_version);
let game_files = self.latest_game_files_for(game_id);
if game_files.is_empty() {
return Ok((Vec::new(), Vec::new(), HashMap::new()));
}
@@ -902,14 +813,6 @@ fn game_is_ready(summary: &GameSummary) -> bool {
summary.availability == Availability::Ready
}
fn game_matches_expected_version(summary: &GameSummary, expected_version: Option<&str>) -> bool {
if !game_is_ready(summary) {
return false;
}
expected_version.is_none_or(|expected| summary.eti_version.as_deref() == Some(expected))
}
fn summary_to_game(summary: &GameSummary) -> Game {
let eti_game_version = game_is_ready(summary)
.then(|| summary.eti_version.clone())
@@ -1022,41 +925,6 @@ mod tests {
assert!(db.peers_with_latest_version("game").is_empty());
}
#[test]
fn catalog_aggregation_counts_only_expected_version_peers() {
let old_addr = addr(12003);
let expected_addr = addr(12004);
let newer_addr = addr(12005);
let mut db = PeerGameDB::new();
db.upsert_peer("old".to_string(), old_addr);
db.upsert_peer("expected".to_string(), expected_addr);
db.upsert_peer("newer".to_string(), newer_addr);
db.update_peer_games(
&"old".to_string(),
vec![summary("game", "20240101", Availability::Ready)],
);
db.update_peer_games(
&"expected".to_string(),
vec![summary("game", "20250101", Availability::Ready)],
);
db.update_peer_games(
&"newer".to_string(),
vec![summary("game", "20260101", Availability::Ready)],
);
let mut catalog = GameCatalog::empty();
catalog.insert("game".to_string(), Some("20250101".to_string()));
let games = db.get_catalog_games(&catalog);
assert_eq!(games.len(), 1);
assert_eq!(games[0].peer_count, 1);
assert_eq!(games[0].eti_game_version.as_deref(), Some("20250101"));
assert_eq!(
db.peers_with_expected_version("game", Some("20250101")),
vec![expected_addr]
);
}
#[test]
fn transport_addr_matches_known_peer_on_ephemeral_port() {
let advertised = ip_addr([10, 66, 0, 2], 40000);
@@ -1111,7 +979,7 @@ mod tests {
}
#[test]
fn validation_uses_expected_version_file_metadata() {
fn validation_uses_latest_version_file_metadata() {
let old_addr = addr(12003);
let new_addr = addr(12004);
let mut db = PeerGameDB::new();
@@ -1142,21 +1010,21 @@ mod tests {
],
);
let aggregated = db.aggregated_game_files("game", Some("20250101"));
let aggregated = db.aggregated_game_files("game");
let archive = aggregated
.iter()
.find(|desc| desc.relative_path == "game/archive.eti")
.expect("expected-version archive should be present");
.expect("latest archive should be present");
assert_eq!(archive.size, 20);
let (validated, peers, file_peer_map) = db
.validate_file_sizes_majority("game", Some("20250101"))
.validate_file_sizes_majority("game")
.expect("old-version file metadata should not create ambiguity");
assert_eq!(peers, vec![new_addr]);
let archive = validated
.iter()
.find(|desc| desc.relative_path == "game/archive.eti")
.expect("expected-version archive should validate");
.expect("latest archive should validate");
assert_eq!(archive.size, 20);
assert_eq!(file_peer_map.get("game/archive.eti"), Some(&vec![new_addr]));
}
@@ -2,7 +2,6 @@
use std::{net::SocketAddr, sync::Arc};
use lanspread_db::db::GameCatalog;
use lanspread_proto::{Hello, HelloAck, PROTOCOL_VERSION};
use tokio::sync::{RwLock, mpsc::UnboundedSender};
@@ -23,7 +22,6 @@ pub(crate) struct HandshakeCtx {
local_library: Arc<RwLock<LocalLibraryState>>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
tx_notify_ui: UnboundedSender<PeerEvent>,
catalog: Arc<RwLock<GameCatalog>>,
}
impl HandshakeCtx {
@@ -34,7 +32,6 @@ impl HandshakeCtx {
local_library: ctx.local_library.clone(),
peer_game_db: ctx.peer_game_db.clone(),
tx_notify_ui: tx_notify_ui.clone(),
catalog: ctx.catalog.clone(),
}
}
@@ -45,7 +42,6 @@ impl HandshakeCtx {
local_library: ctx.local_library.clone(),
peer_game_db: ctx.peer_game_db.clone(),
tx_notify_ui: ctx.tx_notify_ui.clone(),
catalog: ctx.catalog.clone(),
}
}
}
@@ -125,7 +121,7 @@ pub(crate) async fn perform_handshake_with_peer(
.await;
after_peer_library_recorded(&ctx, upsert, record_addr).await;
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
Ok(())
}
@@ -160,7 +156,7 @@ pub(super) async fn accept_inbound_hello(
.await;
after_peer_library_recorded(&handshake_ctx, upsert, addr).await;
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
build_hello_ack(ctx).await
}
@@ -205,13 +201,12 @@ async fn after_peer_library_recorded(
#[cfg(test)]
mod tests {
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
net::SocketAddr,
path::{Path, PathBuf},
sync::Arc,
};
use lanspread_db::db::GameCatalog;
use lanspread_proto::{Availability, GameSummary, Hello, LibrarySnapshot, PROTOCOL_VERSION};
use tokio::sync::{RwLock, mpsc};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
@@ -247,7 +242,6 @@ mod tests {
local_library: Arc::new(RwLock::new(LocalLibraryState::empty())),
peer_game_db,
tx_notify_ui,
catalog: Arc::new(RwLock::new(GameCatalog::empty())),
}
}
@@ -307,19 +301,14 @@ 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(catalog)),
Arc::new(RwLock::new(HashMap::new())),
Arc::new(crate::NoopStreamInstallProvider),
Arc::new(RwLock::new(HashSet::new())),
);
*ctx.local_peer_addr.write().await = Some(addr([127, 0, 0, 1], 4000));
+2 -12
View File
@@ -2,7 +2,6 @@
use std::{collections::HashMap, sync::Arc, time::Duration};
use lanspread_db::db::GameCatalog;
use tokio::sync::{RwLock, mpsc::UnboundedSender};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
@@ -19,7 +18,6 @@ use crate::{
pub async fn run_ping_service(
tx_notify_ui: UnboundedSender<PeerEvent>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
catalog: Arc<RwLock<GameCatalog>>,
active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
active_downloads: Arc<RwLock<HashMap<String, CancellationToken>>>,
shutdown: CancellationToken,
@@ -42,7 +40,6 @@ pub async fn run_ping_service(
ping_idle_peers(
&peer_game_db,
&catalog,
&active_operations,
&active_downloads,
&tx_notify_ui,
@@ -53,7 +50,6 @@ pub async fn run_ping_service(
prune_stale_peers(
&peer_game_db,
&catalog,
&active_operations,
&active_downloads,
&tx_notify_ui,
@@ -64,7 +60,6 @@ pub async fn run_ping_service(
async fn ping_idle_peers(
peer_game_db: &Arc<RwLock<PeerGameDB>>,
catalog: &Arc<RwLock<GameCatalog>>,
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
tx_notify_ui: &UnboundedSender<PeerEvent>,
@@ -80,7 +75,6 @@ async fn ping_idle_peers(
let tx_notify_ui = tx_notify_ui.clone();
let peer_game_db = peer_game_db.clone();
let catalog = catalog.clone();
let active_operations = active_operations.clone();
let active_downloads = active_downloads.clone();
let shutdown = shutdown.clone();
@@ -99,7 +93,6 @@ async fn ping_idle_peers(
log::warn!("Peer {peer_addr} failed ping check");
remove_peer_and_refresh(
&peer_game_db,
&catalog,
&active_operations,
&active_downloads,
&tx_notify_ui,
@@ -112,7 +105,6 @@ async fn ping_idle_peers(
log::error!("Failed to ping peer {peer_addr}: {err}");
remove_peer_and_refresh(
&peer_game_db,
&catalog,
&active_operations,
&active_downloads,
&tx_notify_ui,
@@ -128,7 +120,6 @@ async fn ping_idle_peers(
async fn prune_stale_peers(
peer_game_db: &Arc<RwLock<PeerGameDB>>,
catalog: &Arc<RwLock<GameCatalog>>,
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
tx_notify_ui: &UnboundedSender<PeerEvent>,
@@ -146,7 +137,7 @@ async fn prune_stale_peers(
}
if removed_any {
events::emit_peer_game_list(peer_game_db, catalog, tx_notify_ui).await;
events::emit_peer_game_list(peer_game_db, tx_notify_ui).await;
handle_active_downloads_without_peers(
peer_game_db,
active_operations,
@@ -159,7 +150,6 @@ async fn prune_stale_peers(
async fn remove_peer_and_refresh(
peer_game_db: &Arc<RwLock<PeerGameDB>>,
catalog: &Arc<RwLock<GameCatalog>>,
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
tx_notify_ui: &UnboundedSender<PeerEvent>,
@@ -167,7 +157,7 @@ async fn remove_peer_and_refresh(
log_label: &str,
) {
if remove_peer(peer_game_db, tx_notify_ui, peer_id, log_label).await {
events::emit_peer_game_list(peer_game_db, catalog, tx_notify_ui).await;
events::emit_peer_game_list(peer_game_db, tx_notify_ui).await;
handle_active_downloads_without_peers(
peer_game_db,
active_operations,
@@ -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, ctx.state_dir.as_ref(), &catalog, &id).await {
match rescan_local_game(&game_dir, &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, ctx.state_dir.as_ref(), &catalog).await {
match scan_local_library(&game_dir, &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,18 +373,15 @@ mod tests {
std::fs::write(path, bytes).expect("file should be written");
}
fn test_ctx(game_dir: PathBuf, catalog: GameCatalog) -> Ctx {
fn test_ctx(game_dir: PathBuf, catalog: HashSet<String>) -> Ctx {
Ctx::new(
Arc::new(RwLock::new(PeerGameDB::new())),
"peer".to_string(),
game_dir.clone(),
game_dir.join(".test-state"),
game_dir,
Arc::new(NoopUnpacker),
CancellationToken::new(),
TaskTracker::new(),
Arc::new(RwLock::new(catalog)),
Arc::new(RwLock::new(std::collections::HashMap::new())),
Arc::new(crate::NoopStreamInstallProvider),
)
}
@@ -447,7 +444,7 @@ mod tests {
let temp = TempDir::new("lanspread-local-monitor");
let ctx = test_ctx(
temp.path().to_path_buf(),
GameCatalog::from_ids(["game".to_string()]),
HashSet::from(["game".to_string()]),
);
ctx.active_operations
.write()
@@ -482,7 +479,7 @@ mod tests {
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
let ctx = test_ctx(
temp.path().to_path_buf(),
GameCatalog::from_ids(["game".to_string()]),
HashSet::from(["game".to_string()]),
);
let gate = RescanGate::default();
let (tx, mut rx) = mpsc::unbounded_channel();
@@ -517,7 +514,7 @@ mod tests {
write_file(&game_root.join("version.ini"), b"20250101");
let ctx = test_ctx(
temp.path().to_path_buf(),
GameCatalog::from_ids(["game".to_string()]),
HashSet::from(["game".to_string()]),
);
let gate = RescanGate::default();
let (tx, mut rx) = mpsc::unbounded_channel();
@@ -553,7 +550,7 @@ mod tests {
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
let ctx = test_ctx(
temp.path().to_path_buf(),
GameCatalog::from_ids(["game".to_string()]),
HashSet::from(["game".to_string()]),
);
let (tx, mut rx) = mpsc::unbounded_channel();
@@ -577,7 +574,7 @@ mod tests {
);
let ctx = test_ctx(
temp.path().to_path_buf(),
GameCatalog::from_ids(["game".to_string()]),
HashSet::from(["game".to_string()]),
);
let (tx, mut rx) = mpsc::unbounded_channel();
+26 -212
View File
@@ -12,10 +12,9 @@ use crate::{
context::PeerCtx,
error::PeerError,
events,
local_games::{get_game_file_descriptions, is_local_dir_name, local_download_matches_catalog},
local_games::{get_game_file_descriptions, is_local_dir_name, local_download_available},
peer::{send_game_file_chunk, send_game_file_data},
services::handshake::{HandshakeCtx, accept_inbound_hello, spawn_library_resync},
stream_install::{send_game_install_stream, send_stream_install_error},
};
type ResponseWriter = FramedWrite<SendStream, LengthDelimitedCodec>;
@@ -100,9 +99,6 @@ async fn dispatch_request(
} => {
handle_file_chunk_request(ctx, game_id, relative_path, offset, length, framed_tx).await
}
Request::StreamInstall { game_id } => {
handle_stream_install_request(ctx, game_id, framed_tx).await
}
Request::Goodbye { peer_id } => {
handle_goodbye(ctx, remote_addr, peer_id).await;
framed_tx
@@ -166,7 +162,7 @@ async fn handle_library_delta(ctx: &PeerCtx, peer_id: String, delta: LibraryDelt
};
if applied {
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
} else {
let addr = {
let db = ctx.peer_game_db.read().await;
@@ -213,7 +209,7 @@ async fn get_game_response(ctx: &PeerCtx, id: String) -> Response {
async fn can_serve_game(ctx: &PeerCtx, game_dir: &std::path::Path, game_id: &str) -> bool {
let active_operations = ctx.active_operations.read().await;
let catalog = ctx.catalog.read().await;
local_download_matches_catalog(game_dir, game_id, &active_operations, &catalog).await
local_download_available(game_dir, game_id, &active_operations, &catalog).await
}
async fn can_dispatch_file_transfer(
@@ -222,23 +218,10 @@ async fn can_dispatch_file_transfer(
game_id: &str,
relative_path: &str,
) -> bool {
relative_path_belongs_to_game(game_id, relative_path)
&& !path_points_inside_local(game_id, relative_path)
!path_points_inside_local(game_id, relative_path)
&& can_serve_game(ctx, game_dir, game_id).await
}
fn relative_path_belongs_to_game(game_id: &str, relative_path: &str) -> bool {
let normalised = relative_path.replace('\\', "/");
if normalised.starts_with('/') {
return false;
}
normalised
.split('/')
.find(|part| !part.is_empty())
.is_some_and(|first| first == game_id)
}
fn path_points_inside_local(game_id: &str, relative_path: &str) -> bool {
let normalised = relative_path.replace('\\', "/");
let mut parts = normalised.split('/').filter(|part| !part.is_empty());
@@ -249,67 +232,6 @@ fn path_points_inside_local(game_id: &str, relative_path: &str) -> bool {
}
}
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_TRANSFER_ID: AtomicU64 = AtomicU64::new(1);
struct TransferGuard {
game_id: String,
id: u64,
active_outbound_transfers: crate::context::OutboundTransfers,
tx_notify_ui: tokio::sync::mpsc::UnboundedSender<crate::PeerEvent>,
}
impl TransferGuard {
async fn new(
game_id: String,
active_outbound_transfers: crate::context::OutboundTransfers,
tx_notify_ui: tokio::sync::mpsc::UnboundedSender<crate::PeerEvent>,
shutdown: &tokio_util::sync::CancellationToken,
) -> (Self, tokio_util::sync::CancellationToken) {
let id = NEXT_TRANSFER_ID.fetch_add(1, Ordering::SeqCst);
let token = shutdown.child_token();
{
let mut active = active_outbound_transfers.write().await;
active
.entry(game_id.clone())
.or_default()
.push((id, token.clone()));
}
let _ = tx_notify_ui.send(crate::PeerEvent::OutboundTransferCountChanged);
(
Self {
game_id,
id,
active_outbound_transfers,
tx_notify_ui,
},
token,
)
}
}
impl Drop for TransferGuard {
fn drop(&mut self) {
let game_id = self.game_id.clone();
let id = self.id;
let active_outbound_transfers = self.active_outbound_transfers.clone();
let tx_notify_ui = self.tx_notify_ui.clone();
tokio::spawn(async move {
{
let mut active = active_outbound_transfers.write().await;
if let Some(tokens) = active.get_mut(&game_id) {
tokens.retain(|(tid, _)| *tid != id);
if tokens.is_empty() {
active.remove(&game_id);
}
}
}
let _ = tx_notify_ui.send(crate::PeerEvent::OutboundTransferCountChanged);
});
}
}
async fn handle_file_data_request(
ctx: &PeerCtx,
desc: GameFileDescription,
@@ -320,14 +242,6 @@ async fn handle_file_data_request(
desc.relative_path
);
let (guard, cancel_token) = TransferGuard::new(
desc.game_id.clone(),
ctx.active_outbound_transfers.clone(),
ctx.tx_notify_ui.clone(),
&ctx.shutdown,
)
.await;
let mut tx = framed_tx.into_inner();
let game_dir = ctx.game_dir.read().await.clone();
if !can_dispatch_file_transfer(ctx, &game_dir, &desc.game_id, &desc.relative_path).await {
@@ -335,13 +249,11 @@ async fn handle_file_data_request(
"Declining GetGameFileData for {} because the game is not currently transferable",
desc.relative_path
);
drop(guard);
let _ = tx.close().await;
return FramedWrite::new(tx, LengthDelimitedCodec::new());
}
send_game_file_data(&desc, &mut tx, &game_dir, cancel_token).await;
drop(guard);
send_game_file_data(&desc, &mut tx, &game_dir).await;
FramedWrite::new(tx, LengthDelimitedCodec::new())
}
@@ -357,99 +269,37 @@ async fn handle_file_chunk_request(
"Received GetGameFileChunk request for {relative_path} (offset {offset}, length {length})"
);
let (guard, cancel_token) = TransferGuard::new(
game_id.clone(),
ctx.active_outbound_transfers.clone(),
ctx.tx_notify_ui.clone(),
&ctx.shutdown,
)
.await;
let mut tx = framed_tx.into_inner();
let game_dir = ctx.game_dir.read().await.clone();
if !can_dispatch_file_transfer(ctx, &game_dir, &game_id, &relative_path).await {
log::info!(
"Declining GetGameFileChunk for {relative_path} because the game is not currently transferable"
);
drop(guard);
let _ = tx.close().await;
return FramedWrite::new(tx, LengthDelimitedCodec::new());
}
send_game_file_chunk(
&game_id,
&relative_path,
offset,
length,
&mut tx,
&game_dir,
cancel_token,
)
.await;
drop(guard);
send_game_file_chunk(&game_id, &relative_path, offset, length, &mut tx, &game_dir).await;
FramedWrite::new(tx, LengthDelimitedCodec::new())
}
async fn handle_stream_install_request(
ctx: &PeerCtx,
game_id: String,
framed_tx: ResponseWriter,
) -> ResponseWriter {
log::info!("Received StreamInstall request for {game_id} from peer");
let (guard, cancel_token) = TransferGuard::new(
game_id.clone(),
ctx.active_outbound_transfers.clone(),
ctx.tx_notify_ui.clone(),
&ctx.shutdown,
)
.await;
let mut tx = framed_tx.into_inner();
let game_dir = ctx.game_dir.read().await.clone();
if !can_serve_game(ctx, &game_dir, &game_id).await {
log::info!(
"Declining StreamInstall for {game_id} because the game is not currently transferable"
);
tx = send_stream_install_error(tx, format!("game {game_id} is not transferable")).await;
drop(guard);
return FramedWrite::new(tx, LengthDelimitedCodec::new());
}
let game_root = game_dir.join(&game_id);
let (returned_tx, result) = send_game_install_stream(
ctx.stream_install_provider.clone(),
tx,
&game_root,
&game_id,
cancel_token,
)
.await;
if let Err(err) = result {
log::warn!("StreamInstall for {game_id} ended with error: {err}");
}
drop(guard);
FramedWrite::new(returned_tx, LengthDelimitedCodec::new())
}
async fn handle_goodbye(ctx: &PeerCtx, _remote_addr: Option<SocketAddr>, peer_id: String) {
log::info!("Received Goodbye from peer {peer_id}");
let removed = { ctx.peer_game_db.write().await.remove_peer(&peer_id) };
let Some(peer) = removed else { return };
events::emit_peer_lost(&ctx.peer_game_db, &ctx.tx_notify_ui, peer.addr).await;
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
}
#[cfg(test)]
mod tests {
use std::{
collections::HashSet,
path::{Path, PathBuf},
sync::Arc,
};
use lanspread_db::db::GameCatalog;
use tokio::sync::{RwLock, mpsc};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
@@ -477,19 +327,16 @@ mod tests {
std::fs::write(path, bytes).expect("file should be written");
}
fn test_ctx(game_dir: PathBuf, catalog: GameCatalog) -> PeerCtx {
fn test_ctx(game_dir: PathBuf, catalog: HashSet<String>) -> PeerCtx {
let (tx_notify_ui, _rx) = mpsc::unbounded_channel();
Ctx::new(
Arc::new(RwLock::new(PeerGameDB::new())),
"peer".to_string(),
game_dir.clone(),
game_dir.join(".test-state"),
game_dir,
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)
}
@@ -503,19 +350,6 @@ mod tests {
assert!(!path_points_inside_local("game", "game/archive.eti"));
}
#[test]
fn transferable_paths_must_belong_to_requested_game() {
assert!(relative_path_belongs_to_game("game", "game/version.ini"));
assert!(relative_path_belongs_to_game("game", "game\\archive.eti"));
assert!(!relative_path_belongs_to_game("game", "other/archive.eti"));
assert!(!relative_path_belongs_to_game("game", "archive.eti"));
assert!(!relative_path_belongs_to_game("game", "/game/archive.eti"));
assert!(!relative_path_belongs_to_game(
"game",
"../game/archive.eti"
));
}
#[tokio::test]
async fn get_game_response_respects_serve_gates() {
let temp = TempDir::new("lanspread-stream");
@@ -525,19 +359,17 @@ mod tests {
b"20250101",
);
write_file(&temp.path().join("active").join("version.ini"), b"20250101");
write_file(
&temp.path().join("wrong-version").join("version.ini"),
b"20260101",
);
std::fs::create_dir_all(temp.path().join("missing-sentinel"))
.expect("missing sentinel root should be created");
let mut catalog = GameCatalog::empty();
catalog.insert("ready".to_string(), Some("20250101".to_string()));
catalog.insert("active".to_string(), Some("20250101".to_string()));
catalog.insert("missing-sentinel".to_string(), Some("20250101".to_string()));
catalog.insert("wrong-version".to_string(), Some("20250101".to_string()));
let ctx = test_ctx(temp.path().to_path_buf(), catalog);
let ctx = test_ctx(
temp.path().to_path_buf(),
HashSet::from([
"ready".to_string(),
"active".to_string(),
"missing-sentinel".to_string(),
]),
);
ctx.active_operations
.write()
.await
@@ -555,10 +387,6 @@ mod tests {
get_game_response(&ctx, "active".to_string()).await,
Response::GameNotFound(id) if id == "active"
));
assert!(matches!(
get_game_response(&ctx, "wrong-version".to_string()).await,
Response::GameNotFound(id) if id == "wrong-version"
));
assert!(matches!(
get_game_response(&ctx, "missing-sentinel".to_string()).await,
Response::GameNotFound(id) if id == "missing-sentinel"
@@ -574,28 +402,23 @@ mod tests {
b"20250101",
);
write_file(&temp.path().join("active").join("version.ini"), b"20250101");
write_file(
&temp.path().join("wrong-version").join("version.ini"),
b"20260101",
);
std::fs::create_dir_all(temp.path().join("missing-sentinel"))
.expect("missing sentinel root should be created");
let mut catalog = GameCatalog::empty();
catalog.insert("ready".to_string(), Some("20250101".to_string()));
catalog.insert("active".to_string(), Some("20250101".to_string()));
catalog.insert("missing-sentinel".to_string(), Some("20250101".to_string()));
catalog.insert("wrong-version".to_string(), Some("20250101".to_string()));
let ctx = test_ctx(temp.path().to_path_buf(), catalog);
let ctx = test_ctx(
temp.path().to_path_buf(),
HashSet::from([
"ready".to_string(),
"active".to_string(),
"missing-sentinel".to_string(),
]),
);
ctx.active_operations
.write()
.await
.insert("active".to_string(), OperationKind::Downloading);
assert!(can_dispatch_file_transfer(&ctx, temp.path(), "ready", "ready/version.ini").await);
assert!(
!can_dispatch_file_transfer(&ctx, temp.path(), "ready", "active/version.ini").await
);
assert!(
!can_dispatch_file_transfer(
&ctx,
@@ -608,15 +431,6 @@ mod tests {
assert!(
!can_dispatch_file_transfer(&ctx, temp.path(), "active", "active/version.ini").await
);
assert!(
!can_dispatch_file_transfer(
&ctx,
temp.path(),
"wrong-version",
"wrong-version/version.ini",
)
.await
);
assert!(
!can_dispatch_file_transfer(
&ctx,
+1 -12
View File
@@ -11,7 +11,6 @@ use std::{
};
use futures::FutureExt as _;
use lanspread_db::db::GameCatalog;
use tokio::sync::{
RwLock,
mpsc::{UnboundedReceiver, UnboundedSender},
@@ -23,7 +22,6 @@ use crate::{
PeerCommand,
PeerEvent,
PeerRuntimeComponent,
StreamInstallProvider,
Unpacker,
context::Ctx,
events,
@@ -84,11 +82,8 @@ 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<GameCatalog>>,
active_outbound_transfers: crate::context::OutboundTransfers,
stream_install_provider: Arc<dyn StreamInstallProvider>,
catalog: Arc<RwLock<std::collections::HashSet<String>>>,
) -> PeerRuntimeHandle {
let shutdown = CancellationToken::new();
let task_tracker = TaskTracker::new();
@@ -103,13 +98,10 @@ 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
{
@@ -196,7 +188,6 @@ fn spawn_peer_discovery_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEv
fn spawn_peer_liveness_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
let tx_notify_ui = tx_notify_ui.clone();
let peer_game_db = ctx.peer_game_db.clone();
let catalog = ctx.catalog.clone();
let active_operations = ctx.active_operations.clone();
let active_downloads = ctx.active_downloads.clone();
let shutdown = ctx.shutdown.clone();
@@ -214,7 +205,6 @@ fn spawn_peer_liveness_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEve
move || {
let tx_notify_ui = tx_notify_ui.clone();
let peer_game_db = peer_game_db.clone();
let catalog = catalog.clone();
let active_operations = active_operations.clone();
let active_downloads = active_downloads.clone();
let shutdown = shutdown.clone();
@@ -223,7 +213,6 @@ fn spawn_peer_liveness_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEve
run_ping_service(
tx_notify_ui,
peer_game_db,
catalog,
active_operations,
active_downloads,
shutdown,
-48
View File
@@ -1,48 +0,0 @@
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)
}
-958
View File
@@ -1,958 +0,0 @@
use std::{
future::Future,
net::SocketAddr,
path::{Path, PathBuf},
pin::Pin,
process::Stdio,
sync::Arc,
time::{Duration, Instant},
};
use bytes::Bytes;
use crc32fast::Hasher;
use futures::{SinkExt, StreamExt};
use lanspread_proto::{Message, Request, StreamInstallFrame};
use s2n_quic::stream::SendStream;
use tokio::{
fs::File,
io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
process::Command,
sync::{mpsc, mpsc::UnboundedSender},
time::{self, MissedTickBehavior},
};
use tokio_util::{
codec::{FramedRead, FramedWrite, LengthDelimitedCodec},
sync::CancellationToken,
};
use crate::{
DownloadProgress,
PeerEvent,
install::root_eti_archives,
network::connect_to_peer,
path_validation::validate_game_file_path,
};
const FRAME_CHANNEL_DEPTH: usize = 16;
const STREAM_INSTALL_PROGRESS_UPDATE_INTERVAL: Duration = Duration::from_millis(500);
const STREAM_CHUNK_SIZE: usize = 256 * 1024;
/// Integrity metadata advertised by the sender's RAR archive.
///
/// This catches transport corruption, truncation, and provider bugs. It is not
/// a trusted-content guarantee because a malicious peer controls both the bytes
/// and the archive metadata. Trusted content would need catalog-owned hashes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SenderArchiveIntegrity {
expected_size: u64,
expected_crc32: u32,
}
impl SenderArchiveIntegrity {
fn new(expected_size: u64, expected_crc32: u32) -> Self {
Self {
expected_size,
expected_crc32,
}
}
fn verify(self, relative_path: &str, received: u64, actual_crc32: u32) -> eyre::Result<()> {
if received != self.expected_size {
eyre::bail!(
"streamed file {relative_path} size mismatch: got {received}, expected {}",
self.expected_size
);
}
if actual_crc32 != self.expected_crc32 {
eyre::bail!(
"streamed file {relative_path} sender RAR CRC32 mismatch: got {actual_crc32:08X}, expected {:08X}",
self.expected_crc32
);
}
Ok(())
}
}
pub type StreamInstallFuture<'a> = Pin<Box<dyn Future<Output = eyre::Result<()>> + Send + 'a>>;
#[derive(Clone)]
pub struct StreamInstallFrameSink {
frames: mpsc::Sender<StreamInstallFrame>,
cancel_token: CancellationToken,
}
impl StreamInstallFrameSink {
fn new(frames: mpsc::Sender<StreamInstallFrame>, cancel_token: CancellationToken) -> Self {
Self {
frames,
cancel_token,
}
}
pub async fn send(&self, frame: StreamInstallFrame) -> eyre::Result<()> {
tokio::select! {
() = self.cancel_token.cancelled() => {
eyre::bail!("streamed install frame send was cancelled");
}
result = self.frames.send(frame) => {
result.map_err(|_| eyre::eyre!("streamed install frame receiver closed"))
}
}
}
}
pub trait StreamInstallProvider: Send + Sync {
fn stream_archive<'a>(
&'a self,
archive: &'a Path,
frames: StreamInstallFrameSink,
cancel_token: CancellationToken,
) -> StreamInstallFuture<'a>;
}
#[derive(Debug, Default)]
pub struct NoopStreamInstallProvider;
impl StreamInstallProvider for NoopStreamInstallProvider {
fn stream_archive<'a>(
&'a self,
archive: &'a Path,
_frames: StreamInstallFrameSink,
_cancel_token: CancellationToken,
) -> StreamInstallFuture<'a> {
Box::pin(async move {
eyre::bail!(
"streamed install provider is not configured for {}",
archive.display()
)
})
}
}
#[derive(Debug)]
pub struct ExternalUnrarStreamProvider {
program: PathBuf,
}
impl ExternalUnrarStreamProvider {
#[must_use]
pub fn new(program: PathBuf) -> Self {
Self { program }
}
}
impl StreamInstallProvider for ExternalUnrarStreamProvider {
fn stream_archive<'a>(
&'a self,
archive: &'a Path,
frames: StreamInstallFrameSink,
cancel_token: CancellationToken,
) -> StreamInstallFuture<'a> {
Box::pin(async move {
let listing = unrar_listing(&self.program, archive).await?;
let archive_name = archive
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("archive.eti")
.to_string();
frames
.send(StreamInstallFrame::ArchiveBegin {
archive_name: archive_name.clone(),
solid: listing.solid,
unpacked_size: listing.unpacked_size(),
})
.await?;
stream_unrar_entries(
&self.program,
archive,
&listing.entries,
&frames,
cancel_token.clone(),
)
.await?;
frames
.send(StreamInstallFrame::ArchiveEnd { archive_name })
.await
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RarListing {
solid: bool,
entries: Vec<RarEntry>,
}
impl RarListing {
fn unpacked_size(&self) -> u64 {
self.entries
.iter()
.filter(|entry| entry.kind == RarEntryKind::File)
.map(|entry| entry.size)
.sum()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RarEntry {
relative_path: String,
kind: RarEntryKind,
size: u64,
crc32: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RarEntryKind {
File,
Directory,
}
#[derive(Default)]
struct RarEntryDraft {
relative_path: Option<String>,
kind: Option<RarEntryKind>,
size: Option<u64>,
crc32: Option<u32>,
}
async fn unrar_listing(program: &Path, archive: &Path) -> eyre::Result<RarListing> {
let output = Command::new(program)
.arg("lt")
.arg("-cfg-")
.arg(archive)
.output()
.await?;
if !output.status.success() {
eyre::bail!(
"unrar lt failed for {} with status {}: {}",
archive.display(),
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
parse_unrar_listing(&String::from_utf8_lossy(&output.stdout))
}
fn parse_unrar_listing(output: &str) -> eyre::Result<RarListing> {
let mut solid = false;
let mut entries = Vec::new();
let mut current = RarEntryDraft::default();
for line in output.lines() {
let trimmed = line.trim();
if let Some(details) = trimmed.strip_prefix("Details:") {
solid = details.to_ascii_lowercase().contains("solid");
continue;
}
if let Some(name) = trimmed.strip_prefix("Name:") {
push_rar_entry(&mut entries, std::mem::take(&mut current))?;
current.relative_path = Some(name.trim().to_string());
continue;
}
if let Some(kind) = trimmed.strip_prefix("Type:") {
current.kind = match kind.trim() {
"File" => Some(RarEntryKind::File),
"Directory" => Some(RarEntryKind::Directory),
_ => None,
};
continue;
}
if let Some(size) = trimmed.strip_prefix("Size:") {
current.size = Some(size.trim().parse()?);
continue;
}
if let Some(crc) = trimmed.strip_prefix("CRC32:") {
current.crc32 = Some(u32::from_str_radix(crc.trim(), 16)?);
}
}
push_rar_entry(&mut entries, current)?;
Ok(RarListing { solid, entries })
}
fn push_rar_entry(entries: &mut Vec<RarEntry>, draft: RarEntryDraft) -> eyre::Result<()> {
let Some(relative_path) = draft.relative_path else {
return Ok(());
};
let Some(kind) = draft.kind else {
return Ok(());
};
let (size, crc32) = match kind {
RarEntryKind::File => {
let size = draft
.size
.ok_or_else(|| eyre::eyre!("RAR file entry {relative_path} has no Size"))?;
let crc32 = match (size, draft.crc32) {
(_, Some(crc32)) => crc32,
(0, None) => 0,
(_, None) => {
eyre::bail!("RAR file entry {relative_path} has no CRC32");
}
};
(size, Some(crc32))
}
RarEntryKind::Directory => (0, None),
};
entries.push(RarEntry {
relative_path,
kind,
size,
crc32,
});
Ok(())
}
async fn stream_unrar_entries(
program: &Path,
archive: &Path,
entries: &[RarEntry],
frames: &StreamInstallFrameSink,
cancel_token: CancellationToken,
) -> eyre::Result<()> {
let mut child = Command::new(program)
.arg("p")
.arg("-inul")
.arg("-cfg-")
.arg(archive)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()?;
let result = async {
let mut stdout = child
.stdout
.take()
.ok_or_else(|| eyre::eyre!("unrar stdout was not captured"))?;
let mut buffer = vec![0_u8; STREAM_CHUNK_SIZE];
for entry in entries {
if cancel_token.is_cancelled() {
eyre::bail!("streamed archive {} was cancelled", archive.display());
}
match entry.kind {
RarEntryKind::Directory => {
frames
.send(StreamInstallFrame::Directory {
relative_path: entry.relative_path.clone(),
})
.await?;
}
RarEntryKind::File => {
let Some(crc32) = entry.crc32 else {
eyre::bail!("RAR file entry {} has no CRC32", entry.relative_path);
};
frames
.send(StreamInstallFrame::FileBegin {
relative_path: entry.relative_path.clone(),
size: entry.size,
crc32,
})
.await?;
stream_unrar_file_from_stdout(
&mut stdout,
archive,
entry,
frames,
&mut buffer,
&cancel_token,
)
.await?;
frames
.send(StreamInstallFrame::FileEnd {
relative_path: entry.relative_path.clone(),
})
.await?;
}
}
}
let extra =
read_unrar_stdout(&mut stdout, &mut buffer[..1], &cancel_token, archive).await?;
if extra != 0 {
eyre::bail!(
"unrar produced bytes after listed entries for {}",
archive.display()
);
}
let status = wait_unrar_child(&mut child, &cancel_token, archive).await?;
if !status.success() {
eyre::bail!(
"unrar p failed for {} with status {status}",
archive.display()
);
}
Ok(())
}
.await;
if result.is_err() {
let _ = child.kill().await;
}
result
}
async fn stream_unrar_file_from_stdout(
stdout: &mut (impl AsyncRead + Unpin),
archive: &Path,
entry: &RarEntry,
frames: &StreamInstallFrameSink,
buffer: &mut [u8],
cancel_token: &CancellationToken,
) -> eyre::Result<()> {
let mut remaining = entry.size;
while remaining > 0 {
let read_len = usize::try_from(remaining.min(u64::try_from(buffer.len())?))?;
let read =
read_unrar_stdout(stdout, &mut buffer[..read_len], cancel_token, archive).await?;
if read == 0 {
eyre::bail!(
"unrar ended while streaming {} from {}; {remaining} bytes missing",
entry.relative_path,
archive.display()
);
}
frames
.send(StreamInstallFrame::FileChunk {
bytes: Bytes::copy_from_slice(&buffer[..read]),
})
.await?;
remaining = remaining.saturating_sub(u64::try_from(read)?);
}
Ok(())
}
async fn read_unrar_stdout(
stdout: &mut (impl AsyncRead + Unpin),
buffer: &mut [u8],
cancel_token: &CancellationToken,
archive: &Path,
) -> eyre::Result<usize> {
tokio::select! {
() = cancel_token.cancelled() => {
eyre::bail!("streamed archive {} was cancelled", archive.display());
}
read = stdout.read(buffer) => Ok(read?),
}
}
async fn wait_unrar_child(
child: &mut tokio::process::Child,
cancel_token: &CancellationToken,
archive: &Path,
) -> eyre::Result<std::process::ExitStatus> {
tokio::select! {
() = cancel_token.cancelled() => {
let _ = child.kill().await;
eyre::bail!("streamed archive {} was cancelled", archive.display());
}
status = child.wait() => Ok(status?),
}
}
pub(crate) async fn send_stream_install_error(
tx: SendStream,
message: impl Into<String>,
) -> SendStream {
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
if let Err(err) = framed_tx
.send(
StreamInstallFrame::Error {
message: message.into(),
}
.encode(),
)
.await
{
log::warn!("Failed to send streamed install error frame: {err}");
}
if let Err(err) = framed_tx.close().await {
log::debug!("Failed to close streamed install error response: {err}");
}
framed_tx.into_inner()
}
pub(crate) async fn send_game_install_stream(
provider: Arc<dyn StreamInstallProvider>,
tx: SendStream,
game_root: &Path,
game_id: &str,
cancel_token: CancellationToken,
) -> (SendStream, eyre::Result<()>) {
let archives = match root_eti_archives(game_root).await {
Ok(archives) => archives,
Err(err) => {
let message = err.to_string();
let tx = send_stream_install_error(tx, message.clone()).await;
return (tx, Err(eyre::eyre!(message)));
}
};
if archives.is_empty() {
let message = format!("no .eti archives found for {game_id}");
let tx = send_stream_install_error(tx, message.clone()).await;
return (tx, Err(eyre::eyre!(message)));
}
let (frame_tx, mut frame_rx) = mpsc::channel(FRAME_CHANNEL_DEPTH);
let producer_cancel = cancel_token.child_token();
let frame_sink = StreamInstallFrameSink::new(frame_tx, producer_cancel.clone());
let game_id_for_producer = game_id.to_string();
let producer = tokio::spawn({
let provider = provider.clone();
let producer_cancel = producer_cancel.clone();
async move {
for archive in archives {
if producer_cancel.is_cancelled() {
eyre::bail!("streamed install for {game_id_for_producer} was cancelled");
}
if let Err(err) = provider
.stream_archive(&archive, frame_sink.clone(), producer_cancel.clone())
.await
{
let message = err.to_string();
let _ = frame_sink.send(StreamInstallFrame::Error { message }).await;
return Err(err);
}
}
let _ = frame_sink.send(StreamInstallFrame::Complete).await;
Ok(())
}
});
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
let mut send_result = Ok(());
while let Some(frame) = frame_rx.recv().await {
if let Err(err) = framed_tx.send(frame.encode()).await {
producer_cancel.cancel();
send_result = Err(eyre::eyre!("failed to send streamed install frame: {err}"));
break;
}
}
drop(frame_rx);
let close_result = framed_tx
.close()
.await
.map_err(|err| eyre::eyre!("failed to close streamed install stream: {err}"));
let tx = framed_tx.into_inner();
let producer_result = match producer.await {
Ok(result) => result,
Err(err) => Err(eyre::eyre!("streamed install producer task failed: {err}")),
};
let result = send_result.and(producer_result).and(close_result);
(tx, result)
}
pub(crate) async fn receive_streamed_install(
peer_addr: SocketAddr,
game_id: &str,
staging_dir: &Path,
tx_notify_ui: UnboundedSender<PeerEvent>,
cancel_token: CancellationToken,
) -> eyre::Result<()> {
let staging_dir = tokio::fs::canonicalize(staging_dir)
.await
.unwrap_or_else(|_| staging_dir.to_path_buf());
let mut conn = connect_to_peer(peer_addr).await?;
let stream = conn.open_bidirectional_stream().await?;
let (rx, tx) = stream.split();
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
framed_tx
.send(
Request::StreamInstall {
game_id: game_id.to_string(),
}
.encode(),
)
.await?;
framed_tx.close().await?;
let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new());
let mut current_file: Option<IncomingFile> = None;
let mut progress = StreamInstallProgress::new(game_id.to_string());
let mut progress_interval = time::interval(STREAM_INSTALL_PROGRESS_UPDATE_INTERVAL);
progress_interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
progress_interval.tick().await;
loop {
let next = tokio::select! {
() = cancel_token.cancelled() => eyre::bail!("streamed install for {game_id} was cancelled"),
_ = progress_interval.tick() => {
progress.emit_current(&tx_notify_ui);
continue;
}
next = framed_rx.next() => next,
};
let Some(frame) = next else {
eyre::bail!("streamed install ended before Complete");
};
let frame = frame?.freeze();
let frame = StreamInstallFrame::decode(frame);
match frame {
StreamInstallFrame::ArchiveBegin {
archive_name,
solid,
unpacked_size,
} => {
progress.add_total(unpacked_size);
progress.emit_snapshot(&tx_notify_ui, 0);
log::info!(
"Receiving streamed install archive {archive_name} for {game_id} \
(solid={solid}, unpacked_size={unpacked_size})"
);
}
StreamInstallFrame::Directory { relative_path } => {
let path = resolve_stream_path(&staging_dir, &relative_path)?;
tokio::fs::create_dir_all(path).await?;
}
StreamInstallFrame::FileBegin {
relative_path,
size,
crc32,
} => {
if current_file.is_some() {
eyre::bail!("received FileBegin for {relative_path} before previous FileEnd");
}
let path = resolve_stream_path(&staging_dir, &relative_path)?;
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let file = File::create(&path).await?;
current_file = Some(IncomingFile::new(relative_path, path, size, crc32, file));
}
StreamInstallFrame::FileChunk { bytes } => {
let Some(file) = current_file.as_mut() else {
eyre::bail!("received FileChunk without FileBegin");
};
let length = file
.write_chunk(game_id, peer_addr, &tx_notify_ui, bytes)
.await?;
progress.record_bytes(length);
}
StreamInstallFrame::FileEnd { relative_path } => {
let Some(file) = current_file.take() else {
eyre::bail!("received FileEnd for {relative_path} without FileBegin");
};
file.finish(&relative_path).await?;
}
StreamInstallFrame::ArchiveEnd { archive_name } => {
log::info!("Finished streamed install archive {archive_name} for {game_id}");
}
StreamInstallFrame::Complete => {
if current_file.is_some() {
eyre::bail!("streamed install completed with an open file");
}
progress.emit_snapshot(&tx_notify_ui, 0);
return Ok(());
}
StreamInstallFrame::Error { message } => {
eyre::bail!("streamed install sender failed: {message}");
}
}
}
}
struct StreamInstallProgress {
id: String,
total_bytes: u64,
downloaded_bytes: u64,
last_downloaded_bytes: u64,
last_at: Instant,
}
impl StreamInstallProgress {
fn new(id: String) -> Self {
Self {
id,
total_bytes: 0,
downloaded_bytes: 0,
last_downloaded_bytes: 0,
last_at: Instant::now(),
}
}
fn add_total(&mut self, bytes: u64) {
self.total_bytes = self.total_bytes.saturating_add(bytes);
}
fn record_bytes(&mut self, bytes: u64) {
self.downloaded_bytes = self.downloaded_bytes.saturating_add(bytes);
}
fn emit_current(&mut self, tx_notify_ui: &UnboundedSender<PeerEvent>) {
let now = Instant::now();
let speed = bytes_per_second(
self.downloaded_bytes
.saturating_sub(self.last_downloaded_bytes),
now.duration_since(self.last_at),
);
self.last_downloaded_bytes = self.downloaded_bytes;
self.last_at = now;
self.emit_snapshot(tx_notify_ui, speed);
}
fn emit_snapshot(&self, tx_notify_ui: &UnboundedSender<PeerEvent>, bytes_per_second: u64) {
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFilesProgress(DownloadProgress {
id: self.id.clone(),
downloaded_bytes: self.downloaded_bytes,
total_bytes: self.total_bytes,
bytes_per_second,
active_peer_count: 1,
}));
}
}
fn bytes_per_second(bytes: u64, elapsed: Duration) -> u64 {
let millis = elapsed.as_millis().max(1);
let rate = u128::from(bytes).saturating_mul(1_000) / millis;
u64::try_from(rate).unwrap_or(u64::MAX)
}
struct IncomingFile {
relative_path: String,
path: PathBuf,
integrity: SenderArchiveIntegrity,
received: u64,
crc32: Hasher,
file: File,
}
impl IncomingFile {
fn new(
relative_path: String,
path: PathBuf,
expected_size: u64,
expected_crc32: u32,
file: File,
) -> Self {
Self {
relative_path,
path,
integrity: SenderArchiveIntegrity::new(expected_size, expected_crc32),
received: 0,
crc32: Hasher::new(),
file,
}
}
async fn write_chunk(
&mut self,
game_id: &str,
peer_addr: SocketAddr,
tx_notify_ui: &UnboundedSender<PeerEvent>,
bytes: Bytes,
) -> eyre::Result<u64> {
let offset = self.received;
let length = u64::try_from(bytes.len())?;
if offset.saturating_add(length) > self.integrity.expected_size {
eyre::bail!(
"streamed file {} exceeded expected size {}",
self.relative_path,
self.integrity.expected_size
);
}
self.file.write_all(&bytes).await?;
self.crc32.update(&bytes);
self.received = self.received.saturating_add(length);
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished {
id: game_id.to_string(),
peer_addr,
relative_path: format!("{game_id}/.local.installing/{}", self.relative_path),
offset,
length,
});
Ok(length)
}
async fn finish(mut self, relative_path: &str) -> eyre::Result<()> {
if self.relative_path != relative_path {
eyre::bail!(
"streamed file end mismatch: began {}, ended {relative_path}",
self.relative_path
);
}
self.file.flush().await?;
let actual_crc32 = self.crc32.finalize();
self.integrity
.verify(&self.relative_path, self.received, actual_crc32)?;
log::debug!(
"Received streamed file {} -> {}",
self.relative_path,
self.path.display()
);
Ok(())
}
}
fn resolve_stream_path(staging_dir: &Path, relative_path: &str) -> eyre::Result<PathBuf> {
validate_game_file_path(staging_dir, relative_path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::TempDir;
#[test]
fn stream_paths_stay_inside_staging_dir() {
let temp = TempDir::new("lanspread-stream-install-path");
let staging = temp.path().join("staging");
std::fs::create_dir_all(&staging).expect("staging should be created");
let staging = std::fs::canonicalize(staging).expect("staging should canonicalize");
assert!(resolve_stream_path(&staging, "bin/game.exe").is_ok());
assert!(resolve_stream_path(&staging, "../outside").is_err());
assert!(resolve_stream_path(&staging, "/absolute").is_err());
assert!(resolve_stream_path(&staging, "C:/windows").is_err());
}
#[test]
fn parses_unrar_technical_listing() {
let listing = parse_unrar_listing(
r#"
Archive: game.eti
Details: RAR 5, solid
Name: bin/payload.bin
Type: File
Size: 123
CRC32: 38B488A7
Name: bin
Type: Directory
"#,
)
.expect("listing should parse");
assert!(listing.solid);
assert_eq!(
listing.entries,
vec![
RarEntry {
relative_path: "bin/payload.bin".to_string(),
kind: RarEntryKind::File,
size: 123,
crc32: Some(0x38B4_88A7),
},
RarEntry {
relative_path: "bin".to_string(),
kind: RarEntryKind::Directory,
size: 0,
crc32: None,
},
]
);
}
#[test]
fn rejects_unrar_file_entries_without_crc32() {
let err = parse_unrar_listing(
r#"
Archive: game.eti
Details: RAR 5
Name: bin/payload.bin
Type: File
Size: 123
"#,
)
.expect_err("file entries without CRC32 should be rejected");
assert!(err.to_string().contains("has no CRC32"));
}
#[test]
fn accepts_zero_size_unrar_file_entries_without_crc32() {
let listing = parse_unrar_listing(
r#"
Archive: game.eti
Details: RAR 5
Name: bin/empty.cfg
Type: File
Size: 0
"#,
)
.expect("empty file without CRC32 should parse as CRC32 zero");
assert_eq!(
listing.entries,
vec![RarEntry {
relative_path: "bin/empty.cfg".to_string(),
kind: RarEntryKind::File,
size: 0,
crc32: Some(0),
}]
);
}
#[test]
fn sender_archive_integrity_accepts_matching_size_and_crc32() {
let bytes = b"payload";
let integrity =
SenderArchiveIntegrity::new(u64::try_from(bytes.len()).unwrap(), crc32_of(bytes));
integrity
.verify(
"bin/payload.bin",
u64::try_from(bytes.len()).unwrap(),
crc32_of(bytes),
)
.expect("matching sender archive metadata should verify");
}
#[test]
fn sender_archive_integrity_rejects_size_mismatch() {
let integrity = SenderArchiveIntegrity::new(7, crc32_of(b"payload"));
let err = integrity
.verify("bin/payload.bin", 6, crc32_of(b"payload"))
.expect_err("truncated file should fail sender archive integrity");
assert!(err.to_string().contains("size mismatch"));
}
#[test]
fn sender_archive_integrity_rejects_crc32_mismatch() {
let integrity = SenderArchiveIntegrity::new(7, crc32_of(b"payload"));
let err = integrity
.verify("bin/payload.bin", 7, crc32_of(b"paylord"))
.expect_err("mutated file should fail sender archive integrity");
assert!(err.to_string().contains("sender RAR CRC32 mismatch"));
}
fn crc32_of(bytes: &[u8]) -> u32 {
let mut hasher = Hasher::new();
hasher.update(bytes);
hasher.finalize()
}
}
+10 -10
View File
@@ -3,9 +3,13 @@ name = "lanspread-proto"
version = "0.1.0"
edition = "2024"
[lib]
doctest = false
test = false
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[dependencies]
# local
@@ -17,10 +21,6 @@ serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lints.rust]
unsafe_code = "forbid"
[lib]
test = false
doctest = false
+1 -98
View File
@@ -4,7 +4,7 @@ use bytes::Bytes;
use lanspread_db::db::{Game, GameFileDescription};
use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: u32 = 5;
pub const PROTOCOL_VERSION: u32 = 4;
pub use lanspread_db::db::Availability;
@@ -67,9 +67,6 @@ pub enum Request {
offset: u64,
length: u64,
},
StreamInstall {
game_id: String,
},
Hello(Hello),
LibraryDelta {
peer_id: String,
@@ -97,41 +94,6 @@ pub enum Response {
InternalPeerError(String),
}
const STREAM_INSTALL_CONTROL_FRAME_TAG: u8 = 0;
const STREAM_INSTALL_FILE_CHUNK_FRAME_TAG: u8 = 1;
const STREAM_INSTALL_ENCODE_ERROR_FRAME: &[u8] =
b"\0{\"Error\":{\"message\":\"stream install frame encoding error\"}}";
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum StreamInstallFrame {
ArchiveBegin {
archive_name: String,
solid: bool,
unpacked_size: u64,
},
Directory {
relative_path: String,
},
FileBegin {
relative_path: String,
size: u64,
crc32: u32,
},
FileChunk {
bytes: Bytes,
},
FileEnd {
relative_path: String,
},
ArchiveEnd {
archive_name: String,
},
Complete,
Error {
message: String,
},
}
// Add Message trait
pub trait Message {
fn decode(bytes: Bytes) -> Self;
@@ -183,62 +145,3 @@ impl Message for Response {
}
}
}
impl Message for StreamInstallFrame {
fn decode(bytes: Bytes) -> Self {
if bytes.is_empty() {
return stream_install_decode_error("stream install frame is empty");
}
let tag = bytes[0];
let payload = bytes.slice(1..);
match tag {
STREAM_INSTALL_CONTROL_FRAME_TAG => decode_stream_install_control_frame(&payload),
STREAM_INSTALL_FILE_CHUNK_FRAME_TAG => StreamInstallFrame::FileChunk { bytes: payload },
_ => stream_install_decode_error(format!("unknown stream install frame tag {tag}")),
}
}
fn encode(&self) -> Bytes {
match self {
StreamInstallFrame::FileChunk { bytes } => {
tagged_stream_install_frame(STREAM_INSTALL_FILE_CHUNK_FRAME_TAG, bytes)
}
_ => match serde_json::to_vec(self) {
Ok(payload) => {
tagged_stream_install_frame(STREAM_INSTALL_CONTROL_FRAME_TAG, &payload)
}
Err(e) => {
tracing::error!(?e, "StreamInstallFrame encoding error");
Bytes::from_static(STREAM_INSTALL_ENCODE_ERROR_FRAME)
}
},
}
}
}
fn decode_stream_install_control_frame(payload: &[u8]) -> StreamInstallFrame {
match serde_json::from_slice(payload) {
Ok(StreamInstallFrame::FileChunk { .. }) => {
stream_install_decode_error("stream install control frame cannot contain file bytes")
}
Ok(frame) => frame,
Err(e) => {
tracing::error!(?e, "StreamInstallFrame decoding error");
stream_install_decode_error(format!("stream install frame decoding error: {e}"))
}
}
}
fn tagged_stream_install_frame(tag: u8, payload: &[u8]) -> Bytes {
let mut frame = Vec::with_capacity(1 + payload.len());
frame.push(tag);
frame.extend_from_slice(payload);
Bytes::from(frame)
}
fn stream_install_decode_error(message: impl Into<String>) -> StreamInstallFrame {
StreamInstallFrame::Error {
message: message.into(),
}
}
@@ -1,42 +0,0 @@
use bytes::Bytes;
use lanspread_proto::{Message, StreamInstallFrame};
#[test]
fn file_chunks_encode_raw_bytes() {
let bytes = Bytes::from_static(&[0, 1, 2, 255]);
let encoded = StreamInstallFrame::FileChunk {
bytes: bytes.clone(),
}
.encode();
assert_eq!(&encoded[..], &[1, 0, 1, 2, 255]);
assert_eq!(
StreamInstallFrame::decode(encoded),
StreamInstallFrame::FileChunk { bytes }
);
}
#[test]
fn control_frames_are_tagged_json() {
let frame = StreamInstallFrame::FileBegin {
relative_path: "bin/game.exe".to_string(),
size: 42,
crc32: 0x38B4_88A7,
};
let encoded = frame.encode();
assert_eq!(encoded[0], 0);
assert_eq!(StreamInstallFrame::decode(encoded), frame);
}
#[test]
fn empty_frames_decode_as_errors() {
match StreamInstallFrame::decode(Bytes::new()) {
StreamInstallFrame::Error { message } => {
assert!(message.contains("empty"));
}
other => {
panic!("expected error frame, got {other:?}");
}
}
}
+58 -58
View File
@@ -6,13 +6,13 @@
"npm:@tauri-apps/plugin-dialog@^2.7.1": "2.7.1",
"npm:@tauri-apps/plugin-shell@^2.3.5": "2.3.5",
"npm:@tauri-apps/plugin-store@^2.4.3": "2.4.3",
"npm:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.17",
"npm:@types/react@^19.2.17": "19.2.17",
"npm:@vitejs/plugin-react@^6.0.2": "6.0.2_vite@8.0.16",
"npm:react-dom@^19.2.7": "19.2.7_react@19.2.7",
"npm:react@^19.2.7": "19.2.7",
"npm:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.14",
"npm:@types/react@^19.2.14": "19.2.14",
"npm:@vitejs/plugin-react@^6.0.2": "6.0.2_vite@8.0.13",
"npm:react-dom@^19.2.6": "19.2.6_react@19.2.6",
"npm:react@^19.2.6": "19.2.6",
"npm:typescript@^6.0.3": "6.0.3",
"npm:vite@^8.0.16": "8.0.16"
"npm:vite@^8.0.13": "8.0.13"
},
"npm": {
"@emnapi/core@1.10.0": {
@@ -42,71 +42,71 @@
"@tybys/wasm-util"
]
},
"@oxc-project/types@0.133.0": {
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="
"@oxc-project/types@0.130.0": {
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="
},
"@rolldown/binding-android-arm64@1.0.3": {
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
"@rolldown/binding-android-arm64@1.0.1": {
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
"os": ["android"],
"cpu": ["arm64"]
},
"@rolldown/binding-darwin-arm64@1.0.3": {
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
"@rolldown/binding-darwin-arm64@1.0.1": {
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"@rolldown/binding-darwin-x64@1.0.3": {
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
"@rolldown/binding-darwin-x64@1.0.1": {
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
"os": ["darwin"],
"cpu": ["x64"]
},
"@rolldown/binding-freebsd-x64@1.0.3": {
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
"@rolldown/binding-freebsd-x64@1.0.1": {
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
"os": ["freebsd"],
"cpu": ["x64"]
},
"@rolldown/binding-linux-arm-gnueabihf@1.0.3": {
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
"@rolldown/binding-linux-arm-gnueabihf@1.0.1": {
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
"os": ["linux"],
"cpu": ["arm"]
},
"@rolldown/binding-linux-arm64-gnu@1.0.3": {
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
"@rolldown/binding-linux-arm64-gnu@1.0.1": {
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@rolldown/binding-linux-arm64-musl@1.0.3": {
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
"@rolldown/binding-linux-arm64-musl@1.0.1": {
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@rolldown/binding-linux-ppc64-gnu@1.0.3": {
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
"@rolldown/binding-linux-ppc64-gnu@1.0.1": {
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
"os": ["linux"],
"cpu": ["ppc64"]
},
"@rolldown/binding-linux-s390x-gnu@1.0.3": {
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
"@rolldown/binding-linux-s390x-gnu@1.0.1": {
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
"os": ["linux"],
"cpu": ["s390x"]
},
"@rolldown/binding-linux-x64-gnu@1.0.3": {
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
"@rolldown/binding-linux-x64-gnu@1.0.1": {
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
"os": ["linux"],
"cpu": ["x64"]
},
"@rolldown/binding-linux-x64-musl@1.0.3": {
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
"@rolldown/binding-linux-x64-musl@1.0.1": {
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
"os": ["linux"],
"cpu": ["x64"]
},
"@rolldown/binding-openharmony-arm64@1.0.3": {
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
"@rolldown/binding-openharmony-arm64@1.0.1": {
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
"os": ["openharmony"],
"cpu": ["arm64"]
},
"@rolldown/binding-wasm32-wasi@1.0.3": {
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
"@rolldown/binding-wasm32-wasi@1.0.1": {
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
"dependencies": [
"@emnapi/core",
"@emnapi/runtime",
@@ -114,13 +114,13 @@
],
"cpu": ["wasm32"]
},
"@rolldown/binding-win32-arm64-msvc@1.0.3": {
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
"@rolldown/binding-win32-arm64-msvc@1.0.1": {
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
"os": ["win32"],
"cpu": ["arm64"]
},
"@rolldown/binding-win32-x64-msvc@1.0.3": {
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
"@rolldown/binding-win32-x64-msvc@1.0.1": {
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
"os": ["win32"],
"cpu": ["x64"]
},
@@ -226,19 +226,19 @@
"tslib"
]
},
"@types/react-dom@19.2.3_@types+react@19.2.17": {
"@types/react-dom@19.2.3_@types+react@19.2.14": {
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dependencies": [
"@types/react"
]
},
"@types/react@19.2.17": {
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"@types/react@19.2.14": {
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dependencies": [
"csstype"
]
},
"@vitejs/plugin-react@6.0.2_vite@8.0.16": {
"@vitejs/plugin-react@6.0.2_vite@8.0.13": {
"integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==",
"dependencies": [
"@rolldown/pluginutils",
@@ -349,26 +349,26 @@
"picomatch@4.0.4": {
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="
},
"postcss@8.5.15": {
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"postcss@8.5.14": {
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dependencies": [
"nanoid",
"picocolors",
"source-map-js"
]
},
"react-dom@19.2.7_react@19.2.7": {
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"react-dom@19.2.6_react@19.2.6": {
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"dependencies": [
"react",
"scheduler"
]
},
"react@19.2.7": {
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="
"react@19.2.6": {
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="
},
"rolldown@1.0.3": {
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
"rolldown@1.0.1": {
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"dependencies": [
"@oxc-project/types",
"@rolldown/pluginutils"
@@ -398,8 +398,8 @@
"source-map-js@1.2.1": {
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
},
"tinyglobby@0.2.17": {
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"tinyglobby@0.2.16": {
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dependencies": [
"fdir",
"picomatch"
@@ -412,8 +412,8 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"bin": true
},
"vite@8.0.16": {
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"vite@8.0.13": {
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dependencies": [
"lightningcss",
"picomatch",
@@ -436,12 +436,12 @@
"npm:@tauri-apps/plugin-shell@^2.3.5",
"npm:@tauri-apps/plugin-store@^2.4.3",
"npm:@types/react-dom@^19.2.3",
"npm:@types/react@^19.2.17",
"npm:@types/react@^19.2.14",
"npm:@vitejs/plugin-react@^6.0.2",
"npm:react-dom@^19.2.7",
"npm:react@^19.2.7",
"npm:react-dom@^19.2.6",
"npm:react@^19.2.6",
"npm:typescript@^6.0.3",
"npm:vite@^8.0.16"
"npm:vite@^8.0.13"
]
}
}
+4 -4
View File
@@ -12,17 +12,17 @@
"dependencies": {
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-store": "^2.4.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-shell": "^2.3.5"
},
"devDependencies": {
"@types/react": "^19.2.17",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"typescript": "^6.0.3",
"vite": "^8.0.16",
"vite": "^8.0.13",
"@tauri-apps/cli": "^2.11.2"
}
}
@@ -8,19 +8,28 @@ edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
crate-type = ["cdylib", "rlib", "staticlib"]
doctest = false
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "lanspread_tauri_deno_ts_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
test = true
doctest = false
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
needless_pass_by_value = "allow"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
lanspread-compat = { path = "../../lanspread-compat" }
lanspread-db = { path = "../../lanspread-db" }
# local
lanspread-peer = { path = "../../lanspread-peer" }
lanspread-db = { path = "../../lanspread-db" }
lanspread-compat = { path = "../../lanspread-compat" }
# external
base64 = { workspace = true }
@@ -28,27 +37,13 @@ eyre = { workspace = true }
log = { workspace = true }
mimalloc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tauri = { workspace = true }
tauri-plugin-dialog = { workspace = true }
tauri-plugin-log = { workspace = true }
tauri-plugin-shell = { workspace = true }
tauri-plugin-dialog = { workspace = true }
tauri-plugin-store = { workspace = true }
time = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
tracing = { workspace = true }
tracing-log = { workspace = true }
tracing-subscriber = { workspace = true }
walkdir = { workspace = true }
[build-dependencies]
tauri-build = { version = "2", features = [] }
[target.'cfg(windows)'.dependencies]
windows = { workspace = true }
[lints.clippy]
needless_pass_by_value = "allow"
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
@@ -1,11 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-logs",
"description": "Capability for the main-logs window",
"windows": [
"main-logs"
],
"permissions": [
"core:default"
]
}
@@ -1,31 +1,26 @@
#[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, Mutex, OnceLock},
time::{Duration, SystemTime, UNIX_EPOCH},
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use eyre::bail;
use lanspread_compat::eti::get_games;
use lanspread_db::db::{Availability, Game, GameCatalog, GameDB, GameFileDescription};
use lanspread_db::db::{Availability, Game, GameDB, GameFileDescription};
use lanspread_peer::{
ActiveOperation,
ActiveOperationKind,
ExternalUnrarStreamProvider,
NoopStreamInstallProvider,
PeerCommand,
PeerEvent,
PeerGameDB,
PeerRuntimeHandle,
PeerStartOptions,
StreamInstallProvider,
UnpackFuture,
Unpacker,
migrate_legacy_state,
start_peer_with_options,
start_peer,
};
use tauri::{AppHandle, Emitter as _, Manager};
use tauri_plugin_shell::{ShellExt, process::Command};
@@ -33,51 +28,9 @@ use tokio::sync::{
RwLock,
mpsc::{UnboundedReceiver, UnboundedSender},
};
use tracing::{Event, Level, Metadata, Subscriber, field::Visit};
use tracing_subscriber::{
layer::{Context, Layer},
prelude::*,
registry::LookupSpan,
};
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
type OutboundTransfers =
Arc<RwLock<std::collections::HashMap<String, Vec<(u64, tokio_util::sync::CancellationToken)>>>>;
const OUTBOUND_TRANSFER_EMIT_DEBOUNCE: Duration = Duration::from_millis(100);
#[derive(Default)]
struct OutboundTransferEmitState {
scheduled: bool,
generation: u64,
}
impl OutboundTransferEmitState {
fn record_change(&mut self) -> bool {
self.generation = self.generation.saturating_add(1);
if self.scheduled {
return false;
}
self.scheduled = true;
true
}
fn observed_generation(&self) -> u64 {
self.generation
}
fn finish_emit(&mut self, observed_generation: u64) -> bool {
if self.generation != observed_generation {
return true;
}
self.scheduled = false;
false
}
}
/// Tauri-managed runtime state shared by commands and setup tasks.
#[derive(Default)]
struct LanSpreadState {
@@ -87,18 +40,8 @@ struct LanSpreadState {
active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
games_folder: Arc<RwLock<String>>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
catalog: Arc<RwLock<GameCatalog>>,
catalog: Arc<RwLock<HashSet<String>>>,
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
state_dir: OnceLock<PathBuf>,
main_log_sink: OnceLock<MainLogSink>,
active_outbound_transfers: OutboundTransfers,
outbound_transfer_emit: Arc<RwLock<OutboundTransferEmitState>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct InstallSettings {
account_name: String,
language: String,
}
struct PeerEventTx(UnboundedSender<PeerEvent>);
@@ -129,10 +72,9 @@ struct LauncherGame {
#[serde(flatten)]
game: Game,
can_host_server: bool,
active_outbound_transfers: usize,
}
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
#[derive(Clone, Debug, serde::Serialize)]
struct UnpackLogEntry {
archive: String,
destination: String,
@@ -144,29 +86,11 @@ struct UnpackLogEntry {
success: bool,
}
#[derive(Clone, Debug, serde::Serialize)]
struct MainLogLinePayload {
line: String,
level: String,
sequence: Option<u64>,
}
#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct MainLogHistoryPayload {
contents: String,
last_sequence: u64,
}
struct SidecarUnpacker {
app_handle: AppHandle,
}
const MAX_UNPACK_LOGS: usize = 20;
const UNPACK_LOGS_FILE_NAME: &str = "unpack-logs.json";
const MAIN_LOG_FILE_NAME: &str = "lanspread.log";
const MAX_MAIN_LOG_BYTES: u64 = 2 * 1024 * 1024;
const MAIN_LOG_TRIM_SLACK_BYTES: u64 = 64 * 1024;
const MAX_UNPACK_LOGS: usize = 100;
impl Unpacker for SidecarUnpacker {
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
@@ -185,32 +109,8 @@ async fn get_unpack_logs(
Ok(state.inner().unpack_logs.read().await.clone())
}
#[tauri::command]
async fn get_main_logs(
app_handle: tauri::AppHandle,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<MainLogHistoryPayload> {
if let Some(sink) = state.inner().main_log_sink.get() {
return Ok(sink.read_history()?);
}
let state_dir = app_handle.path().app_data_dir()?;
fs::create_dir_all(&state_dir)?;
let path = main_log_path(&state_dir);
match read_main_log_file_to_limit(&path, MAX_MAIN_LOG_BYTES) {
Ok(contents) => Ok(MainLogHistoryPayload {
contents,
last_sequence: 0,
}),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(MainLogHistoryPayload {
contents: String::new(),
last_sequence: 0,
}),
Err(err) => Err(err.into()),
}
}
#[cfg(target_os = "windows")]
const FIRST_START_DONE_FILE: &str = ".softlan_first_start_done";
#[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))]
@@ -242,12 +142,7 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result
}
#[tauri::command]
async fn install_game(
id: String,
language: String,
username: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<bool> {
if state
.inner()
.active_operations
@@ -273,12 +168,11 @@ async fn install_game(
return Ok(false);
};
let _ = (language, username);
let handled = if let Some(peer_ctrl) = peer_ctrl {
let command = if !downloaded {
PeerCommand::GetGame(id.clone())
PeerCommand::GetGame(id)
} else if !installed {
PeerCommand::InstallGame { id: id.clone() }
PeerCommand::InstallGame { id }
} else {
log::info!("Game is already installed: {id}");
return Ok(false);
@@ -286,7 +180,6 @@ async fn install_game(
if let Err(e) = peer_ctrl.send(command) {
log::error!("Failed to send message to peer: {e:?}");
return Ok(false);
}
true
} else {
@@ -298,62 +191,7 @@ async fn install_game(
}
#[tauri::command]
async fn stream_install_game(
id: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
if state
.inner()
.active_operations
.read()
.await
.contains_key(&id)
{
log::warn!("Game already has an active operation: {id}");
return Ok(false);
}
let Some((downloaded, installed, peer_count)) = state
.inner()
.games
.read()
.await
.get_game_by_id(&id)
.map(|game| (game.downloaded, game.installed, game.peer_count))
else {
log::warn!("Ignoring streamed install request for unknown game: {id}");
return Ok(false);
};
if downloaded || installed || peer_count == 0 {
log::warn!(
"Ignoring streamed install request for {id}: downloaded={downloaded}, \
installed={installed}, peer_count={peer_count}"
);
return Ok(false);
}
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
let peer_ctrl = peer_ctrl_arc.read().await.clone();
let Some(peer_ctrl) = peer_ctrl else {
log::warn!("Peer system not initialized yet");
return Ok(false);
};
if let Err(e) = peer_ctrl.send(PeerCommand::StreamInstallGame { id }) {
log::error!("Failed to send PeerCommand::StreamInstallGame: {e:?}");
return Ok(false);
}
Ok(true)
}
#[tauri::command]
async fn update_game(
id: String,
language: String,
username: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
async fn update_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<bool> {
if state
.inner()
.active_operations
@@ -369,9 +207,8 @@ async fn update_game(
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
let peer_ctrl = peer_ctrl_arc.read().await.clone();
let _ = (language, username);
if let Some(peer_ctrl) = peer_ctrl {
if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id: id.clone() }) {
if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id }) {
log::error!("Failed to send message to peer: {e:?}");
return Ok(false);
}
@@ -577,20 +414,6 @@ fn launch_settings(language: &str, username: &str) -> LaunchSettings {
}
}
fn install_settings(language: &str, username: &str) -> InstallSettings {
InstallSettings {
account_name: sanitize_username(username),
language: install_language(language),
}
}
fn install_language(language: &str) -> String {
match sanitize_language(language).as_str() {
"de" => "german".to_string(),
_ => "english".to_string(),
}
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn sanitize_language(language: &str) -> String {
match language.trim().to_ascii_lowercase().as_str() {
@@ -618,23 +441,8 @@ fn sanitize_username(username: &str) -> String {
#[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" "{}" "{}" "{}"""#,
r#"/d /s /c ""{}" "local" "{}" "{}" "{}"""#,
script_path.display(),
id,
settings.language,
@@ -679,12 +487,7 @@ async fn get_game_thumbnail(
}
#[cfg(target_os = "windows")]
fn run_as_admin(
file: &str,
params: &str,
dir: &str,
show_cmd: windows::Win32::UI::WindowsAndMessaging::SHOW_WINDOW_CMD,
) -> bool {
fn run_as_admin(file: &str, params: &str, dir: &str) -> bool {
use std::{ffi::OsStr, os::windows::ffi::OsStrExt};
use windows::{Win32::UI::Shell::ShellExecuteW, core::PCWSTR};
@@ -701,7 +504,7 @@ fn run_as_admin(
PCWSTR::from_raw(file_wide.as_ptr()),
PCWSTR::from_raw(params_wide.as_ptr()),
PCWSTR::from_raw(dir_wide.as_ptr()),
show_cmd,
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
)
};
@@ -737,13 +540,9 @@ async fn run_game_windows(
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 setup_done_file = lanspread_peer::setup_done_path(&state_dir, &id);
if !setup_done_file.exists() && game_setup_bin.exists() {
let first_start_done_file = game_path.join("local").join(FIRST_START_DONE_FILE);
if !first_start_done_file.exists() && game_setup_bin.exists() {
if !local_install_is_present(&game_path) {
log::warn!(
"local install is missing for {}; skipping game_setup",
@@ -756,7 +555,6 @@ async fn run_game_windows(
"cmd.exe",
&script_params(&game_setup_bin, &id, &settings),
&game_path.display().to_string(),
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
);
if !result {
@@ -764,31 +562,19 @@ async fn run_game_windows(
return Ok(());
}
if let Some(parent) = setup_done_file.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
if let Err(e) = File::create(&first_start_done_file) {
log::error!(
"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()
"failed to create first-start marker {}: {e}",
first_start_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",
&script_params(&game_start_bin, &id, &settings),
&game_path.display().to_string(),
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
);
if !result {
@@ -799,32 +585,6 @@ async fn run_game_windows(
Ok(())
}
/// Stamp the launcher's username and language into the installed game's setting
/// files the first time it is played. Uses the same processed values the install
/// transaction used to write before this step moved to play time.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
async fn apply_launch_settings(
state_dir: &Path,
game_path: &Path,
id: &str,
language: &str,
username: &str,
) {
let settings = install_settings(language, username);
match lanspread_peer::apply_launch_settings_once(
state_dir,
game_path,
id,
Some(&settings.account_name),
Some(&settings.language),
)
.await
{
Ok(outcome) => log::info!("launch settings for {id}: {outcome:?}"),
Err(e) => log::error!("failed to apply launch settings for {id}: {e}"),
}
}
#[tauri::command]
async fn run_game(
id: String,
@@ -884,17 +644,10 @@ async fn start_server_windows(
return Ok(false);
}
let Some(state_dir) = state.inner().state_dir.get().cloned() else {
log::error!("app state directory is not initialized; cannot start server");
return Ok(false);
};
apply_launch_settings(&state_dir, &game_path, &id, &language, &username).await;
let result = run_as_admin(
"cmd.exe",
&server_script_params(&server_start_bin, &id, &settings),
&script_params(&server_start_bin, &id, &settings),
&game_path.display().to_string(),
windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL,
);
if !result {
@@ -973,24 +726,6 @@ fn apply_peer_local_games(game_db: &mut GameDB, local_games: &[Game]) {
}
}
fn apply_peer_remote_games(game_db: &mut GameDB, peer_games: Vec<Game>) {
// Peer events update availability, but catalog metadata stays anchored to game.db.
for game in game_db.games.values_mut() {
game.peer_count = 0;
}
for peer_game in peer_games {
if let Some(existing) = game_db.get_mut_game_by_id(&peer_game.id) {
existing.peer_count = peer_game.peer_count;
} else {
log::debug!(
"Peer advertised unknown game {id}; ignoring because game.db is ground truth",
id = peer_game.id
);
}
}
}
fn clear_all_local_game_states(game_db: &mut GameDB) {
for game in game_db.games.values_mut() {
clear_local_game_state(game);
@@ -1009,24 +744,17 @@ async fn emit_games_list(app_handle: &AppHandle) {
return;
}
let active_transfers = state.active_outbound_transfers.read().await;
let games_to_emit = game_db
.all_games()
.into_iter()
.cloned()
.map(|game| {
let active_outbound_transfers = active_transfers.get(&game.id).map_or(0, Vec::len);
LauncherGame {
can_host_server: game_can_host_server(&games_folder, &game),
active_outbound_transfers,
game,
}
.map(|game| LauncherGame {
can_host_server: game_can_host_server(&games_folder, &game),
game,
})
.collect::<Vec<LauncherGame>>();
drop(game_db);
drop(active_transfers);
let active_operations = {
let active_operations = state.active_operations.read().await;
@@ -1093,11 +821,6 @@ 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}");
@@ -1127,21 +850,6 @@ 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;
@@ -1165,7 +873,36 @@ async fn update_game_db(games: Vec<Game>, app: AppHandle) {
{
let mut game_db = state.games.write().await;
apply_peer_remote_games(&mut game_db, games);
// Reset peer counts up front. Presence/metadata stay anchored to the baked game.db.
for game in game_db.games.values_mut() {
game.peer_count = 0;
}
for peer_game in games {
if let Some(existing) = game_db.get_mut_game_by_id(&peer_game.id) {
existing.peer_count = peer_game.peer_count;
if let Some(peer_version) = &peer_game.eti_game_version {
match &existing.eti_game_version {
Some(current_version) if current_version >= peer_version => {}
_ => {
existing.eti_game_version = Some(peer_version.clone());
log::debug!(
"Updated eti_game_version for {} to {} based on peer data",
peer_game.id,
peer_version
);
}
}
}
} else {
log::debug!(
"Peer advertised unknown game {id}; ignoring because game.db is ground truth",
id = peer_game.id
);
}
}
}
emit_games_list(&app).await;
@@ -1328,8 +1065,8 @@ async fn run_unrar_sidecar(
}
};
let stdout = clean_terminal_log(&String::from_utf8_lossy(&out.stdout));
let stderr = clean_terminal_log(&String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
let status_code = out.status.code();
let success = out.status.success();
@@ -1386,476 +1123,20 @@ 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);
trim_unpack_logs(&mut logs);
logs.clone()
};
persist_unpack_logs(app_handle, &logs).await;
if logs.len() > MAX_UNPACK_LOGS {
let overflow = logs.len() - MAX_UNPACK_LOGS;
logs.drain(..overflow);
}
}
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)
@@ -1895,7 +1176,7 @@ async fn ensure_bundled_game_db_loaded(app_handle: &AppHandle) {
if needs_load {
let game_db = load_bundled_game_db(app_handle).await;
let catalog = GameCatalog::from_game_db(&game_db);
let catalog = game_db.games.keys().cloned().collect::<HashSet<_>>();
*state.games.write().await = game_db;
*state.catalog.write().await = catalog;
}
@@ -1912,26 +1193,16 @@ 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(),
});
let stream_install_provider = stream_install_provider_for_app(app_handle);
match start_peer_with_options(
match start_peer(
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();
@@ -1948,22 +1219,6 @@ async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) {
}
}
fn stream_install_provider_for_app(app_handle: &AppHandle) -> Arc<dyn StreamInstallProvider> {
match resolve_unrar_sidecar_program(app_handle) {
Ok(program) => Arc::new(ExternalUnrarStreamProvider::new(program)),
Err(err) => {
log::error!("Failed to resolve streamed-install unrar sidecar: {err}");
Arc::new(NoopStreamInstallProvider)
}
}
}
fn resolve_unrar_sidecar_program(app_handle: &AppHandle) -> eyre::Result<PathBuf> {
let sidecar = app_handle.shell().sidecar("unrar")?;
let command: std::process::Command = sidecar.into();
Ok(PathBuf::from(command.get_program()))
}
fn emit_game_id_event(app_handle: &AppHandle, event: &str, id: &str, label: &str) {
if let Err(e) = app_handle.emit(event, Some(id.to_owned())) {
log::error!("{label}: Failed to emit {event} event: {e}");
@@ -1984,44 +1239,6 @@ fn spawn_peer_event_loop(app_handle: AppHandle, mut rx_peer_event: UnboundedRece
});
}
async fn schedule_outbound_transfer_emit(app_handle: &AppHandle) {
let state = app_handle.state::<LanSpreadState>();
let should_spawn = {
let mut emit_state = state.outbound_transfer_emit.write().await;
emit_state.record_change()
};
if !should_spawn {
return;
}
let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
loop {
tokio::time::sleep(OUTBOUND_TRANSFER_EMIT_DEBOUNCE).await;
let observed_generation = {
let state = app_handle.state::<LanSpreadState>();
state
.outbound_transfer_emit
.read()
.await
.observed_generation()
};
emit_games_list(&app_handle).await;
let needs_follow_up_emit = {
let state = app_handle.state::<LanSpreadState>();
let mut emit_state = state.outbound_transfer_emit.write().await;
emit_state.finish_emit(observed_generation)
};
if !needs_follow_up_emit {
break;
}
}
});
}
#[allow(clippy::too_many_lines)]
async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
match event {
@@ -2048,10 +1265,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
emit_games_list(app_handle).await;
}
PeerEvent::OutboundTransferCountChanged => {
log::info!("PeerEvent::OutboundTransferCountChanged received");
schedule_outbound_transfer_emit(app_handle).await;
}
PeerEvent::GotGameFiles {
id,
file_descriptions,
@@ -2252,7 +1465,7 @@ async fn handle_got_game_files(
file_descriptions,
})
{
log::error!("Failed to continue queued game transfer: {e}");
log::error!("Failed to send PeerCommand::DownloadGameFiles: {e}");
}
}
@@ -2270,20 +1483,6 @@ 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(),
@@ -2304,139 +1503,6 @@ mod tests {
}
}
fn eti_game_fixture(game_id: &str, game_version: &str) -> lanspread_compat::eti::EtiGame {
lanspread_compat::eti::EtiGame {
game_id: game_id.to_string(),
game_title: "Catalog Game".to_string(),
game_key: "catalog-game".to_string(),
game_release: "2000".to_string(),
game_publisher: "publisher".to_string(),
game_size: 1.0,
game_readme_de: "description".to_string(),
game_readme_en: "description".to_string(),
game_readme_fr: "description".to_string(),
game_maxplayers: 4,
game_master_req: 0,
genre_de: "genre".to_string(),
game_version: game_version.to_string(),
}
}
#[test]
fn eti_game_conversion_uses_catalog_version_as_authoritative_eti_version() {
let game = Game::from(eti_game_fixture("alpha", "20200721"));
assert_eq!(game.version, "20200721");
assert_eq!(game.eti_game_version.as_deref(), Some("20200721"));
assert_eq!(game.local_version, None);
}
#[test]
fn terminal_log_cleanup_preserves_crlf_and_collapses_redrawn_lines() {
let input = "Extracting foo 10%\rExtracting foo 80%\rExtracting foo OK\r\nAll done\r\n";
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([
@@ -2525,32 +1591,6 @@ mod tests {
);
}
#[test]
fn outbound_transfer_emit_state_coalesces_bursts_without_losing_updates() {
let mut state = OutboundTransferEmitState::default();
assert!(
state.record_change(),
"first change should schedule an emit"
);
assert_eq!(state.observed_generation(), 1);
assert!(
!state.record_change(),
"second change should reuse the scheduled emit"
);
assert_eq!(state.observed_generation(), 2);
assert!(
state.finish_emit(1),
"a generation observed before the latest change needs a follow-up emit"
);
assert!(
!state.finish_emit(2),
"the latest observed generation clears the scheduled emit"
);
assert!(state.record_change(), "a later burst should schedule again");
}
#[test]
fn game_file_viewer_ids_must_be_single_path_components() {
assert!(is_single_component_game_id("game"));
@@ -2579,27 +1619,9 @@ mod tests {
);
}
#[test]
fn install_settings_use_language_file_values() {
assert_eq!(
install_settings("de", " Alice \"Ace\"%PATH%\n "),
InstallSettings {
account_name: "Alice AcePATH".to_string(),
language: "german".to_string(),
}
);
assert_eq!(
install_settings("fr", ""),
InstallSettings {
account_name: DEFAULT_USERNAME.to_string(),
language: "english".to_string(),
}
);
}
#[test]
fn script_params_use_common_argument_shape() {
let start_params = script_params(
let params = script_params(
Path::new("C:/Games/My Game")
.join(GAME_START_SCRIPT)
.as_path(),
@@ -2611,25 +1633,9 @@ mod tests {
);
assert_eq!(
start_params,
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]
@@ -2698,61 +1704,32 @@ 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,
@@ -2761,27 +1738,11 @@ pub fn run() {
open_game_files,
get_peer_count,
get_game_thumbnail,
get_unpack_logs,
get_main_logs
get_unpack_logs
])
.manage(LanSpreadState::default())
.manage(PeerEventTx(tx_peer_event))
.setup(move |app| {
let state_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&state_dir)?;
let main_log_sink = MainLogSink::new(app.handle().clone(), main_log_path(&state_dir));
let state = app.state::<LanSpreadState>();
if state.main_log_sink.set(main_log_sink.clone()).is_err() {
log::warn!("main log sink was already initialized");
}
init_main_logging(main_log_sink)?;
let unpack_logs = load_unpack_logs(&state_dir);
tauri::async_runtime::block_on(async {
*state.unpack_logs.write().await = unpack_logs;
});
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(())
})
+2 -7
View File
@@ -1,16 +1,11 @@
import { MainWindow } from './windows/MainWindow';
import { MainLogsWindow, isMainLogsView } from './MainLogsWindow';
import { UnpackLogsWindow, isUnpackLogsView } from './UnpackLogsWindow';
/**
* Tauri can spawn this bundle in either the main launcher window or the
* companion log windows. The URL query string disambiguates the views so
* unpack-logs companion window. The URL query string disambiguates the two so
* a single Vite build serves both.
*/
const App = () => {
if (isMainLogsView()) return <MainLogsWindow />;
if (isUnpackLogsView()) return <UnpackLogsWindow />;
return <MainWindow />;
};
const App = () => (isUnpackLogsView() ? <UnpackLogsWindow /> : <MainWindow />);
export default App;
@@ -1,134 +0,0 @@
.main-log-window {
height: 100vh;
box-sizing: border-box;
padding: 18px;
background: #000313;
color: #D5DBFE;
display: flex;
flex-direction: column;
gap: 12px;
}
.main-log-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-shrink: 0;
}
.main-log-header h1 {
margin: 0;
font-size: 22px;
}
.main-log-controls,
.main-log-filter-row {
display: flex;
align-items: center;
gap: 12px;
}
.main-log-controls {
flex: 1;
justify-content: flex-end;
min-width: 0;
}
.main-log-filter-row {
flex-wrap: wrap;
flex-shrink: 0;
}
.main-log-level-filter {
--accent: #4866b9;
}
.main-log-toggle {
display: flex;
align-items: center;
gap: 6px;
color: #aeb7df;
font-size: 13px;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.main-log-regex {
flex: 1 1 340px;
min-width: 220px;
}
.main-log-copy-status {
color: #8ee6a6;
font-size: 12px;
min-width: 64px;
}
.main-log-load-error {
flex-shrink: 0;
color: #ff8a8a;
font-size: 12px;
font-family: Consolas, "Courier New", monospace;
}
.main-log-stats {
color: #8892b0;
font-size: 12px;
flex-shrink: 0;
}
.main-log-viewport {
flex: 1;
min-height: 0;
overflow: auto;
padding: 14px;
border: 1px solid #2a3252;
border-radius: 6px;
background: #050813;
font-family: Consolas, "Courier New", monospace;
font-size: 12.5px;
line-height: 1.42;
}
.main-log-line {
white-space: pre-wrap;
word-break: break-word;
color: #D5DBFE;
}
.main-log-line.level-trace,
.main-log-line.level-debug {
color: #9aa6c8;
}
.main-log-line.level-warn {
color: #ffd37a;
}
.main-log-line.level-error {
color: #ff8a8a;
}
.main-log-empty {
color: #8892b0;
padding: 8px 4px;
}
@media (max-width: 720px) {
.main-log-window {
padding: 14px;
}
.main-log-header {
align-items: flex-start;
flex-direction: column;
}
.main-log-controls {
justify-content: flex-start;
flex-wrap: wrap;
width: 100%;
}
}
@@ -1,278 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { SegmentedRadio } from './components/SegmentedRadio';
import {
capLogRows,
consumeLoadedHistoryRow,
dedupeBufferedRows,
formatCount,
LEVEL_FILTER_MIN,
LEVEL_FILTER_OPTIONS,
LEVEL_ORDER,
lineCountsFromRows,
type LevelFilter,
type MainLogHistoryPayload,
type MainLogLinePayload,
type MainLogRow,
rowFromPayload,
rowsFromHistory,
} from './lib/mainLogs';
import './MainLogsWindow.css';
export const isMainLogsView = (): boolean =>
new URLSearchParams(window.location.search).get('view') === 'main-logs';
export const MainLogsWindow = () => {
const [logs, setLogs] = useState<MainLogRow[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [regexInput, setRegexInput] = useState('');
const [levelFilter, setLevelFilter] = useState<LevelFilter>('all');
const [autoScroll, setAutoScroll] = useState(true);
const [paused, setPaused] = useState(false);
const [pausedBufferCount, setPausedBufferCount] = useState(0);
const [copyStatus, setCopyStatus] = useState<string | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
const historyLoadedRef = useRef(false);
const initialBufferRef = useRef<MainLogRow[]>([]);
const pausedBufferRef = useRef<MainLogRow[]>([]);
const pausedRef = useRef(false);
const lastHistorySequenceRef = useRef(0);
const historyLineCountsRef = useRef<Map<string, number>>(new Map());
const appendVisibleRows = useCallback((rows: MainLogRow[]) => {
setLogs(current => capLogRows([...current, ...rows]));
}, []);
const bufferPausedRows = useCallback((rows: MainLogRow[]) => {
pausedBufferRef.current = capLogRows([...pausedBufferRef.current, ...rows]);
setPausedBufferCount(pausedBufferRef.current.length);
}, []);
useEffect(() => {
let cancelled = false;
let unlisten: (() => void) | undefined;
const handleIncomingRow = (row: MainLogRow) => {
if (!historyLoadedRef.current) {
initialBufferRef.current = capLogRows([...initialBufferRef.current, row]);
return;
}
if (
consumeLoadedHistoryRow(
historyLineCountsRef.current,
row,
lastHistorySequenceRef.current,
)
) {
return;
}
if (pausedRef.current) {
bufferPausedRows([row]);
return;
}
appendVisibleRows([row]);
};
const setup = async () => {
try {
unlisten = await listen<MainLogLinePayload>('main-log-line', event => {
handleIncomingRow(rowFromPayload(event.payload));
});
const history = await invoke<MainLogHistoryPayload>('get_main_logs');
if (cancelled) return;
lastHistorySequenceRef.current = history.lastSequence;
const historyRows = rowsFromHistory(history.contents);
const historyLineCounts = lineCountsFromRows(historyRows);
const liveRows = dedupeBufferedRows(
historyLineCounts,
initialBufferRef.current,
lastHistorySequenceRef.current,
);
initialBufferRef.current = [];
historyLineCountsRef.current = historyLineCounts;
historyLoadedRef.current = true;
if (pausedRef.current) {
setLogs(capLogRows(historyRows));
bufferPausedRows(liveRows);
} else {
setLogs(capLogRows([...historyRows, ...liveRows]));
}
setLoadError(null);
} catch (err) {
if (!cancelled) {
historyLoadedRef.current = true;
setLoadError(err instanceof Error ? err.message : String(err));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void setup();
return () => {
cancelled = true;
historyLoadedRef.current = false;
initialBufferRef.current = [];
lastHistorySequenceRef.current = 0;
historyLineCountsRef.current = new Map();
unlisten?.();
};
}, [appendVisibleRows, bufferPausedRows]);
const { regex, regexError } = useMemo(() => {
if (!regexInput) {
return { regex: null as RegExp | null, regexError: null as string | null };
}
try {
return { regex: new RegExp(regexInput, 'i'), regexError: null };
} catch (e) {
return { regex: null, regexError: e instanceof Error ? e.message : String(e) };
}
}, [regexInput]);
const filteredRows = useMemo(() => {
const minLevel = LEVEL_FILTER_MIN[levelFilter];
return logs.filter(row => {
if (LEVEL_ORDER[row.level] < minLevel) return false;
return regex ? regex.test(row.line) : true;
});
}, [levelFilter, logs, regex]);
const lastVisibleRow = filteredRows.length > 0 ? filteredRows[filteredRows.length - 1] : null;
useEffect(() => {
if (!autoScroll) return;
const viewport = viewportRef.current;
if (!viewport) return;
requestAnimationFrame(() => {
viewport.scrollTop = viewport.scrollHeight;
});
}, [autoScroll, filteredRows.length, lastVisibleRow?.id]);
const flushPausedRows = useCallback(() => {
const buffered = pausedBufferRef.current;
if (buffered.length === 0) return;
pausedBufferRef.current = [];
setPausedBufferCount(0);
appendVisibleRows(buffered);
}, [appendVisibleRows]);
const togglePaused = useCallback(() => {
if (paused) {
pausedRef.current = false;
setPaused(false);
flushPausedRows();
return;
}
pausedRef.current = true;
setPaused(true);
}, [flushPausedRows, paused]);
const clearLogs = useCallback(() => {
setLogs([]);
initialBufferRef.current = [];
pausedBufferRef.current = [];
setPausedBufferCount(0);
}, []);
const copyFilteredLogs = useCallback(async () => {
try {
await navigator.clipboard.writeText(filteredRows.map(row => row.line).join('\n'));
setCopyStatus('Copied');
} catch {
setCopyStatus('Copy failed');
}
window.setTimeout(() => setCopyStatus(null), 1600);
}, [filteredRows]);
return (
<main className="main-log-window">
<div className="main-log-header">
<h1>Application Logs</h1>
<div className="main-log-controls">
<label className="main-log-toggle">
<input
type="checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
/>
Auto-scroll
</label>
<button className="settings-button" onClick={togglePaused}>
{paused ? 'Resume' : 'Pause'}
</button>
<button className="settings-button" onClick={clearLogs} disabled={logs.length === 0}>
Clear
</button>
<button
className="settings-button"
onClick={() => void copyFilteredLogs()}
disabled={filteredRows.length === 0}
>
Copy
</button>
{copyStatus && <span className="main-log-copy-status">{copyStatus}</span>}
</div>
</div>
<div className="main-log-filter-row">
<div className="main-log-level-filter">
<SegmentedRadio
value={levelFilter}
options={LEVEL_FILTER_OPTIONS}
onChange={setLevelFilter}
/>
</div>
<input
className={`unpack-log-regex main-log-regex ${regexError ? 'invalid' : ''}`}
type="text"
placeholder="Filter lines by regex (case-insensitive)..."
value={regexInput}
onChange={(e) => setRegexInput(e.target.value)}
title={regexError ?? ''}
spellCheck={false}
/>
</div>
{regexError && (
<div className="unpack-log-regex-error">regex error: {regexError}</div>
)}
{loadError && (
<div className="main-log-load-error">load error: {loadError}</div>
)}
<div className="main-log-stats">
{loading ? 'loading' : `showing ${formatCount(filteredRows.length, 'line')} of ${formatCount(logs.length, 'line')}`}
{paused && pausedBufferCount > 0 && ` - ${formatCount(pausedBufferCount, 'paused line')}`}
</div>
<section ref={viewportRef} className="main-log-viewport" aria-live={paused ? 'off' : 'polite'}>
{filteredRows.length === 0 ? (
<div className="main-log-empty">
{logs.length === 0 ? 'No application logs recorded yet.' : 'No log lines match the current filters.'}
</div>
) : filteredRows.map(row => (
<div
key={row.id}
className={`main-log-line level-${row.level.toLowerCase()}`}
>
{row.line}
</div>
))}
</section>
</main>
);
};
@@ -33,9 +33,6 @@ 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;
@@ -100,10 +97,6 @@ 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]);
@@ -1,5 +1,13 @@
import { Game } from '../lib/types';
import { deriveState, stateChipLabel } from '../lib/gameState';
import { deriveState } from '../lib/gameState';
const LABELS: Record<string, string> = {
installed: 'Installed',
local: 'Local',
downloading: 'Downloading',
busy: 'Working',
none: '',
};
interface Props {
game: Game;
@@ -9,7 +17,7 @@ interface Props {
export const StateChip = ({ game, showNone = false }: Props) => {
const state = deriveState(game);
const label = stateChipLabel(game);
const label = LABELS[state] ?? '';
if (!label && !showNone) return null;
return (
<div className="state-chip" data-state={state}>
@@ -7,7 +7,11 @@ interface Props {
export const NoDirectoryState = ({ onChooseDirectory }: Props) => (
<div className="empty-state">
<div className="empty-state-icon"><Icon.folder /></div>
<h2 className="empty-state-title">Please select a game folder</h2>
<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>
<button type="button" className="ghost-btn" onClick={onChooseDirectory}>
<Icon.folder />
<span>Choose folder</span>
@@ -3,7 +3,6 @@ import { JSX, KeyboardEvent } from 'react';
import { Game } from '../../lib/types';
import { CoverAspect } from '../../hooks/useSettings';
import { formatBytes } from '../../lib/format';
import { hasNewerLocalVersion } from '../../lib/gameState';
import { GameCover } from './GameCover';
import { StateChip } from '../StateChip';
@@ -43,14 +42,6 @@ export const GameCard = ({
onOpen(game);
}
};
const newerThanExpected = hasNewerLocalVersion(game);
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
const statusMessage = hasOutbound
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}`
: (game.status_message ?? (newerThanExpected ? 'Newer than expected' : ''));
const statusLevel = hasOutbound
? 'info'
: (game.status_level ?? (newerThanExpected ? 'warning' : undefined));
return (
<button
@@ -75,8 +66,8 @@ export const GameCard = ({
<div className="card-meta">
{metaSeparator(formatBytes(game.size), game.genre || null)}
</div>
<div className={`card-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
{statusMessage}
<div className={`card-status${game.status_level === 'error' ? ' is-error' : ''}`}>
{game.status_message ?? ''}
</div>
<ActionButton
game={game}
@@ -5,7 +5,7 @@ import { StateChip } from '../StateChip';
import { ActionButton } from '../ActionButton';
import { Game, InstallStatus } from '../../lib/types';
import { canStreamInstall, gameStatusLabel, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
import { deriveState, isInProgress } from '../../lib/gameState';
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
interface Props {
@@ -13,7 +13,6 @@ interface Props {
thumbnailUrl: string | null;
onClose: () => void;
onPrimary: (game: Game) => void;
onStreamInstall: (game: Game) => void;
onUninstall: (game: Game) => void;
onRemoveDownload: (game: Game) => void;
onCancelDownload: (game: Game) => void;
@@ -29,12 +28,21 @@ const tagsFromGame = (game: Game): string[] => {
return tags;
};
const statusLabelFor = (game: Game): string => {
switch (deriveState(game)) {
case 'installed': return 'Installed';
case 'local': return 'Downloaded';
case 'downloading': return 'Downloading';
case 'busy': return 'Working…';
case 'none': return 'Not downloaded';
}
};
export const GameDetailModal = ({
game,
thumbnailUrl,
onClose,
onPrimary,
onStreamInstall,
onUninstall,
onRemoveDownload,
onCancelDownload,
@@ -42,28 +50,12 @@ export const GameDetailModal = ({
onViewFiles,
}: Props) => {
const tags = tagsFromGame(game);
// Some game metadata contains a literal <br>; keep sanitization exact.
const description = game.description.split('<br>').join('');
const canRemoveDownload = game.downloaded
&& !game.installed
&& !isInProgress(game.install_status);
const showStreamInstall = canStreamInstall(game);
const canViewFiles = game.downloaded
|| game.installed
|| game.install_status === InstallStatus.Downloading
|| game.install_status === InstallStatus.Installing;
const newerThanExpected = hasNewerLocalVersion(game);
const newerStatus = newerThanExpected
? `Local version ${formatEtiVersion(game.local_version)} is newer than expected ${formatEtiVersion(game.eti_game_version)}.`
: undefined;
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
const outboundStatus = hasOutbound
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}.`
: undefined;
const statusMessage = outboundStatus ?? game.status_message ?? newerStatus;
const statusLevel = hasOutbound
? 'info'
: (game.status_level ?? (newerStatus ? 'warning' : undefined));
|| game.install_status === InstallStatus.Downloading;
return (
<Modal onClose={onClose}>
<button className="modal-close" type="button" onClick={onClose} aria-label="Close">
@@ -100,22 +92,22 @@ export const GameDetailModal = ({
<div className="meta-cell">
<div className="meta-label">Version</div>
<div className="meta-value meta-mono">
{formatEtiVersion(game.eti_game_version ?? game.local_version)}
{formatEtiVersion(game.local_version ?? game.eti_game_version)}
</div>
</div>
<div className="meta-cell">
<div className="meta-label">Status</div>
<div className="meta-value">{gameStatusLabel(game)}</div>
<div className="meta-value">{statusLabelFor(game)}</div>
</div>
</div>
{description && (
<p className="modal-desc">{description}</p>
{game.description && (
<p className="modal-desc">{game.description}</p>
)}
{statusMessage && (
<p className={`modal-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
{statusMessage}
{game.status_message && (
<p className={`modal-status${game.status_level === 'error' ? ' is-error' : ''}`}>
{game.status_message}
</p>
)}
@@ -126,17 +118,6 @@ export const GameDetailModal = ({
onClick={() => onPrimary(game)}
onCancelDownload={onCancelDownload}
/>
{showStreamInstall && (
<button
type="button"
className="ghost-btn"
title="Install without keeping archive files"
onClick={() => onStreamInstall(game)}
>
<Icon.install />
<span>Low disk install</span>
</button>
)}
{game.installed && game.can_host_server === true && (
<button
type="button"
@@ -14,15 +14,10 @@ import {
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;
@@ -59,37 +54,7 @@ const SettingsTextInput = ({ value, placeholder, maxLength, onChange }: TextInpu
</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) => (
export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
<Modal onClose={onClose} className="settings-modal">
<div className="settings-head">
<h2>Settings</h2>
@@ -143,13 +108,6 @@ export const SettingsDialog = ({
<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}
@@ -168,7 +126,6 @@ export const SettingsDialog = ({
</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>
@@ -0,0 +1,17 @@
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,18 +8,15 @@ interface Props {
}
/**
* Search input with `/` and Ctrl+F keyboard shortcuts for focus. Ignores
* shortcuts when the user is already typing into another input or textarea.
* Search input with a `/` keyboard shortcut for focus. Ignores the shortcut
* 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) => {
const isFindShortcut = e.ctrlKey && !e.altKey && !e.shiftKey
&& e.key.toLowerCase() === 'f';
if (e.key !== '/' && !isFindShortcut) return;
if (e.key !== '/') return;
const target = e.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
e.preventDefault();
@@ -52,19 +49,19 @@ export const SearchField = ({ value, onChange }: Props) => {
}}
spellCheck={false}
/>
<button
className={clearClassName}
type="button"
aria-label="Clear search"
aria-hidden={value ? undefined : true}
disabled={!value}
onClick={() => {
onChange('');
inputRef.current?.focus();
}}
>
<Icon.clearCircle />
</button>
{value && (
<button
className="search-clear"
type="button"
aria-label="Clear search"
onClick={() => {
onChange('');
inputRef.current?.focus();
}}
>
<Icon.clearCircle />
</button>
)}
<span className="search-kbd">/</span>
</div>
);
@@ -2,6 +2,7 @@ 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';
@@ -16,6 +17,8 @@ interface Props {
setQuery: (value: string) => void;
sort: GameSort;
setSort: (value: GameSort) => void;
gameDir: string;
onPickDirectory: () => void;
kebabItems: ReadonlyArray<KebabItem>;
}
@@ -28,25 +31,16 @@ export const TopBar = ({
setQuery,
sort,
setSort,
gameDir,
onPickDirectory,
kebabItems,
}: Props) => (
<header className="topbar">
<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>
<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} />
</header>
);
@@ -1,6 +1,5 @@
import { useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { ask } from '@tauri-apps/plugin-dialog';
import { type UseGamesResult } from './useGames';
import { type UISettings } from './useSettings';
@@ -9,7 +8,6 @@ export interface GameActions {
play: (id: string) => Promise<void>;
startServer: (id: string) => Promise<void>;
install: (id: string) => Promise<void>;
streamInstall: (id: string) => Promise<void>;
update: (id: string) => Promise<void>;
uninstall: (id: string) => Promise<void>;
removeDownload: (id: string) => Promise<void>;
@@ -53,11 +51,7 @@ export const useGameActions = (
const install = useCallback(async (id: string) => {
try {
const success = await invoke<boolean>('install_game', {
id,
language: settings.language,
username: settings.username,
});
const success = await invoke<boolean>('install_game', { id });
if (!success) return;
const game = games.games.find(item => item.id === id);
@@ -67,37 +61,16 @@ export const useGameActions = (
} catch (err) {
console.error('install_game failed:', err);
}
}, [games, settings.language, settings.username]);
const streamInstall = useCallback(async (id: string) => {
try {
const success = await invoke<boolean>('stream_install_game', { id });
if (success) games.markChecking(id);
} catch (err) {
console.error('stream_install_game failed:', err);
}
}, [games]);
const update = useCallback(async (id: string) => {
try {
const game = games.games.find(item => item.id === id);
if (game && game.active_outbound_transfers && game.active_outbound_transfers > 0) {
const confirmed = await ask(
`Peers are currently downloading this game from you. Updating will abort their downloads. Do you want to proceed?`,
{ title: 'Active Transfers in Progress', kind: 'warning' }
);
if (!confirmed) return;
}
const success = await invoke<boolean>('update_game', {
id,
language: settings.language,
username: settings.username,
});
const success = await invoke<boolean>('update_game', { id });
if (success) games.markChecking(id);
} catch (err) {
console.error('update_game failed:', err);
}
}, [games, settings.language, settings.username]);
}, [games]);
const uninstall = useCallback(async (id: string) => {
try {
@@ -109,19 +82,11 @@ export const useGameActions = (
const removeDownload = useCallback(async (id: string) => {
try {
const game = games.games.find(item => item.id === id);
if (game && game.active_outbound_transfers && game.active_outbound_transfers > 0) {
const confirmed = await ask(
`Peers are currently downloading this game from you. Removing game files will abort their downloads. Do you want to proceed?`,
{ title: 'Active Transfers in Progress', kind: 'warning' }
);
if (!confirmed) return;
}
await invoke('remove_downloaded_game', { id });
} catch (err) {
console.error('remove_downloaded_game failed:', err);
}
}, [games]);
}, []);
const cancelDownload = useCallback(async (id: string) => {
try {
@@ -139,15 +104,5 @@ export const useGameActions = (
}
}, []);
return {
play,
startServer,
install,
streamInstall,
update,
uninstall,
removeDownload,
cancelDownload,
viewFiles,
};
return { play, startServer, install, update, uninstall, removeDownload, cancelDownload, viewFiles };
};
@@ -11,7 +11,6 @@ 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;
@@ -31,11 +30,7 @@ export const useGameDirectory = () => {
}, []);
useEffect(() => {
if (!gameDir.trim()) {
setGameDirExists(false);
return;
}
let cancelled = false;
if (!gameDir) return;
const sync = async () => {
try {
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
@@ -43,50 +38,19 @@ 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();
return () => {
cancelled = true;
};
invoke('update_game_directory', { path: gameDir }).catch(err =>
console.error('Failed to push game directory to backend:', err),
);
}, [gameDir]);
const hasGameDirectory = gameDir.trim() !== '' && gameDirExists;
const rescan = useCallback(() => {
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();
if (!gameDir) return;
invoke('update_game_directory', { path: gameDir }).catch(err =>
console.error('Failed to rescan game directory:', err),
);
}, [gameDir]);
return { gameDir, gameDirExists, hasGameDirectory, setGameDir, rescan };
return { gameDir, setGameDir, rescan };
};
@@ -19,6 +19,10 @@ 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,73 +82,25 @@ export const deriveState = (game: Game): DerivedState => {
return 'none';
};
export const isInstalledNotShareable = (game: Game): boolean =>
game.installed && !game.downloaded;
export const stateChipLabel = (game: Game): string => {
const state = deriveState(game);
if (state === 'installed' && isInstalledNotShareable(game)) return 'Not shareable';
switch (state) {
case 'installed': return 'Installed';
case 'local': return 'Local';
case 'downloading': return 'Downloading';
case 'busy': return 'Working';
case 'none': return '';
}
};
export const gameStatusLabel = (game: Game): string => {
const state = deriveState(game);
if (state === 'installed' && isInstalledNotShareable(game)) {
return 'Installed, not shareable';
}
switch (state) {
case 'installed': return 'Installed';
case 'local': return 'Downloaded';
case 'downloading': return 'Downloading';
case 'busy': return 'Working…';
case 'none': return 'Not downloaded';
}
};
export const isUnavailable = (game: Game): boolean =>
!game.installed
&& !game.downloaded
&& game.peer_count === 0
&& game.install_status === InstallStatus.NotInstalled;
const parseVersionStamp = (version: string | undefined): number | null => {
if (!version || !/^\d{8}$/.test(version)) return null;
const parsed = parseInt(version, 10);
return Number.isNaN(parsed) ? null : parsed;
};
export const compareVersionStamps = (
left: string | undefined,
right: string | undefined,
): number | null => {
const parsedLeft = parseVersionStamp(left);
const parsedRight = parseVersionStamp(right);
if (parsedLeft === null || parsedRight === null) return null;
return parsedLeft - parsedRight;
};
export const hasNewerLocalVersion = (game: Game): boolean =>
(compareVersionStamps(game.local_version, game.eti_game_version) ?? 0) > 0;
export const needsUpdate = (game: Game): boolean => {
if (!game.installed) return false;
if (game.peer_count <= 0) return false;
if (!game.local_version && game.eti_game_version) return true;
return (compareVersionStamps(game.eti_game_version, game.local_version) ?? 0) > 0;
const peer = game.eti_game_version;
const local = game.local_version;
if (!local && peer) return true;
if (local && peer) {
const l = parseInt(local, 10);
const p = parseInt(peer, 10);
if (!Number.isNaN(l) && !Number.isNaN(p)) return p > l;
}
return false;
};
export const canStreamInstall = (game: Game): boolean =>
!game.downloaded
&& !game.installed
&& game.peer_count > 0
&& !isInProgress(game.install_status);
/** What pressing the card's main action button should do, given the state. */
export type PrimaryAction = 'play' | 'install' | 'update' | 'download' | 'busy' | 'disabled';
@@ -1,148 +0,0 @@
export const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] as const;
export type LogLevel = typeof LOG_LEVELS[number];
export type LevelFilter = 'all' | 'debug' | 'info' | 'warn' | 'error';
export interface MainLogLinePayload {
line: string;
level: string;
sequence?: number | null;
}
export interface MainLogHistoryPayload {
contents: string;
lastSequence: number;
}
export interface MainLogRow {
id: string;
line: string;
level: LogLevel;
sequence?: number;
}
export const LEVEL_ORDER: Record<LogLevel, number> = {
TRACE: 0,
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4,
};
export const LEVEL_FILTER_MIN: Record<LevelFilter, number> = {
all: LEVEL_ORDER.TRACE,
debug: LEVEL_ORDER.DEBUG,
info: LEVEL_ORDER.INFO,
warn: LEVEL_ORDER.WARN,
error: LEVEL_ORDER.ERROR,
};
export const LEVEL_FILTER_OPTIONS: ReadonlyArray<{ value: LevelFilter; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'debug', label: 'Debug+' },
{ value: 'info', label: 'Info+' },
{ value: 'warn', label: 'Warn+' },
{ value: 'error', label: 'Error only' },
];
const MAX_IN_MEMORY_LOG_ROWS = 12_000;
const MAX_IN_MEMORY_LOG_CHARS = 2 * 1024 * 1024;
let nextSyntheticLogRowId = 0;
const syntheticLogRowId = (): string => {
nextSyntheticLogRowId += 1;
return `live-synthetic-${nextSyntheticLogRowId}`;
};
const isLogLevel = (value: string | undefined): value is LogLevel =>
typeof value === 'string' && (LOG_LEVELS as readonly string[]).includes(value);
export const normalizeLogLevel = (value: string | undefined): LogLevel => {
const upper = value?.toUpperCase();
return isLogLevel(upper) ? upper : 'INFO';
};
export const parseLogLevelFromLine = (line: string): LogLevel => {
const match = line.match(/\[(TRACE|DEBUG|INFO|WARN|ERROR)\](?:\s|$)/);
return normalizeLogLevel(match?.[1]);
};
export const rowFromPayload = (payload: MainLogLinePayload): MainLogRow => {
const sequence = typeof payload.sequence === 'number' ? payload.sequence : undefined;
return {
id: sequence === undefined ? syntheticLogRowId() : `live-${sequence}`,
line: payload.line,
level: normalizeLogLevel(payload.level),
sequence,
};
};
export const rowsFromHistory = (text: string): MainLogRow[] =>
text
.split(/\r?\n/)
.filter(line => line.length > 0)
.map((line, index) => ({
id: `history-${index}`,
line,
level: parseLogLevelFromLine(line),
}));
export const capLogRows = (rows: MainLogRow[]): MainLogRow[] => {
let charCount = 0;
const capped: MainLogRow[] = [];
for (let index = rows.length - 1; index >= 0; index -= 1) {
const row = rows[index];
const rowChars = row.line.length + 1;
const wouldExceedRows = capped.length >= MAX_IN_MEMORY_LOG_ROWS;
const wouldExceedChars = charCount + rowChars > MAX_IN_MEMORY_LOG_CHARS;
if (wouldExceedRows || (wouldExceedChars && capped.length > 0)) {
break;
}
capped.push(row);
charCount += rowChars;
}
return capped.reverse();
};
export const rowWasLoadedInHistory = (row: MainLogRow, lastHistorySequence: number): boolean =>
typeof row.sequence === 'number' && row.sequence <= lastHistorySequence;
export const lineCountsFromRows = (rows: MainLogRow[]): Map<string, number> => {
const lineCounts = new Map<string, number>();
rows.forEach(row => {
lineCounts.set(row.line, (lineCounts.get(row.line) ?? 0) + 1);
});
return lineCounts;
};
export const consumeLoadedHistoryRow = (
historyLineCounts: Map<string, number>,
row: MainLogRow,
lastHistorySequence: number,
): boolean => {
if (!rowWasLoadedInHistory(row, lastHistorySequence)) return false;
const count = historyLineCounts.get(row.line) ?? 0;
if (count <= 0) return false;
if (count === 1) {
historyLineCounts.delete(row.line);
} else {
historyLineCounts.set(row.line, count - 1);
}
return true;
};
export const dedupeBufferedRows = (
historyLineCounts: Map<string, number>,
bufferedRows: MainLogRow[],
lastHistorySequence: number,
): MainLogRow[] =>
bufferedRows.filter(row => !consumeLoadedHistoryRow(historyLineCounts, row, lastHistorySequence));
export const formatCount = (count: number, noun: string): string =>
`${count.toLocaleString()} ${noun}${count === 1 ? '' : 's'}`;
@@ -21,7 +21,7 @@ export enum ActiveOperationKind {
RemovingDownload = 'RemovingDownload',
}
export type StatusLevel = 'info' | 'warning' | 'error';
export type StatusLevel = 'info' | 'error';
export interface DownloadProgress {
downloaded_bytes: number;
@@ -59,7 +59,6 @@ export interface Game {
download_progress?: DownloadProgress;
peer_count: number;
can_host_server?: boolean;
active_outbound_transfers?: number;
}
export interface ActiveOperation {
@@ -6,8 +6,6 @@
flex-direction: column;
background: var(--bg-0);
color: var(--t-1);
container-type: inline-size;
container-name: launcher;
overflow: hidden;
position: relative;
isolation: isolate;
@@ -58,7 +56,7 @@
}
}
/* Top bar — three visual zones with search at the geometric center */
/* Top bar */
.topbar {
position: relative;
z-index: 10;
@@ -66,76 +64,13 @@
-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);
display: flex;
align-items: center;
column-gap: 16px;
gap: 18px;
padding: 14px 24px;
flex-wrap: nowrap;
min-height: 64px;
}
.topbar-left {
display: flex;
align-items: center;
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 */
.brand {
@@ -304,10 +239,6 @@
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;
@@ -394,6 +325,46 @@
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;
@@ -739,12 +710,6 @@
.card-status.is-error {
color: #f87171;
}
.card-status.is-warning {
color: #fbbf24;
}
.card-status.is-info {
color: #60a5fa;
}
.density-compact .card-body {
padding: 9px 10px 10px;
@@ -1389,16 +1354,6 @@
border-color: rgba(239, 68, 68, 0.4);
background: rgba(239, 68, 68, 0.08);
}
.modal-status.is-warning {
color: #fbbf24;
border-color: rgba(245, 158, 11, 0.4);
background: rgba(245, 158, 11, 0.08);
}
.modal-status.is-info {
color: #60a5fa;
border-color: rgba(96, 165, 250, 0.4);
background: rgba(96, 165, 250, 0.08);
}
.modal-actions {
display: flex;
align-items: center;
@@ -1513,18 +1468,11 @@
}
.settings-foot {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
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;
@@ -1578,86 +1526,6 @@
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;
@@ -1771,9 +1639,6 @@
margin: 0 0 20px;
max-width: 44ch;
}
.empty-state-title + .ghost-btn {
margin-top: 14px;
}
.empty-state .ghost-btn {
background: var(--accent);
color: white;
-4
View File
@@ -1,5 +1 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_LANSPREAD_BUILD_NR: string;
}
@@ -43,32 +43,9 @@ const openLogsWindow = async () => {
}
};
const openMainLogsWindow = async () => {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow');
try {
const existing = await WebviewWindow.getByLabel('main-logs');
if (existing) {
await existing.setFocus();
return;
}
const win = new WebviewWindow('main-logs', {
url: '/?view=main-logs',
title: 'Application Logs',
width: 980,
height: 720,
resizable: true,
});
await win.once<unknown>('tauri://error', (event) => {
console.error('Error opening application logs window:', event.payload);
});
} catch (err) {
console.error('Error opening application logs window:', err);
}
};
export const MainWindow = () => {
const { settings, set: setSetting } = useSettings();
const { gameDir, hasGameDirectory, setGameDir, rescan } = useGameDirectory();
const { gameDir, setGameDir, rescan } = useGameDirectory();
const games = useGames(rescan);
const actions = useGameActions(games, settings);
const thumbnails = useThumbnails();
@@ -76,17 +53,14 @@ export const MainWindow = () => {
const [openGameId, setOpenGameId] = useState<string | null>(null);
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const visibleGames = useMemo(
() => hasGameDirectory ? games.games : [],
[games.games, hasGameDirectory],
);
const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]);
const counts = useMemo(() => countByFilter(games.games), [games.games]);
// Query is local UI state (no need to persist).
const [query, setQuery] = useState('');
const filteredGames = useMemo(
() => applyFilterAndSort(visibleGames, settings.filter, settings.sort, query),
[visibleGames, settings.filter, settings.sort, query],
() => applyFilterAndSort(games.games, settings.filter, settings.sort, query),
[games.games, settings.filter, settings.sort, query],
);
const openGame = useMemo<Game | null>(
@@ -130,7 +104,6 @@ export const MainWindow = () => {
{ kind: 'item', label: 'Settings', onClick: () => setSettingsOpen(true) },
{ kind: 'item', label: 'Refresh library', onClick: () => rescan() },
{ kind: 'separator' },
{ kind: 'item', label: 'Application logs', onClick: () => void openMainLogsWindow() },
{ kind: 'item', label: 'Unpack logs', onClick: () => void openLogsWindow() },
], [rescan]);
@@ -143,23 +116,25 @@ export const MainWindow = () => {
return (
<div className={className} style={rootStyle}>
<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 ? (
<>
{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">
<ResultsBar shown={filteredGames.length} total={counts.all} />
{filteredGames.length === 0 ? (
visibleGames.length === 0 ? (
games.games.length === 0 ? (
<EmptyResultsState
title="Scanning for games"
hint="Looking for game bundles in your selected directory…"
@@ -180,11 +155,13 @@ export const MainWindow = () => {
onCancelDownload={(g) => actions.cancelDownload(g.id)}
/>
)}
</>
) : (
</main>
</>
) : (
<main className="grid-wrap">
<NoDirectoryState onChooseDirectory={() => void pickDirectory()} />
)}
</main>
</main>
)}
{openGame && (
<GameDetailModal
@@ -192,7 +169,6 @@ export const MainWindow = () => {
thumbnailUrl={thumbnails.get(openGame.id)}
onClose={() => setOpenGameId(null)}
onPrimary={handlePrimary}
onStreamInstall={(g) => actions.streamInstall(g.id)}
onUninstall={handleUninstall}
onRemoveDownload={handleRemoveDownload}
onCancelDownload={(g) => actions.cancelDownload(g.id)}
@@ -212,9 +188,6 @@ export const MainWindow = () => {
{settingsOpen && (
<SettingsDialog
settings={settings}
gameDir={gameDir}
hasGameDirectory={hasGameDirectory}
onPickDirectory={() => void pickDirectory()}
onChange={setSetting}
onClose={() => setSettingsOpen(false)}
/>
@@ -2,7 +2,6 @@ import {
actionLabel,
activeStatusById,
applyFilterAndSort,
canStreamInstall,
countByFilter,
deriveState,
downloadProgressPercent,
@@ -11,9 +10,7 @@ import {
formatDownloadEta,
formatDownloadSpeed,
formatDownloadSpeedShort,
gameStatusLabel,
mergeGameUpdate,
stateChipLabel,
} from '../src/lib/gameState.ts';
import {
ActiveOperationKind,
@@ -212,75 +209,3 @@ Deno.test('download progress formatting matches the progress-bar layouts', () =>
);
assertEquals(formatDownloadEta(485), '8 min', 'eta format should stay compact');
});
Deno.test('stream install is available only for idle remote games', () => {
assertEquals(
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 1 })),
true,
'remote-only idle games should allow streamed install',
);
assertEquals(
canStreamInstall(game({ downloaded: true, installed: false, peer_count: 1 })),
false,
'downloaded games should install from local archives',
);
assertEquals(
canStreamInstall(game({ downloaded: false, installed: true, peer_count: 1 })),
false,
'installed games should not expose streamed install',
);
assertEquals(
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 0 })),
false,
'games without peers should not expose streamed install',
);
assertEquals(
canStreamInstall(game({
downloaded: false,
installed: false,
peer_count: 1,
install_status: InstallStatus.CheckingPeers,
})),
false,
'busy games should not expose streamed install',
);
});
Deno.test('streamed local installs are labeled installed but not shareable', () => {
const streamed = game({
downloaded: false,
installed: true,
install_status: InstallStatus.Installed,
});
const downloadedInstall = game({
downloaded: true,
installed: true,
install_status: InstallStatus.Installed,
});
assertEquals(
deriveState(streamed),
'installed',
'streamed local installs should keep installed visual state',
);
assertEquals(
stateChipLabel(streamed),
'Not shareable',
'card chip should make the non-shareable state visible',
);
assertEquals(
gameStatusLabel(streamed),
'Installed, not shareable',
'detail status should spell out installed plus non-shareable',
);
assertEquals(
stateChipLabel(downloadedInstall),
'Installed',
'normal downloaded installs should keep the installed chip label',
);
assertEquals(
gameStatusLabel(downloadedInstall),
'Installed',
'normal downloaded installs should keep the installed detail label',
);
});
@@ -1,55 +0,0 @@
import {
dedupeBufferedRows,
lineCountsFromRows,
rowFromPayload,
rowsFromHistory,
rowWasLoadedInHistory,
} from '../src/lib/mainLogs.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
Deno.test('history rows parse levels and stable ids', () => {
const rows = rowsFromHistory('[2026-06-07][12:00:00][app][WARN] careful\nplain line\n');
assertEquals(rows.length, 2, 'history should skip trailing empty line');
assertEquals(rows[0].id, 'history-0', 'history id should include row position');
assertEquals(rows[0].level, 'WARN', 'explicit level should be parsed');
assertEquals(rows[1].level, 'INFO', 'unknown level should default to info');
});
Deno.test('buffered main log rows covered by history sequence are removed', () => {
const historyRows = rowsFromHistory('[2026-06-07][12:00:01][app][INFO] included\n');
const included = rowFromPayload({
line: '[2026-06-07][12:00:01][app][INFO] included',
level: 'INFO',
sequence: 4,
});
const fresh = rowFromPayload({
line: '[2026-06-07][12:00:02][app][INFO] fresh',
level: 'INFO',
sequence: 5,
});
const deduped = dedupeBufferedRows(lineCountsFromRows(historyRows), [included, fresh], 4);
assertEquals(rowWasLoadedInHistory(included, 4), true, 'included row should match history');
assertEquals(deduped.length, 1, 'only fresh row should remain');
assertEquals(deduped[0].line, fresh.line, 'fresh row should not be dropped');
});
Deno.test('buffered rows missing from trimmed history are retained', () => {
const retained = rowFromPayload({
line: '[2026-06-07][12:00:00][app][INFO] trimmed out',
level: 'INFO',
sequence: 2,
});
const deduped = dedupeBufferedRows(new Map(), [retained], 4);
assertEquals(deduped.length, 1, 'trimmed row should remain visible');
assertEquals(deduped[0].line, retained.line, 'trimmed row should be preserved');
});
@@ -1,18 +1,12 @@
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`
//
+5 -5
View File
@@ -3,14 +3,14 @@ name = "lanspread-utils"
version = "0.1.0"
edition = "2024"
[lib]
doctest = false
test = false
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lints.rust]
unsafe_code = "forbid"
[lib]
test = false
doctest = false
+18 -85
View File
@@ -32,21 +32,6 @@ 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.
@@ -63,29 +48,21 @@ 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)`. 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.
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:
- **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
- **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
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)`.
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)`.
`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 (AZ)`, `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.
`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 (AZ)`, `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`.
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)`).
@@ -175,11 +152,6 @@ 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]│
@@ -203,10 +175,6 @@ Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim tr
- **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`.
@@ -222,40 +190,6 @@ Persisted settings (write through to local storage / Tauri config):
- `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.
---
@@ -437,8 +371,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).
@@ -492,13 +426,12 @@ type LauncherUI = {
**Persisted settings** (mirror of Settings dialog state):
```ts
type LauncherSettings = {
username: string;
language: 'en' | 'de';
username: string; // new
language: 'en' | 'de'; // new
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
};
```
@@ -590,8 +523,8 @@ design_reference/
├── data.jsx ← mock GAMES array + filter/sort helpers + STORAGE mock
├── components.jsx ← Icon, GameCover, StateChip, ActionButton, GameCard,
│ SegmentedFilters, UnderlineFilters, SearchField,
│ SortMenu, StorageMeter, KebabMenu,
│ GameDetailModal, SettingsDialog (incl. GameFolderField)
│ SortMenu, StorageMeter, DirectoryButton, KebabMenu,
│ GameDetailModal, SettingsDialog
└── launcher.jsx ← <Launcher> component composing chrome + grid + modals
```
@@ -603,7 +536,7 @@ To preview the design in a browser:
- **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.
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). In the production app these live in the Settings dialog.
---
@@ -36,9 +36,8 @@ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"density": "normal",
"aspect": "square",
"bg": "gradient",
"username": "ddidderr",
"language": "en",
"gameFolder": "\/some\/folder\/to\/games"
"username": "d",
"language": "en"
}/*EDITMODE-END*/;
const ACCENTS = ['#3b82f6', '#22d3ee', '#a855f7', '#22c55e', '#f59e0b', '#ef4444'];
@@ -114,10 +113,6 @@ 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>
);
+4 -39
View File
@@ -354,17 +354,12 @@ function StorageMeter({ accent, compact = false }) {
// Directory button (shows path)
// ────────────────────────────────────────────────────────────────────
function DirectoryButton({ path }) {
const isSet = !!(path && path.trim());
const label = isSet ? 'Game folder' : 'Set game folder';
const tooltip = isSet ? path : 'Please select a game folder';
const short = path.length > 36 ? '…' + path.slice(-34) : path;
return (
<button
className={`dirbtn ${isSet ? 'dirbtn-set' : 'dirbtn-unset'}`}
title={tooltip}
aria-label={isSet ? `Game folder: ${path}` : 'Set game folder'}
>
<button className="dirbtn" title={path}>
<Icon.folder/>
<span className="dirbtn-label">{label}</span>
<span className="dirbtn-label">Game directory</span>
<span className="dirbtn-path">{short}</span>
</button>
);
}
@@ -524,31 +519,6 @@ 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">
@@ -607,11 +577,6 @@ 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}
+11 -19
View File
@@ -1,6 +1,8 @@
// 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()) {
@@ -49,26 +51,15 @@ function Launcher({
style={{ '--accent': accent }}>
{variant === 'single' ? (
<header className="topbar topbar-single">
<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 className="brand">
<div className="brand-mark" style={{ background: accent }}>S</div>
<div className="brand-name">SoftLAN</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">
@@ -77,6 +68,7 @@ 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}/>
+21 -149
View File
@@ -41,8 +41,6 @@
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;
@@ -79,70 +77,13 @@
border-bottom: 1px solid var(--bd-1);
}
/* Variant 1: single row — three visual zones with search at geometric center */
/* Variant 1: single row */
.topbar-single {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
display: flex;
align-items: center;
column-gap: 16px;
gap: 18px;
padding: 14px 24px;
}
.topbar-single .topbar-left {
display: flex;
align-items: center;
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; }
flex-wrap: nowrap;
}
/* Variant 2: two row */
@@ -366,27 +307,29 @@
/* ─── Directory button ─── */
.dirbtn {
position: relative;
display: inline-flex; align-items: center; gap: 8px;
height: 36px; padding: 0 14px 0 12px;
height: 36px; padding: 0 12px;
background: var(--bg-2);
border: 1px solid var(--bd-1);
border-radius: 8px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 600;
color: var(--t-2);
font: inherit; font-size: 12.5px;
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
flex-shrink: 0;
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;
white-space: nowrap;
}
.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));
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
/* ─── Kebab menu ─── */
@@ -1240,74 +1183,3 @@
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);
}
-7
View File
@@ -2,12 +2,6 @@ export RUSTFLAGS := "-C target-cpu=native"
export WEBKIT_DISABLE_COMPOSITING_MODE := "1"
export DOCKER_CONFIG := env_var_or_default("DOCKER_CONFIG", ".lanspread-peer-cli/docker-config")
default: run
setup:
cargo install tauri-cli
cd crates/lanspread-tauri-deno-ts && deno install --frozen=true
run:
cargo tauri dev --release
@@ -19,7 +13,6 @@ bundle:
fmt:
cargo +nightly fmt
tombi format
just --fmt
_fix:
+1
View File
@@ -1,3 +1,4 @@
# cargo-vet audits file
[[audits.windows-link]]
+1
View File
@@ -1,3 +1,4 @@
# cargo-vet config file
[cargo-vet]