Compare commits

..
1 Commits
Author SHA1 Message Date
Paul Schulze 20404d0145 Add application log viewer 2026-06-07 16:17:31 +02:00
104 changed files with 2073 additions and 9835 deletions
-1
View File
@@ -2,4 +2,3 @@
/.lanspread-peer-cli/
/game.db
/thumbnails/
__pycache__/
Generated
+554 -364
View File
@@ -25,9 +25,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.4"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
dependencies = [
"alloc-no-stdlib",
]
@@ -49,9 +49,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.104"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "atk"
@@ -99,9 +99,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
version = "1.17.3"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1"
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -110,15 +110,14 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.43.0"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c"
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
@@ -156,9 +155,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.1"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
dependencies = [
"serde_core",
]
@@ -183,9 +182,9 @@ dependencies = [
[[package]]
name = "brotli"
version = "8.0.4"
version = "8.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -194,9 +193,9 @@ dependencies = [
[[package]]
name = "brotli-decompressor"
version = "5.0.3"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -219,9 +218,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
@@ -231,9 +230,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.12.1"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
dependencies = [
"serde",
]
@@ -244,7 +243,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"cairo-sys-rs",
"glib",
"libc",
@@ -265,9 +264,9 @@ dependencies = [
[[package]]
name = "camino"
version = "1.2.4"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0"
checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
dependencies = [
"serde_core",
]
@@ -292,7 +291,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"thiserror 2.0.19",
"thiserror 2.0.18",
]
[[package]]
@@ -307,9 +306,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.3.0"
version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -352,9 +351,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.10.1"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -433,7 +432,7 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"core-foundation",
"core-graphics-types",
"foreign-types",
@@ -446,7 +445,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"core-foundation",
"libc",
]
@@ -495,27 +494,27 @@ dependencies = [
[[package]]
name = "crossbeam-channel"
version = "0.5.16"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.13"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.22"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crypto-common"
@@ -547,7 +546,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -597,7 +596,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -608,14 +607,14 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
name = "dbus"
version = "0.9.12"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73"
dependencies = [
"libc",
"libdbus-sys",
@@ -628,6 +627,7 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
"serde_core",
]
@@ -649,7 +649,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -689,7 +689,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"block2",
"libc",
"objc2",
@@ -703,7 +703,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -726,7 +726,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -737,7 +737,7 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
dependencies = [
"bit-set",
"cssparser",
"foldhash",
"foldhash 0.2.0",
"html5ever",
"precomputed-hash",
"selectors",
@@ -812,14 +812,14 @@ dependencies = [
[[package]]
name = "embed-resource"
version = "3.0.11"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd"
checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb"
dependencies = [
"cc",
"memchr",
"rustc_version",
"toml 1.1.3+spec-1.1.0",
"toml 1.1.2+spec-1.1.0",
"vswhom",
"winreg",
]
@@ -889,9 +889,9 @@ dependencies = [
[[package]]
name = "fastrand"
version = "2.5.0"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fdeflate"
@@ -928,6 +928,17 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "flume"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
dependencies = [
"futures-core",
"futures-sink",
"spin",
]
[[package]]
name = "flume"
version = "0.12.0"
@@ -945,6 +956,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -969,7 +986,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -1004,9 +1021,9 @@ dependencies = [
[[package]]
name = "futures"
version = "0.3.33"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
@@ -1019,9 +1036,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.33"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
@@ -1029,15 +1046,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.33"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.33"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
@@ -1057,38 +1074,38 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.33"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.33"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
name = "futures-sink"
version = "0.3.33"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.33"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.33"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
@@ -1256,14 +1273,16 @@ dependencies = [
[[package]]
name = "getrandom"
version = "0.4.3"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasip2",
"wasip3",
]
[[package]]
@@ -1304,7 +1323,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"futures-channel",
"futures-core",
"futures-executor",
@@ -1332,7 +1351,7 @@ dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -1347,9 +1366,9 @@ dependencies = [
[[package]]
name = "glob"
version = "0.3.4"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "gobject-sys"
@@ -1411,7 +1430,7 @@ dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -1426,6 +1445,15 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[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"
@@ -1434,7 +1462,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -1445,14 +1473,14 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
]
[[package]]
name = "hashlink"
version = "0.11.1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230"
dependencies = [
"hashbrown 0.16.1",
]
@@ -1493,9 +1521,9 @@ dependencies = [
[[package]]
name = "http"
version = "1.4.2"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
dependencies = [
"bytes",
"itoa",
@@ -1503,9 +1531,9 @@ dependencies = [
[[package]]
name = "http-body"
version = "1.1.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
@@ -1513,9 +1541,9 @@ dependencies = [
[[package]]
name = "http-body-util"
version = "0.1.4"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
@@ -1532,9 +1560,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hyper"
version = "1.11.0"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
@@ -1689,6 +1717,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -1766,29 +1800,29 @@ dependencies = [
[[package]]
name = "inotify"
version = "0.11.4"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "intrusive-collections"
version = "0.10.2"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b719c59241cfaac1042a6d26787e28ed7ee4a4e21a5a907786f54222d1b0062"
checksum = "80e165935eba36cb526af8389effd2005a741adcbb6ed32106cc68e3f7b92960"
dependencies = [
"memoffset",
]
@@ -1888,27 +1922,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
name = "jobserver"
version = "0.1.35"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.4.3",
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.103"
version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -1940,7 +1975,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"serde",
"unicode-segmentation",
]
@@ -1961,7 +1996,7 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"libc",
]
@@ -2000,7 +2035,6 @@ name = "lanspread-peer"
version = "0.1.0"
dependencies = [
"bytes",
"crc32fast",
"eyre",
"futures",
"gethostname",
@@ -2083,6 +2117,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libappindicator"
version = "0.9.0"
@@ -2109,9 +2149,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.188"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libdbus-sys"
@@ -2149,9 +2189,9 @@ dependencies = [
[[package]]
name = "libredox"
version = "0.1.18"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
dependencies = [
"libc",
]
@@ -2190,9 +2230,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.33"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "markup5ever"
@@ -2207,12 +2247,12 @@ dependencies = [
[[package]]
name = "mdns-sd"
version = "0.20.2"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f18d8ec9d1869796fb2910d95f4d957072df0b6a22e247a1d760d8b4c805e17a"
checksum = "892f96f6d2ebe1ea641279f986ac52a2a6bac71e8f743bb258315cfe2bd7e88e"
dependencies = [
"fastrand",
"flume",
"flume 0.11.1",
"if-addrs",
"log",
"mio",
@@ -2222,9 +2262,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.8.3"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "memoffset"
@@ -2262,9 +2302,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.2"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"log",
@@ -2274,9 +2314,9 @@ dependencies = [
[[package]]
name = "muda"
version = "0.19.3"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878"
checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c"
dependencies = [
"crossbeam-channel",
"dpi",
@@ -2289,7 +2329,7 @@ dependencies = [
"once_cell",
"png 0.18.1",
"serde",
"thiserror 2.0.19",
"thiserror 2.0.18",
"windows-sys 0.61.2",
]
@@ -2299,7 +2339,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"jni-sys 0.3.1",
"log",
"ndk-sys",
@@ -2329,7 +2369,7 @@ version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"fsevent-sys",
"inotify",
"kqueue",
@@ -2347,7 +2387,7 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
]
[[package]]
@@ -2413,7 +2453,7 @@ dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -2441,7 +2481,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"block2",
"objc2",
"objc2-core-foundation",
@@ -2454,7 +2494,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"objc2",
"objc2-foundation",
]
@@ -2475,7 +2515,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"dispatch2",
"objc2",
]
@@ -2486,7 +2526,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"dispatch2",
"objc2",
"objc2-core-foundation",
@@ -2519,7 +2559,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -2546,7 +2586,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"block2",
"libc",
"objc2",
@@ -2559,7 +2599,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"objc2",
"objc2-core-foundation",
]
@@ -2570,7 +2610,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
@@ -2582,7 +2622,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"block2",
"objc2",
"objc2-cloud-kit",
@@ -2613,7 +2653,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"block2",
"objc2",
"objc2-app-kit",
@@ -2629,13 +2669,14 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "open"
version = "5.4.0"
version = "5.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5"
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
dependencies = [
"dunce",
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
@@ -2708,6 +2749,12 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2755,7 +2802,7 @@ dependencies = [
"phf_shared",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -2781,9 +2828,9 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plist"
version = "1.10.0"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
@@ -2811,7 +2858,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"crc32fast",
"fdeflate",
"flate2",
@@ -2848,6 +2895,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.117",
]
[[package]]
name = "proc-macro-crate"
version = "1.3.1"
@@ -2874,7 +2931,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.13+spec-1.1.0",
"toml_edit 0.25.12+spec-1.1.0",
]
[[package]]
@@ -2903,27 +2960,27 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.107"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
version = "0.39.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.47"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@@ -2955,12 +3012,12 @@ dependencies = [
[[package]]
name = "rand"
version = "0.10.2"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"getrandom 0.4.2",
"rand_core 0.10.1",
]
@@ -3010,7 +3067,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
]
[[package]]
@@ -3021,34 +3078,34 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 2.0.19",
"thiserror 2.0.18",
]
[[package]]
name = "ref-cast"
version = "1.0.26"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.26"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
"syn 2.0.117",
]
[[package]]
name = "regex"
version = "1.13.1"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
@@ -3058,9 +3115,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.16"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
@@ -3069,9 +3126,9 @@ 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 = "reqwest"
@@ -3147,9 +3204,9 @@ dependencies = [
[[package]]
name = "rustc-hash"
version = "2.1.3"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc_version"
@@ -3166,7 +3223,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"errno",
"libc",
"linux-raw-sys",
@@ -3175,9 +3232,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.42"
version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"aws-lc-rs",
"log",
@@ -3190,9 +3247,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.15.0"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"zeroize",
]
@@ -3211,15 +3268,15 @@ dependencies = [
[[package]]
name = "rustversion"
version = "1.0.23"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "s2n-codec"
version = "0.83.0"
version = "0.81.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5323db3697b61f4f5161c346573a9089cf6b4e13656991a5e84c18dc91bc17d7"
checksum = "d197a3c92bbe21fc00ba8366f6ba14edb8685316b6c8c14c622d3aba0a3816d8"
dependencies = [
"byteorder",
"bytes",
@@ -3228,16 +3285,16 @@ dependencies = [
[[package]]
name = "s2n-quic"
version = "1.83.0"
version = "1.81.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54598beca69a3970deaa988edcb2a00f5812433db085f23b08f3ebe40a3bf86d"
checksum = "8728244102e791769cebe44a4abace966d8826f3266e9691c4233f47921b94b8"
dependencies = [
"bytes",
"cfg-if",
"cuckoofilter",
"futures",
"hash_hasher",
"rand 0.10.2",
"rand 0.10.1",
"s2n-codec",
"s2n-quic-core",
"s2n-quic-crypto",
@@ -3252,9 +3309,9 @@ dependencies = [
[[package]]
name = "s2n-quic-core"
version = "0.83.0"
version = "0.81.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf9897515906f85528b3301f6f54d9d7b02827a9f5a2e13fcd5c965161571e"
checksum = "6cc69861a4909ea508b26309504899f4b0f77bb35348f6a36b7de9a28b1a4b92"
dependencies = [
"atomic-waker",
"byteorder",
@@ -3274,9 +3331,9 @@ dependencies = [
[[package]]
name = "s2n-quic-crypto"
version = "0.83.0"
version = "0.81.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc67b81d13e30e2b5d69a154fa312bfe523ec3a95c81a694c15c902a210b80de"
checksum = "5a3ce7f399a87be4b49d76895cdddb987620d34f334072d011bcac913d20fe69"
dependencies = [
"aws-lc-rs",
"cfg-if",
@@ -3288,9 +3345,9 @@ dependencies = [
[[package]]
name = "s2n-quic-platform"
version = "0.83.0"
version = "0.81.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a488f217c6dec4eac1a0c61d25af5235a2abcd03af31ae2f3bc047901caad80"
checksum = "fa9004809ae3a778b8e015581a47e9fb389f9ec230456a24b81c6287b000fefe"
dependencies = [
"cfg-if",
"futures",
@@ -3303,9 +3360,9 @@ dependencies = [
[[package]]
name = "s2n-quic-rustls"
version = "0.83.0"
version = "0.81.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7349312d82172938036b92119f666ba93ce9bed7cbfeedc968115574932398fc"
checksum = "cf7c34876c77f7560ee4385cd5ff0510acade2eb66dc237a45f7c63d2e7f1af3"
dependencies = [
"bytes",
"rustls",
@@ -3317,9 +3374,9 @@ dependencies = [
[[package]]
name = "s2n-quic-tls"
version = "0.83.0"
version = "0.81.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "093191572f12842106d8e37326d1a99dd5d6c96bfcdd14c1718e6b6ae51730cb"
checksum = "fc7b14505cff3d9e39b930c31c150fe2965ee5fe1b654f7c6d33b1f50680ac0b"
dependencies = [
"bytes",
"errno",
@@ -3332,9 +3389,9 @@ dependencies = [
[[package]]
name = "s2n-quic-tls-default"
version = "0.83.0"
version = "0.81.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3488ac2d7fab0b4921e412dbc8599419f8d1bfd6f9c4422fdb4ef8b5e4a33c68"
checksum = "3297fc8531b3c19f339a3ce1969fdc0e9928cfe439ca6c9f9d9d7ca4522a3b8c"
dependencies = [
"s2n-quic-rustls",
"s2n-quic-tls",
@@ -3342,9 +3399,9 @@ dependencies = [
[[package]]
name = "s2n-quic-transport"
version = "0.83.0"
version = "0.81.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899b3ffff460a7d4e8b8ed0933aca1b4d309a1e52bfffdd9fb54bd78e3c922b0"
checksum = "d1ddd739c1776770dd2ab0b33da1cf372a395500252ae5250c08e2d6bf51b38f"
dependencies = [
"bytes",
"futures-channel",
@@ -3360,9 +3417,9 @@ dependencies = [
[[package]]
name = "s2n-tls"
version = "0.3.40"
version = "0.3.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db76c77f8c280a581031ad88f8bc09c18581b11d785f530025d135dd22c03b9e"
checksum = "f60ada8adc59c848686744ff0ca93f1d1edc565a69d96a58a26abd84a521a5a0"
dependencies = [
"errno",
"hex",
@@ -3373,9 +3430,9 @@ dependencies = [
[[package]]
name = "s2n-tls-sys"
version = "0.3.40"
version = "0.3.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce900e83d6cb0dee6623e6e0c955ce1973ad2994ce76c5ed577e27fa564cb82b"
checksum = "e6960ccb00e47fe4b641f9c7b769515a4f89cb65459ca7afbed20fa39aac0b1b"
dependencies = [
"aws-lc-rs",
"cc",
@@ -3439,7 +3496,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -3454,7 +3511,7 @@ version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"cssparser",
"derive_more",
"log",
@@ -3479,9 +3536,9 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.229"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
@@ -3501,22 +3558,22 @@ dependencies = [
[[package]]
name = "serde_core"
version = "1.0.229"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
"syn 2.0.117",
]
[[package]]
@@ -3527,14 +3584,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
name = "serde_json"
version = "1.0.151"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
@@ -3545,13 +3602,13 @@ dependencies = [
[[package]]
name = "serde_repr"
version = "0.1.21"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
"syn 2.0.117",
]
[[package]]
@@ -3601,7 +3658,7 @@ dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -3623,7 +3680,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -3705,9 +3762,9 @@ dependencies = [
[[package]]
name = "simd-adler32"
version = "0.3.10"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "siphasher"
@@ -3723,26 +3780,26 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket-pktinfo"
version = "0.4.0"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e8e43b4bdce7cff8a4d3f8025ee38fce5ca138fab868ebbf9529c81328fbf9d"
checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f"
dependencies = [
"libc",
"socket2",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
name = "socket2"
version = "0.6.5"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.61.2",
@@ -3798,9 +3855,9 @@ dependencies = [
[[package]]
name = "spin"
version = "0.9.9"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
dependencies = [
"lock_api",
]
@@ -3842,7 +3899,7 @@ dependencies = [
"serde",
"sha2",
"smallvec",
"thiserror 2.0.19",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tracing",
@@ -3859,7 +3916,7 @@ dependencies = [
"quote",
"sqlx-core",
"sqlx-macros-core",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -3880,7 +3937,7 @@ dependencies = [
"sha2",
"sqlx-core",
"sqlx-sqlite",
"syn 2.0.119",
"syn 2.0.117",
"tokio",
"url",
]
@@ -3892,7 +3949,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
dependencies = [
"atoi",
"flume",
"flume 0.12.0",
"form_urlencoded",
"futures-channel",
"futures-core",
@@ -3904,7 +3961,7 @@ dependencies = [
"percent-encoding",
"serde",
"sqlx-core",
"thiserror 2.0.19",
"thiserror 2.0.18",
"tracing",
"url",
]
@@ -3963,7 +4020,7 @@ dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -3995,20 +4052,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.119"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
@@ -4032,7 +4078,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -4054,7 +4100,7 @@ version = "0.35.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"block2",
"core-foundation",
"core-graphics",
@@ -4096,7 +4142,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -4107,9 +4153,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.11.5"
version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5"
checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28"
dependencies = [
"anyhow",
"bytes",
@@ -4146,7 +4192,7 @@ dependencies = [
"tauri-runtime",
"tauri-runtime-wry",
"tauri-utils",
"thiserror 2.0.19",
"thiserror 2.0.18",
"tokio",
"tray-icon",
"url",
@@ -4158,9 +4204,9 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "2.6.3"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632"
checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7"
dependencies = [
"anyhow",
"cargo_toml",
@@ -4179,9 +4225,9 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.6.3"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5"
checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9"
dependencies = [
"base64 0.22.1",
"brotli",
@@ -4195,9 +4241,9 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"syn 2.0.119",
"syn 2.0.117",
"tauri-utils",
"thiserror 2.0.19",
"thiserror 2.0.18",
"time",
"url",
"uuid",
@@ -4206,23 +4252,23 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.6.3"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45"
checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
"tauri-codegen",
"tauri-utils",
]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e"
dependencies = [
"anyhow",
"glob",
@@ -4236,9 +4282,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.2"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
@@ -4248,7 +4294,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.19",
"thiserror 2.0.18",
"url",
]
@@ -4271,8 +4317,8 @@ dependencies = [
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.19",
"toml 1.1.3+spec-1.1.0",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
@@ -4293,31 +4339,31 @@ dependencies = [
"shared_child",
"tauri",
"tauri-plugin",
"thiserror 2.0.19",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "tauri-plugin-store"
version = "2.4.4"
version = "2.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6708afbe549f176b712066e71648ba8fafba20789453718260c7ca356733cb0c"
checksum = "6c72dda16786eb4a3f903e43a17b64d8d78dc0f00fe2aa4b757c28f617a8630b"
dependencies = [
"dunce",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.19",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]]
name = "tauri-runtime"
version = "2.11.3"
version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8"
checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef"
dependencies = [
"cookie",
"dpi",
@@ -4331,7 +4377,7 @@ dependencies = [
"serde",
"serde_json",
"tauri-utils",
"thiserror 2.0.19",
"thiserror 2.0.18",
"url",
"webkit2gtk",
"webview2-com",
@@ -4340,9 +4386,9 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
version = "2.11.4"
version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
dependencies = [
"gtk",
"http",
@@ -4366,9 +4412,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.9.3"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887"
checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95"
dependencies = [
"anyhow",
"brotli",
@@ -4394,8 +4440,8 @@ dependencies = [
"serde_json",
"serde_with",
"swift-rs",
"thiserror 2.0.19",
"toml 1.1.3+spec-1.1.0",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
"urlpattern",
"uuid",
@@ -4410,16 +4456,17 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
dependencies = [
"dunce",
"embed-resource",
"toml 1.1.3+spec-1.1.0",
"toml 1.1.2+spec-1.1.0",
]
[[package]]
name = "tendril"
version = "0.5.1"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24"
dependencies = [
"new_debug_unreachable",
"utf-8",
]
[[package]]
@@ -4433,11 +4480,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.19"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.19",
"thiserror-impl 2.0.18",
]
[[package]]
@@ -4448,36 +4495,37 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
name = "thiserror-impl"
version = "2.0.19"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
"syn 2.0.117",
]
[[package]]
name = "thread_local"
version = "1.1.10"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.54"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
@@ -4489,15 +4537,15 @@ dependencies = [
[[package]]
name = "time-core"
version = "0.1.9"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.32"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
@@ -4515,9 +4563,9 @@ dependencies = [
[[package]]
name = "tinyvec"
version = "1.12.0"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
@@ -4530,9 +4578,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.53.1"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
@@ -4547,13 +4595,13 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.7.1"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -4569,15 +4617,14 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.19"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"futures-util",
"libc",
"pin-project-lite",
"tokio",
]
@@ -4611,9 +4658,9 @@ dependencies = [
[[package]]
name = "toml"
version = "1.1.3+spec-1.1.0"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap 2.14.0",
"serde_core",
@@ -4621,7 +4668,7 @@ dependencies = [
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.4",
"winnow 1.0.3",
]
[[package]]
@@ -4677,14 +4724,14 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.13+spec-1.1.0"
version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.4",
"winnow 1.0.3",
]
[[package]]
@@ -4693,14 +4740,14 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow 1.0.4",
"winnow 1.0.3",
]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "tower"
@@ -4723,7 +4770,7 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.13.1",
"bitflags 2.12.1",
"bytes",
"futures-util",
"http",
@@ -4767,7 +4814,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -4807,9 +4854,9 @@ dependencies = [
[[package]]
name = "tray-icon"
version = "0.24.1"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc"
checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773"
dependencies = [
"crossbeam-channel",
"dirs",
@@ -4823,7 +4870,7 @@ dependencies = [
"once_cell",
"png 0.18.1",
"serde",
"thiserror 2.0.19",
"thiserror 2.0.18",
"windows-sys 0.61.2",
]
@@ -4898,6 +4945,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.7.1"
@@ -4935,6 +4988,12 @@ dependencies = [
"url",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4943,11 +5002,11 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.24.0"
version = "1.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
dependencies = [
"getrandom 0.4.3",
"getrandom 0.4.2",
"js-sys",
"serde_core",
"wasm-bindgen",
@@ -5030,18 +5089,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen",
"wit-bindgen 0.57.1",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.126"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
dependencies = [
"cfg-if",
"once_cell",
@@ -5052,9 +5120,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.76"
version = "0.4.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -5062,9 +5130,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -5072,26 +5140,48 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.126"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.126"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap 2.14.0",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
@@ -5106,10 +5196,22 @@ dependencies = [
]
[[package]]
name = "web-sys"
version = "0.3.103"
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.12.1",
"hashbrown 0.15.5",
"indexmap 2.14.0",
"semver",
]
[[package]]
name = "web-sys"
version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -5117,9 +5219,9 @@ dependencies = [
[[package]]
name = "web_atoms"
version = "0.2.5"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297"
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [
"phf",
"phf_codegen",
@@ -5193,7 +5295,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -5202,7 +5304,7 @@ version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.19",
"thiserror 2.0.18",
"windows 0.61.3",
"windows-core 0.61.2",
]
@@ -5352,7 +5454,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -5363,7 +5465,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -5709,9 +5811,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winnow"
version = "1.0.4"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
dependencies = [
"memchr",
]
@@ -5726,12 +5828,100 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck 0.5.0",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck 0.5.0",
"indexmap 2.14.0",
"prettyplease",
"syn 2.0.117",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn 2.0.117",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.12.1",
"indexmap 2.14.0",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap 2.14.0",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "writeable"
version = "0.6.3"
@@ -5771,7 +5961,7 @@ dependencies = [
"sha2",
"soup3",
"tao-macros",
"thiserror 2.0.19",
"thiserror 2.0.18",
"url",
"webkit2gtk",
"webkit2gtk-sys",
@@ -5822,28 +6012,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.55"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.55"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -5863,28 +6053,28 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
@@ -5917,11 +6107,11 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 2.0.117",
]
[[package]]
name = "zmij"
version = "1.0.23"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+1 -2
View File
@@ -14,7 +14,6 @@ members = [
[workspace.dependencies]
base64 = "0.22"
bytes = { version = "1", features = ["serde"] }
crc32fast = "1"
eyre = "0.6"
futures = "0.3"
gethostname = "1"
@@ -40,12 +39,12 @@ tauri = { version = "2", features = [] }
tauri-plugin-dialog = "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"
time = { version = "0.3", features = ["local-offset"] }
uuid = { version = "1", features = ["v7"] }
walkdir = "2"
windows = {
-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.
+33 -261
View File
@@ -18,61 +18,53 @@ for deterministic local runs; mDNS/macvlan remains an environment smoke path.
| S8 | Ambiguous metadata rejection | Two peers advertise the same game/version with conflicting file sizes. | Download fails with a `download-failed` event; no committed `version.ini` is left for the target game. |
| S9 | Missing game | Client asks for a game none of its peers can serve. | CLI reports a deterministic command failure and emits `no-peers-have-game`; no local files are created. |
| S10 | Shutdown and goodbye cleanup | Alpha and bravo are connected, then bravo shuts down. | Alpha receives peer loss/removal and remote games from bravo disappear. |
| S11 | Same identity reconnect | Bravo restarts with the same state dir (the OS assigns an ephemeral listener port that usually, but not necessarily, differs), then alpha connects again. | Alpha has exactly one bravo peer entry reusing the same peer ID, not a duplicate identity, at whatever address bravo now advertises. |
| S11 | Same identity reconnect | Bravo restarts with the same state dir but a new port, then alpha connects to the new address. | Alpha has one bravo peer entry with the updated address, not duplicate identities. |
| 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 | A source advertises a synthetic catalog game whose `.eti` is a sparse file of `4 * CHUNK_SIZE` (four 128 MiB chunks). A second peer downloads it, then a third peer downloads it from both peers. | The third peer's downloaded files match the source by SHA-256; `download-chunk-finished` shows the `.eti` split across exactly both peers, all four chunks accounted for, and the per-peer byte totals balanced within one `CHUNK_SIZE` (a fair 2+2 split; a 3+1 imbalance would trip the check). |
| 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 manifests; the `.eti` is inflated to `2 * CHUNK_SIZE` so it can fan out. | The aggregated row counts only catalog-version ready peers. The `.eti` chunks split across exactly B and C; peer A is not listed as downloadable and contributes no manifest vote or file chunks. |
| S17 | Catalog-version conflict rejection | Peer A has a stale version. Peers B and C both advertise the catalog version, but their file sizes conflict. | Validation considers only the catalog-version peers, so A cannot rescue the majority. The download fails with `download-failed`, and no committed target `version.ini` remains. |
| S18 | Mid-download source drop with redundancy | Client downloads a large shared multi-chunk game (sparse `4 * CHUNK_SIZE`, so both peers are assigned `.eti` chunks) from two ready peers, then one source is killed right after the download has begun. | The download survives the source kill: it finishes, no `download-failed` is emitted over the whole download window, every byte is delivered (chunk totals sum to the file size) with the survivor serving part of it, and the receiver's files match the source by diff or SHA-256. (Retry-onto-survivor is the mechanism that makes this possible, exercised when the kill interrupts an unfinished chunk, but it is not asserted because the kill timing cannot be forced; the per-source split is likewise not asserted.) |
| S19 | Mid-download sole-source drop | Client downloads a large multi-chunk game (sparse `4 * CHUNK_SIZE`) from one source, then that source is force-killed immediately after `download-begin`. An individual chunk may complete before the kill lands, but the full multi-chunk download cannot, so the failure is deterministic on a fast LAN. | The download emits a terminal failure (`download-failed`, or `download-peers-gone` when the sole source vanishes) and no `download-finished`; no committed target `version.ini` remains; any partial payload is not advertised as ready; active operation state clears so a retry is possible. |
| S14 | Large multi-peer chunked download | `fixture-alpha/alienswarm` contains a renamed RAR `.eti` larger than 100 MB. A second peer downloads it, then a third peer downloads `alienswarm` from both peers. | The third peer's downloaded files match the source by SHA-256; `download-chunk-finished` events show the large `.eti` chunks coming from both peers with byte counts balanced within one chunk. |
| S15 | Three-way version skew | Three peers advertise the same catalog game ID. Peer A has `version.ini=20250101`, peer B has `version.ini=20250201`, and peer C has `version.ini=20250301`; each version has distinguishable file contents. An empty client connects to all three and downloads the game with `install=false`. | `list-games` shows one row for the game with `peer_count=3` and `eti_game_version=20250301`. The `got-game-files` descriptor set and transfer source are peer C's newest version only; no chunks come from A or B. The receiver's `version.ini` and SHA-256 manifest match C exactly. |
| S16 | Latest-version fanout with stale peers present | Peer A has an older version of a game. Peers B and C both advertise the same newest version with matching file manifests; use a large file when proving chunk split. | The aggregated row still counts all ready peers, but eligible transfer peers are only B and C. Large-file chunks may split between B and C; peer A contributes no manifest majority vote and no file chunks. |
| S17 | Latest-version conflict rejection | Peer A has an older version. Peers B and C both advertise the newest version, but their latest-version file sizes conflict. | Validation considers only the latest-version peers, so A cannot rescue the majority. The download fails with `download-failed`, and no committed target `version.ini` remains. |
| 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 CLI command fails cleanly (CLI-level guard), no self-peer entry is created, and the peer remains responsive. The protocol-level guard (a hello whose `peer_id` equals the local id is acknowledged but never recorded) is covered by the `handshake::tests::inbound_hello_from_self_is_ignored` unit test, which the CLI string-compare never reaches. |
| 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`, and `install-finished` (the install-start transition is observable via `active-operations-changed`; there is no separate `install-begin` event). 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 +78,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,10 +87,10 @@ 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.
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.
## First-Play Launch-Setting Contract
@@ -113,233 +105,13 @@ Use S38 to pin down how launcher settings are stamped into an installed game:
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.
- S38 needs a real archive expanded with `--unrar`, so it runs against the host
`lanspread-peer-cli` binary rather than the Docker matrix image (which omits
`unrar`). The peer crate's `launch_settings` unit tests cover the rewrite,
line-ending, and marker logic deterministically.
## Run Log
### 2026-06-21 - Test-Suite Integrity Audit And Hardening
- An adversarial review of `run_extended_scenarios.py` found assertions that
passed vacuously, raced, or diverged from the spec. A full baseline run
(S1-S47, rebuilt image) passed beforehand, confirming these were test-quality
gaps, not peer regressions. Baseline evidence of the gaps: S14 chunk totals
were `{134217728, 1048576}` (a 2-chunk file whose "balanced within one chunk"
check can never fail), and S16/S18 each served the whole ~120 MiB
`alienswarm.eti` from a single source, so neither fanout (S16) nor
retry-onto-survivor (S18) was actually exercised.
- Fixes applied to the runner (and the matching rows above):
- S18: replaced the dead `assert_no_event` (it reused a `LineWaiter` already
advanced past `download-finished`, so it scanned an empty tail and could
never fire) with `assert_no_event_since` over the whole download window;
switched to a multi-chunk sparse archive (`4 * CHUNK_SIZE`) so both peers
own `.eti` chunks and the test proves the download survives a mid-download
source kill (retry-onto-survivor is the mechanism, exercised when the kill
interrupts an unfinished chunk, but not asserted since the race can't be
forced).
- S7: added chunk-source, both-sources-served, single-`download-finished`,
and no-duplicate-chunk checks (the byte-identical `ggoo` fixtures made the
old diff-only assertion source-agnostic).
- S14: `4 * CHUNK_SIZE` file so the balance check is meaningful (a 3+1 split
would now exceed one chunk); asserts an exact 2+2 split and full byte total.
- S16: inflated `.eti` to `2 * CHUNK_SIZE` so it fans out across both
catalog-version peers (the stock 120 MiB fixture is a single chunk).
- S19: force-kill right after `download-begin` on a multi-chunk file, accept
`download-failed`/`download-peers-gone`, assert no `download-finished` (the
old graceful shutdown could let a single-chunk transfer finish first).
- S26: large sparse source so the first op is reliably still active, and
asserts the active `operation == "Downloading"` (no scenario checked it).
- S37: validates the throughput rate fields (positive, self-consistent
`mbit_per_s/mib_per_s == 8.388608`, `mib_per_s == bytes/duration`), not just
the byte count.
- S35: asserts the source actually advertises `mystery-game` before checking
it is filtered (distinguishes "filtered" from "never sent").
- S15: cross-checks each peer's raw advertised `eti_version` via list-peers
(the list-games `eti_game_version` is synthesized from the local catalog and
can only ever equal the catalog value).
- S2: polls for library convergence and verifies the bidirectional exchange
(bravo sees alpha's 3 games, not just alpha seeing bravo's 4).
- S11: dropped the "listener address must change" assertion (it tested the OS
ephemeral-port allocator and could fail spuriously).
- S12/S28: require the gating unit test to appear as `<name> ... ok` so an
`#[ignore]`d (un-run) test no longer satisfies the check.
- S24/S25: assert the requested `install=false` final state.
- S34: assert exactly 21 coherent chunks (20 files + version.ini), 21 distinct
paths, no duplicates, instead of a `>= 21` floor.
- S27: added the `handshake::tests::inbound_hello_from_self_is_ignored` unit
test for the protocol-level self guard; the CLI scenario only exercises the
CLI string-compare guard, which short-circuits before any network call.
- Harness: `find_fixture_game` now iterates `sorted(...)`, so the ambiguous
`cnctw` (bravo/multi/solid) resolves deterministically to `fixture-bravo`.
- Accepted as-is (reviewed, deliberately not changed): S20 (disk-full via chunk
`write_all` is equivalent coverage), S21 (inotify across the bind mount is
inherent to the harness), S30 (dup-row/self-peer checks are cheap defensive
guards), S32/S39/S44 absence checks (cheap regression guards against committing
a root sentinel), S42 IP-order precondition (deterministic by container start
order), S45 (the spec already names both terminal events).
- Live runs against the rebuilt `lanspread-peer-cli:dev` image: baseline S1-S47
passed; post-fix S1-S47 passed. Post-fix evidence: S14 `{268435456, 268435456}`
(balanced 2+2); S16 `.eti` split across B and C `{134217728, 134217728}`; S18
all `536870912` bytes delivered despite the source drop (the survivor served
the whole archive in that run); S19 deterministic `download-failed`; S37
`874.24 MiB/s`. Gates: `just test` (incl. the new handshake test),
`just clippy` (`-D warnings`), and `just fmt` all passed.
### 2026-06-20 - Prune Dead Lifecycle Events
- Code under test removed the unconsumed `InstallGameBegin`, `UninstallGameBegin`,
and `RemoveDownloadedGameBegin` `PeerEvent` variants (and their peer-cli JSONL
`install-begin`/`uninstall-begin`/`remove-download-begin` events), plus the
Tauri webview emits that no frontend listener consumed (`peer-local-ready`,
`game-download-begin`, `game-download-pre`, `game-download-finished`,
`game-uninstall-finished`, `peer-connected`/`-disconnected`/`-discovered`/`-lost`).
`peer-runtime-failed` was kept pending a UI decision.
- Rationale: the GUI is state-as-source-of-truth (it renders the `games-list`
snapshot), and no scenario asserted these begin events; the install, uninstall,
and removal start transitions stay observable via `active-operations-changed`.
- Contract update: the S39 row no longer lists `install-begin`. Older run-log
entries below predate the removal and are left intact as historical records.
- Gates: `just test`, `just clippy`, `just frontend-test`, and `just build`
passed. (`just fmt`'s `tombi` step needs network and was skipped; no TOML
changed.) The Docker S39-S47 matrix was not re-run for this cleanup; S39-S47
never asserted the removed begin events, so coverage is unchanged.
### 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
+155
View File
@@ -0,0 +1,155 @@
---
sessionId: session-260605-123454-ub3w
---
# Requirements
### Overview & Goals
Add a "main log output" viewer so users can inspect the application's runtime logs. Logs must still be written to stdout, must also be captured to a persistent single file in the app data directory, must be bounded to the last ~2 MB, and must be accessible via a dedicated window opened from the main UI.
### Scope
**In Scope:**
- Capture `log::` and `tracing::` output to a persistent log file in the app data directory.
- Enforce ~2 MB size limit on the stored log (keep the most recent data).
- Expose a Tauri command to retrieve the current log content.
- Add a new companion window (`?view=main-logs`) with a viewer UI modeled on the existing unpack logs feature.
- Support **live tailing** of the logs, displaying new log entries as they are emitted from the backend in real time.
- Support **dynamic log level filtering** in the UI to toggle/filter visible logs by log level (TRACE, DEBUG, INFO, WARN, ERROR).
- Add "Application logs" entry to the main window kebab menu that opens/focuses the logs window.
**Out of Scope:**
- Configurable log levels or per-module filters in the backend.
- Multi-file rotation or archival of old logs.
- Persisting logs across app reinstalls or explicit export.
### User Stories
- As a user troubleshooting an issue, I want to open the application logs from the menu so that I can see recent log output without needing external tools.
- As a power user, I want the log file to stay bounded (~2 MB) so that it does not grow unbounded on disk.
- As a developer or user experiencing an application crash, I want the logs to be persistently saved to a file on disk so that I can inspect them externally even if the application UI cannot be opened or crashes.
### Functional Requirements
- Logger writes to stdout, app-data file target (`lanspread.log`), and a Tauri event stream for real-time UI tailing.
- Log file is kept at ~2 MB; older content is dropped to keep the tail.
- `get_main_logs` command returns up to the last 2 MB of log text.
- Main logs window renders logs in real-time, automatically appending new log lines.
- Auto-scroll to the bottom of the log viewer container when new lines are appended (active by default, can be toggled).
- Pause/Resume control to freeze the log stream so the user can easily scroll, select, and read without being interrupted by new lines.
- Case-insensitive regex filtering of the displayed lines, using the exact same regex input validation, compilation, and error styling (red input border and error label) as the unpack logs window.
- Support **dynamic log level filtering** in the UI via a `SegmentedRadio` filter with options: "All", "Debug+", "Info+", "Warn+", "Error only".
- Support copying all/filtered logs to the clipboard and clearing the active log window's in-memory rows. Clearing the viewer does not delete the persisted log file.
- Window title: "Application Logs"; label: "main-logs".
- Menu item appears in the same kebab menu as "Unpack logs".
# Technical Design
### Current Implementation
- Logging setup: `crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs:2223` creates `tauri_plugin_log::Builder` with only `TargetKind::Stdout` + `LevelFilter::Info` (plus mdns off). Plugin registered at 2237.
- State & persistence pattern: `LanSpreadState` holds `unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>`, `state_dir: OnceLock<PathBuf>`; `load_unpack_logs` / `persist_unpack_logs` + `trim_unpack_logs` (MAX=20) in the same file; exposed by `get_unpack_logs` (153).
- UI pattern: `src/windows/MainWindow.tsx:23` defines `openLogsWindow` using `WebviewWindow` with label `unpack-logs` and `?view=unpack-logs`; menu item at 110; `src/App.tsx:9` dispatches via `isUnpackLogsView`; full viewer in `src/UnpackLogsWindow.tsx` (filtering, list+detail, regex).
- The repo uses mostly `log::` in the Tauri and peer crates, with some `tracing::` in shared crates. The current Tauri app does not bridge or subscribe to `tracing::` output.
### Key Decisions
- Store the main log as a single plain-text file at `app.path().app_data_dir()?.join("lanspread.log")`.
- Do not use `TargetKind::LogDir` for this feature. It writes to the OS log directory and uses file replacement rotation, which does not match the requested app-data location or "keep the latest tail" behavior.
- Replace the current `tauri_plugin_log` logger setup with an app-owned logging setup initialized during `.setup` after the app data directory has been resolved.
- Install a `tracing_subscriber` subscriber as the single fan-out path for stdout, persistent file writes, and live UI events.
- Install `tracing_log::LogTracer` so existing `log::` calls are captured by the same subscriber as `tracing::` events.
- If `tauri-plugin-log` is kept registered for future frontend log commands, configure it with `skip_logger()` so it does not compete for the global logger.
- Use one backend formatter for both file and live-event output. Format lines as:
- `[YYYY-MM-DD][HH:MM:SS][target][LEVEL] message`
- This makes historical and live rows comparable and makes client-side level parsing deterministic.
- Implement a shared `MainLogSink` that:
- Appends formatted lines to `lanspread.log`.
- Emits the same formatted line to the frontend via a Tauri event, e.g. `main-log-line`.
- Bounds the file to the latest ~2 MB by rewriting the tail when the file exceeds the configured limit.
- Never logs from inside the logging path; write/emit failures are ignored or recorded without recursive logging.
- Handle the transition from loading history to receiving real-time logs in `MainLogsWindow.tsx` by using a **buffering and deduplication strategy**:
- Register the `main-log-line` listener before requesting history.
- Buffer incoming live rows while the initial `get_main_logs` file history is being requested.
- Once history is loaded, append buffered rows after removing any rows already present in the loaded history by exact line match.
- Transition to direct real-time appending with auto-scrolling to the bottom (if auto-scroll is enabled).
- Implement **dynamic log level filtering client-side** using the existing `SegmentedRadio` component.
- For live-streamed logs, use the structured event payload's level field.
- For historical log files loaded via `get_main_logs`, parse the level from the controlled `[LEVEL]` field, falling back to `Info`.
- Provide UI controls for "Pause / Resume", "Auto-scroll", and "Log Level Filter" to ensure high-quality user experience during busy logging periods.
- Model the new viewer on the unpack-logs style, retaining the identical case-insensitive regex filter ability and regex error reporting UI, but customize it for linear line streaming (sans split-pane list+detail structure, since main logs are a single continuous log flow).
### Proposed Changes
- **Rust dependencies:** add `tracing-subscriber` and `tracing-log` to the workspace/Tauri crate.
- **Rust (lib.rs):** initialize the custom logging pipeline in `.setup`, add bounded `MainLogSink`, add `get_main_logs`, emit `main-log-line`, and register the command.
- **Tauri capabilities:** add a `main-logs` capability file for the new window label with `core:default`.
- **Frontend (MainLogsWindow.tsx):** implement the scrollable log viewport using `listen('main-log-line')` for live tailing, supporting buffering/deduplication, pause/resume, auto-scroll, regex filtering, copying, and clearing.
- **Frontend (App.tsx & MainWindow.tsx):** dispatch the new companion view and add the kebab menu entry to open the window.
- No changes to peer crate, db, or protocol.
### File Structure
- Modified: `Cargo.toml`, `Cargo.lock`, `crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml`, `crates/lanspread-tauri-deno-ts/src-tauri/Cargo.lock`, `crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs`, `crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx`, `crates/lanspread-tauri-deno-ts/src/App.tsx`
- Added: `crates/lanspread-tauri-deno-ts/src/MainLogsWindow.tsx` (and `.css` if custom styles needed beyond shared)
- Added: `crates/lanspread-tauri-deno-ts/src-tauri/capabilities/main-logs.json`
### Risks
- Trimming on every write would be expensive; mitigate by trimming only when the file exceeds the 2 MB limit plus a small slack threshold, then rewriting the latest tail.
- The logging path is synchronous and global; keep it small, lock-protected, and non-recursive.
- Log lines may contain very long messages; UI should handle wrapping/horizontal scrolling with stable dimensions.
- On Windows release builds the console is hidden, so file becomes the only log source — acceptable.
- Installing `LogTracer` plus a `tracing_subscriber` subscriber must happen once. Tests should avoid double-initializing global logging.
# Delivery Steps
### Step 1: extend-logger-and-add-get-main-logs-command
Logger writes to stdout, persistent app-data `lanspread.log`, and a live UI event stream. A bounded sink enforces the 2 MB tail.
- Add workspace/Tauri dependencies for `tracing-subscriber` and `tracing-log`.
- Add constants `MAIN_LOG_FILE_NAME: &str = "lanspread.log"` and `MAX_MAIN_LOG_BYTES: u64 = 2 * 1024 * 1024` near the unpack log consts.
- Implement `main_log_path(state_dir: &Path) -> PathBuf`.
- Implement `trim_main_log_file(path: &Path)` that checks file size and rewrites only the last ~2 MB if exceeded, preserving valid UTF-8 by trimming to a character boundary when needed.
- Implement a `MainLogSink` or equivalent shared target that formats each log record once, appends it to `lanspread.log`, emits `main-log-line`, and trims when the file grows past the limit plus slack.
- Initialize logging during `.setup` after resolving `let state_dir = app.path().app_data_dir()?` and creating the app data directory.
- Install `LogTracer` for `log::` macros.
- Install a `tracing_subscriber` subscriber/layer that writes to stdout and the shared main-log sink.
- Preserve the current global level behavior: `Info` by default and `mdns_sd::service_daemon` off.
- Implement `#[tauri::command] async fn get_main_logs(app_handle: tauri::AppHandle) -> tauri::Result<String>` that resolves `app_handle.path().app_data_dir()`, trims `lanspread.log` if needed, reads the file (or returns empty string), and returns the content.
- Add `get_main_logs` to the `invoke_handler!` macro list.
- Add focused unit tests for tail trimming and level parsing/formatting helpers where practical.
### Step 2: implement-main-logs-frontend-window-and-dispatch
Implement `MainLogsWindow` with loading + real-time streaming and handle routing.
- Create `crates/lanspread-tauri-deno-ts/src/MainLogsWindow.tsx`:
- Fetch existing logs via `invoke('get_main_logs')` on load and split into an array of lines.
- Register `listen<MainLogLinePayload>('main-log-line', ...)` from `@tauri-apps/api/event` before fetching history.
- Buffer incoming logs while loading history, and append/deduplicate them once loaded.
- Store logs as objects containing the log line text and its associated `LogLevel`.
- Extract `LogLevel` for live logs from the event payload. For historical logs, parse `LogLevel` from the controlled `[LEVEL]` field (default to Info).
- Cap in-memory rows/bytes so a long-running logs window does not grow without bound.
- Render the log lines in a scrollable view.
- Implement the identical case-insensitive regex filter ability on lines (compiling regex with error catching, displaying error strings below header, and toggling an invalid input class name).
- Implement dynamic log level filtering in the UI using the shared `SegmentedRadio` component with options: "All", "Debug+", "Info+", "Warn+", "Error only".
- Implement UI controls: "Auto-scroll" checkbox (scrolls viewport to bottom when new logs arrive, active by default), "Pause / Resume" toggle (buffers/pauses incoming stream rendering), "Clear" button, "Copy to clipboard" button.
- Implement `isMainLogsView()` helper that checks the `view=main-logs` query param (export it).
- Update `crates/lanspread-tauri-deno-ts/src/App.tsx` to import `MainLogsWindow, isMainLogsView` and dispatch to it when the query matches (before falling back to MainWindow).
- Create `MainLogsWindow.css` for styling the viewer, search bars, controls, and scrollable log pane.
### Step 3: add-main-logs-capability
The new companion window can invoke commands and listen for log events.
- Add `crates/lanspread-tauri-deno-ts/src-tauri/capabilities/main-logs.json`.
- Set `"windows": ["main-logs"]`.
- Grant `"permissions": ["core:default"]`.
- No `log:default` permission is needed because the UI listens to the app-owned `main-log-line` event via Tauri core events.
### Step 4: add-menu-item-and-window-opener-in-mainwindow
Users can open the main logs viewer from the existing kebab menu.
- In `crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx`, add an `openMainLogsWindow` async function (copy of `openLogsWindow` but with label `'main-logs'`, title `'Application Logs'`, url `'/?view=main-logs'`).
- Add a new `KebabItem` for `'Application logs'` that calls `openMainLogsWindow`, placed near the existing `'Unpack logs'` entry in the `kebabItems` array.
- Verify that opening the window focuses an existing instance if already open (same pattern as unpack logs).
### Step 5: verify
Use the repo's just commands.
- `just fmt`
- `just clippy`
- `just frontend-test`
- `just test`
- Manual smoke test with `just run`: open "Application logs", verify history loads from app-data `lanspread.log`, new backend logs append live, filters work, pause/resume works, copy works, and the file remains bounded near 2 MB.
+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"]
-5
View File
@@ -9,7 +9,6 @@ and writes JSONL events, results, and errors to stdout.
```bash
just peer-cli-build
just peer-cli-image
just peer-cli-tests
just peer-cli-run alpha
```
@@ -43,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 +1 @@
20240623
20250101
@@ -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:
@@ -448,21 +423,12 @@ class Runner:
alpha = self.peer("s2-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
bravo = self.peer("s2-bravo", games_dir=FIXTURES / "fixture-bravo", readonly_games=True)
connect_many(alpha, [bravo])
# Poll for library convergence rather than reading once: wait-peers only
# guarantees peer presence, and the mDNS path can upsert a peer with an
# empty library before its snapshot arrives.
wait_peer_has_game(alpha, bravo.peer_id, "bfbc2")
peers = alpha.list_peers()
if len(peers) != 1 or peers[0]["peer_id"] == alpha.peer_id:
raise ScenarioError(f"bad alpha peers after connect: {peers}")
if peers[0]["game_count"] != 4:
raise ScenarioError(f"expected bravo game_count=4, got {peers}")
# The handshake is bidirectional: bravo must also record alpha's library
# without a separate connect, proving both directions of the exchange.
bravo_view = wait_peer_has_game(bravo, alpha.peer_id, "alienswarm")
if bravo_view["game_count"] != 3:
raise ScenarioError(f"expected alpha game_count=3 on bravo, got {bravo_view}")
return "alpha<->bravo exchanged libraries: bravo had 4 games on alpha, alpha had 3 on bravo"
return "alpha connected to bravo, saw one non-self peer with four games"
def s3_remote_aggregation(self) -> str:
alpha = self.peer("s3-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
@@ -538,37 +504,25 @@ class Runner:
client.send({"cmd": "download", "game_id": "ggoo", "install": False})
client.wait_for(event_is("download-finished", "ggoo"), timeout=60, description="finish ggoo", waiter=waiter)
diff_game_dirs(FIXTURES / "fixture-alpha" / "ggoo", client.host_games_dir / "ggoo")
# The fixtures are byte-identical, so the diff alone is source-agnostic.
# Prove the duplicate-source path: download committed exactly once, every
# chunk came from the validated 2-peer set, both peers actually served,
# and nothing was fetched twice.
if count_events(client, "download-finished", "ggoo") != 1:
raise ScenarioError("ggoo did not finish exactly once")
assert_only_chunk_sources(client, "ggoo", {alpha.ready_addr, bravo.ready_addr})
if chunk_sources(client, "ggoo") != {alpha.ready_addr, bravo.ready_addr}:
raise ScenarioError(
f"expected both validated sources to serve ggoo, got {chunk_sources(client, 'ggoo')}"
)
assert_no_duplicate_chunks(client, "ggoo")
return "ggoo downloaded once, served from both validated sources with no duplicate chunks"
return "shared ggoo downloaded once from duplicate sources and diffed cleanly"
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")
@@ -602,17 +556,13 @@ class Runner:
bravo = self.peer("s11-bravo", games_dir=bravo_dir, readonly_games=True)
connect_many(alpha, [bravo])
peers = alpha.list_peers()
# The real invariant is a single peer entry reusing the same identity (no
# duplicate). The listener address is an OS-assigned ephemeral port: it
# almost always differs across restarts, but asserting it MUST change
# tests the kernel's port allocator, not the peer, and can fail spuriously
# if the same port is reused. So we only require the identity to be stable.
if len(peers) != 1:
raise ScenarioError(f"expected one bravo peer after reconnect, got {peers}")
if peers[0]["peer_id"] != first_id:
raise ScenarioError(f"bravo peer id changed: {first_id} -> {peers[0]['peer_id']}")
changed = "new" if peers[0]["addr"] != first_addr else "same"
return f"bravo reused peer id {first_id} as a single entry at {changed} address {peers[0]['addr']}"
if peers[0]["addr"] == first_addr:
raise ScenarioError(f"bravo listener address did not change: {first_addr}")
return f"bravo reused peer id {first_id} with new address {peers[0]['addr']}"
def s12_transfer_serving_gates(self) -> str:
output = run_just_test()
@@ -622,9 +572,9 @@ class Runner:
"file_transfer_dispatch_respects_serve_gates",
"local_relative_paths_are_never_transferable",
]
missing = [name for name in required if f"{name} ... ok" not in output]
missing = [name for name in required if name not in output]
if missing:
raise ScenarioError(f"S12 unit proof tests did not run-and-pass: {missing}")
raise ScenarioError(f"S12 unit proof missing tests: {missing}")
return "just test passed including catalog/sentinel/active/local-path serve gate tests"
def s13_exact_transfer_equality(self) -> str:
@@ -644,41 +594,31 @@ class Runner:
def s14_large_multi_peer_chunking(self) -> str:
game_id = PERF_GAME_ID
source_dir = self.fixture_root / "s14-alpha"
# Four 128 MiB chunks so the balance assertion is meaningful: with two
# peers a fair split is 2+2 chunks (diff 0) and a 3+1 imbalance would
# exceed one CHUNK_SIZE. A 2-chunk file could never trip the check.
file_size = CHUNK_SIZE * 4
create_large_sparse_game(source_dir / game_id, size=file_size)
create_large_sparse_game(source_dir / game_id, size=CHUNK_SIZE + 1024 * 1024)
alpha = self.peer("s14-alpha", games_dir=source_dir)
stage = self.peer("s14-stage")
connect_many(stage, [alpha])
waiter = LineWaiter(len(stage.output))
stage.send({"cmd": "download", "game_id": game_id, "install": False})
stage.wait_for(event_is("download-finished", game_id), timeout=180, description="stage finish", waiter=waiter)
stage.wait_for(event_is("download-finished", game_id), timeout=90, description="stage finish", waiter=waiter)
diff_game_dirs(source_dir / game_id, stage.host_games_dir / game_id)
client = self.peer("s14-client")
connect_many(client, [alpha, stage])
wait_remote_game(client, game_id, peer_count=2, version=PERF_GAME_VERSION)
wait_remote_game(client, game_id, peer_count=2, version="20260520")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": game_id, "install": False})
client.wait_for(event_is("download-finished", game_id), timeout=180, description="client finish", waiter=waiter)
client.wait_for(event_is("download-finished", game_id), timeout=90, description="client finish", waiter=waiter)
diff_game_dirs(source_dir / game_id, client.host_games_dir / game_id)
totals = chunk_totals(client, game_id, f"{game_id}/{game_id}.eti")
if len(totals) != 2:
raise ScenarioError(f"expected .eti chunks from exactly two peers, got {totals}")
if sum(totals.values()) != file_size:
raise ScenarioError(f"chunk bytes {sum(totals.values())} != file size {file_size}: {totals}")
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} ({file_size // (1024 * 1024)} MiB) split across two sources, balanced, diff matched: {totals}"
return f"{game_id} downloaded from two sources, diff matched, chunk totals={totals}"
def s15_three_way_version_skew(self) -> str:
specs = [
("s15-a", "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
@@ -686,58 +626,43 @@ 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"])
# Cross-check the RAW advertised versions via list-peers (not the
# catalog-synthesized list-games field), proving the three peers really
# differ and that only the catalog-version peer is aggregated.
for peer, expected_version in zip(peers, ["20150101", "20160101", CATALOG_VERSIONS["cnc4"]]):
advertised = peer_advertised_version(client, peer.peer_id, "cnc4")
if advertised != expected_version:
raise ScenarioError(
f"{peer.name} advertised cnc4 version {advertised}, expected {expected_version}"
)
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:
game_dir = self.fixture_root / name
copy_game("alienswarm", game_dir, version=version)
# Two 128 MiB chunks so the .eti can actually fan out across the two
# catalog-version peers; the stock 120 MiB fixture is a single chunk
# that can only ever come from one source.
inflate_archive_sparse(game_dir / "alienswarm", "alienswarm", CHUNK_SIZE * 2)
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=180, description="alienswarm finish", waiter=waiter)
client.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="alienswarm finish", waiter=waiter)
assert_only_chunk_sources(client, "alienswarm", {peers[1].ready_addr, peers[2].ready_addr})
totals = chunk_totals(client, "alienswarm", "alienswarm/alienswarm.eti")
if peers[0].ready_addr in totals:
raise ScenarioError(f"stale peer contributed chunks: {totals}")
if set(totals) != {peers[1].ready_addr, peers[2].ready_addr}:
raise ScenarioError(f"expected .eti to fan out across both B and C, got {totals}")
diff_game_dirs(peers[1].host_games_dir / "alienswarm", client.host_games_dir / "alienswarm")
return f"catalog-version B/C peers split alienswarm.eti 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:
@@ -749,95 +674,51 @@ 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:
game_id = "bf1942"
source_a_dir = self.fixture_root / "s18-a"
source_b_dir = self.fixture_root / "s18-b"
# Multi-chunk sparse archive so BOTH peers are assigned .eti chunks; a
# single-chunk file could be served entirely by one peer, so killing the
# other would prove nothing. We verify the download SURVIVES a mid-download
# source kill (every byte still arrives, no download-failed, diff matches).
# Retry-onto-survivor is the mechanism that makes this work and is
# exercised whenever the kill interrupts an unfinished chunk, but the race
# against `docker rm -f` means we cannot deterministically force it, so we
# do not assert it. Sparse zero bytes are identical, so duplicate-source
# majority validation still agrees.
file_size = CHUNK_SIZE * 4
create_large_sparse_game(source_a_dir / game_id, size=file_size)
create_large_sparse_game(source_b_dir / game_id, size=file_size)
copy_game("alienswarm", source_a_dir)
copy_game("alienswarm", source_b_dir)
source_a = self.peer("s18-a", games_dir=source_a_dir)
source_b = self.peer("s18-b", games_dir=source_b_dir)
client = self.peer("s18-client")
connect_many(client, [source_a, source_b])
wait_remote_game(client, game_id, peer_count=2)
start = len(client.output)
waiter = LineWaiter(start)
client.send({"cmd": "download", "game_id": game_id, "install": False})
client.wait_for(event_is("download-begin", game_id), timeout=20, description="download begin", waiter=waiter)
wait_remote_game(client, "alienswarm", peer_count=2)
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": "alienswarm", "install": False})
client.wait_for(event_is("download-begin", "alienswarm"), timeout=20, description="download begin", waiter=waiter)
source_a.kill()
client.wait_for(event_is("download-finished", game_id), timeout=180, description="download finish", waiter=waiter)
# Scan the WHOLE download window (start..) for download-failed. The old
# assert_no_event reused a waiter already advanced past download-finished,
# so it only saw the empty tail and could never fire.
assert_no_event_since(client, start, "download-failed", game_id)
diff_game_dirs(source_b_dir / game_id, client.host_games_dir / game_id)
totals = chunk_totals(client, game_id, f"{game_id}/{game_id}.eti")
if source_b.ready_addr not in totals:
raise ScenarioError(f"surviving source served no .eti chunks: {totals}")
if sum(totals.values()) != file_size:
raise ScenarioError(f"download did not deliver the whole archive ({sum(totals.values())} != {file_size}): {totals}")
# We deliberately do NOT assert the exact per-source split. The killed
# source can serve a chunk or two before `docker rm -f` lands (a fast-LAN
# race), so requiring totals == {survivor: file_size} would be flaky. The
# robust proof of redundancy is that a source died mid-download yet every
# byte still arrived (sum == file_size plus the diff), the survivor served
# part of it, and no download-failed was emitted.
survivor_bytes = totals[source_b.ready_addr]
return (
f"source killed after begin; all {file_size} bytes delivered "
f"({survivor_bytes} from the survivor), no download-failed; diff matched; bytes={totals}"
)
client.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="download finish", waiter=waiter)
assert_no_event(client, waiter, "download-failed", "alienswarm")
diff_game_dirs(source_b_dir / "alienswarm", client.host_games_dir / "alienswarm")
totals = chunk_totals(client, "alienswarm", "alienswarm/alienswarm.eti")
if not totals:
raise ScenarioError("S18 did not record chunk evidence")
return f"source killed after begin; download finished; diff matched; chunk bytes={totals}"
def s19_sole_source_drop(self) -> str:
game_id = "bf1942"
source_dir = self.fixture_root / "s19-source"
# Multi-chunk sparse archive force-killed right after download-begin. A
# 120 MiB single-chunk file served by a graceful shutdown could finish
# (~0.15s at LAN speed) before the drop landed, flipping the expected
# failure into a download-finished. With four 128 MiB chunks and a
# forceful kill issued right after download-begin, the source dies with
# the bulk of the transfer still outstanding (an individual chunk may
# complete first, but the full download cannot), so a terminal failure is
# deterministic.
create_large_sparse_game(source_dir / game_id, size=CHUNK_SIZE * 4)
copy_game("alienswarm", source_dir)
source = self.peer("s19-source", games_dir=source_dir)
client = self.peer("s19-client")
connect_many(client, [source])
wait_remote_game(client, game_id, peer_count=1)
start = len(client.output)
waiter = LineWaiter(start)
client.send({"cmd": "download", "game_id": game_id, "install": False})
client.wait_for(event_is("download-begin", game_id), timeout=20, description="download begin", waiter=waiter)
# Forceful kill (no Goodbye) drops the connection mid-transfer.
source.kill()
terminal = client.wait_for(
event_name_in({"download-failed", "download-peers-gone"}, game_id),
timeout=120,
description="sole-source drop terminal failure",
waiter=waiter,
)
assert_no_event_since(client, start, "download-finished", game_id)
assert_not_exists(client.host_games_dir / game_id / "version.ini")
assert_no_active(client, game_id)
assert_local_absent(client, game_id)
return f"sole-source forceful drop mid-transfer -> {terminal['event']}; version.ini absent; no ready row; no active op"
wait_remote_game(client, "alienswarm", peer_count=1)
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": "alienswarm", "install": False})
client.wait_for(event_is("download-begin", "alienswarm"), timeout=20, description="download begin", waiter=waiter)
source.shutdown()
client.wait_for(event_is("download-failed", "alienswarm"), timeout=90, description="download failed", waiter=waiter)
assert_not_exists(client.host_games_dir / "alienswarm" / "version.ini")
assert_no_active(client, "alienswarm")
assert_local_absent(client, "alienswarm")
return "download-failed emitted; version.ini absent; local ready row absent; active operations empty"
def s20_receiver_write_failure(self) -> str:
source_dir = self.fixture_root / "s20-source"
@@ -881,13 +762,13 @@ class Runner:
def s23_version_bump_propagation(self) -> str:
alpha = self.peer("s23-alpha")
bravo_dir = self.fixture_root / "s23-bravo"
copy_game("cnc4", bravo_dir, version="20160101")
copy_game("cnc4", bravo_dir, version="20250101")
bravo = self.peer("s23-bravo", games_dir=bravo_dir)
connect_many(alpha, [bravo])
wait_remote_absent(alpha, "cnc4", timeout=5)
(bravo_dir / "cnc4" / "version.ini").write_text(CATALOG_VERSIONS["cnc4"], encoding="utf-8")
wait_remote_game(alpha, "cnc4", peer_count=1, version=CATALOG_VERSIONS["cnc4"])
return "alpha observed stale cnc4 become catalog-version downloadable without reconnect"
wait_remote_game(alpha, "cnc4", peer_count=1, version="20250101")
(bravo_dir / "cnc4" / "version.ini").write_text("20260501", encoding="utf-8")
wait_remote_game(alpha, "cnc4", peer_count=1, version="20260501")
return "alpha observed cnc4 eti_game_version change 20250101 -> 20260501 without reconnect"
def s24_two_clients_one_source(self) -> str:
source = self.peer("s24-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
@@ -903,12 +784,8 @@ class Runner:
c2.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="client-b finish", waiter=waiter2)
diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", c1.host_games_dir / "alienswarm")
diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", c2.host_games_dir / "alienswarm")
wait_local_game(c1, "alienswarm", downloaded=True, installed=False)
wait_local_game(c2, "alienswarm", downloaded=True, installed=False)
# Responsiveness: the source still answers and still advertises its games.
if not any(g["id"] == "alienswarm" for g in source.list_games()["local"]):
raise ScenarioError("source no longer advertises alienswarm after serving two clients")
return "two concurrent clients finished alienswarm install=false; both diffs matched; source still responsive"
source.status()
return "two concurrent clients finished alienswarm; both diffs matched; source status responded"
def s25_two_downloads_one_client(self) -> str:
source = self.peer("s25-bravo", games_dir=FIXTURES / "fixture-bravo", readonly_games=True)
@@ -921,49 +798,34 @@ class Runner:
client.wait_for(event_is("download-finished", "cnctw"), timeout=60, description="cnctw finish", waiter=waiter)
diff_game_dirs(FIXTURES / "fixture-bravo" / "bfbc2", client.host_games_dir / "bfbc2")
diff_game_dirs(FIXTURES / "fixture-bravo" / "cnctw", client.host_games_dir / "cnctw")
wait_local_game(client, "bfbc2", downloaded=True, installed=False)
wait_local_game(client, "cnctw", downloaded=True, installed=False)
return "bfbc2 and cnctw concurrent downloads both finished install=false and diffed cleanly"
return "bfbc2 and cnctw concurrent downloads both finished and diffed cleanly"
def s26_duplicate_download_rejection(self) -> str:
game_id = "bf1942"
source_dir = self.fixture_root / "s26-source"
# A large sparse archive keeps the first download active long enough that
# the duplicate request is guaranteed to race against an in-progress
# operation rather than a near-instant 3 MB transfer that may already be
# finished by the time the second command is read.
create_large_sparse_game(source_dir / game_id, size=CHUNK_SIZE * 2)
source = self.peer("s26-source", games_dir=source_dir)
source = self.peer("s26-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
client = self.peer("s26-client")
connect_many(client, [source])
wait_remote_game(client, game_id, peer_count=1)
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": game_id, "install": False})
active = client.wait_for(
client.send({"cmd": "download", "game_id": "alienswarm", "install": False})
client.wait_for(
lambda item: item.get("type") == "event"
and item.get("event") == "active-operations-changed"
and any(
op.get("game_id") == game_id
op.get("game_id") == "alienswarm"
for op in item.get("data", {}).get("active_operations", [])
),
timeout=20,
description="active operation",
waiter=waiter,
)
# Verify the operation kind, not just its presence (the only scenario that
# inspects the active_operations 'operation' field).
op = next(o for o in active["data"]["active_operations"] if o.get("game_id") == game_id)
if op.get("operation") != "Downloading":
raise ScenarioError(f"expected Downloading active operation, got {op}")
err = client.send(
{"cmd": "download", "game_id": game_id, "install": False},
{"cmd": "download", "game_id": "alienswarm", "install": False},
expect_error=True,
)
if "operation already in progress" not in err["error"]:
raise ScenarioError(f"unexpected duplicate error: {err}")
client.wait_for(event_is("download-finished", game_id), timeout=180, description="first download finish", waiter=waiter)
diff_game_dirs(source_dir / game_id, client.host_games_dir / game_id)
return f"duplicate rejected while Downloading active ('{err['error']}'); first download diff matched"
client.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="first download finish", waiter=waiter)
diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", client.host_games_dir / "alienswarm")
return f"second command errored '{err['error']}'; first download diff matched"
def s27_self_connect_rejection(self) -> str:
alpha = self.peer("s27-alpha")
@@ -979,8 +841,8 @@ class Runner:
def s28_address_change_unit(self) -> str:
output = run_just_test()
test_name = "peer_db::tests::address_update_preserves_peer_identity_and_library"
if f"{test_name} ... ok" not in output:
raise ScenarioError(f"S28 unit proof did not run-and-pass in just test output:\n{output}")
if test_name not in output:
raise ScenarioError(f"S28 unit proof missing from just test output:\n{output}")
return "`just test` passed including address_update_preserves_peer_identity_and_library"
def s29_empty_peer_participates(self) -> str:
@@ -1003,11 +865,11 @@ class Runner:
def s30_mesh_aggregation(self) -> str:
dirs = []
specs = [
("s30-a", [("ggoo", CATALOG_VERSIONS["ggoo"]), ("bf1942", CATALOG_VERSIONS["bf1942"])]),
("s30-b", [("ggoo", CATALOG_VERSIONS["ggoo"]), ("cnc4", CATALOG_VERSIONS["cnc4"])]),
("s30-c", [("cnc4", CATALOG_VERSIONS["cnc4"]), ("cod5", CATALOG_VERSIONS["cod5"])]),
("s30-d", [("cnctw", CATALOG_VERSIONS["cnctw"]), ("coh", CATALOG_VERSIONS["coh"])]),
("s30-e", [("cnctw", CATALOG_VERSIONS["cnctw"]), ("bf1942", CATALOG_VERSIONS["bf1942"])]),
("s30-a", [("ggoo", "20250101"), ("bf1942", "20250101")]),
("s30-b", [("ggoo", "20250101"), ("cnc4", "20250101")]),
("s30-c", [("cnc4", "20250301"), ("cod5", "20250101")]),
("s30-d", [("cnctw", "20250101"), ("coh", "20250101")]),
("s30-e", [("cnctw", "20250201"), ("bf1942", "20250201")]),
]
peers = []
for name, games in specs:
@@ -1019,12 +881,12 @@ class Runner:
client = self.peer("s30-client")
connect_many(client, peers)
expected = {
"ggoo": (2, CATALOG_VERSIONS["ggoo"]),
"bf1942": (2, CATALOG_VERSIONS["bf1942"]),
"cnc4": (2, CATALOG_VERSIONS["cnc4"]),
"cod5": (1, CATALOG_VERSIONS["cod5"]),
"cnctw": (2, CATALOG_VERSIONS["cnctw"]),
"coh": (1, CATALOG_VERSIONS["coh"]),
"ggoo": (2, "20250101"),
"bf1942": (2, "20250201"),
"cnc4": (2, "20250301"),
"cod5": (1, "20250101"),
"cnctw": (2, "20250201"),
"coh": (1, "20250101"),
}
for game_id, (peer_count, version) in expected.items():
wait_remote_game(client, game_id, peer_count=peer_count, version=version)
@@ -1034,7 +896,7 @@ class Runner:
raise ScenarioError(f"duplicate game rows: {ids}")
if any(peer["peer_id"] == client.peer_id for peer in client.list_peers()):
raise ScenarioError("client listed itself as a peer")
return f"client aggregated {len(expected)} IDs from 5 peers with expected peer_count/catalog versions"
return f"client aggregated {len(expected)} IDs from 5 peers with expected peer_count/latest versions"
def s31_bootstrapped_peer_source(self) -> str:
source = self.peer("s31-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
@@ -1115,24 +977,14 @@ class Runner:
and item.get("event") == "download-chunk-finished"
and item.get("data", {}).get("game_id") == "bf1942"
]
# 20 small files + version.ini, each a single coherent chunk: exactly 21
# chunk events, 21 distinct relative paths, no splits and no duplicates.
if len(chunks) != 21:
raise ScenarioError(f"expected exactly 21 file chunks (20 files + version.ini), got {len(chunks)}")
assert_no_duplicate_chunks(client, "bf1942")
distinct_paths = {item.get("data", {}).get("relative_path") for item in chunks}
if len(distinct_paths) != 21:
raise ScenarioError(f"expected 21 distinct file paths, got {len(distinct_paths)}: {sorted(distinct_paths)}")
return f"20 small files plus version.ini each transferred as one coherent chunk; diff matched; chunk events={len(chunks)}"
if len(chunks) < 21:
raise ScenarioError(f"expected at least 21 file chunks, got {len(chunks)}")
return f"20 small files plus version.ini transferred; diff matched; chunk events={len(chunks)}"
def s35_unknown_game_filtered(self) -> str:
source = self.peer("s35-source", fixtures=["mystery-game"])
client = self.peer("s35-client")
connect_many(client, [source])
# Establish the premise: the source really does advertise mystery-game in
# its raw library snapshot. Without this, wait_remote_absent could pass
# vacuously ("absent because never sent" vs "absent because filtered").
wait_peer_has_game(client, source.peer_id, "mystery-game")
wait_remote_absent(client, "mystery-game")
err = client.send({"cmd": "download", "game_id": "mystery-game", "install": False}, expect_error=True)
if "not in the local catalog" not in err["error"]:
@@ -1140,34 +992,34 @@ class Runner:
assert_not_exists(client.host_games_dir / "mystery-game")
return f"unknown game absent from list-games; download errored '{err['error']}'; no local files"
def s36_catalog_singleton(self) -> str:
def s36_latest_singleton(self) -> str:
peers = []
for index in range(5):
game_dir = self.fixture_root / f"s36-{index}"
version = CATALOG_VERSIONS["cnc4"] if index == 0 else "20160101"
version = "20260501" if index == 0 else "20250101"
copy_game("cnc4", game_dir, version=version)
peers.append(self.peer(f"s36-{index}", games_dir=game_dir))
client = self.peer("s36-client")
connect_many(client, peers)
wait_remote_game(client, "cnc4", peer_count=1, version=CATALOG_VERSIONS["cnc4"])
wait_remote_game(client, "cnc4", peer_count=5, version="20260501")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": "cnc4", "install": False})
got = client.wait_for(event_is("got-game-files", "cnc4"), timeout=20, description="got game files", waiter=waiter)
client.wait_for(event_is("download-finished", "cnc4"), timeout=60, description="download finish", waiter=waiter)
catalog_addr = peers[0].ready_addr
if catalog_addr is None:
raise ScenarioError("catalog-version peer had no ready addr")
latest_addr = peers[0].ready_addr
if latest_addr is None:
raise ScenarioError("latest peer had no ready addr")
for item in client.output:
if item.get("type") != "event" or item.get("event") != "download-chunk-finished":
continue
data = item["data"]
if data.get("game_id") == "cnc4" and data.get("peer_addr") != catalog_addr:
if data.get("game_id") == "cnc4" and data.get("peer_addr") != latest_addr:
raise ScenarioError(f"stale peer contributed chunk: {data}")
diff_game_dirs(peers[0].host_games_dir / "cnc4", client.host_games_dir / "cnc4")
descs = got["data"]["file_descriptions"]
if not descs:
raise ScenarioError("got-game-files had no descriptors")
return "client reported singleton catalog-version peer; stale peers stayed hidden and sent no chunks; diff matched"
return "client reported latest 20260501 with peer_count=5; only singleton latest peer sent chunks; diff matched"
def s37_single_source_download_throughput(self) -> str:
source_dir = self.fixture_root / "s37-source"
@@ -1175,7 +1027,7 @@ class Runner:
source = self.peer("s37-source", games_dir=source_dir)
client = self.peer("s37-client")
connect_many(client, [source])
wait_remote_game(client, PERF_GAME_ID, peer_count=1, version=PERF_GAME_VERSION)
wait_remote_game(client, PERF_GAME_ID, peer_count=1, version="20260520")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": PERF_GAME_ID, "install": False})
@@ -1194,32 +1046,12 @@ 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}"
)
# The byte count alone never exercises the rate math. Validate the rate
# fields are positive and mutually consistent so a units/divisor bug
# (MiB vs MB, off-by-1000, zero duration) cannot slip through.
if throughput["duration_ms"] <= 0:
raise ScenarioError(f"throughput duration_ms not positive: {throughput}")
if throughput["mib_per_s"] <= 0 or throughput["mbit_per_s"] <= 0:
raise ScenarioError(f"throughput rates not positive: {throughput}")
derived_mib = (int(throughput["bytes"]) / 1_048_576) / (throughput["duration_ms"] / 1000.0)
if abs(derived_mib - throughput["mib_per_s"]) > max(1.0, derived_mib * 0.02):
raise ScenarioError(
f"mib_per_s {throughput['mib_per_s']} disagrees with bytes/duration {derived_mib}"
)
# mbit_per_s = bytes*8/s/1e6; mib_per_s = bytes/s/1048576 -> ratio is fixed.
ratio = throughput["mbit_per_s"] / throughput["mib_per_s"]
expected_ratio = 1_048_576 * 8 / 1_000_000 # 8.388608
if abs(ratio - expected_ratio) > 0.01:
raise ScenarioError(
f"mbit/mib ratio {ratio} != expected {expected_ratio}: {throughput}"
)
return (
f"{PERF_GAME_ID} {format_bytes(PERF_GAME_SIZE)} single-source download: "
f"{throughput['mib_per_s']:.2f} MiB/s, "
@@ -1228,535 +1060,6 @@ class Runner:
f"{throughput['chunks']} chunks"
)
def s38_first_play_launch_settings(self) -> str:
client_dir = self.fixture_root / "s38-client"
copy_game("css", client_dir)
client = self.peer(
"s38-client",
games_dir=client_dir,
extra_args=["--unrar", "/usr/local/bin/unrar"],
)
waiter = LineWaiter(len(client.output))
client.send({"cmd": "install", "game_id": "css"})
client.wait_for(
event_is("install-finished", "css"),
timeout=30,
description="css install",
waiter=waiter,
)
wait_local_game(client, "css", downloaded=True, installed=True)
marker = client.host_state_dir / "games" / "css" / "launch_settings_applied"
if marker.exists():
raise ScenarioError("launch settings marker existed before first play")
local_root = client.host_games_dir / "css" / "local"
account_file = local_root / "profiles" / "local" / "account_name.txt"
language_file = local_root / "profiles" / "local" / "language.txt"
ini_file = (
local_root
/ "engine"
/ "bin"
/ "win64"
/ "steam_settings"
/ "SmartSteamEmu.ini"
)
for path in [account_file, language_file, ini_file]:
if not path.is_file():
raise ScenarioError(f"expected installed launch settings file: {path}")
if b"PersonaName = stubplayer\r\n" not in ini_file.read_bytes():
raise ScenarioError("installed SmartSteamEmu.ini did not preserve CRLF stub PersonaName")
first = client.send(
{
"cmd": "play",
"game_id": "css",
"username": "Lan Hero",
"language": "german",
}
)["data"]["outcome"]
expected_first = {
"already_applied": False,
"account_name_written": True,
"language_written": True,
"persona_name_written": True,
}
if first != expected_first:
raise ScenarioError(f"unexpected first play outcome: {first}")
if not marker.is_file():
raise ScenarioError("launch settings marker was not written after first play")
if account_file.read_text(encoding="utf-8") != "Lan Hero":
raise ScenarioError("account_name.txt was not stamped with username")
if language_file.read_text(encoding="utf-8") != "german":
raise ScenarioError("language.txt was not stamped with language")
stamped_ini = ini_file.read_bytes()
if b"PersonaName = Lan Hero\r\n" not in stamped_ini:
raise ScenarioError("PersonaName was not stamped with CRLF preserved")
if b"AppId = 240\r\n" not in stamped_ini or b"Language = english\r\n" not in stamped_ini:
raise ScenarioError("SmartSteamEmu.ini sibling lines were not preserved")
client.docker_exec(
"sh",
"-c",
"printf resetaccount > /games/css/local/profiles/local/account_name.txt",
)
client.docker_exec(
"sh",
"-c",
"printf resetlang > /games/css/local/profiles/local/language.txt",
)
client.docker_exec(
"sh",
"-c",
"printf '[Settings]\\r\\nAppId = 240\\r\\n"
"PersonaName = resetplayer\\r\\nLanguage = english\\r\\n' > "
"/games/css/local/engine/bin/win64/steam_settings/SmartSteamEmu.ini",
)
second = client.send(
{
"cmd": "play",
"game_id": "css",
"username": "Second User",
"language": "french",
}
)["data"]["outcome"]
expected_second = {
"already_applied": True,
"account_name_written": False,
"language_written": False,
"persona_name_written": False,
}
if second != expected_second:
raise ScenarioError(f"unexpected second play outcome: {second}")
if account_file.read_text(encoding="utf-8") != "resetaccount":
raise ScenarioError("second play rewrote account_name.txt despite marker")
if language_file.read_text(encoding="utf-8") != "resetlang":
raise ScenarioError("second play rewrote language.txt despite marker")
if b"PersonaName = resetplayer\r\n" not in ini_file.read_bytes():
raise ScenarioError("second play rewrote PersonaName despite marker")
return "css first play stamped launch settings once; second play respected the marker"
def stream_install_cnctw(self, prefix: str) -> tuple[Peer, Peer]:
source_dir = self.fixture_root / f"{prefix}-bravo"
copy_game("cnctw", source_dir, version="20160128")
source = self.peer(f"{prefix}-bravo", games_dir=source_dir)
client = self.peer(f"{prefix}-client")
connect_many(client, [source])
wait_remote_game(client, "cnctw", peer_count=1)
waiter = LineWaiter(len(client.output))
client.send({"cmd": "stream-install", "game_id": "cnctw"})
client.wait_for(
event_is("download-begin", "cnctw"),
timeout=20,
description="stream begin cnctw",
waiter=waiter,
)
client.wait_for(
event_is("download-finished", "cnctw"),
timeout=60,
description="stream finish cnctw",
waiter=waiter,
)
client.wait_for(
event_is("install-finished", "cnctw"),
timeout=30,
description="stream install cnctw",
waiter=waiter,
)
return source, client
def s39_streamed_install_local_only(self) -> str:
source, client = self.stream_install_cnctw("s39")
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
assert_game_state(
game,
downloaded=False,
installed=True,
availability="LocalOnly",
)
game_root = client.host_games_dir / "cnctw"
assert_not_exists(game_root / "version.ini")
assert_not_exists(game_root / "cnctw.eti")
expected = {
"bin/cnctw-payload.bin": unrar_entry_sha256(
source, "cnctw", "bin/cnctw-payload.bin"
),
"data/cnctw-assets.dat": unrar_entry_sha256(
source, "cnctw", "data/cnctw-assets.dat"
),
}
actual = {
rel: sha256_file(game_root / "local" / rel)
for rel in expected
}
if actual != expected:
raise ScenarioError(f"streamed local payload hashes mismatched: {actual} != {expected}")
streamed_bytes = sum(
int(item.get("data", {}).get("length", 0))
for item in client.output
if item.get("type") == "event"
and item.get("event") == "download-chunk-finished"
and item.get("data", {}).get("game_id") == "cnctw"
)
expected_bytes = 3 * 1024 * 1024
if streamed_bytes != expected_bytes:
raise ScenarioError(
f"streamed byte count mismatch: {streamed_bytes} != {expected_bytes}"
)
wait_no_outbound_transfer(source, "cnctw")
return (
"cnctw streamed into local/ only; root archive and version.ini absent; "
f"payload hashes={actual}; source outbound transfer drained"
)
def s40_streamed_receiver_not_source(self) -> str:
_source, receiver = self.stream_install_cnctw("s40")
observer = self.peer("s40-observer")
connect_many(observer, [receiver])
receiver_snapshot = wait_peer_has_game(observer, receiver.peer_id, "cnctw")
summary = next(
game
for game in receiver_snapshot.get("games", [])
if game.get("id") == "cnctw"
)
if summary.get("availability") != "LocalOnly" or summary.get("downloaded"):
raise ScenarioError(f"receiver did not advertise cnctw as local-only: {summary}")
wait_remote_absent(observer, "cnctw", timeout=5)
err = observer.send(
{"cmd": "download", "game_id": "cnctw", "install": False},
expect_error=True,
)
if "no peers have game cnctw" not in err["error"]:
raise ScenarioError(f"unexpected local-only download error: {err}")
assert_not_exists(observer.host_games_dir / "cnctw")
return (
"observer saw receiver's local-only cnctw snapshot, but remote aggregation hid it "
f"and download errored '{err['error']}'"
)
def s41_solid_archive_streamed_install(self) -> str:
source_dir = self.fixture_root / "s41-solid-source"
source_game = source_dir / "cnctw"
shutil.copytree(FIXTURES / "fixture-solid" / "cnctw", source_game)
source = self.peer("s41-solid-source", games_dir=source_dir)
assert_peer_rar_archive_solid(source, "cnctw")
client = self.peer("s41-solid-client")
connect_many(client, [source])
wait_remote_game(client, "cnctw", peer_count=1, version="20160128")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "stream-install", "game_id": "cnctw"})
client.wait_for(
event_is("download-finished", "cnctw"),
timeout=60,
description="solid stream finish cnctw",
waiter=waiter,
)
client.wait_for(
event_is("install-finished", "cnctw"),
timeout=30,
description="solid stream install cnctw",
waiter=waiter,
)
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
assert_game_state(
game,
downloaded=False,
installed=True,
availability="LocalOnly",
)
game_root = client.host_games_dir / "cnctw"
assert_not_exists(game_root / "version.ini")
assert_not_exists(game_root / "cnctw.eti")
expected = {
"bin/cnctw-solid-payload.bin": unrar_entry_sha256(
source, "cnctw", "bin/cnctw-solid-payload.bin"
),
"data/cnctw-solid-assets.dat": unrar_entry_sha256(
source, "cnctw", "data/cnctw-solid-assets.dat"
),
}
actual = {
rel: sha256_file(game_root / "local" / rel)
for rel in expected
}
if actual != expected:
raise ScenarioError(
f"solid streamed payload hashes mismatched: {actual} != {expected}"
)
streamed_bytes = sum(
int(item.get("data", {}).get("length", 0))
for item in client.output
if item.get("type") == "event"
and item.get("event") == "download-chunk-finished"
and item.get("data", {}).get("game_id") == "cnctw"
)
expected_bytes = sum((game_root / "local" / rel).stat().st_size for rel in expected)
if streamed_bytes != expected_bytes:
raise ScenarioError(
f"solid streamed byte count mismatch: {streamed_bytes} != {expected_bytes}"
)
return (
"solid cnctw archive streamed through one local-only install; "
f"payload hashes={actual}, bytes={streamed_bytes}"
)
def s42_streamed_install_retries_next_source(self) -> str:
bad_dir = self.fixture_root / "s42-bad-source"
good_dir = self.fixture_root / "s42-good-source"
copy_game("cnctw", bad_dir, version="20160128")
copy_game("cnctw", good_dir, version="20160128")
bad = self.peer(
"s42-bad-source",
games_dir=bad_dir,
extra_args=["--unrar", "/missing-unrar"],
)
good = self.peer("s42-good-source", games_dir=good_dir)
if socket_addr_sort_key(bad.ready_addr) > socket_addr_sort_key(good.ready_addr):
raise ScenarioError(
"S42 requires the broken source to sort before the good source; "
f"bad={bad.ready_addr}, good={good.ready_addr}"
)
client = self.peer("s42-client")
connect_many(client, [bad, good])
wait_remote_game(client, "cnctw", peer_count=2, version="20160128")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "stream-install", "game_id": "cnctw"})
client.wait_for(
event_is("download-finished", "cnctw"),
timeout=60,
description="retry stream finish cnctw",
waiter=waiter,
)
client.wait_for(
event_is("install-finished", "cnctw"),
timeout=30,
description="retry stream install cnctw",
waiter=waiter,
)
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
assert_game_state(
game,
downloaded=False,
installed=True,
availability="LocalOnly",
)
game_root = client.host_games_dir / "cnctw"
assert_not_exists(game_root / ".local.installing")
assert_not_exists(game_root / "version.ini")
assert_not_exists(game_root / "cnctw.eti")
assert_only_chunk_sources(client, "cnctw", {good.ready_addr})
expected = {
"bin/cnctw-payload.bin": unrar_entry_sha256(
good, "cnctw", "bin/cnctw-payload.bin"
),
"data/cnctw-assets.dat": unrar_entry_sha256(
good, "cnctw", "data/cnctw-assets.dat"
),
}
actual = {
rel: sha256_file(game_root / "local" / rel)
for rel in expected
}
if actual != expected:
raise ScenarioError(f"retry streamed payload hashes mismatched: {actual} != {expected}")
streamed_bytes = sum(
int(item.get("data", {}).get("length", 0))
for item in client.output
if item.get("type") == "event"
and item.get("event") == "download-chunk-finished"
and item.get("data", {}).get("game_id") == "cnctw"
)
expected_bytes = 3 * 1024 * 1024
if streamed_bytes != expected_bytes:
raise ScenarioError(
f"retry streamed byte count mismatch: {streamed_bytes} != {expected_bytes}"
)
return (
"broken first source failed without chunks, next source completed whole stream; "
f"good={good.ready_addr}, bad={bad.ready_addr}, bytes={streamed_bytes}"
)
def s43_streamed_install_rejects_installed_game(self) -> str:
_source, client = self.stream_install_cnctw("s43")
start = len(client.output)
waiter = LineWaiter(start)
client.send({"cmd": "stream-install", "game_id": "cnctw"})
client.wait_for(
event_is("download-failed", "cnctw"),
timeout=20,
description="already-installed stream rejection",
waiter=waiter,
)
assert_no_event_since(client, start, "install-finished", "cnctw")
assert_no_event_since(client, start, "download-finished", "cnctw")
wait_no_active(client, "cnctw")
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
assert_game_state(
game,
downloaded=False,
installed=True,
availability="LocalOnly",
)
return "already-installed cnctw rejected a second streamed install without state drift"
def s44_corrupt_stream_rolls_back(self) -> str:
source_dir = self.fixture_root / "s44-corrupt-source"
copy_game("cnctw", source_dir, version="20160128")
(source_dir / "cnctw" / "cnctw.eti").write_bytes(b"not a rar archive")
source = self.peer("s44-corrupt-source", games_dir=source_dir)
client = self.peer("s44-client")
connect_many(client, [source])
wait_remote_game(client, "cnctw", peer_count=1, version="20160128")
start = len(client.output)
waiter = LineWaiter(start)
client.send({"cmd": "stream-install", "game_id": "cnctw"})
client.wait_for(
event_is("download-failed", "cnctw"),
timeout=30,
description="corrupt stream failed",
waiter=waiter,
)
assert_no_event_since(client, start, "download-finished", "cnctw")
assert_no_event_since(client, start, "install-finished", "cnctw")
wait_no_active(client, "cnctw")
assert_failed_stream_left_no_local(client, "cnctw")
return "corrupt cnctw archive emitted download-failed and left no local install"
def s45_sender_disconnect_mid_stream(self) -> str:
source_dir = self.fixture_root / "s45-source"
copy_game("alienswarm", source_dir, version="20190317")
source = self.peer("s45-source", games_dir=source_dir)
client = self.peer("s45-client")
connect_many(client, [source])
wait_remote_game(client, "alienswarm", peer_count=1, version="20190317")
start = len(client.output)
waiter = LineWaiter(start)
client.send({"cmd": "stream-install", "game_id": "alienswarm"})
client.wait_for(
event_is("download-chunk-finished", "alienswarm"),
timeout=30,
description="first alienswarm stream chunk before source drop",
waiter=waiter,
)
source.kill()
terminal = client.wait_for(
event_name_in({"download-failed", "download-peers-gone"}, "alienswarm"),
timeout=60,
description="sender disconnect terminal event",
waiter=waiter,
)
assert_no_event_since(client, start, "download-finished", "alienswarm")
assert_no_event_since(client, start, "install-finished", "alienswarm")
wait_no_active(client, "alienswarm")
assert_failed_stream_left_no_local(client, "alienswarm")
return (
"sender disconnect after first alienswarm chunk rolled back stream; "
f"terminal={terminal['event']}"
)
def s46_receiver_cancel_mid_stream(self) -> str:
source_dir = self.fixture_root / "s46-source"
copy_game("alienswarm", source_dir, version="20190317")
source = self.peer("s46-source", games_dir=source_dir)
client = self.peer("s46-client")
connect_many(client, [source])
wait_remote_game(client, "alienswarm", peer_count=1, version="20190317")
start = len(client.output)
waiter = LineWaiter(start)
client.send({"cmd": "stream-install", "game_id": "alienswarm"})
client.wait_for(
event_is("download-chunk-finished", "alienswarm"),
timeout=30,
description="first alienswarm stream chunk before receiver cancel",
waiter=waiter,
)
client.send({"cmd": "cancel-download", "game_id": "alienswarm"})
wait_no_active(client, "alienswarm", timeout=60)
assert_no_event_since(client, start, "download-finished", "alienswarm")
assert_no_event_since(client, start, "download-failed", "alienswarm")
assert_no_event_since(client, start, "install-finished", "alienswarm")
assert_failed_stream_left_no_local(client, "alienswarm")
return "receiver cancel after first alienswarm chunk rolled back without failed event"
def s47_multi_archive_streams_in_sorted_order(self) -> str:
source_dir = self.fixture_root / "s47-source"
source_game = source_dir / "cnctw"
shutil.copytree(FIXTURES / "fixture-multi" / "cnctw", source_game)
source = self.peer("s47-source", games_dir=source_dir)
client = self.peer("s47-client")
connect_many(client, [source])
wait_remote_game(client, "cnctw", peer_count=1, version="20160128")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "stream-install", "game_id": "cnctw"})
client.wait_for(
event_is("download-finished", "cnctw"),
timeout=30,
description="multi-archive stream finish",
waiter=waiter,
)
client.wait_for(
event_is("install-finished", "cnctw"),
timeout=30,
description="multi-archive stream install",
waiter=waiter,
)
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
assert_game_state(
game,
downloaded=False,
installed=True,
availability="LocalOnly",
)
game_root = client.host_games_dir / "cnctw"
assert_not_exists(game_root / "version.ini")
assert_not_exists(game_root / "a-first.eti")
assert_not_exists(game_root / "z-second.eti")
chunk_paths = streamed_chunk_paths(client, "cnctw")
expected_paths = [
"cnctw/.local.installing/order/first.txt",
"cnctw/.local.installing/order/second.txt",
]
if chunk_paths != expected_paths:
raise ScenarioError(f"multi-archive stream order mismatch: {chunk_paths}")
first = (game_root / "local" / "order" / "first.txt").read_text(encoding="utf-8")
second = (game_root / "local" / "order" / "second.txt").read_text(encoding="utf-8")
if first != "first archive payload\n" or second != "second archive payload\n":
raise ScenarioError(f"multi-archive payload mismatch: {first!r}, {second!r}")
return f"multi-archive cnctw streamed in sorted order: {chunk_paths}"
def run(command: list[str], description: str) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
@@ -1825,16 +1128,12 @@ 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")
def find_fixture_game(game_id: str) -> Path:
# Sorted so resolution is deterministic: several fixtures define the same
# game id (e.g. cnctw exists under fixture-bravo, fixture-multi and
# fixture-solid), and downstream assertions expect the fixture-bravo layout.
for fixture_dir in sorted(FIXTURES.iterdir()):
for fixture_dir in FIXTURES.iterdir():
candidate = fixture_dir / game_id
if candidate.exists():
return candidate
@@ -1865,73 +1164,19 @@ def create_many_small_game(root: Path) -> None:
for index in range(20):
child = root / f"file-{index:02}.bin"
child.write_bytes(hashlib.sha256(f"small-{index}".encode()).digest() * 8)
(root / "version.ini").write_text(CATALOG_VERSIONS.get(root.name, "20250101"), encoding="utf-8")
(root / "version.ini").write_text("20250101", encoding="utf-8")
def create_large_sparse_game(root: Path, *, size: int, version: str | None = None) -> None:
def create_large_sparse_game(root: Path, *, size: int) -> None:
if root.exists():
shutil.rmtree(root)
root.mkdir(parents=True)
resolved = version if version is not None else CATALOG_VERSIONS.get(root.name, PERF_GAME_VERSION)
(root / "version.ini").write_text(resolved, 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 inflate_archive_sparse(game_root: Path, game_id: str, size: int) -> None:
"""Replace a copied game's `.eti` with a sparse file of `size` bytes so the
archive spans multiple 128 MiB download chunks. Sparse zero bytes are
identical across copies, so duplicate-source majority validation still
agrees and a `diff` against any catalog-version source still matches."""
archive = game_root / f"{game_id}.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"
@@ -2008,32 +1253,6 @@ def wait_local_game(
)
def wait_no_active(peer: Peer, game_id: str, timeout: float = 20) -> None:
deadline = time.monotonic() + timeout
last_active: list[dict[str, Any]] = []
while time.monotonic() < deadline:
active = peer.status()["active_operations"]
last_active = active
if all(item["game_id"] != game_id for item in active):
return
time.sleep(0.4)
raise ScenarioError(f"{peer.name} still has active operation for {game_id}: {last_active}")
def wait_no_outbound_transfer(peer: Peer, game_id: str, timeout: float = 20) -> None:
deadline = time.monotonic() + timeout
last_active: dict[str, int] = {}
while time.monotonic() < deadline:
active = peer.status()["active_outbound_transfers"]
last_active = active
if active.get(game_id, 0) == 0:
return
time.sleep(0.4)
raise ScenarioError(
f"{peer.name} still has outbound transfer for {game_id}: {last_active}"
)
def assert_game_state(
game: dict[str, Any],
*,
@@ -2080,10 +1299,7 @@ def wait_peer_has_game(
def assert_local_absent(peer: Peer, game_id: str) -> None:
rows = peer.list_games()["local"]
if any(
row["id"] == game_id and (row.get("downloaded") or row.get("installed"))
for row in rows
):
if any(row["id"] == game_id and row.get("downloaded") for row in rows):
raise ScenarioError(f"{peer.name} advertises failed local {game_id}: {rows}")
@@ -2099,15 +1315,6 @@ def assert_not_exists(path: Path) -> None:
raise ScenarioError(f"expected path to be absent: {path}")
def assert_failed_stream_left_no_local(peer: Peer, game_id: str) -> None:
game_root = peer.host_games_dir / game_id
assert_local_absent(peer, game_id)
assert_not_exists(game_root / "local")
assert_not_exists(game_root / ".local.installing")
assert_not_exists(game_root / "version.ini")
assert_not_exists(game_root / f"{game_id}.eti")
def event_is(event: str, game_id: str | None = None) -> Callable[[dict[str, Any]], bool]:
def predicate(item: dict[str, Any]) -> bool:
if item.get("type") != "event" or item.get("event") != event:
@@ -2119,19 +1326,8 @@ 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_since(peer: Peer, start: int, event: str, game_id: str) -> None:
for item in peer.output[start:]:
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:
if item.get("data", {}).get("game_id") == game_id:
raise ScenarioError(f"unexpected {event} for {game_id}: {item}")
@@ -2162,16 +1358,6 @@ def assert_only_chunk_sources(
raise ScenarioError(f"no chunk events recorded for {game_id}")
def streamed_chunk_paths(peer: Peer, game_id: str) -> list[str]:
return [
item["data"]["relative_path"]
for item in peer.output
if item.get("type") == "event"
and item.get("event") == "download-chunk-finished"
and item.get("data", {}).get("game_id") == game_id
]
def chunk_totals(peer: Peer, game_id: str, relative_path: str) -> dict[str, int]:
totals: dict[str, int] = {}
for item in peer.output:
@@ -2184,61 +1370,6 @@ def chunk_totals(peer: Peer, game_id: str, relative_path: str) -> dict[str, int]
return totals
def chunk_sources(peer: Peer, game_id: str) -> set[str]:
return {
item["data"]["peer_addr"]
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 assert_no_duplicate_chunks(peer: Peer, game_id: str) -> None:
seen: set[tuple[str | None, int]] = set()
for item in peer.output:
if item.get("type") != "event" or item.get("event") != "download-chunk-finished":
continue
data = item["data"]
if data.get("game_id") != game_id:
continue
key = (data.get("relative_path"), int(data.get("offset", 0)))
if key in seen:
raise ScenarioError(f"{peer.name} downloaded a duplicate chunk for {game_id}: {key}")
seen.add(key)
def count_events(peer: Peer, event: str, game_id: str) -> int:
return sum(
1
for item in peer.output
if item.get("type") == "event"
and item.get("event") == event
and item.get("data", {}).get("game_id") == game_id
)
def peer_advertised_version(
observer: Peer, peer_id: str | None, game_id: str, timeout: float = 20
) -> str | None:
"""Returns the raw `eti_version` a peer advertises for a game in its library
snapshot (list-peers). Unlike the list-games `remote` rows, this value is NOT
synthesized from the local catalog, so it faithfully reports the source.
Polls until the peer's library snapshot is observed."""
if peer_id is None:
raise ScenarioError("cannot read advertised version without peer_id")
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
for peer in observer.list_peers():
if peer.get("peer_id") != peer_id:
continue
for game in peer.get("games", []):
if game.get("id") == game_id:
return game.get("eti_version")
time.sleep(0.4)
raise ScenarioError(f"{observer.name} does not see {game_id} advertised by {peer_id}")
def diff_game_dirs(source: Path, destination: Path) -> None:
source_manifest = manifest(source)
destination_manifest = manifest(destination)
-40
View File
@@ -33,12 +33,6 @@ pub enum CliCommand {
game_id: String,
install_after_download: bool,
},
StreamInstall {
game_id: String,
},
CancelDownload {
game_id: String,
},
Install {
game_id: String,
},
@@ -69,8 +63,6 @@ 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",
@@ -109,12 +101,6 @@ 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)?,
},
@@ -358,32 +344,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");
+13 -47
View File
@@ -16,9 +16,7 @@ use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
use lanspread_peer::{
ActiveOperation,
ActiveOperationKind,
ExternalUnrarStreamProvider,
NoopStreamInstallProvider,
OutboundTransfers,
InstallOperation,
PeerCommand,
PeerEvent,
PeerGameDB,
@@ -26,7 +24,6 @@ use lanspread_peer::{
PeerRuntimeHandle,
PeerSnapshot,
PeerStartOptions,
StreamInstallProvider,
migrate_legacy_state,
start_peer_with_options,
};
@@ -119,7 +116,6 @@ struct SharedState {
state: RwLock<CliState>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
catalog: Arc<RwLock<GameCatalog>>,
active_outbound_transfers: OutboundTransfers,
notify: Notify,
games_dir: PathBuf,
state_dir: PathBuf,
@@ -138,16 +134,10 @@ async fn main() -> eyre::Result<()> {
let (tx_events, rx_events) = mpsc::unbounded_channel();
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
let catalog = Arc::new(RwLock::new(catalog));
let active_outbound_transfers: OutboundTransfers = Arc::new(RwLock::new(HashMap::new()));
let unrar_for_streaming = args.unrar.clone().or_else(default_unrar_program);
let unpacker: Arc<dyn lanspread_peer::Unpacker> = match args.unrar.clone() {
let unpacker: Arc<dyn lanspread_peer::Unpacker> = match args.unrar {
Some(path) => Arc::new(ExternalUnrarUnpacker::new(path)),
None => Arc::new(FixtureUnpacker),
};
let stream_install_provider: Arc<dyn StreamInstallProvider> = match unrar_for_streaming {
Some(path) => Arc::new(ExternalUnrarStreamProvider::new(path)),
None => Arc::new(NoopStreamInstallProvider),
};
let mut handle = start_peer_with_options(
args.games_dir.clone(),
@@ -157,8 +147,7 @@ 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),
active_outbound_transfers: None,
},
)?;
let sender = handle.sender();
@@ -167,7 +156,6 @@ 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(),
@@ -261,21 +249,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?;
@@ -316,20 +289,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,
}))
}
@@ -540,10 +505,16 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
download_terminal_event(shared, "download-failed", id).await
}
PeerEvent::DownloadGameFilesAllPeersGone { id } => game_id_event("download-peers-gone", id),
PeerEvent::InstallGameBegin { id, operation } => (
"install-begin",
json!({"game_id": id, "operation": install_operation_name(operation)}),
),
PeerEvent::InstallGameFinished { id } => game_id_event("install-finished", id),
PeerEvent::InstallGameFailed { id } => game_id_event("install-failed", id),
PeerEvent::UninstallGameBegin { id } => game_id_event("uninstall-begin", id),
PeerEvent::UninstallGameFinished { id } => game_id_event("uninstall-finished", id),
PeerEvent::UninstallGameFailed { id } => game_id_event("uninstall-failed", id),
PeerEvent::RemoveDownloadedGameBegin { id } => game_id_event("remove-download-begin", id),
PeerEvent::RemoveDownloadedGameFinished { id } => {
game_id_event("remove-download-finished", id)
}
@@ -679,6 +650,10 @@ fn active_operation_name(operation: ActiveOperationKind) -> &'static str {
(&operation).into()
}
fn install_operation_name(operation: InstallOperation) -> &'static str {
(&operation).into()
}
fn runtime_component_name(component: PeerRuntimeComponent) -> &'static str {
(&component).into()
}
@@ -754,15 +729,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"))?
-12
View File
@@ -166,18 +166,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.
-1
View File
@@ -14,7 +14,6 @@ lanspread-utils = { path = "../lanspread-utils" }
# external
bytes = { workspace = true }
crc32fast = { workspace = true }
eyre = { workspace = true }
futures = { workspace = true }
gethostname = { workspace = true }
+4 -24
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
+1 -13
View File
@@ -6,14 +6,7 @@ use lanspread_db::db::{GameCatalog, 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,
};
use crate::{PeerEvent, 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)>>>>;
@@ -45,7 +38,6 @@ 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 peer_id: Arc<String>,
pub shutdown: CancellationToken,
@@ -65,7 +57,6 @@ pub struct PeerCtx {
pub catalog: Arc<RwLock<GameCatalog>>,
pub peer_id: Arc<String>,
pub tx_notify_ui: tokio::sync::mpsc::UnboundedSender<PeerEvent>,
pub stream_install_provider: Arc<dyn StreamInstallProvider>,
pub shutdown: CancellationToken,
pub task_tracker: TaskTracker,
pub active_outbound_transfers: OutboundTransfers,
@@ -95,7 +86,6 @@ impl Ctx {
task_tracker: TaskTracker,
catalog: Arc<RwLock<GameCatalog>>,
active_outbound_transfers: OutboundTransfers,
stream_install_provider: Arc<dyn StreamInstallProvider>,
) -> Self {
Self {
game_dir: Arc::new(RwLock::new(game_dir)),
@@ -107,7 +97,6 @@ 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,
@@ -131,7 +120,6 @@ 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(),
@@ -225,16 +225,12 @@ mod tests {
assert_eq!(chunk_counts.get(&peers[0]), Some(&2));
assert_eq!(chunk_counts.get(&peers[1]), Some(&2));
let assigned_bytes = [
byte_counts.get(&peers[0]).copied().unwrap_or_default(),
byte_counts.get(&peers[1]).copied().unwrap_or_default(),
];
assert_eq!(assigned_bytes.iter().sum::<u64>(), file_size);
let peer_a_bytes = byte_counts.get(&peers[0]).copied().unwrap_or_default();
let peer_b_bytes = byte_counts.get(&peers[1]).copied().unwrap_or_default();
assert_eq!(peer_a_bytes + peer_b_bytes, file_size);
assert!(
assigned_bytes[0].abs_diff(assigned_bytes[1]) <= CHUNK_SIZE,
"large file bytes should be balanced within one chunk: {} vs {}",
assigned_bytes[0],
assigned_bytes[1]
peer_a_bytes.abs_diff(peer_b_bytes) <= CHUNK_SIZE,
"large file bytes should be balanced within one chunk: {peer_a_bytes} vs {peer_b_bytes}"
);
}
+104 -357
View File
@@ -11,7 +11,6 @@ use std::{
use lanspread_db::db::{GameDB, GameFileDescription};
use tokio::sync::{RwLock, mpsc::UnboundedSender};
use tokio_util::sync::CancellationToken;
use crate::{
InstallOperation,
@@ -34,7 +33,6 @@ use crate::{
peer_db::PeerGameDB,
remote_peer::ensure_peer_id_for_addr,
services::{HandshakeCtx, perform_handshake_with_peer},
stream_install::receive_streamed_install,
};
// =============================================================================
@@ -452,60 +450,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,277 +490,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(),
);
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();
@@ -911,6 +584,14 @@ async fn run_started_install_operation(
tx_notify_ui.clone(),
);
let result = {
events::send(
tx_notify_ui,
PeerEvent::InstallGameBegin {
id: id.clone(),
operation,
},
);
let state_dir = ctx.state_dir.as_ref();
match operation {
InstallOperation::Installing => {
@@ -980,7 +661,14 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
ctx.active_operations.clone(),
tx_notify_ui.clone(),
);
let result = install::uninstall(&game_root, ctx.state_dir.as_ref(), &id).await;
let result = {
events::send(
tx_notify_ui,
PeerEvent::UninstallGameBegin { id: id.clone() },
);
install::uninstall(&game_root, ctx.state_dir.as_ref(), &id).await
};
match result {
Ok(()) => {
@@ -1046,7 +734,14 @@ async fn run_remove_downloaded_operation(
ctx.active_operations.clone(),
tx_notify_ui.clone(),
);
let result = install::remove_downloaded(&game_dir, &id).await;
let result = {
events::send(
tx_notify_ui,
PeerEvent::RemoveDownloadedGameBegin { id: id.clone() },
);
install::remove_downloaded(&game_dir, &id).await
};
match result {
Ok(()) => {
@@ -1559,18 +1254,16 @@ mod tests {
}
fn test_ctx(game_dir: PathBuf) -> Ctx {
let state_dir = game_dir.join(".test-state");
Ctx::new(
Arc::new(RwLock::new(PeerGameDB::new())),
"peer".to_string(),
game_dir,
state_dir,
game_dir.clone(),
game_dir.join(".test-state"),
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),
)
}
@@ -1660,15 +1353,15 @@ mod tests {
game
}
fn assert_active_update(event: PeerEvent, expected: &[ActiveOperation]) {
fn assert_active_update(event: PeerEvent, expected: Vec<ActiveOperation>) {
let PeerEvent::ActiveOperationsChanged { active_operations } = event else {
panic!("expected ActiveOperationsChanged");
};
assert_eq!(active_operations, expected);
}
fn active_update(id: &str, operation: ActiveOperationKind) -> [ActiveOperation; 1] {
[ActiveOperation {
fn active_update(id: &str, operation: ActiveOperationKind) -> Vec<ActiveOperation> {
vec![ActiveOperation {
id: id.to_string(),
operation,
}]
@@ -1906,7 +1599,7 @@ mod tests {
);
assert_active_update(
recv_event(&mut rx).await,
&[ActiveOperation {
vec![ActiveOperation {
id: "game".to_string(),
operation: ActiveOperationKind::Updating,
}],
@@ -1943,12 +1636,12 @@ mod tests {
assert!(token.is_cancelled());
assert_active_update(
recv_event(&mut rx).await,
&[ActiveOperation {
vec![ActiveOperation {
id: "game".to_string(),
operation: ActiveOperationKind::Updating,
}],
);
assert_active_update(recv_event(&mut rx).await, &[]);
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"
@@ -2001,9 +1694,16 @@ mod tests {
assert_active_update(
recv_event(&mut rx).await,
&active_update("game", ActiveOperationKind::Updating),
active_update("game", ActiveOperationKind::Updating),
);
assert_active_update(recv_event(&mut rx).await, &[]);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameBegin {
id,
operation: InstallOperation::Updating
} if id == "game"
));
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
@@ -2025,10 +1725,17 @@ mod tests {
assert_active_update(
recv_event(&mut rx).await,
&active_update("game", ActiveOperationKind::Installing),
active_update("game", ActiveOperationKind::Installing),
);
match recv_event(&mut rx).await {
PeerEvent::InstallGameBegin { id, operation } => {
assert_eq!(id, "game");
assert_eq!(operation, InstallOperation::Installing);
}
_ => panic!("expected InstallGameBegin"),
}
assert_local_update(recv_event(&mut rx).await, true, true);
assert_active_update(recv_event(&mut rx).await, &[]);
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
@@ -2079,10 +1786,17 @@ mod tests {
assert_active_update(
recv_event(&mut rx).await,
&active_update("game", ActiveOperationKind::Installing),
active_update("game", ActiveOperationKind::Installing),
);
match recv_event(&mut rx).await {
PeerEvent::InstallGameBegin { id, operation } => {
assert_eq!(id, "game");
assert_eq!(operation, InstallOperation::Installing);
}
_ => panic!("expected InstallGameBegin"),
}
assert_local_update(recv_event(&mut rx).await, true, true);
assert_active_update(recv_event(&mut rx).await, &[]);
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
@@ -2132,10 +1846,17 @@ mod tests {
assert_active_update(
recv_event(&mut rx).await,
&active_update("game", ActiveOperationKind::Updating),
active_update("game", ActiveOperationKind::Updating),
);
match recv_event(&mut rx).await {
PeerEvent::InstallGameBegin { id, operation } => {
assert_eq!(id, "game");
assert_eq!(operation, InstallOperation::Updating);
}
_ => panic!("expected InstallGameBegin"),
}
assert_local_update(recv_event(&mut rx).await, true, true);
assert_active_update(recv_event(&mut rx).await, &[]);
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
@@ -2156,11 +1877,18 @@ mod tests {
run_install_operation(&ctx, &tx, "game".to_string()).await;
assert_active_update(
recv_event(&mut rx).await,
&active_update("game", ActiveOperationKind::Installing),
active_update("game", ActiveOperationKind::Installing),
);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameBegin {
id,
operation: InstallOperation::Installing
} if id == "game"
));
let game = local_update_game(recv_event(&mut rx).await, true, true);
assert_eq!(game.local_version.as_deref(), Some("20240101"));
assert_active_update(recv_event(&mut rx).await, &[]);
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
@@ -2172,11 +1900,18 @@ mod tests {
run_install_operation(&ctx, &tx, "game".to_string()).await;
assert_active_update(
recv_event(&mut rx).await,
&active_update("game", ActiveOperationKind::Updating),
active_update("game", ActiveOperationKind::Updating),
);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameBegin {
id,
operation: InstallOperation::Updating
} if id == "game"
));
let game = local_update_game(recv_event(&mut rx).await, true, true);
assert_eq!(game.local_version.as_deref(), Some("20250101"));
assert_active_update(recv_event(&mut rx).await, &[]);
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
@@ -2185,11 +1920,15 @@ mod tests {
run_uninstall_operation(&ctx, &tx, "game".to_string()).await;
assert_active_update(
recv_event(&mut rx).await,
&active_update("game", ActiveOperationKind::Uninstalling),
active_update("game", ActiveOperationKind::Uninstalling),
);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::UninstallGameBegin { id } if id == "game"
));
let game = local_update_game(recv_event(&mut rx).await, false, true);
assert_eq!(game.local_version.as_deref(), Some("20250101"));
assert_active_update(recv_event(&mut rx).await, &[]);
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::UninstallGameFinished { id } if id == "game"
@@ -2212,10 +1951,14 @@ mod tests {
assert_active_update(
recv_event(&mut rx).await,
&active_update("game", ActiveOperationKind::Uninstalling),
active_update("game", ActiveOperationKind::Uninstalling),
);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::UninstallGameBegin { id } if id == "game"
));
assert_local_update(recv_event(&mut rx).await, false, true);
assert_active_update(recv_event(&mut rx).await, &[]);
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::UninstallGameFinished { id } if id == "game"
@@ -2243,13 +1986,17 @@ mod tests {
assert_active_update(
recv_event(&mut rx).await,
&active_update("game", ActiveOperationKind::RemovingDownload),
active_update("game", ActiveOperationKind::RemovingDownload),
);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::RemoveDownloadedGameBegin { id } if id == "game"
));
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
panic!("expected LocalLibraryChanged");
};
assert!(games.is_empty());
assert_active_update(recv_event(&mut rx).await, &[]);
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::RemoveDownloadedGameFinished { id } if id == "game"
+1 -9
View File
@@ -4,13 +4,5 @@ mod transaction;
pub mod unpack;
pub use remove::remove_downloaded;
pub(crate) use transaction::root_eti_archives;
pub use transaction::{
StreamedInstallTransaction,
begin_streamed_install,
install,
recover_on_startup,
uninstall,
update,
};
pub use transaction::{install, recover_on_startup, uninstall, update};
pub use unpack::{UnpackFuture, Unpacker};
+74 -308
View File
@@ -33,144 +33,6 @@ 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,
@@ -396,7 +258,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? {
@@ -627,28 +489,6 @@ async fn remove_dir_all_if_exists(path: &Path) -> eyre::Result<()> {
}
}
async fn remove_created_empty_game_root(game_root: &Path, created: bool) -> eyre::Result<()> {
if !created {
return Ok(());
}
remove_empty_dir_if_exists(game_root).await
}
async fn remove_empty_dir_if_exists(path: &Path) -> eyre::Result<()> {
match tokio::fs::remove_dir(path).await {
Ok(()) => Ok(()),
Err(err)
if matches!(
err.kind(),
ErrorKind::NotFound | ErrorKind::DirectoryNotEmpty
) =>
{
Ok(())
}
Err(err) => Err(err.into()),
}
}
async fn path_is_dir(path: &Path) -> bool {
tokio::fs::metadata(path)
.await
@@ -790,74 +630,6 @@ mod tests {
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");
@@ -1044,8 +816,53 @@ mod tests {
const INSTALLING_PAYLOAD: &[u8] = b"installing";
const BACKUP_PAYLOAD: &[u8] = b"backup";
fn recovery_cases() -> [RecoveryCase; 10] {
[
fn seed_recovery_case(root: &Path, case: &RecoveryCase) {
write_file(&root.join("version.ini"), b"20250101");
if case.has_local {
write_file(&root.join(LOCAL_DIR).join("payload.txt"), LOCAL_PAYLOAD);
}
if case.has_installing {
write_file(
&root.join(INSTALLING_DIR).join("payload.txt"),
INSTALLING_PAYLOAD,
);
}
if case.has_backup {
write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD);
}
}
fn assert_recovered_case(root: &Path, case: &RecoveryCase) {
let local_payload = root.join(LOCAL_DIR).join("payload.txt");
match case.expected_local_payload {
Some(expected) => assert_eq!(
std::fs::read(&local_payload)
.unwrap_or_else(|err| panic!("{} local payload: {err}", case.name)),
expected,
"{} local payload",
case.name
),
None => assert!(
!root.join(LOCAL_DIR).exists(),
"{} local dir should be absent",
case.name
),
}
assert!(
!root.join(INSTALLING_DIR).exists(),
"{} installing dir should be absent",
case.name
);
assert!(
!root.join(BACKUP_DIR).exists(),
"{} backup dir should be absent",
case.name
);
}
#[tokio::test]
async fn recovery_covers_install_matrix_rows() {
let cases = [
RecoveryCase {
name: "installing_commit_landed",
intent_state: InstallIntentState::Installing,
@@ -1126,85 +943,34 @@ mod tests {
has_backup: false,
expected_local_payload: None,
},
]
}
];
fn seed_recovery_case(root: &Path, case: &RecoveryCase) {
write_file(&root.join("version.ini"), b"20250101");
if case.has_local {
write_file(&root.join(LOCAL_DIR).join("payload.txt"), LOCAL_PAYLOAD);
}
if case.has_installing {
write_file(
&root.join(INSTALLING_DIR).join("payload.txt"),
INSTALLING_PAYLOAD,
);
}
if case.has_backup {
write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD);
}
}
fn assert_recovered_case(root: &Path, case: &RecoveryCase) {
let local_payload = root.join(LOCAL_DIR).join("payload.txt");
match case.expected_local_payload {
Some(expected) => assert_eq!(
std::fs::read(&local_payload)
.unwrap_or_else(|err| panic!("{} local payload: {err}", case.name)),
expected,
"{} local payload",
case.name
),
None => assert!(
!root.join(LOCAL_DIR).exists(),
"{} local dir should be absent",
case.name
),
}
assert!(
!root.join(INSTALLING_DIR).exists(),
"{} installing dir should be absent",
case.name
);
assert!(
!root.join(BACKUP_DIR).exists(),
"{} backup dir should be absent",
case.name
);
}
async fn assert_recovery_case(state_dir: &Path, case: RecoveryCase) {
let temp = TempDir::new("lanspread-install");
let root = temp.game_root();
seed_recovery_case(&root, &case);
write_intent(
state_dir,
"game",
&InstallIntent::new("game", case.intent_state.clone(), Some("20250101".into())),
)
.await
.unwrap_or_else(|err| panic!("{} intent should be written: {err}", case.name));
recover_game_root(&root, state_dir, "game")
.await
.unwrap_or_else(|err| panic!("{} recovery should succeed: {err}", case.name));
assert_recovered_case(&root, &case);
let intent = read_intent(state_dir, "game").await;
assert_eq!(intent.state, InstallIntentState::None, "{}", case.name);
assert_eq!(
intent.eti_version.as_deref(),
Some("20250101"),
"{}",
case.name
);
}
#[tokio::test]
async fn recovery_covers_install_matrix_rows() {
let state = test_state();
for case in recovery_cases() {
assert_recovery_case(state.path(), case).await;
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",
&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")
.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;
assert_eq!(intent.state, InstallIntentState::None, "{}", case.name);
assert_eq!(
intent.eti_version.as_deref(),
Some("20250101"),
"{}",
case.name
);
}
}
+10 -39
View File
@@ -32,7 +32,6 @@ mod remote_peer;
mod services;
mod startup;
mod state_paths;
mod stream_install;
#[cfg(test)]
mod test_support;
@@ -80,17 +79,9 @@ use crate::{
state_paths::resolve_state_dir,
};
pub use crate::{
context::OutboundTransfers,
launch_settings::{LaunchSettingsOutcome, apply_launch_settings_once},
startup::PeerRuntimeHandle,
state_paths::{launch_settings_applied_path, setup_done_path},
stream_install::{
ExternalUnrarStreamProvider,
NoopStreamInstallProvider,
StreamInstallFrameSink,
StreamInstallFuture,
StreamInstallProvider,
},
};
// =============================================================================
@@ -127,14 +118,23 @@ pub enum PeerEvent {
DownloadGameFilesFailed { id: String },
/// All peers with the game have disconnected during download.
DownloadGameFilesAllPeersGone { id: String },
/// Install or update transaction has started for a game.
InstallGameBegin {
id: String,
operation: InstallOperation,
},
/// Install or update transaction has completed successfully.
InstallGameFinished { id: String },
/// Install or update transaction has failed after rollback.
InstallGameFailed { id: String },
/// Uninstall transaction has started for a game.
UninstallGameBegin { id: String },
/// Uninstall transaction has completed successfully.
UninstallGameFinished { id: String },
/// Uninstall transaction has failed after rollback.
UninstallGameFailed { id: String },
/// Downloaded archive removal has started for an uninstalled game.
RemoveDownloadedGameBegin { id: String },
/// Downloaded archive removal has completed successfully.
RemoveDownloadedGameFinished { id: String },
/// Downloaded archive removal has failed before deleting the game root.
@@ -243,8 +243,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.
@@ -262,29 +260,11 @@ 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()
}
}
// =============================================================================
@@ -334,14 +314,11 @@ pub fn start_peer_with_options(
let PeerStartOptions {
state_dir,
active_outbound_transfers,
stream_install_provider,
} = options;
let state_dir = resolve_state_dir(state_dir.as_deref());
let game_dir = game_dir.into();
let active_outbound_transfers = active_outbound_transfers
.unwrap_or_else(|| Arc::new(RwLock::new(std::collections::HashMap::new())));
let stream_install_provider =
stream_install_provider.unwrap_or_else(|| Arc::new(NoopStreamInstallProvider));
log::info!(
"Starting peer system with game directory: {}",
game_dir.display()
@@ -361,7 +338,6 @@ pub fn start_peer_with_options(
unpacker,
catalog,
active_outbound_transfers,
stream_install_provider,
))
}
@@ -379,7 +355,6 @@ async fn run_peer(
task_tracker: TaskTracker,
catalog: Arc<RwLock<GameCatalog>>,
active_outbound_transfers: crate::context::OutboundTransfers,
stream_install_provider: Arc<dyn StreamInstallProvider>,
) -> eyre::Result<()> {
let ctx = Ctx::new(
peer_game_db,
@@ -391,7 +366,6 @@ async fn run_peer(
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}");
@@ -465,9 +439,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;
}
@@ -319,7 +319,6 @@ mod tests {
TaskTracker::new(),
Arc::new(RwLock::new(catalog)),
Arc::new(RwLock::new(HashMap::new())),
Arc::new(crate::NoopStreamInstallProvider),
);
*ctx.local_peer_addr.write().await = Some(addr([127, 0, 0, 1], 4000));
@@ -373,53 +372,4 @@ mod tests {
assert_eq!(games[0].id, "remote-game");
assert_eq!(games[0].peer_count, 1);
}
#[tokio::test]
async fn inbound_hello_from_self_is_ignored() {
// Protocol-level self-detection: a hello whose peer_id matches the local
// peer id must be acknowledged but never recorded as a peer. The CLI
// harness short-circuits self-connects with a string compare before any
// network call, so this guard (handshake.rs) is only exercised here.
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
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(GameCatalog::empty())),
Arc::new(RwLock::new(HashMap::new())),
Arc::new(crate::NoopStreamInstallProvider),
);
*ctx.local_peer_addr.write().await = Some(addr([127, 0, 0, 1], 4000));
let (tx_notify_ui, mut rx_notify_ui) = mpsc::unbounded_channel();
let peer_ctx = ctx.to_peer_ctx(tx_notify_ui);
let self_hello = Hello {
peer_id: "local-peer".to_string(),
proto_ver: PROTOCOL_VERSION,
listen_addr: addr([127, 0, 0, 1], 4000),
library: LibrarySnapshot {
library_rev: 9,
games: vec![summary("self-game")],
},
features: Vec::new(),
};
let ack = accept_inbound_hello(&peer_ctx, None, self_hello)
.await
.expect("self hello should still be acknowledged");
assert_eq!(ack.peer_id, "local-peer");
assert!(
peer_game_db.read().await.peer_snapshots().is_empty(),
"self must never be recorded as a peer"
);
assert!(
rx_notify_ui.try_recv().is_err(),
"self hello must emit no peer discovery events"
);
}
}
@@ -374,23 +374,21 @@ mod tests {
}
fn test_ctx(game_dir: PathBuf, catalog: GameCatalog) -> Ctx {
let state_dir = game_dir.join(".test-state");
Ctx::new(
Arc::new(RwLock::new(PeerGameDB::new())),
"peer".to_string(),
game_dir,
state_dir,
game_dir.clone(),
game_dir.join(".test-state"),
Arc::new(NoopUnpacker),
CancellationToken::new(),
TaskTracker::new(),
Arc::new(RwLock::new(catalog)),
Arc::new(RwLock::new(std::collections::HashMap::new())),
Arc::new(crate::NoopStreamInstallProvider),
)
}
fn watch_event(path: PathBuf) -> Event {
Event::new(EventKind::Any).add_path(path)
fn watch_event(path: PathBuf) -> notify::Result<Event> {
Ok(Event::new(EventKind::Any).add_path(path))
}
async fn recv_local_update(
@@ -461,7 +459,7 @@ mod tests {
&ctx,
&tx,
&gate,
Ok(watch_event(temp.path().join("game").join("version.ini"))),
watch_event(temp.path().join("game").join("version.ini")),
)
.await;
ctx.task_tracker.close();
+2 -51
View File
@@ -15,7 +15,6 @@ use crate::{
local_games::{get_game_file_descriptions, is_local_dir_name, local_download_matches_catalog},
peer::{send_game_file_chunk, send_game_file_data},
services::handshake::{HandshakeCtx, accept_inbound_hello, spawn_library_resync},
stream_install::{send_game_install_stream, send_stream_install_error},
};
type ResponseWriter = FramedWrite<SendStream, LengthDelimitedCodec>;
@@ -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
@@ -390,49 +386,6 @@ async fn handle_file_chunk_request(
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) };
@@ -479,18 +432,16 @@ mod tests {
fn test_ctx(game_dir: PathBuf, catalog: GameCatalog) -> PeerCtx {
let (tx_notify_ui, _rx) = mpsc::unbounded_channel();
let state_dir = game_dir.join(".test-state");
Ctx::new(
Arc::new(RwLock::new(PeerGameDB::new())),
"peer".to_string(),
game_dir,
state_dir,
game_dir.clone(),
game_dir.join(".test-state"),
Arc::new(NoopUnpacker),
CancellationToken::new(),
TaskTracker::new(),
Arc::new(RwLock::new(catalog)),
Arc::new(RwLock::new(std::collections::HashMap::new())),
Arc::new(crate::NoopStreamInstallProvider),
)
.to_peer_ctx(tx_notify_ui)
}
-3
View File
@@ -23,7 +23,6 @@ use crate::{
PeerCommand,
PeerEvent,
PeerRuntimeComponent,
StreamInstallProvider,
Unpacker,
context::Ctx,
events,
@@ -88,7 +87,6 @@ pub(crate) fn spawn_peer_runtime(
unpacker: Arc<dyn Unpacker>,
catalog: Arc<RwLock<GameCatalog>>,
active_outbound_transfers: crate::context::OutboundTransfers,
stream_install_provider: Arc<dyn StreamInstallProvider>,
) -> PeerRuntimeHandle {
let shutdown = CancellationToken::new();
let task_tracker = TaskTracker::new();
@@ -109,7 +107,6 @@ pub(crate) fn spawn_peer_runtime(
runtime_tracker.clone(),
catalog,
active_outbound_transfers,
stream_install_provider,
)
.await
{
-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)
.kill_on_drop(true)
.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())
// Safety net: if this task is dropped before its cancel/error path runs
// (e.g. on shutdown), tokio still kills unrar instead of orphaning it.
.kill_on_drop(true)
.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 byte_len = u64::try_from(bytes.len()).expect("test payload length should fit in u64");
let integrity = SenderArchiveIntegrity::new(byte_len, crc32_of(bytes));
integrity
.verify("bin/payload.bin", byte_len, 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()
}
}
+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:?}");
}
}
}
+6 -6
View File
@@ -6,8 +6,8 @@
"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:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.16",
"npm:@types/react@^19.2.16": "19.2.16",
"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",
@@ -226,14 +226,14 @@
"tslib"
]
},
"@types/react-dom@19.2.3_@types+react@19.2.17": {
"@types/react-dom@19.2.3_@types+react@19.2.16": {
"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.16": {
"integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==",
"dependencies": [
"csstype"
]
@@ -436,7 +436,7 @@
"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.16",
"npm:@vitejs/plugin-react@^6.0.2",
"npm:react-dom@^19.2.7",
"npm:react@^19.2.7",
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/softlan-icon.svg" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+1 -1
View File
@@ -18,7 +18,7 @@
"@tauri-apps/plugin-shell": "^2.3.5"
},
"devDependencies": {
"@types/react": "^19.2.17",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"typescript": "^6.0.3",
@@ -1,29 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100" role="img" aria-label="SoftLAN">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#7dabfb"></stop>
<stop offset="0.55" stop-color="#3b82f6"></stop>
<stop offset="1" stop-color="#2456a8"></stop>
</linearGradient>
</defs>
<rect x="0" y="0" width="100" height="100" rx="22.5" fill="url(#g)"></rect>
<g transform="translate(8 8) scale(0.84)">
<rect x="76.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="59.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="42.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="25.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="8.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="8.7" y="25.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="8.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="25.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="42.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="59.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="76.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="76.7" y="59.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="76.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="59.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="42.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="25.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="8.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -33,12 +33,12 @@ tauri = { workspace = true }
tauri-plugin-dialog = { workspace = true }
tauri-plugin-shell = { workspace = true }
tauri-plugin-store = { workspace = true }
time = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
tracing = { workspace = true }
tracing-log = { workspace = true }
tracing-subscriber = { workspace = true }
time = { workspace = true }
walkdir = { workspace = true }
[build-dependencies]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 14 KiB

@@ -1,14 +1,10 @@
use std::{
collections::{HashMap, HashSet},
fs::{self, OpenOptions},
io::{self, Read as _, Seek as _, SeekFrom, Write as _},
io::{Read as _, Seek as _, SeekFrom, Write as _},
net::SocketAddr,
path::{Component, Path, PathBuf},
sync::{
Arc,
Mutex,
OnceLock,
atomic::{AtomicU64, Ordering},
},
sync::{Arc, Mutex, OnceLock},
time::{Duration, SystemTime, UNIX_EPOCH},
};
@@ -18,24 +14,18 @@ use lanspread_db::db::{Availability, Game, GameCatalog, GameDB, GameFileDescript
use lanspread_peer::{
ActiveOperation,
ActiveOperationKind,
ExternalUnrarStreamProvider,
NoopStreamInstallProvider,
PeerCommand,
PeerEvent,
PeerGameDB,
PeerRuntimeHandle,
PeerStartOptions,
StreamInstallProvider,
UnpackFuture,
Unpacker,
migrate_legacy_state,
start_peer_with_options,
};
use tauri::{AppHandle, Emitter as _, Manager};
use tauri_plugin_shell::{
ShellExt,
process::{Command, CommandChild, CommandEvent},
};
use tauri_plugin_shell::{ShellExt, process::Command};
use tokio::sync::{
RwLock,
mpsc::{UnboundedReceiver, UnboundedSender},
@@ -97,35 +87,10 @@ struct LanSpreadState {
catalog: Arc<RwLock<GameCatalog>>,
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
state_dir: OnceLock<PathBuf>,
main_log_sink: OnceLock<MainLogSink>,
active_outbound_transfers: OutboundTransfers,
outbound_transfer_emit: Arc<RwLock<OutboundTransferEmitState>>,
/// Live unrar sidecar processes, so they can be killed if the launcher exits
/// mid-unpack. The shell plugin does not kill spawned children on app exit,
/// and the OS does not cascade-kill child processes, so without this an
/// in-progress unrar keeps running after the launcher closes.
active_unrar_children: Arc<Mutex<UnrarChildRegistry>>,
}
/// Live unrar sidecar children plus a shutdown latch.
///
/// Children are keyed by a monotonic id (not pid) so a finishing install never
/// deregisters another install's child after a pid is recycled. The
/// `shutting_down` latch closes a time-of-check/time-of-use hole: the exit kill
/// sweep drains the map only once, so a child registered *after* the sweep (an
/// install task caught between `spawn()` and registration, or a later archive in
/// a multi-archive install — `unpack_archives` does not observe the shutdown
/// token) would be orphaned. Once the latch is set under this mutex, registration
/// kills the child immediately instead of inserting it where nothing will reap it.
#[derive(Default)]
struct UnrarChildRegistry {
shutting_down: bool,
children: HashMap<u64, CommandChild>,
}
/// Monotonic id source for [`UnrarChildRegistry`] entries.
static UNRAR_CHILD_SEQ: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Debug, PartialEq, Eq)]
struct InstallSettings {
account_name: String,
@@ -179,14 +144,6 @@ struct UnpackLogEntry {
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 {
@@ -217,27 +174,15 @@ async fn get_unpack_logs(
}
#[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()?);
}
async fn get_main_logs(app_handle: tauri::AppHandle) -> tauri::Result<String> {
let state_dir = app_handle.path().app_data_dir()?;
fs::create_dir_all(&state_dir)?;
let path = main_log_path(&state_dir);
trim_main_log_file(&path)?;
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,
}),
match fs::read_to_string(&path) {
Ok(contents) => Ok(contents),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(err) => Err(err.into()),
}
}
@@ -328,56 +273,6 @@ async fn install_game(
Ok(handled)
}
#[tauri::command]
async fn stream_install_game(
id: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
if state
.inner()
.active_operations
.read()
.await
.contains_key(&id)
{
log::warn!("Game already has an active operation: {id}");
return Ok(false);
}
let Some((downloaded, installed, peer_count)) = state
.inner()
.games
.read()
.await
.get_game_by_id(&id)
.map(|game| (game.downloaded, game.installed, game.peer_count))
else {
log::warn!("Ignoring streamed install request for unknown game: {id}");
return Ok(false);
};
if downloaded || installed || peer_count == 0 {
log::warn!(
"Ignoring streamed install request for {id}: downloaded={downloaded}, \
installed={installed}, peer_count={peer_count}"
);
return Ok(false);
}
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
let peer_ctrl = peer_ctrl_arc.read().await.clone();
let Some(peer_ctrl) = peer_ctrl else {
log::warn!("Peer system not initialized yet");
return Ok(false);
};
if let Err(e) = peer_ctrl.send(PeerCommand::StreamInstallGame { id }) {
log::error!("Failed to send PeerCommand::StreamInstallGame: {e:?}");
return Ok(false);
}
Ok(true)
}
#[tauri::command]
async fn update_game(
id: String,
@@ -1335,19 +1230,16 @@ async fn run_unrar_sidecar(
paths: &UnrarPaths,
started_at_ms: u64,
) -> eyre::Result<()> {
// Spawn (instead of `.output()`) so we keep a killable handle. The shell
// plugin's `output()` drops the `CommandChild` immediately and only drains
// the event channel, leaving the unrar process orphaned if the launcher
// exits before extraction finishes.
let (mut events, child) = match sidecar
let out = match sidecar
.arg("x") // extract files
.arg(&paths.archive)
.arg("-y") // Assume Yes on all queries
.arg("-o") // Set overwrite mode
.arg(&paths.destination_arg)
.spawn()
.output()
.await
{
Ok(spawned) => spawned,
Ok(out) => out,
Err(err) => {
let stderr = format!("failed to run unrar sidecar: {err}");
record_unpack_failure(
@@ -1362,42 +1254,10 @@ async fn run_unrar_sidecar(
}
};
// Register the live child so a launcher exit can kill it, and deregister it
// automatically on every exit path via the RAII guard.
let registry = app_handle
.state::<LanSpreadState>()
.active_unrar_children
.clone();
let child_id = UNRAR_CHILD_SEQ.fetch_add(1, Ordering::Relaxed);
register_unrar_child(&registry, child_id, child);
let _child_guard = UnrarChildGuard { registry, child_id };
let mut stdout_bytes = Vec::new();
let mut stderr_bytes = Vec::new();
let mut status_code = None;
while let Some(event) = events.recv().await {
match event {
CommandEvent::Stdout(line) => {
stdout_bytes.extend(line);
stdout_bytes.push(b'\n');
}
CommandEvent::Stderr(line) => {
stderr_bytes.extend(line);
stderr_bytes.push(b'\n');
}
CommandEvent::Terminated(payload) => {
status_code = payload.code;
}
CommandEvent::Error(err) => {
log::warn!("unrar sidecar event error: {err}");
}
_ => {}
}
}
let stdout = clean_terminal_log(&String::from_utf8_lossy(&stdout_bytes));
let stderr = clean_terminal_log(&String::from_utf8_lossy(&stderr_bytes));
let success = status_code == Some(0);
let stdout = clean_terminal_log(&String::from_utf8_lossy(&out.stdout));
let stderr = clean_terminal_log(&String::from_utf8_lossy(&out.stderr));
let status_code = out.status.code();
let success = out.status.success();
record_unpack_log(
app_handle,
@@ -1427,79 +1287,6 @@ async fn run_unrar_sidecar(
Ok(())
}
/// Tracks a spawned unrar sidecar so the launcher can kill it on shutdown.
///
/// If shutdown has already begun (or the registry is poisoned), the child is
/// killed immediately instead of inserted, since the exit kill sweep has already
/// run and would never reap a late registration.
fn register_unrar_child(
registry: &Arc<Mutex<UnrarChildRegistry>>,
child_id: u64,
child: CommandChild,
) {
let Ok(mut guard) = registry.lock() else {
// A poisoned registry means we can no longer guarantee the child is
// killed on exit, so kill it now rather than risk orphaning it.
let pid = child.pid();
log::warn!("unrar child registry is poisoned; killing pid {pid} immediately");
if let Err(err) = child.kill() {
log::warn!("Failed to kill untracked unrar child (pid {pid}): {err}");
}
return;
};
if guard.shutting_down {
drop(guard);
let pid = child.pid();
log::info!("Killing unrar child (pid {pid}) that spawned during shutdown");
if let Err(err) = child.kill() {
log::warn!("Failed to kill unrar child (pid {pid}) spawned during shutdown: {err}");
}
return;
}
guard.children.insert(child_id, child);
}
/// Removes an unrar sidecar from the registry when its `run_unrar_sidecar` call
/// returns, regardless of success, error, or early bail.
struct UnrarChildGuard {
registry: Arc<Mutex<UnrarChildRegistry>>,
child_id: u64,
}
impl Drop for UnrarChildGuard {
fn drop(&mut self) {
if let Ok(mut guard) = self.registry.lock() {
guard.children.remove(&self.child_id);
}
}
}
/// Kills every in-progress unrar sidecar and latches the registry into shutdown
/// so any install task that spawns unrar after this point kills it on
/// registration. Called on app exit so a game install that is mid-extraction does
/// not leave `unrar` running after the launcher closes.
fn kill_active_unrar_children(app_handle: &AppHandle) {
let state = app_handle.state::<LanSpreadState>();
let children = {
let Ok(mut guard) = state.active_unrar_children.lock() else {
log::warn!("unrar child registry is poisoned; cannot kill children on shutdown");
return;
};
guard.shutting_down = true;
guard.children.drain().collect::<Vec<_>>()
};
for (_child_id, child) in children {
let pid = child.pid();
match child.kill() {
Ok(()) => log::info!("Killed in-progress unrar child (pid {pid}) on shutdown"),
Err(err) => log::warn!("Failed to kill unrar child (pid {pid}) on shutdown: {err}"),
}
}
}
async fn record_unpack_failure(
app_handle: &AppHandle,
archive: String,
@@ -1597,70 +1384,33 @@ 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(()),
fn trim_main_log_file(path: &Path) -> std::io::Result<()> {
trim_main_log_file_to_limit(path, MAX_MAIN_LOG_BYTES)
}
fn trim_main_log_file_to_limit(path: &Path, max_bytes: u64) -> std::io::Result<()> {
let metadata = match fs::metadata(path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::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))?;
if max_bytes == 0 {
fs::write(path, [])?;
return Ok(());
}
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)
}
file.seek(SeekFrom::Start(metadata.len() - 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);
let mut bytes = Vec::with_capacity(usize::try_from(max_bytes).unwrap_or(usize::MAX));
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))
}
let tail = valid_utf8_tail(bytes);
fs::write(path, tail.as_bytes())
}
fn valid_utf8_tail(bytes: Vec<u8>) -> String {
@@ -1677,13 +1427,7 @@ fn valid_utf8_tail(bytes: Vec<u8>) -> String {
struct MainLogSink {
app_handle: AppHandle,
path: PathBuf,
file_state: Arc<Mutex<MainLogFileState>>,
}
#[derive(Default)]
struct MainLogFileState {
file: Option<fs::File>,
last_sequence: u64,
file_lock: Arc<Mutex<()>>,
}
impl MainLogSink {
@@ -1691,101 +1435,54 @@ impl MainLogSink {
Self {
app_handle,
path,
file_state: Arc::new(Mutex::new(MainLogFileState::default())),
file_lock: Arc::new(Mutex::new(())),
}
}
fn write_line(&self, line: String, level: Level) {
write_main_log_stdout(&line);
let sequence = self.append_file_line(&line);
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)?
fn append_file_line(&self, line: &str) {
let Ok(_guard) = self.file_lock.lock() else {
return;
};
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;
if let Some(parent) = self.path.parent() {
let _ = fs::create_dir_all(parent);
}
file_state.last_sequence = file_state.last_sequence.saturating_add(1);
let sequence = file_state.last_sequence;
let Ok(mut file) = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
else {
return;
};
let should_trim = file_state.file.as_ref().is_some_and(|file| {
file.metadata().is_ok_and(|metadata| {
if writeln!(file, "{line}").is_err() {
return;
}
let should_trim = 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);
if should_trim {
let _ = trim_main_log_file(&self.path);
}
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 {
@@ -1820,8 +1517,13 @@ where
.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);
let line = format_main_log_line_parts(
&date,
&time,
&target,
metadata.level().as_str(),
&message,
);
self.sink.write_line(line, *metadata.level());
}
@@ -1920,9 +1622,8 @@ fn format_main_log_line_parts(
message: &str,
) -> String {
format!(
"[{date}][{time}][{}][{level}] {}",
normalize_main_log_target(target),
normalize_main_log_message(message)
"[{date}][{time}][{}][{level}] {message}",
normalize_main_log_target(target)
)
}
@@ -1940,8 +1641,12 @@ fn write_main_log_stdout(line: &str) {
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));
fn init_main_logging(
app_handle: AppHandle,
path: PathBuf,
) -> Result<(), Box<dyn std::error::Error>> {
let subscriber =
tracing_subscriber::registry().with(MainLogLayer::new(MainLogSink::new(app_handle, path)));
tracing::subscriber::set_global_default(subscriber)?;
tracing_log::LogTracer::builder()
.with_max_level(log::LevelFilter::Info)
@@ -2059,7 +1764,6 @@ async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) {
let unpacker = Arc::new(SidecarUnpacker {
app_handle: app_handle.clone(),
});
let stream_install_provider = stream_install_provider_for_app(app_handle);
match start_peer_with_options(
games_folder.to_path_buf(),
tx_peer_event,
@@ -2069,7 +1773,6 @@ async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) {
PeerStartOptions {
state_dir: Some(state_dir),
active_outbound_transfers: Some(state.active_outbound_transfers.clone()),
stream_install_provider: Some(stream_install_provider),
},
) {
Ok(handle) => {
@@ -2087,28 +1790,18 @@ 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}");
}
}
fn emit_peer_addr_event(app_handle: &AppHandle, event: &str, addr: SocketAddr) {
if let Err(e) = app_handle.emit(event, Some(addr.to_string())) {
log::error!("Failed to emit {event} event: {e}");
}
}
fn spawn_peer_event_loop(app_handle: AppHandle, mut rx_peer_event: UnboundedReceiver<PeerEvent>) {
tauri::async_runtime::spawn(async move {
while let Some(event) = rx_peer_event.recv().await {
@@ -2160,6 +1853,9 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
match event {
PeerEvent::LocalPeerReady { peer_id, addr } => {
log::info!("Local peer ready: {peer_id} at {addr}");
if let Err(e) = app_handle.emit("peer-local-ready", Some((peer_id, addr.to_string()))) {
log::error!("Failed to emit peer-local-ready event: {e}");
}
}
PeerEvent::ListGames(games) => {
log::info!("PeerEvent::ListGames received");
@@ -2198,7 +1894,13 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
);
}
PeerEvent::DownloadGameFilesBegin { id } => {
log::info!("PeerEvent::DownloadGameFilesBegin received for {id}");
log::info!("PeerEvent::DownloadGameFilesBegin received");
emit_game_id_event(
app_handle,
"game-download-begin",
&id,
"PeerEvent::DownloadGameFilesBegin",
);
}
PeerEvent::DownloadGameFileChunkFinished {
id,
@@ -2218,7 +1920,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
}
PeerEvent::DownloadGameFilesFinished { id } => {
log::info!("PeerEvent::DownloadGameFilesFinished received for {id}");
handle_download_finished(app_handle, id);
}
PeerEvent::DownloadGameFilesFailed { id } => {
log::warn!("PeerEvent::DownloadGameFilesFailed received");
@@ -2238,6 +1940,16 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
"PeerEvent::DownloadGameFilesAllPeersGone",
);
}
PeerEvent::InstallGameBegin { id, operation } => {
let operation_name: &'static str = (&operation).into();
log::info!("PeerEvent::InstallGameBegin received for {id}: {operation_name}");
emit_game_id_event(
app_handle,
"game-install-begin",
&id,
"PeerEvent::InstallGameBegin",
);
}
PeerEvent::InstallGameFinished { id } => {
log::info!("PeerEvent::InstallGameFinished received for {id}");
emit_game_id_event(
@@ -2256,8 +1968,23 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
"PeerEvent::InstallGameFailed",
);
}
PeerEvent::UninstallGameBegin { id } => {
log::info!("PeerEvent::UninstallGameBegin received for {id}");
emit_game_id_event(
app_handle,
"game-uninstall-begin",
&id,
"PeerEvent::UninstallGameBegin",
);
}
PeerEvent::UninstallGameFinished { id } => {
log::info!("PeerEvent::UninstallGameFinished received for {id}");
emit_game_id_event(
app_handle,
"game-uninstall-finished",
&id,
"PeerEvent::UninstallGameFinished",
);
}
PeerEvent::UninstallGameFailed { id } => {
log::warn!("PeerEvent::UninstallGameFailed received for {id}");
@@ -2268,6 +1995,15 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
"PeerEvent::UninstallGameFailed",
);
}
PeerEvent::RemoveDownloadedGameBegin { id } => {
log::info!("PeerEvent::RemoveDownloadedGameBegin received for {id}");
emit_game_id_event(
app_handle,
"game-remove-download-begin",
&id,
"PeerEvent::RemoveDownloadedGameBegin",
);
}
PeerEvent::RemoveDownloadedGameFinished { id } => {
log::info!("PeerEvent::RemoveDownloadedGameFinished received for {id}");
emit_game_id_event(
@@ -2288,15 +2024,19 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
PeerEvent::PeerConnected(addr) => {
log::info!("Peer connected: {addr}");
emit_peer_addr_event(app_handle, "peer-connected", addr);
}
PeerEvent::PeerDisconnected(addr) => {
log::info!("Peer disconnected: {addr}");
emit_peer_addr_event(app_handle, "peer-disconnected", addr);
}
PeerEvent::PeerDiscovered(addr) => {
log::info!("Peer discovered: {addr}");
emit_peer_addr_event(app_handle, "peer-discovered", addr);
}
PeerEvent::PeerLost(addr) => {
log::info!("Peer lost: {addr}");
emit_peer_addr_event(app_handle, "peer-lost", addr);
}
PeerEvent::PeerCountUpdated(count) => {
log::info!("Peer count updated: {count}");
@@ -2323,6 +2063,12 @@ async fn handle_got_game_files(
file_descriptions: Vec<GameFileDescription>,
) {
log::info!("PeerEvent::GotGameFiles received");
emit_game_id_event(
app_handle,
"game-download-pre",
&id,
"PeerEvent::GotGameFiles",
);
let state = app_handle.state::<LanSpreadState>();
let peer_ctrl = state.peer_ctrl.read().await.clone();
@@ -2332,88 +2078,18 @@ 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}");
}
}
#[allow(clippy::missing_panics_doc)]
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// 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_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,
remove_downloaded_game,
cancel_download,
open_game_files,
get_peer_count,
get_game_thumbnail,
get_unpack_logs,
get_main_logs
])
.manage(LanSpreadState::default())
.manage(PeerEventTx(tx_peer_event))
.setup(move |app| {
let state_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&state_dir)?;
let main_log_sink = MainLogSink::new(app.handle().clone(), main_log_path(&state_dir));
let state = app.state::<LanSpreadState>();
if state.main_log_sink.set(main_log_sink.clone()).is_err() {
log::warn!("main log sink was already initialized");
}
init_main_logging(main_log_sink)?;
let unpack_logs = load_unpack_logs(&state_dir);
tauri::async_runtime::block_on(async {
*state.unpack_logs.write().await = unpack_logs;
});
if state.state_dir.set(state_dir).is_err() {
log::warn!("app state directory was already initialized");
}
spawn_peer_event_loop(app.handle().clone(), rx_peer_event);
Ok(())
})
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
if matches!(event, tauri::RunEvent::Exit) {
// Kill unrar first: an in-progress extraction would otherwise keep
// running after the launcher closes, and killing it lets the
// install task unwind so the peer runtime can stop promptly.
kill_active_unrar_children(app_handle);
shutdown_peer_runtime(app_handle);
}
});
}
fn shutdown_peer_runtime(app_handle: &AppHandle) {
let state = app_handle.state::<LanSpreadState>();
let peer_runtime = state.peer_runtime.clone();
tauri::async_runtime::block_on(async move {
let Some(mut handle) = peer_runtime.write().await.take() else {
return;
};
handle.shutdown();
if tokio::time::timeout(std::time::Duration::from_secs(2), handle.wait_stopped())
.await
.is_err()
{
log::warn!("Peer runtime did not stop within 2s of shutdown request");
}
});
fn handle_download_finished(app_handle: &AppHandle, id: String) {
log::info!("PeerEvent::DownloadGameFilesFinished received");
emit_game_id_event(
app_handle,
"game-download-finished",
&id,
"PeerEvent::DownloadGameFilesFinished",
);
}
#[cfg(test)]
@@ -2580,7 +2256,7 @@ mod tests {
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.len() <= 21);
assert!(trimmed.as_bytes().len() <= 21);
assert!(trimmed.starts_with('é'));
assert!(trimmed.ends_with('é'));
@@ -2885,3 +2561,74 @@ mod tests {
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() {
// 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_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
request_games,
install_game,
run_game,
start_server,
game_directory_exists,
update_game_directory,
update_game,
uninstall_game,
remove_downloaded_game,
cancel_download,
open_game_files,
get_peer_count,
get_game_thumbnail,
get_unpack_logs,
get_main_logs
])
.manage(LanSpreadState::default())
.manage(PeerEventTx(tx_peer_event))
.setup(move |app| {
let state_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&state_dir)?;
init_main_logging(app.handle().clone(), main_log_path(&state_dir))?;
let state = app.state::<LanSpreadState>();
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(())
})
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
if matches!(event, tauri::RunEvent::Exit) {
shutdown_peer_runtime(app_handle);
}
});
}
fn shutdown_peer_runtime(app_handle: &AppHandle) {
let state = app_handle.state::<LanSpreadState>();
let peer_runtime = state.peer_runtime.clone();
tauri::async_runtime::block_on(async move {
let Some(mut handle) = peer_runtime.write().await.take() else {
return;
};
handle.shutdown();
if tokio::time::timeout(std::time::Duration::from_secs(2), handle.wait_stopped())
.await
.is_err()
{
log::warn!("Peer runtime did not stop within 2s of shutdown request");
}
});
}
@@ -3,28 +3,114 @@ 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';
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] as const;
type LogLevel = typeof LOG_LEVELS[number];
type LevelFilter = 'all' | 'debug' | 'info' | 'warn' | 'error';
interface MainLogLinePayload {
line: string;
level: string;
}
interface MainLogRow {
line: string;
level: LogLevel;
}
const LEVEL_ORDER: Record<LogLevel, number> = {
TRACE: 0,
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4,
};
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,
};
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;
const isLogLevel = (value: string | undefined): value is LogLevel =>
typeof value === 'string' && (LOG_LEVELS as readonly string[]).includes(value);
const normalizeLogLevel = (value: string | undefined): LogLevel => {
const upper = value?.toUpperCase();
return isLogLevel(upper) ? upper : 'INFO';
};
const parseLogLevelFromLine = (line: string): LogLevel => {
const match = line.match(/\[(TRACE|DEBUG|INFO|WARN|ERROR)\](?:\s|$)/);
return normalizeLogLevel(match?.[1]);
};
const rowFromPayload = (payload: MainLogLinePayload): MainLogRow => ({
line: payload.line,
level: normalizeLogLevel(payload.level),
});
const rowsFromHistory = (text: string): MainLogRow[] =>
text
.split(/\r?\n/)
.filter(line => line.length > 0)
.map(line => ({ line, level: parseLogLevelFromLine(line) }));
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();
};
const dedupeBufferedRows = (historyRows: MainLogRow[], bufferedRows: MainLogRow[]): MainLogRow[] => {
const lineCounts = new Map<string, number>();
historyRows.forEach(row => {
lineCounts.set(row.line, (lineCounts.get(row.line) ?? 0) + 1);
});
return bufferedRows.filter(row => {
const count = lineCounts.get(row.line) ?? 0;
if (count <= 0) return true;
lineCounts.set(row.line, count - 1);
return false;
});
};
const formatCount = (count: number, noun: string): string =>
`${count.toLocaleString()} ${noun}${count === 1 ? '' : 's'}`;
export const MainLogsWindow = () => {
const [logs, setLogs] = useState<MainLogRow[]>([]);
const [loading, setLoading] = useState(true);
@@ -41,8 +127,6 @@ export const MainLogsWindow = () => {
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]));
@@ -62,15 +146,6 @@ export const MainLogsWindow = () => {
initialBufferRef.current = capLogRows([...initialBufferRef.current, row]);
return;
}
if (
consumeLoadedHistoryRow(
historyLineCountsRef.current,
row,
lastHistorySequenceRef.current,
)
) {
return;
}
if (pausedRef.current) {
bufferPausedRows([row]);
return;
@@ -84,19 +159,12 @@ export const MainLogsWindow = () => {
handleIncomingRow(rowFromPayload(event.payload));
});
const history = await invoke<MainLogHistoryPayload>('get_main_logs');
const history = await invoke<string>('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,
);
const historyRows = rowsFromHistory(history);
const liveRows = dedupeBufferedRows(historyRows, initialBufferRef.current);
initialBufferRef.current = [];
historyLineCountsRef.current = historyLineCounts;
historyLoadedRef.current = true;
if (pausedRef.current) {
@@ -124,8 +192,6 @@ export const MainLogsWindow = () => {
cancelled = true;
historyLoadedRef.current = false;
initialBufferRef.current = [];
lastHistorySequenceRef.current = 0;
historyLineCountsRef.current = new Map();
unlisten?.();
};
}, [appendVisibleRows, bufferPausedRows]);
@@ -149,8 +215,6 @@ export const MainLogsWindow = () => {
});
}, [levelFilter, logs, regex]);
const lastVisibleRow = filteredRows.length > 0 ? filteredRows[filteredRows.length - 1] : null;
useEffect(() => {
if (!autoScroll) return;
const viewport = viewportRef.current;
@@ -159,7 +223,7 @@ export const MainLogsWindow = () => {
requestAnimationFrame(() => {
viewport.scrollTop = viewport.scrollHeight;
});
}, [autoScroll, filteredRows.length, lastVisibleRow?.id]);
}, [autoScroll, filteredRows.length]);
const flushPausedRows = useCallback(() => {
const buffered = pausedBufferRef.current;
@@ -264,9 +328,9 @@ export const MainLogsWindow = () => {
<div className="main-log-empty">
{logs.length === 0 ? 'No application logs recorded yet.' : 'No log lines match the current filters.'}
</div>
) : filteredRows.map(row => (
) : filteredRows.map((row, index) => (
<div
key={row.id}
key={`${index}-${row.line}`}
className={`main-log-line level-${row.level.toLowerCase()}`}
>
{row.line}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -1,18 +1,11 @@
import { LiveLogo } from './LiveLogo';
interface Props {
accent: string;
peerCount: number;
}
export const Brand = ({ accent, peerCount }: Props) => (
export const Brand = ({ peerCount }: Props) => (
<div className="brand">
<LiveLogo className="brand-mark" accent={accent} size={28} />
{/* Two-tone wordmark per design/logo/INTEGRATION.md: "Soft" in --t-1,
"LAN" always in the accent. */}
<div className="brand-name">
Soft<span className="brand-name-lan">LAN</span>
</div>
<div className="brand-mark">S</div>
<div className="brand-name">SoftLAN</div>
{peerCount > 0 && (
<span
className="brand-peers"
@@ -1,280 +0,0 @@
// The LIVE animated pixel "S" for SoftLAN. At rest it is the baseline pixel S;
// on hover (and at random idle moments) it "comes alive": it slithers like the
// game Snake across the 5×5 grid and reforms, glitches sideways, or splits into
// an RGB datamosh. Exposes play(trick) via ref.
//
// Grid geometry matches the static marks in design/design_reference/logo/assets
// exactly (viewBox 0 0 100 100, cell 17, offset 7.5, gap 2.4, radius 3) so the
// swap between the static and animated S is pixel-identical.
import {
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
type ReactElement,
type Ref,
} from 'react';
// ── shared grid geometry ──
const LCELL = 17;
const LOFF = 7.5;
const LGAP = 2.4;
const LR = 3;
const LSIZE = LCELL - LGAP; // 14.6
// the "S" as an ORDERED walk (tail → head). Each step is grid-adjacent.
// head = (0,4) bottom-left, tail = (4,0) top-right.
const SORD: ReadonlyArray<readonly [number, number]> = [
[4, 0], [3, 0], [2, 0], [1, 0], [0, 0], // top bar (R→L)
[0, 1], // left drop
[0, 2], [1, 2], [2, 2], [3, 2], [4, 2], // middle bar (L→R)
[4, 3], // right drop
[4, 4], [3, 4], [2, 4], [1, 4], [0, 4], // bottom bar (R→L)
];
// boustrophedon sweep of the whole 5×5 board, (0,4) → (4,0) — the Snake "playfield".
const BOUS: ReadonlyArray<readonly [number, number]> = (() => {
const b: Array<[number, number]> = [];
for (let col = 0; col < 5; col++) {
const rows = col % 2 === 0 ? [4, 3, 2, 1, 0] : [0, 1, 2, 3, 4];
rows.forEach(r => b.push([col, r]));
}
return b; // 25 cells, b[0]=(0,4), b[24]=(4,0)
})();
// full head path P: lay the S, sweep the board, re-lay the S.
const PATH: ReadonlyArray<readonly [number, number]> = (() => {
const p: Array<[number, number]> = SORD.map(c => [c[0], c[1]]); // 0..16 (full S)
for (let i = 1; i < BOUS.length; i++) p.push([BOUS[i][0], BOUS[i][1]]); // 17..40
for (let i = 1; i < SORD.length; i++) p.push([SORD[i][0], SORD[i][1]]); // 41..56
return p; // length 57
})();
const I0 = 16;
const I1 = PATH.length - 1; // animate head index 16 → 56
// snake length schedule: full S → short snake → full S
function lenAt(i: number): number {
if (i <= 16) return 17;
if (i <= 26) return Math.round(17 + (7 - 17) * ((i - 16) / 10));
if (i <= 40) return 7;
if (i <= 56) return Math.round(7 + (17 - 7) * ((i - 40) / 16));
return 17;
}
// hex → rgb mixed toward white(t>0)/black(t<0)
function lshade(hex: string, t: number): string {
let h = (hex || '#000').replace('#', '');
if (h.length === 3) h = h.split('').map(x => x + x).join('');
let r = parseInt(h.slice(0, 2), 16);
let g = parseInt(h.slice(2, 4), 16);
let b = parseInt(h.slice(4, 6), 16);
const tgt = t >= 0 ? 255 : 0;
const a = Math.min(1, Math.abs(t));
r = Math.round(r + (tgt - r) * a);
g = Math.round(g + (tgt - g) * a);
b = Math.round(b + (tgt - b) * a);
return `rgb(${r},${g},${b})`;
}
function px(col: number, row: number): { x: number; y: number } {
return { x: LOFF + col * LCELL + LGAP / 2, y: LOFF + row * LCELL + LGAP / 2 };
}
interface CellProps {
col: number;
row: number;
fill: string;
opacity?: number;
dx?: number;
dy?: number;
w?: number;
h?: number;
rx?: number;
}
function Cell({ col, row, fill, opacity = 1, dx = 0, dy = 0, w = LSIZE, h = LSIZE, rx = LR }: CellProps) {
const { x, y } = px(col, row);
return <rect x={x + dx} y={y + dy} width={w} height={h} rx={rx} fill={fill} opacity={opacity} />;
}
// ── renderers ──
function renderStatic(accent: string): ReactElement[] {
return SORD.map(([c, r], i) => <Cell key={i} col={c} row={r} fill={accent} />);
}
function renderSnake(p: number, accent: string): ReactElement[] {
const i = Math.min(I1, Math.max(I0, I0 + Math.round(p * (I1 - I0))));
const L = lenAt(i);
const start = Math.max(0, i - L + 1);
const body = PATH.slice(start, i + 1);
const n = body.length;
// over the final stretch, ease the per-cell gradient/opacity/head-grow toward
// the flat, uniform static S so the hand-off to renderStatic is seamless.
const settle = Math.max(0, Math.min(1, (p - 0.82) / 0.18));
return body.map((cell, j) => {
const t = n > 1 ? j / (n - 1) : 1; // 0 tail → 1 head
const fill = lshade(accent, (t - 0.45) * 0.5 * (1 - settle)); // head brighter, tail darker
const opacity = (0.42 + 0.58 * t) + (1 - (0.42 + 0.58 * t)) * settle;
const isHead = j === n - 1;
const grow = (isHead ? 1.06 : 1) * (1 - settle) + 1 * settle;
const s = LSIZE * grow;
const d = (LSIZE - s) / 2;
return <Cell key={j} col={cell[0]} row={cell[1]} fill={fill} opacity={opacity} dx={d} dy={d} w={s} h={s} />;
});
}
const GLX = [11, -7, 13, -5, 9]; // per-row horizontal kick
function renderGlitch(p: number, accent: string): ReactElement[] {
const burst = Math.max(0, 1 - p) * (0.45 + 0.55 * Math.abs(Math.sin(p * 26)));
return SORD.map(([c, r], i) => {
const dx = GLX[r] * burst;
const drop = burst > 0.55 && Math.sin(i * 12.9 + p * 50) > 0.82; // occasional dropout
const flick = 0.7 + 0.3 * Math.abs(Math.sin(p * 70 + i));
return <Cell key={i} col={c} row={r} fill={accent} opacity={drop ? 0.12 : flick} dx={dx} />;
});
}
function renderRGB(p: number, accent: string): ReactElement[] {
const burst = Math.pow(Math.max(0, 1 - p), 1.25);
const jit = Math.sin(p * 52) * 0.5 + Math.sin(p * 31) * 0.5;
const off = (4.5 + 2.5 * jit) * burst;
const voff = 1.6 * jit * burst;
const layers = [
{ fill: '#ff2d55', dx: -off, dy: -voff, op: 0.8 },
{ fill: '#21e3a8', dx: off, dy: voff, op: 0.8 },
{ fill: accent, dx: 0, dy: 0, op: 1 },
];
return layers.flatMap((ly, li) =>
SORD.map(([c, r], i) => (
<g key={`${li}-${i}`} style={{ mixBlendMode: li < 2 ? 'screen' : 'normal' }}>
<Cell col={c} row={r} fill={ly.fill} opacity={ly.op} dx={ly.dx} dy={ly.dy} />
</g>
))
);
}
export type Trick = 'snake' | 'rgb' | 'glitch';
const RENDER: Record<Trick, (p: number, accent: string) => ReactElement[]> = {
snake: renderSnake,
glitch: renderGlitch,
rgb: renderRGB,
};
const TRICK_DUR: Record<Trick, number> = { snake: 2600, glitch: 1100, rgb: 1350 };
// snake is the long (~2.6s) headline trick, so it's deliberately rare —
// half as likely as either of the short glitch/rgb flickers.
const POOL: ReadonlyArray<Trick> = ['snake', 'rgb', 'rgb', 'glitch', 'glitch'];
function randomTrick(): Trick {
return POOL[Math.floor(Math.random() * POOL.length)];
}
const prefersReducedMotion = (): boolean =>
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
export interface LiveLogoHandle {
/** Trigger a specific trick (ignored if one is already playing). */
play: (trick: Trick) => void;
isPlaying: () => boolean;
}
interface Props {
/** The S color (any CSS color). Wire this to the theme accent. */
accent?: string;
/** Rendered width/height in px (square SVG). */
size?: number;
/** When true, plays a random trick by itself every idleMinidleMax ms. */
idleAuto?: boolean;
idleMin?: number;
idleMax?: number;
className?: string;
/** Accessible label for the brand mark. */
label?: string;
ref?: Ref<LiveLogoHandle>;
}
export function LiveLogo({
accent = '#3b82f6',
size = 140,
idleAuto = true,
idleMin = 5000,
idleMax = 11000,
className,
label = 'SoftLAN',
ref,
}: Props) {
const [anim, setAnim] = useState<{ trick: Trick; p: number } | null>(null);
const [reduce, setReduce] = useState(prefersReducedMotion);
const rafRef = useRef(0);
const playingRef = useRef(false);
const play = useCallback((trick: Trick) => {
if (playingRef.current) return;
playingRef.current = true;
const dur = TRICK_DUR[trick];
const t0 = performance.now();
const step = (now: number) => {
const p = (now - t0) / dur;
if (p >= 1) {
setAnim(null);
playingRef.current = false;
return;
}
setAnim({ trick, p });
rafRef.current = requestAnimationFrame(step);
};
setAnim({ trick, p: 0 });
rafRef.current = requestAnimationFrame(step);
}, []);
useImperativeHandle(ref, () => ({ play, isPlaying: () => playingRef.current }), [play]);
// Track prefers-reduced-motion live so toggling the OS setting takes effect.
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
const onChange = () => setReduce(mq.matches);
onChange();
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
// Idle auto-play on a jittered timer — paused in hidden tabs and when the
// user prefers reduced motion (at rest it stays a clean static S).
useEffect(() => {
if (!idleAuto || reduce) return;
let timer: ReturnType<typeof setTimeout>;
const schedule = () => {
timer = setTimeout(() => {
if (!document.hidden && !playingRef.current) play(randomTrick());
schedule();
}, idleMin + Math.random() * (idleMax - idleMin));
};
schedule();
return () => clearTimeout(timer);
}, [idleAuto, idleMin, idleMax, reduce, play]);
useEffect(() => () => cancelAnimationFrame(rafRef.current), []);
const content = anim ? RENDER[anim.trick](anim.p, accent) : renderStatic(accent);
return (
<svg
viewBox="0 0 100 100"
width={size}
height={size}
fill="none"
role="img"
aria-label={label}
className={className}
style={{ cursor: 'pointer', overflow: 'visible' }}
onMouseEnter={() => { if (!reduce) play(randomTrick()); }}
onClick={() => { if (!reduce) play('snake'); }}
>
{content}
</svg>
);
}
@@ -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}>
@@ -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, hasNewerLocalVersion, 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,
@@ -47,7 +55,6 @@ export const GameDetailModal = ({
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
@@ -105,7 +112,7 @@ export const GameDetailModal = ({
</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>
@@ -126,17 +133,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"
@@ -8,7 +8,6 @@ import { FilterCounts } from '../../lib/gameState';
import { GameFilter, GameSort } from '../../lib/types';
interface Props {
accent: string;
peerCount: number;
filter: GameFilter;
setFilter: (value: GameFilter) => void;
@@ -21,7 +20,6 @@ interface Props {
}
export const TopBar = ({
accent,
peerCount,
filter,
setFilter,
@@ -34,7 +32,7 @@ export const TopBar = ({
}: Props) => (
<header className="topbar">
<div className="topbar-left">
<Brand accent={accent} peerCount={peerCount} />
<Brand peerCount={peerCount} />
<div className="topbar-left-trail">
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} />
</div>
@@ -9,7 +9,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>;
@@ -69,15 +68,6 @@ export const useGameActions = (
}
}, [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);
@@ -139,15 +129,5 @@ export const useGameActions = (
}
}, []);
return {
play,
startServer,
install,
streamInstall,
update,
uninstall,
removeDownload,
cancelDownload,
viewFiles,
};
return { play, startServer, install, update, uninstall, removeDownload, cancelDownload, viewFiles };
};
@@ -82,35 +82,6 @@ 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
@@ -143,12 +114,6 @@ export const needsUpdate = (game: Game): boolean => {
return (compareVersionStamps(game.eti_game_version, game.local_version) ?? 0) > 0;
};
export const canStreamInstall = (game: Game): boolean =>
!game.downloaded
&& !game.installed
&& game.peer_count > 0
&& !isInProgress(game.install_status);
/** What pressing the card's main action button should do, given the state. */
export type PrimaryAction = 'play' | 'install' | 'update' | 'download' | 'busy' | 'disabled';
@@ -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'}`;
@@ -144,25 +144,26 @@
gap: 10px;
flex-shrink: 0;
}
/* The live pixel-S logo renders its own self-contained SVG; this just reserves
its 28px slot and lets the animation's glitch/datamosh spill past the edges. */
.brand-mark {
display: block;
flex-shrink: 0;
width: 28px;
height: 28px;
overflow: visible;
border-radius: 7px;
display: grid;
place-items: center;
background: var(--accent);
font-family: var(--font-display);
font-size: 20px;
letter-spacing: 0.02em;
color: white;
box-shadow:
0 6px 20px -6px color-mix(in srgb, var(--accent) 60%, black),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
}
.brand-name {
font-family: var(--font-ui);
font-weight: 700;
font-size: 15px;
letter-spacing: -0.01em;
color: var(--t-1); /* "Soft" — #e6edf3 on dark UI */
}
/* "LAN" is always the accent (per the logo wordmark spec). */
.brand-name-lan {
color: var(--accent);
letter-spacing: -0.005em;
color: var(--t-1);
}
/* Peer count chip in brand area */
@@ -144,7 +144,6 @@ export const MainWindow = () => {
return (
<div className={className} style={rootStyle}>
<TopBar
accent={settings.accent}
peerCount={games.totalPeerCount}
filter={settings.filter}
setFilter={(v) => setSetting('filter', v)}
@@ -193,7 +192,6 @@ export const MainWindow = () => {
thumbnailUrl={thumbnails.get(openGame.id)}
onClose={() => setOpenGameId(null)}
onPrimary={handlePrimary}
onStreamInstall={(g) => actions.streamInstall(g.id)}
onUninstall={handleUninstall}
onRemoveDownload={handleRemoveDownload}
onCancelDownload={(g) => actions.cancelDownload(g.id)}
@@ -2,7 +2,6 @@ import {
actionLabel,
activeStatusById,
applyFilterAndSort,
canStreamInstall,
countByFilter,
deriveState,
downloadProgressPercent,
@@ -11,9 +10,7 @@ import {
formatDownloadEta,
formatDownloadSpeed,
formatDownloadSpeedShort,
gameStatusLabel,
mergeGameUpdate,
stateChipLabel,
} from '../src/lib/gameState.ts';
import {
ActiveOperationKind,
@@ -212,75 +209,3 @@ Deno.test('download progress formatting matches the progress-bar layouts', () =>
);
assertEquals(formatDownloadEta(485), '8 min', 'eta format should stay compact');
});
Deno.test('stream install is available only for idle remote games', () => {
assertEquals(
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 1 })),
true,
'remote-only idle games should allow streamed install',
);
assertEquals(
canStreamInstall(game({ downloaded: true, installed: false, peer_count: 1 })),
false,
'downloaded games should install from local archives',
);
assertEquals(
canStreamInstall(game({ downloaded: false, installed: true, peer_count: 1 })),
false,
'installed games should not expose streamed install',
);
assertEquals(
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 0 })),
false,
'games without peers should not expose streamed install',
);
assertEquals(
canStreamInstall(game({
downloaded: false,
installed: false,
peer_count: 1,
install_status: InstallStatus.CheckingPeers,
})),
false,
'busy games should not expose streamed install',
);
});
Deno.test('streamed local installs are labeled installed but not shareable', () => {
const streamed = game({
downloaded: false,
installed: true,
install_status: InstallStatus.Installed,
});
const downloadedInstall = game({
downloaded: true,
installed: true,
install_status: InstallStatus.Installed,
});
assertEquals(
deriveState(streamed),
'installed',
'streamed local installs should keep installed visual state',
);
assertEquals(
stateChipLabel(streamed),
'Not shareable',
'card chip should make the non-shareable state visible',
);
assertEquals(
gameStatusLabel(streamed),
'Installed, not shareable',
'detail status should spell out installed plus non-shareable',
);
assertEquals(
stateChipLabel(downloadedInstall),
'Installed',
'normal downloaded installs should keep the installed chip label',
);
assertEquals(
gameStatusLabel(downloadedInstall),
'Installed',
'normal downloaded installs should keep the installed detail label',
);
});
@@ -1,55 +0,0 @@
import {
dedupeBufferedRows,
lineCountsFromRows,
rowFromPayload,
rowsFromHistory,
rowWasLoadedInHistory,
} from '../src/lib/mainLogs.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
Deno.test('history rows parse levels and stable ids', () => {
const rows = rowsFromHistory('[2026-06-07][12:00:00][app][WARN] careful\nplain line\n');
assertEquals(rows.length, 2, 'history should skip trailing empty line');
assertEquals(rows[0].id, 'history-0', 'history id should include row position');
assertEquals(rows[0].level, 'WARN', 'explicit level should be parsed');
assertEquals(rows[1].level, 'INFO', 'unknown level should default to info');
});
Deno.test('buffered main log rows covered by history sequence are removed', () => {
const historyRows = rowsFromHistory('[2026-06-07][12:00:01][app][INFO] included\n');
const included = rowFromPayload({
line: '[2026-06-07][12:00:01][app][INFO] included',
level: 'INFO',
sequence: 4,
});
const fresh = rowFromPayload({
line: '[2026-06-07][12:00:02][app][INFO] fresh',
level: 'INFO',
sequence: 5,
});
const deduped = dedupeBufferedRows(lineCountsFromRows(historyRows), [included, fresh], 4);
assertEquals(rowWasLoadedInHistory(included, 4), true, 'included row should match history');
assertEquals(deduped.length, 1, 'only fresh row should remain');
assertEquals(deduped[0].line, fresh.line, 'fresh row should not be dropped');
});
Deno.test('buffered rows missing from trimmed history are retained', () => {
const retained = rowFromPayload({
line: '[2026-06-07][12:00:00][app][INFO] trimmed out',
level: 'INFO',
sequence: 2,
});
const deduped = dedupeBufferedRows(new Map(), [retained], 4);
assertEquals(deduped.length, 1, 'trimmed row should remain visible');
assertEquals(deduped[0].line, retained.line, 'trimmed row should be preserved');
});
+598 -70
View File
@@ -1,90 +1,618 @@
# SoftLAN Launcher — Design Handoff
# Handoff: SoftLAN Launcher redesign
**This folder is the complete, current state of design for the SoftLAN Launcher.**
Everything an implementor needs to build the product is in here — and nothing
that isn't. (The exploration mockups, logo concept boards, and variant studies
live back in the project workspace; they're history, not handoff.)
Target codebase: **Tauri + React** desktop app. The references here are
HTML/React prototypes that communicate the intended look, layout, and behavior —
recreate them inside the codebase using its own patterns. They are not meant to
be shipped as-is.
A modern, gamer-friendly redesign of the SoftLAN local-network game launcher, replacing the current basic UI with a Steam-inspired dark layout that keeps high usability while adding cover art, state-coded actions, a game-detail overlay, and an in-app Settings dialog.
---
## What's inside
## About the design files
```
design_handoff_softlan_launcher/
├── README.md ← you are here — start here
├── launcher/ ← THE APP
│ ├── SPEC.md full spec: every screen, component, token, interaction
│ └── design_reference/ the working prototype the spec describes
│ ├── SoftLAN Launcher.html open this to see all screens/variants
│ ├── styles.css
│ ├── data.jsx mock catalog + LAN peer roster + format helpers
│ ├── components.jsx Icon set, cards, modals, Settings dialog
│ ├── ctp-chat.jsx Call-to-Play chat panel + shared helpers
│ ├── calltoplay.jsx Call-to-Play: quick bars, overlay, create form
│ └── launcher.jsx composes chrome + grid + modals + Call to Play
└── logo/ ← THE BRAND MARK
├── INTEGRATION.md how to ship the logo (component + assets + lockup)
├── pixel-live.jsx the live animated "S" + Wordmark + Lockup components
├── demo.html open to see the mark animate + the lockup
└── assets/
├── softlan-mark.svg static S (currentColor)
├── softlan-mark-blue.svg static S (fixed blue)
├── softlan-tile.svg app-icon tile (gradient + white S)
├── softlan-lockup.svg full lockup (icon + wordmark), on dark
├── softlan-lockup-ink.svg full lockup, on light
└── favicon.svg = tile, drop-in favicon
```
The files in `design_reference/` are **design references created in HTML/React via Babel-in-the-browser** — prototypes built to communicate the intended look, layout, and behavior. They are **not production code to copy directly**.
The target codebase is a **Tauri + React** desktop app. The task is to **recreate these designs inside that codebase**, using its existing patterns (component conventions, state management, routing, IPC to Rust for filesystem / process work). Use the design files for:
- Exact pixel/spacing/color/typography values
- Component composition and interactions
- Copy and microcopy
- Animation easings/durations
But:
- Don't ship the Babel-in-browser setup or import the .jsx files as-is
- Don't keep the `<deck>` / design-canvas wrapping — that's only for presenting variants
- Don't ship the Tweaks panel — it's superseded by the in-app **Settings dialog** (see "Screens" below)
- Re-implement using whatever the codebase uses (Vite + plain JSX, CSS modules / styled-components / tailwind, etc.)
## Fidelity
**High-fidelity.** Final colors, typography, spacing, and interactions are decided. Pixel-fidelity to the mock is the goal — recreate exactly, using the codebase's libraries/patterns. Only deviate where the codebase has its own dictate (e.g. an existing button primitive that's near-identical).
## Layout variants
The HTML mock includes two chrome variants — **A (single-row)** and **B (two-row)** — to choose from. **The user selected A as the primary direction.** Implement A. Variant B is left in the reference for context only.
---
## Two pieces, one product
## Changes since v3
| | **launcher/** | **logo/** |
- **Game-folder button removed from the top bar.** Setting the games directory is a one-time action — it doesn't deserve permanent real estate in the chrome. The button is gone from both top-bar variants, freeing the right zone for the kebab menu alone (variant A) / the storage meter + kebab pair (variant B).
- **Game folder moved into Settings → Library.** Now a row inside the Settings dialog, styled like the other Library rows. Two visual states (set / not-set) carry over from the old button — see "Settings dialog → Library → Game folder" below.
- **Persisted setting renamed.** `gameFolderSet: boolean``gameFolder: string | null`. The actual path is now persisted, not just a "is it configured?" flag. Default is `null` (unset on first run; user must pick a folder before the library scans).
## Changes since v2
- **Top bar layout reorganized.** The single-row top bar is now structured as three visual zones (still one row on wide windows):
- **Left:** brand mark + wordmark.
- **Center (semantically the "search cluster"):** segmented filter pills · search field · sort menu. The **search field is positioned at the geometric center of the window** — filter pills sit immediately to its left, sort menu immediately to its right.
- **Right:** kebab menu (game-folder configuration has moved into Settings — see v3 changes).
- Below ~1100 px of launcher width (container query), the three zones collapse into a single left-to-right flowing row (no wrap, no centering). Implement via container query on the launcher root; viewport media query is acceptable if your codebase doesn't use container queries yet.
- See "Top bar (variant A)" below for the full spec and rationale.
## Changes since v1
- **Settings → Profile section** added at the top of the dialog with two new persisted preferences: **Username** (text input) and **Language** (segmented `English` / `Deutsch`). See "Settings dialog" below for shape + persistence keys.
- **Start Server** action added to the **game detail overlay**, next to **Play**, for installed games that support a dedicated server. Driven by a new `canHostServer: true` flag on the game record. See "Detail overlay → Actions row" and "Game data shape" for the full spec.
- Grid cards are **unchanged** — Start Server only ever appears in the detail overlay.
---
## Screens / views
### 1. Main library (variant A — primary)
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.
- **Left zone (col 1, flex space-between):**
- **Brand** (pinned far-left) — 28×28 px rounded square in `--accent` (default `#3b82f6`) with the letter "S" in Bebas Neue 20 px white. Next to it, the wordmark "SoftLAN" in 15 px / 700 weight `--t-1` `#e6edf3`.
- **Segmented filter pills** (pinned right, hugging the search field) — pill-shaped container (`background var(--bg-2) #131b25`, `1px solid rgba(255,255,255,0.06)`, `border-radius: 999px`, `padding: 4px`). Three buttons:
- `All Games` · count chip
- `Local` · count chip
- `Installed` · count chip
Active button has an animated pill thumb (background `var(--accent)`, transitions `left` and `width` with `cubic-bezier(.4,1.2,.5,1)` over 220 ms), text becomes white, count-chip background goes `rgba(0,0,0,0.25)`. Inactive: text `var(--t-2) #9aa6b4`, count-chip background `rgba(255,255,255,0.08)`.
`Local` = installed *or* downloaded-but-not-yet-installed. `Installed` = installed only. `All Games` = everything available on the network.
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.
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)`).
- Right: compact **storage meter** — 200px min-width, 4px-tall horizontal bar with two stacked segments (`installed` and `local`), plus a 11px text row underneath: `<sq> 78 GB installed <sq> 41 GB local 384 GB free`. Squares are 8×8px rounded 2px, colored `var(--accent)` and `color-mix(var(--accent), 55%)`.
3. **Grid** — CSS grid with `repeat(auto-fill, minmax(188px, 1fr))` at default density, 16px gap, 24px horizontal padding, 32px bottom padding. Scrolls vertically.
- Density: `compact` → min 148, gap 12. `normal` → min 188, gap 16. `large` → min 244, gap 20.
**Game card** (see "Game card" below for full anatomy).
---
### 2. Game detail overlay
Opens when the user **clicks anywhere on a game card except the action button**. Modal over a scrim. Closes on scrim click, Esc key, or the close button. Should also work via keyboard nav (Enter on focused card).
**Scrim:** absolutely positioned over the launcher, `inset: 0`, `z-index: 100`, `background: rgba(4,7,11,0.7)`, `backdrop-filter: blur(8px)`, fade-in 180ms. Padding 32px, content centered.
**Modal panel:** `min(880px, 100%)` wide, `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-2)`, `border-radius: 14px`, drop shadow `0 30px 80px -10px rgba(0,0,0,0.7)`. Scales in from 0.96 with 250ms `cubic-bezier(.3,1.3,.4,1)`.
**Modal structure (top-to-bottom):**
1. **Hero banner**`aspect-ratio: 16/7`. Full-bleed cover art rendered as a banner (same gradient + accent treatment as the small cards, scaled up). Bottom-fade gradient `linear-gradient(180deg, transparent 40%, var(--bg-2) 100%)` so text reads.
- **State chip** in the top-left of the hero (same chip style as on cards — see Game Card).
- **Close button** top-right: 32×32 square, `background rgba(8,12,16,0.7)`, `1px solid var(--bd-2)`, `border-radius: 8px`, `backdrop-filter: blur(8px)`, X icon.
- **Title overlay** in bottom-left at `left: 28px, right: 28px, bottom: 22px`:
- Tags row — small uppercase pills (`background rgba(8,12,16,0.6)`, `1px solid var(--bd-2)`, `border-radius: 4px`, `padding: 3px 8px`, `font 11px / 600 / 0.04em letter-spacing`)
- **Title** as `<h2>` — system sans 32px / 700 / -0.015em, white, text-shadow `0 4px 24px rgba(0,0,0,0.6)`. **Not Bebas Neue** here — this is normal UI typography, not stylized cover art.
2. **Body** — 22px top, 26px bottom, 28px horizontal:
- **Meta grid** — 4-column CSS grid, 12px gap. Each cell: `padding 10px 12px`, `background rgba(255,255,255,0.025)`, `1px solid var(--bd-1)`, `border-radius: 8px`. Cells (in order): `Size` (e.g. 8.2 GB), `Players` (icon + range), `Version` (mono, e.g. 2018.04.12), `Status` (Installed / Local / Not downloaded).
- **Description** — 14px / 1.55 line-height, `var(--t-2)`, `text-wrap: pretty`, `max-width: 64ch`.
- **Actions row** — flex row, 10px gap, 4px top padding. Order, left → right:
1. **Primary action button** (44px tall, see "Action button" below — Play / Install / Download depending on state).
2. **Start Server***only* when `game.canHostServer === true` **and** `state === 'installed'`. Same 44px height as Play, but visually a peer secondary action (see "Start Server button" below). Triggers a Tauri command that spawns the game's dedicated-server executable in headless mode against the local LAN (port + server config out of scope here — leave a `startServer(gameId)` IPC stub).
3. If `state === 'installed'`: ghost-button **Uninstall** — 44px, `background rgba(255,255,255,0.04)`, `1px solid var(--bd-2)`, `border-radius: 8px`, text `#f87171`, trash icon. On hover: bg `rgba(239,68,68,0.10)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`.
4. If `state === 'local'`: ghost-button **Delete from disk** (same danger styling).
5. If `state === 'downloading'`: ghost-button **Cancel** (same danger styling).
6. Spacer (`flex: 1`).
7. Ghost-button **View files** (neutral) — opens system file manager at the game folder.
#### Start Server button
A secondary-but-equal action that sits next to **Play**. The intent is to read as a host-action ("I want to put this game on the LAN") without competing with the green Play button for the player's primary attention.
- Same shape and height as Play: 44px tall, `border-radius: 8px`, `font 14px / 600`, 8px gap between icon and label, padding `0 22px`.
- Surface: `background: color-mix(in srgb, var(--accent) 14%, rgba(255,255,255,0.04))`, `border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent)`, `box-shadow: inset 0 1px 0 rgba(255,255,255,0.06)`. Text in `--t-1`.
- **Icon** in `--accent`: a small server-rack glyph (two stacked rounded rectangles each with an LED dot and a hint of wiring). 13×13. SVG in `components.jsx → Icon.server`.
- Hover: `background: color-mix(in srgb, var(--accent) 22%, ...)`, border darkens to `color-mix(... 75%, transparent)`. Active: `transform: scale(0.98)` (shared with `.act-btn`).
- A future *running* state (live indicator dot + "Server running" label + click-to-stop) is **not** in this round — flag as a follow-up when wiring the real spawn.
The button is purposefully **not** present on game cards in the grid — hosting a server is intentional and benefits from the context of the detail overlay (player count, version, etc.). Don't add it to cards.
---
### 3. Settings dialog
Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim treatment as the game-detail modal, but the panel is narrower (`min(640px, 100%)`) and styled as a list of preferences.
**Structure:**
```
┌─────────────────────────────────────────┐
│ Settings [×] │ ← head: 22 28 18, 1px bottom border
├─────────────────────────────────────────┤
│ │
│ PROFILE │ ← section title (new): 10.5px / 700 / 0.12em / uppercase / --t-3
│ │
│ Username │ ← row label: 14px / 600 / --t-1
│ Shown to other players on the LAN │ ← row hint: 12px / --t-3
│ [ Enter a username ] │ ← text input (220×36)
│ │
│ Language │
│ Interface language │
│ [English│Deutsch] │ ← segmented radio (new)
│ │
│ APPEARANCE │
│ │
│ Accent color │
│ Used for primary actions and highlights │
│ ⬤⬤⬤⬤⬤⬤ │ ← 6 swatches, right-aligned
│ │
│ Background │
│ Backdrop behind the library │
│ [Flat │Gradient│Animat]│ ← segmented radio
│ │
│ 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]│
│ │
│ Cover aspect │
│ Shape of the cover art on each card │
│ [Box-art│Square│Banner│
│ │
├─────────────────────────────────────────┤
│ [Done] │ ← foot: 14 22 18, 1px top border
└─────────────────────────────────────────┘
```
**Sections** are separated by 26px gap (column flex). Rows within a section: 14px gap. Each **row** is flex row with space-between (24px gap):
- Left (`settings-row-info`): label (14px / 600 / `--t-1`) + hint (3px-top, 12px / `--t-3`)
- Right (`settings-row-control`): the control
**Profile section** (new in this round). Two rows, rendered **above** Appearance — it's the most personal/identity-shaped setting so it's the first thing the user sees in Settings.
- **Username** — `<input type="text">` wrapped in a styled container: 220px wide, 36px tall, `background var(--bg-3)`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 0 12px`. Input itself is transparent/borderless, `font 13.5px / 600`, color `--t-1`, placeholder `"Enter a username"` in `--t-3` / 500. `maxLength={24}`, `spellCheck={false}`. On focus the container gets `background var(--bg-2)`, border `var(--accent)`, and an accent focus ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent)`.
- **Language** — same segmented-radio control as Background / Density / Cover aspect, with two options: `English` (value `'en'`) and `Deutsch` (value `'de'`). Active option gets the accent fill, same as the other segmented radios.
**Library section.** Three rows: **Game folder** (new in v3 — moved out of the top bar), **Grid density**, **Cover aspect**.
- **Game folder** — see "Game-folder field" below. The first row in the section because it's the only setting users *must* configure for the launcher to work; density and aspect are pure preference.
**Color swatch picker:** flex row of 8px-gapped buttons. Each swatch is 32×32, `border-radius: 9px`, no border. Inside, a 100% × 100% rounded-8 colored dot with inset shadow `0 0 0 1px rgba(255,255,255,0.08)`. Hover: dot scales 1.06. **Active**: dot has ring `box-shadow: 0 0 0 2px var(--bg-2), 0 0 0 4px <swatch-color>` and shows a centered white check icon with drop-shadow `0 1px 2px rgba(0,0,0,0.5)`.
Six accent options: Blue `#3b82f6`, Cyan `#22d3ee`, Violet `#a855f7`, Green `#22c55e`, Amber `#f59e0b`, Red `#ef4444`.
**Segmented radio:** inline-flex with `background var(--bg-3) #1a2330`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 3px`. Each button: 30px tall, `padding: 0 14px`, `border-radius: 6px`, `font 12.5px / 600`. Inactive: `color var(--t-2)`. Active: `background var(--accent)`, `color white`, inset top shadow `0 1px 0 rgba(255,255,255,0.18)`.
**Done button:** filled button in `--accent`, 36px tall, 13.5px / 600. Closes the dialog.
Persisted settings (write through to local storage / Tauri config):
- `username`: string, max 24 chars. Default `"Commander"` (placeholder — feel free to default to the OS username on first run). Used as the network identity for LAN sessions; the hint copy *"Shown to other players on the LAN"* tells the user what it does.
- `language`: `'en'` | `'de'`. Default `'en'`. Drives an i18n layer (introduce one if it doesn't exist yet — `react-i18next` or similar). Initial copy is English-only in the mock; German translations need to be added as part of implementation. Recommend detecting the OS locale on first run and defaulting to `'de'` if the system language starts with `de`.
- `accent`: one of the six hex values above. Default `#3b82f6`.
- `bg`: `flat` | `gradient` | `animated`. Default `gradient`.
- `density`: `compact` | `normal` | `large`. Default `normal`.
- `aspect`: `box` | `square` | `banner`. Default `box`.
- `gameFolder`: `string | null`. Absolute path to the parent directory where games are downloaded and installed. Default `null` (unset on first run). See "Game-folder field" below.
---
## Game-folder field
A settings row inside the **Library** section of the Settings dialog. Exposes the user's currently-configured game folder (the parent directory under which all per-game subfolders live).
**Why it lives in Settings now:** users set this once at install time and basically never touch it again. A permanent top-bar button burned high-attention chrome on a control nobody used after day one. Settings is where one-time configuration belongs.
Two visual states, driven by whether `settings.gameFolder` resolves to an accessible directory:
| State | Trigger | Path display | Border | Button label |
|---|---|---|---|---|
| **Set & valid** | path is configured and exists on disk | full path in mono, truncated head-first | default `--bd-1` | `Change…` (neutral pill) |
| **Not set / invalid** | path is `null`/empty, or path is set but the directory no longer exists | `Not set` in red | tinted red (`color-mix(in srgb, var(--danger) 35%, var(--bd-1))`) + faint red bg tint | `Choose…` (accent-filled pill) |
"Invalid" is intentionally collapsed into the same visual state as "not set" — the user's job is identical (open the picker and pick a folder), so we don't differentiate. If we later need a distinct "missing" state (e.g. to show the *last known* path so the user can re-attach an external drive), introduce a third state then; for now, keep it simple.
**Anatomy:** `inline-flex`, `width: 340px`, `height: 36px`, `padding: 0 4px 0 12px`, `gap: 8px`. `background: var(--bg-3)`, `border-radius: 8px`. Children, left to right:
1. **Folder icon**`Icon.folder` from `components.jsx`, 14×14, `var(--t-3)` (set state) or `#f87171` (unset state).
2. **Path display**`flex: 1`, mono `12px / ui-monospace`, `--t-1`, single line, `overflow: hidden; text-overflow: ellipsis`. **`direction: rtl` + `unicode-bidi: plaintext`** so truncation happens from the head and the leaf folder (the part the user actually cares about) stays visible. When unset: shows the word `Not set` in 12.5 px / 600 / `#f87171` instead.
3. **Action button** — 28 px tall pill, `border-radius: 6px`, `padding: 0 12px`, `font 12.5px / 600`. Set state: neutral `rgba(255,255,255,0.06)` bg, label `Change…`. Unset state: `var(--accent)` fill at 85% alpha, white text, label `Choose…` (so the call-to-action reads stronger when the path needs picking). Click → native folder picker via Tauri; on selection, write through to `settings.gameFolder` and rescan library.
**Hover:** border darkens to `--bd-2` (set state) or to `color-mix(in srgb, var(--danger) 55%, var(--bd-2))` (unset state). The inner button has its own hover (background opacity bumps).
**Accessibility:** the path itself is selectable text inside the field; the action button carries `aria-label="Change game folder"` / `"Choose game folder"`. The full path is also exposed via `title` on the path-display element so it's reachable on hover when truncated.
**Why no inline path on the previous top-bar button anymore?** Original design squeezed the full path into a top-bar button as truncated mono. It rarely showed the meaningful part of the path on real-world configurations, ate horizontal space, and competed with the actual primary controls (filter / search / sort) for the top bar's attention budget. In the new home (Settings), the field has all the width it needs to show a useful prefix of the path while still keeping the leaf visible — and it's only on screen when the user is actively reconfiguring.
**Data:** the component takes `value: string | null` and an `onChange(next: string)` callback. `null` (or empty/whitespace string) renders the unset state; any non-empty string renders the set state. The `onChange` callback should fire only on successful picker confirmation (not on cancel). In production, derive `value` from your settings store; if you want to additionally validate existence, do the `fs.metadata` check in the store / a hook and pass `null` when the directory is missing.
**Dev preview:** the prototype's Tweaks panel exposes a `Game folder` **text field** (under the *Library* section) that writes directly to `t.gameFolder`. Type any string to simulate the set state; clear it to simulate the unset state. This is dev-only — in the real app the value comes from the settings store via the picker, **not** from a free-form text input. Don't ship the Tweaks panel.
---
## Game card
The unit element of the library grid.
**Container:** flex column. `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-1)`, `border-radius: 10px`, `overflow: hidden`. Cursor pointer.
**Hover/focus state:**
- `transform: translateY(-2px)` (180ms `cubic-bezier(.4,1.2,.5,1)`)
- `border-color: color-mix(in srgb, var(--accent) 45%, var(--bd-2))`
- Box-shadow `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)`
- Cover inner image scales to 1.03 (350ms cubic-bezier)
- Focus-visible: same lift + 2px solid accent outline
### Anatomy (top to bottom)
1. **Cover wrap**`width: 100%`, `aspect-ratio: 2/3` (box) / `1/1` (square) / `16/9` (banner). `position: relative`, `overflow: hidden`, fallback bg `var(--bg-3)`.
2. **Cover** (inside cover-wrap, `position: absolute; inset: 0`):
- **Base gradient** — diagonal (`linear-gradient(<110-170deg>, c1, c2)` — angle hashed from game id for variety). Per-game color pair from the game's `cover` metadata.
- **Radial accent blob** — `radial-gradient(ellipse at <x>% <y>%, <accent>38, transparent 55%)`. x/y also hashed from id.
- **Grain / scanline** — two `repeating-linear-gradient` overlays at 1px intervals, `mix-blend-mode: overlay`, opacity 0.7.
- **Decorative SVG mark** — preserveAspectRatio bottom-right, draws a triangle and dot in the accent color at 12% opacity. Variation via id hash.
- **Title** absolutely positioned at bottom-left, padding `14px`. Font `Bebas Neue` (free Google Font, fallback `Oswald, Impact, "Arial Narrow Bold", sans-serif`), 400 weight, uppercase, `letter-spacing: 0.018em`, `line-height: 1.02`, white, text-shadow `0 4px 16px <c2 + alpha>, 0 1px 0 rgba(0,0,0,0.3)`. Size scales by title length: 26px for ≤14 chars, 21px for ≤20, 17px for ≤26, 15px for longer (box aspect; see `components.jsx → GameCover` for square/banner variants).
- **Vignette** — `linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.62) 100%)` over the whole cover, painted *after* the title (so the dark gradient is behind the title visually — title is z-index 2).
- **State chip** in top-right: pill with backdrop-blur, `background rgba(8,12,16,0.78)`, `1px solid rgba(255,255,255,0.08)`, `border-radius: 999px`, `padding: 4px 9px`, font `10.5px / 600`. A 6×6 colored dot (green `#22c55e` for installed, amber `#f59e0b` for local; hidden for "not downloaded") + label. Dot has glow `box-shadow: 0 0 8px <color>`.
- **Multiplayer badge** in top-left: same pill style but slightly lighter background (`rgba(8,12,16,0.65)`). Tiny "users" icon + player range (e.g. `232`). Always visible — every LAN game is multiplayer.
3. **Card body**`padding: 11px 12px 12px`, flex column, 8px gap:
- **Title** — game's full (mixed-case) title in 13.5px / 600 / `--t-1`, single line, ellipsis on overflow.
- **Meta line** — 11.5px tabular-nums, `--t-3`: size · genre. Dot separator at 50% opacity.
- **Action button** (full width) — primary action depending on state, see below.
### Action button
A single button per card with the *primary action for the current state*. Color-coded as the main affordance for state at a glance.
```
state label button style
───────────── ────────── ────────────────────────────────────────────
not downloaded Download neutral: bg rgba(255,255,255,0.08), 1px var(--bd-2), text var(--t-1)
local Install bg var(--accent), text white, inset top hl
installed Play bg linear-gradient(180deg, #2bd07f 0%, #1aa460 100%), text white, inset top hl
downloading — progress see "Download progress" below — the button slot is replaced with a live progress component
```
Common sizing: 32px tall (card) or 44px tall (modal). `border-radius: 7px` (card) / 8px (modal). `font 12.5px / 600` (card) / `14px / 600` (modal). 6px gap between icon and label. Icons: filled play triangle, download arrow, install arrow-onto-line (all 12×12).
Hover: `filter: brightness(1.12)`. Active: `transform: scale(0.98)`.
**Uninstall / Delete-from-disk** are NOT on the card — only in the detail overlay (as ghost-danger buttons).
---
## Download progress (state === 'downloading')
When a game is actively downloading, the **action-button slot is replaced** by an inline progress component. The component is its own visual primitive (`DownloadProgress` in `components.jsx`); it is NOT a button with a `<progress>` child. Two layouts share the same primitive:
### Shared visuals
- Container: `border-radius: 7px` (card) / `9px` (modal), `1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2))`, faint accent halo via `box-shadow`. `container-type: inline-size` (we use container queries for graceful fallback, see below).
- **Progress fill** (`.dl-fill`): absolutely positioned, `width: <pct>%`, animated via `transition: width 480ms cubic-bezier(.4,0,.2,1)`. Background is a vertical gradient of `color-mix(in srgb, var(--accent) 3826%, transparent)`. Right edge gets a 1px accent rule + accent glow.
- **Live shimmer** on top of the fill: `repeating-linear-gradient(115deg, transparent 0 14px, rgba(255,255,255,0.05) 14px 22px)` panned via `animation: dl-stripe 1.4s linear infinite`, `mix-blend-mode: screen`. Subtle — it reads as "live" without being distracting.
- **Pulse dot** (`.dl-pulse`): 7px accent dot with an outward-pulsing `box-shadow` ring (1.4s ease-out infinite). Visual cue that the network transfer is active.
- **Tabular numerics** on all values (`font-variant-numeric: tabular-nums`) so the percentage and speed don't jitter as digits roll over.
### Card layout (`.dl-md`, replaces the 32px action button)
A single row. Two values, separated by `justify-content: space-between`:
- **Left:** `<pulse> <pct>%` — 12px / 600, `var(--t-1)`. `%` glyph at 0.55 opacity. e.g. `• 32%`.
- **Right:** `<speed>` — 11px / 500, `var(--t-2)`. Short format: `49 MB/s` (no decimals at card scale).
Heights match the action button per density: 30px compact / 32px normal / 34px large. Padding `0 10px` (9 compact / 12 large). Font sizes scale similarly (see `styles.css`).
**Container-query graceful degradation** — this is the important part, it has to fit every aspect/density combo:
```css
@container (max-width: 132px) { .dl-md .dl-speed { display: none; } .dl-md-row { justify-content: center; gap: 6px; } }
@container (max-width: 96px) { .dl-md .dl-pulse { display: none; } }
```
At 132 px and below, the speed disappears and the percentage centres. At 96 px and below, the pulse dot also drops, leaving just the percentage. This is what guarantees `compact` density + `box` aspect (the narrowest combination) still reads cleanly.
The state chip in the cover corner still says "Downloading" — we are deliberately NOT repeating that label inside the progress bar.
### Detail-overlay layout (`.dl-lg`, replaces the 44px modal action button)
Fixed 56px height. CSS-grid with three columns and two rows:
```
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-areas:
"primary pct cancel"
"secondary pct cancel";
```
- **Primary row** (`.dl-lg-primary`, top-left) — pulse dot + the uppercase live label `DOWNLOADING` in `color-mix(in srgb, var(--accent) 80%, white)`, 13px / 600, `letter-spacing: 0.02em`. This is the only place the word "Downloading" appears in the component.
- **Secondary row** (`.dl-lg-secondary`, bottom-left) — the live stats. 12px, four groups separated by `·` (0.45 opacity):
1. `<strong>11.4 GB</strong> / 35 GB` (`var(--t-1)` strong + `var(--t-2)` rest)
2. `47.6 MB/s` (`var(--t-1)`)
3. `[users-icon] 5``.dl-peers`, inline-flex with 4px gap, icon at 0.7 opacity, count in `var(--t-1)` 600 tabular-nums. Hidden entirely when `game.peers` is falsy. Communicates this is a LAN swarm transfer; the full sentence lives in the `title` tooltip.
4. `8 min left` (`var(--t-2)`)
- **pct column** — large percentage, 20px / 700, `letter-spacing: -0.01em`, `var(--t-1)`. `%` glyph at 12px / 600 / 0.55 opacity.
- **cancel column** — 28×28 square, `1px solid var(--bd-2)`, `border-radius: 6px`, X icon. Hover: bg `rgba(239,68,68,0.12)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`. Cancelling reverts the game to its prior state (`local` if any data was kept, `none` otherwise) — dev decides the underlying behavior.
**Graceful degradation in narrow modals:**
```css
@container (max-width: 320px) { .dl-lg-secondary .dl-eta, .dl-lg-secondary .dl-sep-eta { display: none; } }
@container (max-width: 240px) { .dl-lg-secondary .dl-peers, .dl-lg-secondary .dl-sep-peers { display: none; } }
```
ETA drops first, then peers; bytes + speed always stay (they're the actionable numbers). The pct/cancel column never collapses.
### Number formatting
All helpers live in `data.jsx`:
- `fmtSpeed(mbps)``49.4 MB/s` below 100, `MM MB/s` (rounded) at/above 100. Used in `.dl-lg`.
- `fmtSpeedShort(mbps)` — always rounded: `49 MB/s`. Used in `.dl-md` so the card stays compact.
- `fmtBytes(gb)``<1 GB → MB rounded`, `<10 GB → up to 2 decimals` (trailing zeros stripped: `2.35 GB`, `2.3 GB`, `2 GB`), `≥10 GB → 1 decimal max` (`11.4 GB`, `35 GB`).
- `fmtEta(seconds)``< 60s → "N s"`, `< 60min → "N min"`, else `"H h M min"`.
Keep these formats; they're tuned so the secondary row never wraps at normal modal width.
### Data shape
The `Game` type gains a `downloading` state plus two transient fields:
```ts
type Game = {
// … existing fields …
state: 'installed' | 'local' | 'downloading' | 'none';
progress?: number; // 01, only when state === 'downloading'
speed?: number; // current throughput in MB/s
peers?: number; // number of LAN peers currently seeding
};
```
In the real app, `progress`, `speed`, and `peers` come from the download worker (Tauri command emitting events). The mock's `useLiveDownload(game)` hook (in `components.jsx`) is just a placeholder — 600ms `setInterval` advancing `progress` proportional to `speed`, with `speed` smoothed via a low-pass filter and small random drift so the number doesn't look fake. `peers` is read straight off the game object (static in the mock); in production, push updates as peers join/leave the swarm — the `.dl-peers` chip re-renders silently. Replace the hook with a `useEffect` that subscribes to your real progress events; the rendering layer needs nothing else.
Filter changes:
- `Local` filter includes `installed` + `local` + `downloading` (in-flight downloads belong on the Local tab — you're managing them).
- Sort by `state` orders `installed < local < downloading < none`.
### State chip
Add a fourth entry to `STATE_META`:
```js
downloading: { label: 'Downloading', dot: 'var(--accent)' }
```
Dot uses the live accent so it visually ties to the progress fill.
---
## Filter controls — variant B (not used, kept for reference)
The two-row chrome has a different filter style — **underlined tabs with counts**, like browser tabs:
- Buttons: no background, `padding: 10px 14px 12px`, font `13.5px / 600`. Color `--t-2` inactive, `--t-1` active.
- Count chip after label: 11.5px / 600, `padding 1px 7px`, rounded pill. Inactive bg `rgba(255,255,255,0.06)`, text `--t-3`. Active bg `rgba(255,255,255,0.10)`, text `--t-1`.
- Active tab has a 2px underline at the bottom (`left: 12px, right: 12px`) in `--accent`, animated in via opacity + scaleX (220ms cubic-bezier).
Implement only if you decide variant A doesn't work after building.
---
## Interactions & behavior
- **Click game card** (anywhere except the action button) → open detail overlay.
- **Click action button on card** → trigger the state-appropriate action without opening the overlay. `e.stopPropagation()` on the button.
- **Press / (slash)** → focus the search input.
- **Type in search** → live-filter the visible grid by title or tag (case-insensitive substring).
- **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 "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).
### Transitions / animations
- Card hover: `180ms cubic-bezier(.4,1.2,.5,1)` on transform/border, `350ms cubic-bezier(.4,1.2,.5,1)` on cover scale.
- Modal fade-in: scrim `opacity 0 → 1` over 180ms ease; modal `transform: scale(.96) translateY(8px) → scale(1) translateY(0)` and opacity over 250ms `cubic-bezier(.3,1.3,.4,1)`.
- Segmented filter thumb: `220ms cubic-bezier(.4,1.2,.5,1)` on `left` and `width`.
- Underline tab indicator (variant B): `200ms` on opacity, `250ms cubic-bezier(.4,1.2,.5,1)` on `transform: scaleX`.
- Animated background option: subtle 18s ease-in-out infinite alternate background-position shift on two accent-tinted radial gradients.
---
## State management
Recommend Zustand or a single React context for global launcher state; Tauri commands for filesystem and process operations.
**Library state** (rebuilt on `refresh`):
```ts
type Game = {
id: string;
title: string;
size: number; // GB
version: string; // "YYYY.MM.DD"
desc: string;
state: 'installed' | 'local' | 'downloading' | 'none';
progress?: number; // 01 — present only when state === 'downloading'
speed?: number; // MB/s — present only when state === 'downloading'
peers?: number; // LAN peers currently seeding
players: string; // e.g. "232"
tags: string[];
cover: { c1: string; c2: string; accent: string; mood?: string };
canHostServer?: boolean; // true if the game ships with a dedicated-server binary
};
```
**Server-capable games** in the mock catalog (`canHostServer: true`): BF1942, BF2, CoD2, CoD4, CoD:UO, CS 1.6, CS:Source, Cube 2/Sauerbraten, Doom 3, L4D2, Minecraft, Quake III, TF2, UT2004. RTS / social-deduction / co-op-only-P2P games (AoE II HD, RA3, Generals ZH, Among Us, Portal 2, StarCraft, Warcraft III, AvP, 8-Bit Armies, BlazeRush) are not flagged — they host in-game. In production the flag should come from the same per-game manifest that drives titles / sizes / cover art. Wire each entry to whatever launch command the dedicated server uses (`hldsexec`, `srcds`, `minecraft_server.jar`, etc.); the IPC stub looks like `startServer(gameId)` returning a handle or process id.
**UI state:**
```ts
type LauncherUI = {
filter: 'all' | 'local' | 'installed';
sort: 'az' | 'size' | 'recent' | 'state';
query: string;
openGameId: string | null;
settingsOpen: boolean;
};
```
**Persisted settings** (mirror of Settings dialog state):
```ts
type LauncherSettings = {
username: string;
language: 'en' | 'de';
accent: string; // hex from the curated 6-color palette
bg: 'flat' | 'gradient' | 'animated';
density: 'compact' | 'normal' | 'large';
aspect: 'box' | 'square' | 'banner';
gameFolder: string | null; // v3: moved out of top bar, persists actual path
};
```
Persist via Tauri's plugin-store or a local JSON file in app data dir. Changes from the Settings dialog should write through immediately (no Apply button).
**Storage figures:** computed by summing game sizes per state, plus free-space query via Tauri.
---
## Design tokens
### Color
| token | value | usage |
|---|---|---|
| What | The full launcher UI redesign | The brand mark + wordmark lockup |
| Read first | `launcher/SPEC.md` | `logo/INTEGRATION.md` |
| Preview | open `launcher/design_reference/SoftLAN Launcher.html` | open `logo/demo.html` |
| Fidelity | High — final colors/type/spacing/interactions | Final — recolors live via the `accent` token |
| `--bg-0` | `#0a0e13` | launcher background |
| `--bg-1` | `#0f151c` | card bottom gradient stop |
| `--bg-2` | `#131b25` | top bar / card top / search bg |
| `--bg-3` | `#1a2330` | settings segmented bg / cover fallback |
| `--bg-4` | `#232f3e` | (reserved) |
| `--bd-1` | `rgba(255,255,255,0.06)` | subtle border |
| `--bd-2` | `rgba(255,255,255,0.10)` | stronger border |
| `--bd-3` | `rgba(255,255,255,0.16)` | scrollbar thumb |
| `--t-1` | `#e6edf3` | primary text |
| `--t-2` | `#9aa6b4` | secondary text |
| `--t-3` | `#6b7785` | muted text / metadata |
| `--t-4` | `#4a5663` | (reserved) |
| `--ok` | `#22c55e` | "installed" dot |
| `--warn` | `#f59e0b` | "local" dot |
| `--danger` | `#ef4444` | destructive actions |
| `--accent` | user-selected, default `#3b82f6` | primary actions, focus rings, brand mark |
The two share one design language. The **accent** color is a single token
(`--accent`, default `#3b82f6`) that drives the launcher's primary actions *and*
the logo — wire it once and both follow. The brand mark in the launcher's top
bar (`launcher/SPEC.md` → "Top bar → Brand") **is** the logo component from
`logo/pixel-live.jsx` at `size={28}`; the static 28px "S" in the mock is a
placeholder for it.
### Typography
- **UI font** — system sans stack: `-apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", system-ui, sans-serif`
- **Cover-art display font** — `"Bebas Neue"` (Google Fonts, weight 400) with fallback `"Oswald", Impact, "Arial Narrow Bold", sans-serif`
- **Monospace** — `ui-monospace, "SF Mono", Menlo, Consolas, monospace` (used for: directory path, version field in detail overlay)
Sizing reference:
- Brand wordmark: 15 / 700
- Modal title: 32 / 700 / -0.015em
- Card title: 13.5 / 600
- Filter pill label: 12.5 / 600
- Settings row label: 14 / 600
- Section title (settings): 10.5 / 700 / 0.12em uppercase
- Meta (size · genre, etc.): 11.5 / tabular-nums / `--t-3`
- Hint text: 12 / `--t-3`
### Spacing & radii
- Card radius: 10px
- Modal radius: 14px
- Pill/control radius: 8px (search, sort, dir button), 999px (filter segmented), 7px (action button)
- Common gaps: 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 28
- Card body padding: 11 12 12
### Shadows
- Card hover: `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)`
- Modal: `0 30px 80px -10px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.04)`
- Brand mark: `0 6px 20px -6px color-mix(var(--accent), 60%, black), inset 0 1px 0 rgba(255,255,255,0.22)`
- Action button (filled): `0 6px 16px -8px <color>, inset 0 1px 0 rgba(255,255,255,0.22)`
---
## Recommended reading order
## Assets
1. **This file** — the map.
2. **`launcher/SPEC.md`** — the bulk of the work. Screens, components, data
shapes, design tokens, interactions, and what's out of scope. Start at
"Screens / views" and keep the "Design tokens" section open alongside.
3. **`logo/INTEGRATION.md`** — drop the live mark and the wordmark lockup into
the chrome. Short; mostly a component API + static-asset reference.
4. Open both preview files in a browser to see the real thing in motion.
Cover art in the design files is **stylized placeholder art** — generated entirely from the game's metadata (color pair + accent color + id hash for angle/blob position) plus the title typeset in Bebas Neue. There are no real game cover image assets in this design.
In the production app, the launcher should ideally use real cover-art when available (fetch from IGDB / Steam / local game folder) and fall back to the placeholder generator for games without art. The placeholder generator is in `design_reference/components.jsx → GameCover`.
The icon set (search, play, **server**, install, download, folder, kebab, sort, users, close, check, chevron, trash) is in `design_reference/components.jsx → Icon`. They are 12-14px inline SVGs using `currentColor`. Reuse as-is or substitute with the codebase's existing icon library at the same visual weight. The `server` glyph is new in this round — two stacked rounded rectangles with LED dots, used only on the Start Server button.
Fonts to load:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap">
```
---
## Status
## File reference
- **Launcher:** high-fidelity, decisions locked. Variant **A** is the chosen
chrome. Includes **Call to Play** (rally the LAN around a game + time — live
or scheduled, with RSVP, check-in window, and per-call chat). Open questions
(empty/error states, logs viewer, keyboard grid nav, German strings, "server
running" state, real-time transport for Call to Play) are listed at the end
of `SPEC.md`.
- **Logo:** final. Live component + static assets + horizontal lockup (dark and
light) all included.
```
design_reference/
├── SoftLAN Launcher.html ← entry; wires React + Babel, mounts <App>
├── styles.css ← all visual styles (CSS custom props + components)
├── 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)
└── launcher.jsx ← <Launcher> component composing chrome + grid + modals
```
Do **not** ship the Babel-in-browser setup, the design-canvas wrappers, or the
Tweaks panel from the prototypes — they're presentation scaffolding. The Tweaks
panel is superseded by the in-app **Settings dialog** (spec'd in `SPEC.md`).
To preview the design in a browser:
1. Open `SoftLAN Launcher.html` in a static-server (e.g. `python -m http.server` from the folder).
2. You'll see a design canvas with all variants (A, B, C, D, E, F) side-by-side. Click an artboard's expand button to view it full-screen.
- **A / B** — chrome variants (A is the chosen direction)
- **C** — detail overlay for an installed, server-capable game (Counter-Strike 1.6) → shows **Play + Start Server + Uninstall**
- **D** — detail overlay for a downloaded-but-not-installed game (CoD 4) → shows **Install + Delete from disk**
- **E** — detail overlay for a downloading game (AvP) → shows the live progress component + **Cancel**
- **F** — Settings dialog open, with the new **Profile** section at the top
3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you live-change every persisted setting (username / language / accent / background / density / aspect / game folder). In the production app these all live in the Settings dialog.
---
## Out of scope / open questions for the developer
- **Unpack logs viewer** — referenced from kebab menu but not designed. Surface it as a separate window or a slide-in panel, dev's choice.
- **Empty state** — when filter returns 0 games (e.g. nothing installed yet). Show a centered message with a CTA to install the first game.
- **Error state on action** — if a Download / Install fails, show inline error on the affected card (red border + retry button), and a toast.
- **Progress state** — designed. See "Download progress" section above. The action-button slot is swapped for a live `DownloadProgress` component (card + modal variants with container-query fallback for narrow tiles). Wire it to your real progress events; the rendering layer is dev-ready.
- **Keyboard arrow nav** — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal.
- **"Server running" state** — once Start Server actually spawns a process, the button should switch to a *running* state (live indicator dot + "Server running" label + click-to-stop). Not designed this round — flag for follow-up alongside whatever server-status panel the app grows.
- **German translations** — the language toggle is wired in Settings, but the catalog of translated UI strings hasn't been compiled. Stand up `react-i18next` (or equivalent) and seed `en.json` from the existing copy; `de.json` is a translation task for whoever owns localization.
@@ -23,8 +23,6 @@
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="data.jsx"></script>
<script type="text/babel" src="components.jsx"></script>
<script type="text/babel" src="ctp-chat.jsx"></script>
<script type="text/babel" src="calltoplay.jsx"></script>
<script type="text/babel" src="launcher.jsx"></script>
</head>
<body>
@@ -34,7 +32,7 @@
const { useState } = React;
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accent": "#a855f7",
"accent": "#3b82f6",
"density": "normal",
"aspect": "square",
"bg": "gradient",
@@ -49,77 +47,9 @@ function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const heroGame = GAMES.find(g => g.id === 'cs'); // installed + canHostServer → shows Play + Start Server + Uninstall
// Seed data for the "Call to Play" showcase — a live call with chat, a
// fresh call started by you, a SCHEDULED call whose 15-minute check-in
// window just opened (highlighted, you still have to ready up), and one
// scheduled for later tonight that's still collecting "I'm in" RSVPs.
const ctpSeeds = React.useMemo(() => {
const now = Date.now();
const me = (t.username || 'you').trim() || 'you';
return [
{
gameId: 'cs', creator: 'shadowfox', maxPlayers: 10, ageSec: 95, durationMin: 25,
participants: {
pixelqueen: { status: 'ready', joinedAt: now - 60000 },
kbmaster: { status: 'ready', joinedAt: now - 40000 },
nullptr: { status: 'pending', joinedAt: now - 20000, readyAt: now + 50000 },
},
messages: [
{ from: 'shadowfox', text: 'dust2, default rules — who is in?', agoSec: 90 },
{ from: 'kbmaster', text: 'in. someone seed the map pack for nullptr', agoSec: 60 },
{ from: 'nullptr', text: 'downloading, 2 min', agoSec: 35 },
],
},
{
gameId: 'l4d2', creator: me, maxPlayers: 4, ageSec: 15, durationMin: 6,
participants: {
afk_andy: { status: 'pending', joinedAt: now - 5000, readyAt: now + 35000 },
},
},
{
gameId: 'tf2', creator: 'wavedash', maxPlayers: 12, ageSec: 3600, scheduledInMin: 12,
participants: {
[me]: { status: 'in', joinedAt: now - 3000000 },
gg_val: { status: 'ready', joinedAt: now - 2000000 },
crit_happens: { status: 'in', joinedAt: now - 1500000 },
lowping_larry: { status: 'pending', joinedAt: now - 1000000, readyAt: now + 240000 },
},
messages: [
{ from: 'wavedash', text: 'payload maps tonight — check in when you are at your seat', agoSec: 300 },
{ from: 'gg_val', text: 'ready, lobby is open', agoSec: 120 },
],
},
{
gameId: 'amongus', creator: 'pixelqueen', maxPlayers: 10, ageSec: 5400, scheduledInMin: 128,
participants: {
ctrlaltdave: { status: 'in', joinedAt: now - 4000000 },
afk_andy: { status: 'in', joinedAt: now - 3000000 },
},
messages: [
{ from: 'pixelqueen', text: 'right after the pizza break — bring headphones', agoSec: 4800 },
{ from: 'ctrlaltdave', text: 'in. can we start 10 min later?', agoSec: 3600 },
],
},
];
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<React.Fragment>
<DesignCanvas>
<DCSection id="call-to-play" title="Call to Play — now, scheduled &amp; chat"
subtitle="Anyone can call a match — right now (give people N minutes to ready up) or scheduled for a clock time ('Among Us at 20:00'). Scheduled calls collect 'I'm in' RSVPs; 15 minutes before start the call enters its check-in window and lights up everywhere, nudging everyone who's in to answer Ready now or +N minutes. Each call carries a small group chat. The quick bars lead with the status — SCHEDULED / CALL TO PLAY / STARTING SOON / READY, each with its own LED color — sorted ready → starting soon → the rest (soonest first), with per-player ready-bubbles and the latest chat line inline. Click anything to interact live.">
<DCArtboard id="ctp-quickbars" label="Main view — quick bars with ready-bubbles + check-in highlight" width={1340} height={840}>
<Launcher variant="single" tweaks={t} setTweak={setTweak}
initialFilter="all" initialSort="recent"
seedCallToPlay={ctpSeeds}/>
</DCArtboard>
<DCArtboard id="ctp-live" label="Overlay — live call, your call, check-in &amp; scheduled RSVP + chat" width={1340} height={840}>
<Launcher variant="single" tweaks={t} setTweak={setTweak}
initialFilter="all" initialSort="recent"
seedCallToPlay={ctpSeeds} initialCtpOpen={true}/>
</DCArtboard>
</DCSection>
<DCSection id="chrome" title="Chrome variations"
subtitle="Two ways to organize the top bar — pick whichever density of controls you prefer. Click any card to open the detail overlay, or the kebab menu to open Settings.">
<DCArtboard id="single-row" label="A · Single-row + segmented filters" width={1340} height={840}>
@@ -20,12 +20,6 @@ const Icon = {
check: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m3 8 3.5 3.5L13 5"/></svg>,
chevron:(p) => <svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m4 6 4 4 4-4"/></svg>,
trash: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 4.5h10"/><path d="M5.5 4.5V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1.5"/><path d="M4.5 4.5 5 13a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1l.5-8.5"/></svg>,
flag: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3.5 1.5v13"/><path d="M3.5 2.25c3-1.3 4.5 1.3 7.5 0v6.7c-3 1.3-4.5-1.3-7.5 0"/></svg>,
clock: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/></svg>,
chat: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M2 3.5A1.5 1.5 0 0 1 3.5 2h9A1.5 1.5 0 0 1 14 3.5v6a1.5 1.5 0 0 1-1.5 1.5H6.5L3 14v-3h.5A1.5 1.5 0 0 1 2 9.5z"/></svg>,
send: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" {...p}><path d="M1.5 2 15 8 1.5 14l2.3-6z"/></svg>,
caretUp: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m4 10 4-4 4 4"/></svg>,
caretDown: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m4 6 4 4 4-4"/></svg>,
};
//
@@ -223,10 +217,6 @@ function GameCard({ game, accent, aspect, onOpen }) {
<span>{fmtSize(game.size)}</span>
<span className="card-dot">·</span>
<span>{game.tags[0]}</span>
<span className="card-dot">·</span>
<span className="card-peers" title={`${installedPeersFor(game).length} of ${PEERS.length} peers on the LAN have this installed`}>
<Icon.users/>{installedPeersFor(game).length}/{PEERS.length}
</span>
</div>
<ActionButton state={game.state} accent={accent} full game={game}/>
</div>
@@ -303,7 +293,6 @@ const SORTS = [
{ key: 'size', label: 'Size (largest)' },
{ key: 'recent', label: 'Recently Played' },
{ key: 'state', label: 'Status' },
{ key: 'peers', label: 'Peers with it (most)' },
];
function SortMenu({ value, onChange, accent }) {
@@ -203,37 +203,7 @@ const STORAGE = {
total: 512,
};
//
// LAN roster (mock) used by the peer-install indicator and by the
// Call to Play feature to simulate other people in the room.
//
const PEERS = [
'shadowfox', 'pixelqueen', 'kbmaster', 'nullptr', 'wavedash',
'crit_happens', 'afk_andy', 'lowping_larry', 'gg_val', 'ctrlaltdave',
];
const hashStr = (s) => [...String(s)].reduce((a, c) => a + c.charCodeAt(0), 0);
// Deterministic-but-varied subset of PEERS who "have this game installed"
// stable per game id so the same game always shows the same roster/count.
function installedPeersFor(game) {
const h = hashStr(game.id);
const count = 1 + (h % PEERS.length); // 1..PEERS.length
const scored = PEERS.map((p, i) => ({ p, s: (h * 7 + i * 13 + hashStr(p)) % 97 }));
scored.sort((a, b) => a.s - b.s);
return scored.slice(0, count).map(x => x.p);
}
// "216" / "1-8" 16 / 8. Falls back to 8 if unparsable.
function parseMaxPlayers(playersStr) {
const m = String(playersStr).match(/(\d+)\s*$/);
return m ? parseInt(m[1], 10) : 8;
}
window.GAMES = GAMES;
window.PEERS = PEERS;
window.installedPeersFor = installedPeersFor;
window.parseMaxPlayers = parseMaxPlayers;
window.STATE_META = STATE_META;
window.ACTION_FOR_STATE = ACTION_FOR_STATE;
window.countByFilter = countByFilter;
@@ -15,8 +15,6 @@ function applyFilterAndSort(games, filter, sort, query) {
} else if (sort === 'recent') {
const order = { installed: 0, local: 1, none: 2 };
g = [...g].sort((a, b) => order[a.state] - order[b.state] || b.version.localeCompare(a.version));
} else if (sort === 'peers') {
g = [...g].sort((a, b) => installedPeersFor(b).length - installedPeersFor(a).length || a.title.localeCompare(b.title));
}
return g;
}
@@ -27,8 +25,6 @@ function Launcher({
initialFilter = 'all', initialSort = 'recent', initialQuery = '',
initialOpenGame = null,
initialSettingsOpen = false,
seedCallToPlay = [],
initialCtpOpen = false,
}) {
const { density, aspect, accent, bg } = tweaks;
const [filter, setFilter] = useState(initialFilter);
@@ -36,11 +32,6 @@ function Launcher({
const [query, setQuery] = useState(initialQuery);
const [openGame, setOpenGame] = useState(initialOpenGame);
const [settingsOpen, setSettingsOpen] = useState(initialSettingsOpen);
const [ctpOpen, setCtpOpen] = useState(initialCtpOpen);
const [ctpFocusId, setCtpFocusId] = useState(null);
const nomUsername = tweaks.username && tweaks.username.trim() ? tweaks.username.trim() : 'you';
const ctp = useNominations({ username: nomUsername, seed: seedCallToPlay });
const counts = useMemo(() => countByFilter(GAMES), []);
const list = useMemo(() => applyFilterAndSort(GAMES, filter, sort, query), [filter, sort, query]);
@@ -75,7 +66,6 @@ function Launcher({
<SortMenu value={sort} onChange={setSort} accent={accent}/>
</div>
<div className="topbar-right-trail">
<CallToPlayButton nominations={ctp.nominations} onClick={() => setCtpOpen(true)}/>
<KebabMenu items={menuItems}/>
</div>
</div>
@@ -89,7 +79,6 @@ function Launcher({
</div>
<div className="topbar-row1-right">
<StorageMeter accent={accent}/>
<CallToPlayButton nominations={ctp.nominations} onClick={() => setCtpOpen(true)}/>
<KebabMenu items={menuItems}/>
</div>
</div>
@@ -104,8 +93,6 @@ function Launcher({
)}
<main className="grid-wrap">
<CallToPlayTicker nominations={ctp.nominations} accent={accent}
onOpen={(id) => { setCtpFocusId(id); setCtpOpen(true); }}/>
{variant === 'single' && (
<div className="results-bar">
<div className="results-count">
@@ -126,12 +113,6 @@ function Launcher({
{settingsOpen && setTweak && (
<SettingsDialog settings={tweaks} onChange={setTweak} onClose={() => setSettingsOpen(false)}/>
)}
{ctpOpen && (
<CallToPlayOverlay nominations={ctp.nominations} username={nomUsername} accent={accent}
focusId={ctpFocusId}
onClose={() => { setCtpOpen(false); setCtpFocusId(null); }}
actions={{ createNomination: ctp.createNomination, respond: ctp.respond, rsvp: ctp.rsvp, sendMessage: ctp.sendMessage, leave: ctp.leave, cancel: ctp.cancel, startNow: ctp.startNow, addTime: ctp.addTime }}/>
)}
</div>
);
}
@@ -636,14 +636,8 @@
font-size: 11.5px;
color: var(--t-3);
font-variant-numeric: tabular-nums;
min-width: 0;
}
.card-meta .card-dot { opacity: 0.5; flex-shrink: 0; }
.card-peers {
display: inline-flex; align-items: center; gap: 3px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.card-peers svg { flex-shrink: 0; opacity: 0.75; }
.card-meta .card-dot { opacity: 0.5; }
.density-compact .card-body { padding: 9px 10px 10px; gap: 6px; }
.density-compact .card-title { font-size: 12.5px; }
@@ -1317,624 +1311,3 @@
.folder-field.is-unset .folder-field-btn:hover {
background: var(--accent, #3b82f6);
}
/*
Call to Play rally a game + a time
*/
/* ─── Top-bar entry button ─── */
.ctp-btn {
position: relative;
display: inline-flex; align-items: center; gap: 8px;
height: 36px; padding: 0 14px;
background: color-mix(in srgb, var(--accent) 14%, var(--bg-2));
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
border-radius: 8px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 700;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: background .15s, border-color .15s;
}
.ctp-btn svg { color: var(--accent); flex-shrink: 0; }
.ctp-btn:hover {
background: color-mix(in srgb, var(--accent) 22%, var(--bg-2));
border-color: color-mix(in srgb, var(--accent) 65%, var(--bd-2));
}
.ctp-badge {
display: inline-grid; place-items: center;
min-width: 18px; height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--accent);
color: white;
font-size: 10.5px; font-weight: 800;
font-variant-numeric: tabular-nums;
}
/* ─── Ticker strip ─── */
.ctp-ticker-stack {
display: flex; flex-direction: column; gap: 8px;
margin-bottom: 14px;
}
.ctp-ticker-stack .ctp-ticker { margin-bottom: 0; }
.ctp-ticker {
display: grid;
grid-template-columns:
10px /* LED */
98px /* status */
220px /* game */
130px /* by */
88px /* count */
170px /* time */
minmax(0, 1fr) /* chat — sole flexible track, absorbs bubbles variance */
max-content /* bubbles */
82px; /* cta */
align-items: center;
column-gap: 12px;
width: 100%;
padding: 10px 16px;
background: color-mix(in srgb, var(--accent) 10%, var(--bg-2));
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--bd-2));
border-radius: 10px;
color: var(--t-1);
font: inherit; font-size: 12.5px;
cursor: pointer;
text-align: left;
transition: background .15s, border-color .15s;
}
.ctp-ticker:hover { background: color-mix(in srgb, var(--accent) 16%, var(--bg-2)); }
.ctp-ticker-dot {
width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0;
background: var(--accent);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent);
animation: ctp-tickerpulse 1.6s ease-out infinite;
}
@keyframes ctp-tickerpulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
}
.ctp-ticker-label {
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
font-size: 10.5px;
letter-spacing: 0.06em;
flex-shrink: 0;
}
.ctp-ticker-game { font-weight: 700; color: var(--t-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-by { color: var(--t-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-sep { display: none; }
.ctp-ticker-ready, .ctp-ticker-time { color: var(--t-2); font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-more {
color: var(--t-3);
font-size: 11.5px;
flex-shrink: 0;
}
.ctp-ticker-cta {
display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px;
font-weight: 700;
color: var(--accent);
}
.ctp-ticker-cta svg { transform: rotate(-90deg); }
/* ─── Overlay modal ─── */
.ctp-modal { width: min(720px, 100%); }
.ctp-head-row {
display: flex; align-items: center; justify-content: space-between;
gap: 12px;
margin-right: 36px;
}
.ctp-head-new { height: 40px; padding: 0 18px; font-size: 13px; margin-top: 14px; width: 100%; }
.ctp-head { padding: 26px 28px 14px; border-bottom: 1px solid var(--bd-1); }
.ctp-head h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -0.01em; color: var(--t-1); }
.ctp-head-sub { margin: 6px 0 0; font-size: 12.5px; color: var(--t-3); max-width: 52ch; }
.ctp-body {
padding: 18px 24px 24px;
display: flex; flex-direction: column; gap: 14px;
max-height: 66vh;
overflow: auto;
}
.ctp-empty {
padding: 28px 8px;
text-align: center;
color: var(--t-3);
font-size: 13px;
}
/* ─── Nomination card ─── */
.ctp-card {
display: flex; flex-direction: column; gap: 12px;
padding: 14px;
background: rgba(255,255,255,0.025);
border: 1px solid var(--bd-1);
border-radius: 12px;
}
.ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); }
.ctp-card.is-started { opacity: 0.6; }
.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; }
@keyframes ctp-cardflash {
0% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
}
.ctp-card-top { display: flex; align-items: flex-start; gap: 12px; position: relative; }
.ctp-card-cover {
position: relative;
width: 52px; height: 52px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
}
.ctp-card-info { flex: 1; min-width: 0; }
.ctp-card-title { font-size: 14.5px; font-weight: 700; color: var(--t-1); }
.ctp-card-sub { margin-top: 3px; font-size: 11.5px; color: var(--t-3); }
.ctp-card-sub strong { color: var(--t-2); font-weight: 700; }
.ctp-card-timer {
font-variant-numeric: tabular-nums;
font-size: 18px;
font-weight: 700;
color: var(--t-1);
flex-shrink: 0;
padding-top: 2px;
}
.ctp-card-timer[data-urgency="mid"] { color: var(--warn); }
.ctp-card-timer[data-urgency="high"] { color: var(--danger); }
.ctp-card.is-done .ctp-card-timer { color: var(--ok); font-size: 14px; text-transform: uppercase; letter-spacing: 0.04em; }
.ctp-cancel-link {
display: inline-flex; align-items: center; gap: 6px;
align-self: flex-start;
margin-top: -2px;
padding: 6px 2px;
background: transparent;
border: 0;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 600;
cursor: pointer;
transition: color .15s;
}
.ctp-cancel-link:hover { color: #fca5a5; }
.ctp-cancel-confirm {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
gap: 10px;
padding: 10px 12px;
background: rgba(239,68,68,0.08);
border: 1px solid rgba(239,68,68,0.35);
border-radius: 8px;
font-size: 12.5px;
color: #fca5a5;
font-weight: 600;
}
.ctp-cancel-confirm-btns { display: inline-flex; gap: 8px; flex-shrink: 0; }
.ctp-cancel-confirm-btns .ghost-btn { height: 32px; padding: 0 12px; font-size: 12px; }
.ctp-progress {
height: 5px;
border-radius: 3px;
background: rgba(255,255,255,0.06);
overflow: hidden;
}
.ctp-progress-fill { height: 100%; transition: width 1s linear; }
.ctp-roster { display: flex; flex-direction: column; gap: 8px; }
.ctp-roster-count { font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--t-3); }
.ctp-avatars { display: flex; flex-wrap: wrap; gap: 6px; }
.ctp-avatar {
position: relative;
display: inline-flex;
}
.ctp-avatar-dot {
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 999px;
color: white;
font-size: 10.5px; font-weight: 800;
letter-spacing: 0.01em;
}
.ctp-avatar.is-pending .ctp-avatar-dot { opacity: 0.55; }
.ctp-avatar-check {
position: absolute; bottom: -2px; right: -2px;
width: 14px; height: 14px;
display: grid; place-items: center;
border-radius: 999px;
background: var(--ok);
color: #06240f;
border: 2px solid var(--bg-2);
}
.ctp-avatar-pending {
position: absolute; bottom: -6px; left: 50%;
transform: translateX(-50%);
font-size: 9px; font-weight: 700;
color: var(--t-2);
background: var(--bg-3);
border: 1px solid var(--bd-2);
padding: 0 4px;
border-radius: 999px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.ctp-avatar-empty {
width: 30px; height: 30px;
border-radius: 999px;
border: 1.5px dashed var(--bd-2);
}
.ctp-actions {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
padding-top: 2px;
}
.ctp-actions .act-btn { height: 36px; padding: 0 16px; font-size: 12.5px; }
.ctp-actions .ghost-btn { height: 36px; padding: 0 14px; font-size: 12.5px; }
.ctp-note { font-size: 12.5px; color: var(--t-3); }
.ctp-note-launch { color: var(--ok); font-weight: 600; }
.ctp-me-status { font-size: 12.5px; font-weight: 600; color: var(--t-2); }
.ctp-buffer-group { display: inline-flex; gap: 6px; }
.ctp-buffer-btn {
height: 36px; padding: 0 11px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--bd-2);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 12px; font-weight: 600;
cursor: pointer;
transition: background .15s, border-color .15s;
}
.ctp-buffer-btn:hover { background: rgba(255,255,255,0.08); border-color: var(--bd-3); }
/* ─── Create-nomination form ─── */
.ctp-create-cta {
display: flex; align-items: center; justify-content: center; gap: 8px;
height: 48px;
background: transparent;
border: 1.5px dashed var(--bd-3);
border-radius: 12px;
color: var(--t-2);
font: inherit; font-size: 13px; font-weight: 700;
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.ctp-create-cta:hover { border-color: var(--accent); color: var(--t-1); background: rgba(255,255,255,0.02); }
.ctp-create {
display: flex; flex-direction: column; gap: 14px;
padding: 16px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--bd-2);
border-radius: 12px;
}
.ctp-create-row { display: flex; flex-direction: column; gap: 6px; position: relative; }
.ctp-create-row-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; }
.ctp-create-label {
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--t-3);
}
.ctp-create-search { width: 100%; }
.ctp-create-matches {
position: absolute;
top: calc(100% + 4px);
left: 0; right: 0;
z-index: 20;
max-height: 220px;
overflow: auto;
padding: 4px;
background: var(--bg-3);
border: 1px solid var(--bd-2);
border-radius: 10px;
box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5);
}
.ctp-create-matches button {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
width: 100%;
padding: 9px 10px;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 600;
text-align: left;
cursor: pointer;
}
.ctp-create-matches button:hover { background: rgba(255,255,255,0.06); }
.ctp-create-match-meta { color: var(--t-3); font-size: 11px; font-weight: 500; }
.ctp-create-nomatch { padding: 10px; font-size: 12px; color: var(--t-3); }
.ctp-create-num {
width: 70px; height: 34px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 13px; font-weight: 600;
text-align: center;
}
.ctp-create-hint { font-size: 11.5px; color: var(--t-3); }
.ctp-duration-opts { display: inline-flex; gap: 6px; flex-wrap: wrap; }
.ctp-duration-btn {
height: 32px; padding: 0 13px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-2);
font: inherit; font-size: 12.5px; font-weight: 700;
cursor: pointer;
transition: background .15s, color .15s, border-color .15s;
}
.ctp-duration-btn:hover { color: var(--t-1); }
.ctp-duration-btn.is-active { color: white; }
.ctp-create-foot {
display: flex; justify-content: flex-end; gap: 10px;
padding-top: 4px;
}
.ctp-create-foot .act-btn,
.ctp-create-foot .ghost-btn { height: 40px; padding: 0 18px; }
.ctp-time-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
/* ─── Time stepper ─── */
.ctp-timepick {
display: flex; align-items: center; gap: 6px;
margin-top: 6px;
}
.ctp-timepick-field {
display: flex; flex-direction: column; align-items: center; gap: 3px;
}
.ctp-time-step {
width: 54px; height: 24px;
display: grid; place-items: center;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-2);
cursor: pointer;
transition: background .12s, color .12s, border-color .12s;
}
.ctp-time-step:hover { background: var(--bg-4); color: var(--t-1); border-color: var(--bd-3); }
.ctp-time-step:active { background: color-mix(in srgb, var(--accent) 30%, var(--bg-3)); }
.ctp-time-cell {
width: 54px; height: 44px;
display: grid; place-items: center;
background: var(--bg-3);
border: 1px solid var(--bd-2);
border-radius: 9px;
font-size: 26px; font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--t-1);
letter-spacing: 0.02em;
}
.ctp-time-editinput {
width: 132px; height: 44px;
padding: 0;
background: var(--bg-3);
border: 1px solid color-mix(in srgb, var(--accent) 70%, var(--bd-2));
border-radius: 9px;
font: inherit; font-size: 26px; font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--t-1);
text-align: center;
letter-spacing: 0.06em;
}
.ctp-time-editinput:focus { outline: none; }
.ctp-time-editinput::placeholder { color: var(--t-4); }
.ctp-time-colon { font-size: 26px; font-weight: 700; color: var(--t-2); padding-bottom: 2px; }
.ctp-time-type {
align-self: center;
margin-left: 8px;
height: 30px; padding: 0 12px;
background: transparent;
border: 1px dashed var(--bd-3);
border-radius: 7px;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 600;
cursor: pointer;
transition: color .12s, border-color .12s;
}
.ctp-time-type:hover { color: var(--t-1); border-color: var(--accent); }
.ctp-day-row { gap: 5px; }
.ctp-day-label {
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--t-3);
margin-right: 4px;
}
.ctp-day-btn { height: 26px; padding: 0 10px; font-size: 11px; border-radius: 6px; }
/* Quick-bar status variants LED + label + row tint per status:
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
STARTING SOON (amber, glowing) · READY (green, steady) */
.ctp-ticker[data-status="scheduled"] {
background: var(--bg-2);
border-color: var(--bd-2);
}
.ctp-ticker[data-status="scheduled"]:hover { background: var(--bg-3); }
.ctp-ticker[data-status="soon"] {
background: color-mix(in srgb, var(--warn) 10%, var(--bg-2));
border-color: color-mix(in srgb, var(--warn) 50%, var(--bd-2));
animation: ctp-soon-glow 2.4s ease-in-out infinite;
}
.ctp-ticker[data-status="soon"]:hover { background: color-mix(in srgb, var(--warn) 16%, var(--bg-2)); }
@keyframes ctp-soon-glow {
0%, 100% { box-shadow: 0 0 0 0 transparent; }
50% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 28%, transparent); }
}
.ctp-ticker[data-status="ready"] {
background: color-mix(in srgb, var(--ok) 11%, var(--bg-2));
border-color: color-mix(in srgb, var(--ok) 55%, var(--bd-2));
box-shadow: 0 0 14px -2px color-mix(in srgb, var(--ok) 35%, transparent);
}
.ctp-ticker[data-status="ready"]:hover { background: color-mix(in srgb, var(--ok) 17%, var(--bg-2)); }
.ctp-ticker-dot[data-status="scheduled"] { background: var(--t-3); animation: none; box-shadow: none; }
.ctp-ticker-dot[data-status="soon"] { background: var(--warn); animation: ctp-tickerpulse-warn 1.6s ease-out infinite; }
.ctp-ticker-dot[data-status="ready"] { background: var(--ok); animation: none; box-shadow: 0 0 6px var(--ok); }
@keyframes ctp-tickerpulse-warn {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); }
70% { box-shadow: 0 0 0 6px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
.ctp-ticker-label[data-status="scheduled"] { color: var(--t-2); }
.ctp-ticker-label[data-status="soon"] { color: var(--warn); }
.ctp-ticker-label[data-status="ready"] { color: var(--ok); }
.ctp-ticker[data-status="soon"] .ctp-ticker-cta { color: var(--warn); }
.ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); }
/* ─── Quick-bar inline chat preview ─── */
.ctp-ticker-chat {
min-width: 0;
display: inline-flex; align-items: center; gap: 5px;
color: var(--t-3);
font-size: 12px;
}
.ctp-ticker-chat svg { flex-shrink: 0; opacity: 0.7; }
.ctp-ticker-chat b { flex-shrink: 0; font-weight: 700; }
.ctp-ticker-chat-text {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--t-2);
}
.ctp-card.is-checkin { border-color: color-mix(in srgb, var(--accent) 70%, var(--bd-2)); }
.ctp-checkin-note {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px;
background: color-mix(in srgb, var(--accent) 13%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
border-radius: 8px;
font-size: 12.5px; font-weight: 600;
color: var(--t-1);
}
.ctp-checkin-note svg { color: var(--accent); flex-shrink: 0; }
.ctp-card-timer.is-sched {
display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
}
.ctp-card-clock { font-size: 18px; line-height: 1; }
.ctp-card-until { font-size: 10.5px; font-weight: 600; color: var(--t-3); letter-spacing: 0.01em; }
.ctp-avatar.is-in .ctp-avatar-dot { opacity: 0.8; }
.ctp-avatar-in {
position: absolute; bottom: -6px; left: 50%;
transform: translateX(-50%);
font-size: 8.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--t-2);
background: var(--bg-3);
border: 1px solid var(--bd-2);
padding: 0 4px;
border-radius: 999px;
white-space: nowrap;
}
/* ─── Ticker mini ready-bubbles ─── */
.ctp-ticker-bubbles {
display: inline-flex; align-items: center;
justify-self: end;
}
.ctp-mini {
position: relative;
width: 22px; height: 22px;
display: grid; place-items: center;
border-radius: 999px;
margin-left: -6px;
border: 2px solid var(--bg-2);
color: white;
font-size: 8px; font-weight: 800; letter-spacing: 0.02em;
}
.ctp-mini:first-child { margin-left: 0; }
.ctp-mini[data-state="ready"] { box-shadow: 0 0 0 1.5px var(--ok); }
.ctp-mini[data-state="pending"] { opacity: 0.75; }
.ctp-mini[data-state="in"] { opacity: 0.85; }
.ctp-mini-check {
position: absolute; bottom: -3px; right: -4px;
width: 11px; height: 11px;
display: grid; place-items: center;
border-radius: 999px;
background: var(--ok);
color: #06240f;
border: 1.5px solid var(--bg-2);
}
.ctp-mini-check svg { width: 7px; height: 7px; }
.ctp-mini-tag {
position: absolute; bottom: -6px; right: -7px;
font-style: normal;
font-size: 8px; font-weight: 700;
line-height: 11px;
padding: 0 3px;
border-radius: 999px;
background: var(--bg-3);
border: 1px solid var(--bd-2);
color: var(--t-2);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ctp-mini-more { background: var(--bg-4); color: var(--t-2); font-size: 8.5px; }
/* ─── Per-call chat ─── */
.ctp-chat {
display: flex; flex-direction: column;
border-top: 1px solid var(--bd-1);
margin-top: 2px;
padding-top: 8px;
}
.ctp-chat-toggle {
display: flex; align-items: center; gap: 8px;
width: 100%;
padding: 4px 2px;
background: transparent;
border: 0;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 700;
cursor: pointer;
transition: color .15s;
}
.ctp-chat-toggle:hover { color: var(--t-1); }
.ctp-chat-toggle svg { flex-shrink: 0; }
.ctp-chat-count { color: var(--t-4); font-weight: 600; }
.ctp-chat-unread {
min-width: 16px; height: 16px;
display: grid; place-items: center;
padding: 0 4px;
border-radius: 999px;
background: var(--accent);
color: white;
font-size: 9.5px; font-weight: 800;
flex-shrink: 0;
}
.ctp-chat-preview {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-weight: 500;
color: var(--t-3);
text-align: left;
}
.ctp-chat-preview b { font-weight: 700; }
.ctp-chat-chevron { margin-left: auto; flex-shrink: 0; transition: transform .15s; }
.ctp-chat.is-open .ctp-chat-chevron { transform: rotate(180deg); }
.ctp-chat-list {
display: flex; flex-direction: column; gap: 6px;
max-height: 168px;
overflow-y: auto;
margin: 8px 0;
padding: 10px 12px;
background: rgba(0,0,0,0.22);
border: 1px solid var(--bd-1);
border-radius: 8px;
}
.ctp-chat-msg { font-size: 12px; line-height: 1.45; color: var(--t-2); overflow-wrap: anywhere; }
.ctp-chat-msg b { font-weight: 700; }
.ctp-chat-text { color: var(--t-1); }
.ctp-chat-time { margin-left: 6px; font-size: 10px; color: var(--t-4); font-variant-numeric: tabular-nums; }
.ctp-chat-empty { font-size: 11.5px; color: var(--t-4); text-align: center; padding: 6px 0; }
.ctp-chat-form { display: flex; gap: 6px; }
.ctp-chat-input {
flex: 1; height: 32px;
padding: 0 10px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 12px;
}
.ctp-chat-input::placeholder { color: var(--t-4); }
.ctp-chat-input:focus { outline: none; border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2)); }
.ctp-chat-send {
width: 32px; height: 32px;
display: grid; place-items: center;
background: color-mix(in srgb, var(--accent) 24%, var(--bg-3));
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
border-radius: 7px;
color: var(--t-1);
cursor: pointer;
flex-shrink: 0;
transition: background .15s;
}
.ctp-chat-send:hover { background: color-mix(in srgb, var(--accent) 40%, var(--bg-3)); }
-777
View File
@@ -1,777 +0,0 @@
# Handoff: SoftLAN Launcher redesign
A modern, gamer-friendly redesign of the SoftLAN local-network game launcher, replacing the current basic UI with a Steam-inspired dark layout that keeps high usability while adding cover art, state-coded actions, a game-detail overlay, and an in-app Settings dialog.
---
## About the design files
The files in `design_reference/` are **design references created in HTML/React via Babel-in-the-browser** — prototypes built to communicate the intended look, layout, and behavior. They are **not production code to copy directly**.
The target codebase is a **Tauri + React** desktop app. The task is to **recreate these designs inside that codebase**, using its existing patterns (component conventions, state management, routing, IPC to Rust for filesystem / process work). Use the design files for:
- Exact pixel/spacing/color/typography values
- Component composition and interactions
- Copy and microcopy
- Animation easings/durations
But:
- Don't ship the Babel-in-browser setup or import the .jsx files as-is
- Don't keep the `<deck>` / design-canvas wrapping — that's only for presenting variants
- Don't ship the Tweaks panel — it's superseded by the in-app **Settings dialog** (see "Screens" below)
- Re-implement using whatever the codebase uses (Vite + plain JSX, CSS modules / styled-components / tailwind, etc.)
## Fidelity
**High-fidelity.** Final colors, typography, spacing, and interactions are decided. Pixel-fidelity to the mock is the goal — recreate exactly, using the codebase's libraries/patterns. Only deviate where the codebase has its own dictate (e.g. an existing button primitive that's near-identical).
## Layout variants
The HTML mock includes two chrome variants — **A (single-row)** and **B (two-row)** — to choose from. **The user selected A as the primary direction.** Implement A. Variant B is left in the reference for context only.
---
## Changes since v4
- **Call to Play added.** A whole new coordination layer on top of the library:
rally the LAN around a game and a time. Two flavors — **Play now** (give
people N minutes to ready up) and **Scheduled** ("Among Us at 20:00", collect
"I'm in" RSVPs, then a **check-in window** opens 15 min before start). Every
call carries a small group **chat**. Surfaced in three places: a **Call to
Play button** in the top bar (with an active-call badge), a persistent stack
of **quick bars** above the grid, and a full **overlay** with per-call cards
and a create form. Full spec in the new **"Call to Play"** section below.
New source files: `calltoplay.jsx`, `ctp-chat.jsx`. New data: a mock LAN
**peer roster** (`PEERS`) in `data.jsx`. New icons: `flag`, `chat`, `send`,
`clock`, `caretUp`, `caretDown`.
## Changes since v3
- **Game-folder button removed from the top bar.** Setting the games directory is a one-time action — it doesn't deserve permanent real estate in the chrome. The button is gone from both top-bar variants, freeing the right zone for the kebab menu alone (variant A) / the storage meter + kebab pair (variant B).
- **Game folder moved into Settings → Library.** Now a row inside the Settings dialog, styled like the other Library rows. Two visual states (set / not-set) carry over from the old button — see "Settings dialog → Library → Game folder" below.
- **Persisted setting renamed.** `gameFolderSet: boolean``gameFolder: string | null`. The actual path is now persisted, not just a "is it configured?" flag. Default is `null` (unset on first run; user must pick a folder before the library scans).
## Changes since v2
- **Top bar layout reorganized.** The single-row top bar is now structured as three visual zones (still one row on wide windows):
- **Left:** brand mark + wordmark.
- **Center (semantically the "search cluster"):** segmented filter pills · search field · sort menu. The **search field is positioned at the geometric center of the window** — filter pills sit immediately to its left, sort menu immediately to its right.
- **Right:** kebab menu (game-folder configuration has moved into Settings — see v3 changes).
- Below ~1100 px of launcher width (container query), the three zones collapse into a single left-to-right flowing row (no wrap, no centering). Implement via container query on the launcher root; viewport media query is acceptable if your codebase doesn't use container queries yet.
- See "Top bar (variant A)" below for the full spec and rationale.
## Changes since v1
- **Settings → Profile section** added at the top of the dialog with two new persisted preferences: **Username** (text input) and **Language** (segmented `English` / `Deutsch`). See "Settings dialog" below for shape + persistence keys.
- **Start Server** action added to the **game detail overlay**, next to **Play**, for installed games that support a dedicated server. Driven by a new `canHostServer: true` flag on the game record. See "Detail overlay → Actions row" and "Game data shape" for the full spec.
- Grid cards are **unchanged** — Start Server only ever appears in the detail overlay.
---
## Screens / views
### 1. Main library (variant A — primary)
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.
- **Left zone (col 1, flex space-between):**
- **Brand** (pinned far-left) — 28×28 px rounded square in `--accent` (default `#3b82f6`) with the letter "S" in Bebas Neue 20 px white. Next to it, the wordmark "SoftLAN" in 15 px / 700 weight `--t-1` `#e6edf3`.
- **Segmented filter pills** (pinned right, hugging the search field) — pill-shaped container (`background var(--bg-2) #131b25`, `1px solid rgba(255,255,255,0.06)`, `border-radius: 999px`, `padding: 4px`). Three buttons:
- `All Games` · count chip
- `Local` · count chip
- `Installed` · count chip
Active button has an animated pill thumb (background `var(--accent)`, transitions `left` and `width` with `cubic-bezier(.4,1.2,.5,1)` over 220 ms), text becomes white, count-chip background goes `rgba(0,0,0,0.25)`. Inactive: text `var(--t-2) #9aa6b4`, count-chip background `rgba(255,255,255,0.08)`.
`Local` = installed *or* downloaded-but-not-yet-installed. `Installed` = installed only. `All Games` = everything available on the network.
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.
- **Call to Play button** (`.ctp-btn`, sits just left of the kebab in the far-right sub-group) — 36 px pill, flag icon + `Call to Play` label. Carries an accent-filled **badge** with the count of active (non-started) calls. Opens the Call to Play overlay. See the **"Call to Play"** section for the full feature. In variant B it lives in row 1's right group, between the storage meter and the kebab.
**Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet.
2. **Results bar** — 18px top padding inside the scroll wrapper, 24px horizontal. Flex row with space-between:
- Left: `Showing <strong>N</strong> of M games` in 12.5px `var(--t-2)` (strong is `var(--t-1)`).
- Right: compact **storage meter** — 200px min-width, 4px-tall horizontal bar with two stacked segments (`installed` and `local`), plus a 11px text row underneath: `<sq> 78 GB installed <sq> 41 GB local 384 GB free`. Squares are 8×8px rounded 2px, colored `var(--accent)` and `color-mix(var(--accent), 55%)`.
3. **Grid** — CSS grid with `repeat(auto-fill, minmax(188px, 1fr))` at default density, 16px gap, 24px horizontal padding, 32px bottom padding. Scrolls vertically.
- Density: `compact` → min 148, gap 12. `normal` → min 188, gap 16. `large` → min 244, gap 20.
**Game card** (see "Game card" below for full anatomy).
---
### 2. Game detail overlay
Opens when the user **clicks anywhere on a game card except the action button**. Modal over a scrim. Closes on scrim click, Esc key, or the close button. Should also work via keyboard nav (Enter on focused card).
**Scrim:** absolutely positioned over the launcher, `inset: 0`, `z-index: 100`, `background: rgba(4,7,11,0.7)`, `backdrop-filter: blur(8px)`, fade-in 180ms. Padding 32px, content centered.
**Modal panel:** `min(880px, 100%)` wide, `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-2)`, `border-radius: 14px`, drop shadow `0 30px 80px -10px rgba(0,0,0,0.7)`. Scales in from 0.96 with 250ms `cubic-bezier(.3,1.3,.4,1)`.
**Modal structure (top-to-bottom):**
1. **Hero banner**`aspect-ratio: 16/7`. Full-bleed cover art rendered as a banner (same gradient + accent treatment as the small cards, scaled up). Bottom-fade gradient `linear-gradient(180deg, transparent 40%, var(--bg-2) 100%)` so text reads.
- **State chip** in the top-left of the hero (same chip style as on cards — see Game Card).
- **Close button** top-right: 32×32 square, `background rgba(8,12,16,0.7)`, `1px solid var(--bd-2)`, `border-radius: 8px`, `backdrop-filter: blur(8px)`, X icon.
- **Title overlay** in bottom-left at `left: 28px, right: 28px, bottom: 22px`:
- Tags row — small uppercase pills (`background rgba(8,12,16,0.6)`, `1px solid var(--bd-2)`, `border-radius: 4px`, `padding: 3px 8px`, `font 11px / 600 / 0.04em letter-spacing`)
- **Title** as `<h2>` — system sans 32px / 700 / -0.015em, white, text-shadow `0 4px 24px rgba(0,0,0,0.6)`. **Not Bebas Neue** here — this is normal UI typography, not stylized cover art.
2. **Body** — 22px top, 26px bottom, 28px horizontal:
- **Meta grid** — 4-column CSS grid, 12px gap. Each cell: `padding 10px 12px`, `background rgba(255,255,255,0.025)`, `1px solid var(--bd-1)`, `border-radius: 8px`. Cells (in order): `Size` (e.g. 8.2 GB), `Players` (icon + range), `Version` (mono, e.g. 2018.04.12), `Status` (Installed / Local / Not downloaded).
- **Description** — 14px / 1.55 line-height, `var(--t-2)`, `text-wrap: pretty`, `max-width: 64ch`.
- **Actions row** — flex row, 10px gap, 4px top padding. Order, left → right:
1. **Primary action button** (44px tall, see "Action button" below — Play / Install / Download depending on state).
2. **Start Server***only* when `game.canHostServer === true` **and** `state === 'installed'`. Same 44px height as Play, but visually a peer secondary action (see "Start Server button" below). Triggers a Tauri command that spawns the game's dedicated-server executable in headless mode against the local LAN (port + server config out of scope here — leave a `startServer(gameId)` IPC stub).
3. If `state === 'installed'`: ghost-button **Uninstall** — 44px, `background rgba(255,255,255,0.04)`, `1px solid var(--bd-2)`, `border-radius: 8px`, text `#f87171`, trash icon. On hover: bg `rgba(239,68,68,0.10)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`.
4. If `state === 'local'`: ghost-button **Delete from disk** (same danger styling).
5. If `state === 'downloading'`: ghost-button **Cancel** (same danger styling).
6. Spacer (`flex: 1`).
7. Ghost-button **View files** (neutral) — opens system file manager at the game folder.
#### Start Server button
A secondary-but-equal action that sits next to **Play**. The intent is to read as a host-action ("I want to put this game on the LAN") without competing with the green Play button for the player's primary attention.
- Same shape and height as Play: 44px tall, `border-radius: 8px`, `font 14px / 600`, 8px gap between icon and label, padding `0 22px`.
- Surface: `background: color-mix(in srgb, var(--accent) 14%, rgba(255,255,255,0.04))`, `border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent)`, `box-shadow: inset 0 1px 0 rgba(255,255,255,0.06)`. Text in `--t-1`.
- **Icon** in `--accent`: a small server-rack glyph (two stacked rounded rectangles each with an LED dot and a hint of wiring). 13×13. SVG in `components.jsx → Icon.server`.
- Hover: `background: color-mix(in srgb, var(--accent) 22%, ...)`, border darkens to `color-mix(... 75%, transparent)`. Active: `transform: scale(0.98)` (shared with `.act-btn`).
- A future *running* state (live indicator dot + "Server running" label + click-to-stop) is **not** in this round — flag as a follow-up when wiring the real spawn.
The button is purposefully **not** present on game cards in the grid — hosting a server is intentional and benefits from the context of the detail overlay (player count, version, etc.). Don't add it to cards.
---
### 3. Settings dialog
Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim treatment as the game-detail modal, but the panel is narrower (`min(640px, 100%)`) and styled as a list of preferences.
**Structure:**
```
┌─────────────────────────────────────────┐
│ Settings [×] │ ← head: 22 28 18, 1px bottom border
├─────────────────────────────────────────┤
│ │
│ PROFILE │ ← section title (new): 10.5px / 700 / 0.12em / uppercase / --t-3
│ │
│ Username │ ← row label: 14px / 600 / --t-1
│ Shown to other players on the LAN │ ← row hint: 12px / --t-3
│ [ Enter a username ] │ ← text input (220×36)
│ │
│ Language │
│ Interface language │
│ [English│Deutsch] │ ← segmented radio (new)
│ │
│ APPEARANCE │
│ │
│ Accent color │
│ Used for primary actions and highlights │
│ ⬤⬤⬤⬤⬤⬤ │ ← 6 swatches, right-aligned
│ │
│ Background │
│ Backdrop behind the library │
│ [Flat │Gradient│Animat]│ ← segmented radio
│ │
│ 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]│
│ │
│ Cover aspect │
│ Shape of the cover art on each card │
│ [Box-art│Square│Banner│
│ │
├─────────────────────────────────────────┤
│ [Done] │ ← foot: 14 22 18, 1px top border
└─────────────────────────────────────────┘
```
**Sections** are separated by 26px gap (column flex). Rows within a section: 14px gap. Each **row** is flex row with space-between (24px gap):
- Left (`settings-row-info`): label (14px / 600 / `--t-1`) + hint (3px-top, 12px / `--t-3`)
- Right (`settings-row-control`): the control
**Profile section** (new in this round). Two rows, rendered **above** Appearance — it's the most personal/identity-shaped setting so it's the first thing the user sees in Settings.
- **Username** — `<input type="text">` wrapped in a styled container: 220px wide, 36px tall, `background var(--bg-3)`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 0 12px`. Input itself is transparent/borderless, `font 13.5px / 600`, color `--t-1`, placeholder `"Enter a username"` in `--t-3` / 500. `maxLength={24}`, `spellCheck={false}`. On focus the container gets `background var(--bg-2)`, border `var(--accent)`, and an accent focus ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent)`.
- **Language** — same segmented-radio control as Background / Density / Cover aspect, with two options: `English` (value `'en'`) and `Deutsch` (value `'de'`). Active option gets the accent fill, same as the other segmented radios.
**Library section.** Three rows: **Game folder** (new in v3 — moved out of the top bar), **Grid density**, **Cover aspect**.
- **Game folder** — see "Game-folder field" below. The first row in the section because it's the only setting users *must* configure for the launcher to work; density and aspect are pure preference.
**Color swatch picker:** flex row of 8px-gapped buttons. Each swatch is 32×32, `border-radius: 9px`, no border. Inside, a 100% × 100% rounded-8 colored dot with inset shadow `0 0 0 1px rgba(255,255,255,0.08)`. Hover: dot scales 1.06. **Active**: dot has ring `box-shadow: 0 0 0 2px var(--bg-2), 0 0 0 4px <swatch-color>` and shows a centered white check icon with drop-shadow `0 1px 2px rgba(0,0,0,0.5)`.
Six accent options: Blue `#3b82f6`, Cyan `#22d3ee`, Violet `#a855f7`, Green `#22c55e`, Amber `#f59e0b`, Red `#ef4444`.
**Segmented radio:** inline-flex with `background var(--bg-3) #1a2330`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 3px`. Each button: 30px tall, `padding: 0 14px`, `border-radius: 6px`, `font 12.5px / 600`. Inactive: `color var(--t-2)`. Active: `background var(--accent)`, `color white`, inset top shadow `0 1px 0 rgba(255,255,255,0.18)`.
**Done button:** filled button in `--accent`, 36px tall, 13.5px / 600. Closes the dialog.
Persisted settings (write through to local storage / Tauri config):
- `username`: string, max 24 chars. Default `"Commander"` (placeholder — feel free to default to the OS username on first run). Used as the network identity for LAN sessions; the hint copy *"Shown to other players on the LAN"* tells the user what it does.
- `language`: `'en'` | `'de'`. Default `'en'`. Drives an i18n layer (introduce one if it doesn't exist yet — `react-i18next` or similar). Initial copy is English-only in the mock; German translations need to be added as part of implementation. Recommend detecting the OS locale on first run and defaulting to `'de'` if the system language starts with `de`.
- `accent`: one of the six hex values above. Default `#3b82f6`.
- `bg`: `flat` | `gradient` | `animated`. Default `gradient`.
- `density`: `compact` | `normal` | `large`. Default `normal`.
- `aspect`: `box` | `square` | `banner`. Default `box`.
- `gameFolder`: `string | null`. Absolute path to the parent directory where games are downloaded and installed. Default `null` (unset on first run). See "Game-folder field" below.
---
## Game-folder field
A settings row inside the **Library** section of the Settings dialog. Exposes the user's currently-configured game folder (the parent directory under which all per-game subfolders live).
**Why it lives in Settings now:** users set this once at install time and basically never touch it again. A permanent top-bar button burned high-attention chrome on a control nobody used after day one. Settings is where one-time configuration belongs.
Two visual states, driven by whether `settings.gameFolder` resolves to an accessible directory:
| State | Trigger | Path display | Border | Button label |
|---|---|---|---|---|
| **Set & valid** | path is configured and exists on disk | full path in mono, truncated head-first | default `--bd-1` | `Change…` (neutral pill) |
| **Not set / invalid** | path is `null`/empty, or path is set but the directory no longer exists | `Not set` in red | tinted red (`color-mix(in srgb, var(--danger) 35%, var(--bd-1))`) + faint red bg tint | `Choose…` (accent-filled pill) |
"Invalid" is intentionally collapsed into the same visual state as "not set" — the user's job is identical (open the picker and pick a folder), so we don't differentiate. If we later need a distinct "missing" state (e.g. to show the *last known* path so the user can re-attach an external drive), introduce a third state then; for now, keep it simple.
**Anatomy:** `inline-flex`, `width: 340px`, `height: 36px`, `padding: 0 4px 0 12px`, `gap: 8px`. `background: var(--bg-3)`, `border-radius: 8px`. Children, left to right:
1. **Folder icon**`Icon.folder` from `components.jsx`, 14×14, `var(--t-3)` (set state) or `#f87171` (unset state).
2. **Path display**`flex: 1`, mono `12px / ui-monospace`, `--t-1`, single line, `overflow: hidden; text-overflow: ellipsis`. **`direction: rtl` + `unicode-bidi: plaintext`** so truncation happens from the head and the leaf folder (the part the user actually cares about) stays visible. When unset: shows the word `Not set` in 12.5 px / 600 / `#f87171` instead.
3. **Action button** — 28 px tall pill, `border-radius: 6px`, `padding: 0 12px`, `font 12.5px / 600`. Set state: neutral `rgba(255,255,255,0.06)` bg, label `Change…`. Unset state: `var(--accent)` fill at 85% alpha, white text, label `Choose…` (so the call-to-action reads stronger when the path needs picking). Click → native folder picker via Tauri; on selection, write through to `settings.gameFolder` and rescan library.
**Hover:** border darkens to `--bd-2` (set state) or to `color-mix(in srgb, var(--danger) 55%, var(--bd-2))` (unset state). The inner button has its own hover (background opacity bumps).
**Accessibility:** the path itself is selectable text inside the field; the action button carries `aria-label="Change game folder"` / `"Choose game folder"`. The full path is also exposed via `title` on the path-display element so it's reachable on hover when truncated.
**Why no inline path on the previous top-bar button anymore?** Original design squeezed the full path into a top-bar button as truncated mono. It rarely showed the meaningful part of the path on real-world configurations, ate horizontal space, and competed with the actual primary controls (filter / search / sort) for the top bar's attention budget. In the new home (Settings), the field has all the width it needs to show a useful prefix of the path while still keeping the leaf visible — and it's only on screen when the user is actively reconfiguring.
**Data:** the component takes `value: string | null` and an `onChange(next: string)` callback. `null` (or empty/whitespace string) renders the unset state; any non-empty string renders the set state. The `onChange` callback should fire only on successful picker confirmation (not on cancel). In production, derive `value` from your settings store; if you want to additionally validate existence, do the `fs.metadata` check in the store / a hook and pass `null` when the directory is missing.
**Dev preview:** the prototype's Tweaks panel exposes a `Game folder` **text field** (under the *Library* section) that writes directly to `t.gameFolder`. Type any string to simulate the set state; clear it to simulate the unset state. This is dev-only — in the real app the value comes from the settings store via the picker, **not** from a free-form text input. Don't ship the Tweaks panel.
---
## Game card
The unit element of the library grid.
**Container:** flex column. `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-1)`, `border-radius: 10px`, `overflow: hidden`. Cursor pointer.
**Hover/focus state:**
- `transform: translateY(-2px)` (180ms `cubic-bezier(.4,1.2,.5,1)`)
- `border-color: color-mix(in srgb, var(--accent) 45%, var(--bd-2))`
- Box-shadow `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)`
- Cover inner image scales to 1.03 (350ms cubic-bezier)
- Focus-visible: same lift + 2px solid accent outline
### Anatomy (top to bottom)
1. **Cover wrap**`width: 100%`, `aspect-ratio: 2/3` (box) / `1/1` (square) / `16/9` (banner). `position: relative`, `overflow: hidden`, fallback bg `var(--bg-3)`.
2. **Cover** (inside cover-wrap, `position: absolute; inset: 0`):
- **Base gradient** — diagonal (`linear-gradient(<110-170deg>, c1, c2)` — angle hashed from game id for variety). Per-game color pair from the game's `cover` metadata.
- **Radial accent blob** — `radial-gradient(ellipse at <x>% <y>%, <accent>38, transparent 55%)`. x/y also hashed from id.
- **Grain / scanline** — two `repeating-linear-gradient` overlays at 1px intervals, `mix-blend-mode: overlay`, opacity 0.7.
- **Decorative SVG mark** — preserveAspectRatio bottom-right, draws a triangle and dot in the accent color at 12% opacity. Variation via id hash.
- **Title** absolutely positioned at bottom-left, padding `14px`. Font `Bebas Neue` (free Google Font, fallback `Oswald, Impact, "Arial Narrow Bold", sans-serif`), 400 weight, uppercase, `letter-spacing: 0.018em`, `line-height: 1.02`, white, text-shadow `0 4px 16px <c2 + alpha>, 0 1px 0 rgba(0,0,0,0.3)`. Size scales by title length: 26px for ≤14 chars, 21px for ≤20, 17px for ≤26, 15px for longer (box aspect; see `components.jsx → GameCover` for square/banner variants).
- **Vignette** — `linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.62) 100%)` over the whole cover, painted *after* the title (so the dark gradient is behind the title visually — title is z-index 2).
- **State chip** in top-right: pill with backdrop-blur, `background rgba(8,12,16,0.78)`, `1px solid rgba(255,255,255,0.08)`, `border-radius: 999px`, `padding: 4px 9px`, font `10.5px / 600`. A 6×6 colored dot (green `#22c55e` for installed, amber `#f59e0b` for local; hidden for "not downloaded") + label. Dot has glow `box-shadow: 0 0 8px <color>`.
- **Multiplayer badge** in top-left: same pill style but slightly lighter background (`rgba(8,12,16,0.65)`). Tiny "users" icon + player range (e.g. `232`). Always visible — every LAN game is multiplayer.
3. **Card body**`padding: 11px 12px 12px`, flex column, 8px gap:
- **Title** — game's full (mixed-case) title in 13.5px / 600 / `--t-1`, single line, ellipsis on overflow.
- **Meta line** — 11.5px tabular-nums, `--t-3`: size · genre. Dot separator at 50% opacity.
- **Action button** (full width) — primary action depending on state, see below.
### Action button
A single button per card with the *primary action for the current state*. Color-coded as the main affordance for state at a glance.
```
state label button style
───────────── ────────── ────────────────────────────────────────────
not downloaded Download neutral: bg rgba(255,255,255,0.08), 1px var(--bd-2), text var(--t-1)
local Install bg var(--accent), text white, inset top hl
installed Play bg linear-gradient(180deg, #2bd07f 0%, #1aa460 100%), text white, inset top hl
downloading — progress see "Download progress" below — the button slot is replaced with a live progress component
```
Common sizing: 32px tall (card) or 44px tall (modal). `border-radius: 7px` (card) / 8px (modal). `font 12.5px / 600` (card) / `14px / 600` (modal). 6px gap between icon and label. Icons: filled play triangle, download arrow, install arrow-onto-line (all 12×12).
Hover: `filter: brightness(1.12)`. Active: `transform: scale(0.98)`.
**Uninstall / Delete-from-disk** are NOT on the card — only in the detail overlay (as ghost-danger buttons).
---
## Download progress (state === 'downloading')
When a game is actively downloading, the **action-button slot is replaced** by an inline progress component. The component is its own visual primitive (`DownloadProgress` in `components.jsx`); it is NOT a button with a `<progress>` child. Two layouts share the same primitive:
### Shared visuals
- Container: `border-radius: 7px` (card) / `9px` (modal), `1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2))`, faint accent halo via `box-shadow`. `container-type: inline-size` (we use container queries for graceful fallback, see below).
- **Progress fill** (`.dl-fill`): absolutely positioned, `width: <pct>%`, animated via `transition: width 480ms cubic-bezier(.4,0,.2,1)`. Background is a vertical gradient of `color-mix(in srgb, var(--accent) 3826%, transparent)`. Right edge gets a 1px accent rule + accent glow.
- **Live shimmer** on top of the fill: `repeating-linear-gradient(115deg, transparent 0 14px, rgba(255,255,255,0.05) 14px 22px)` panned via `animation: dl-stripe 1.4s linear infinite`, `mix-blend-mode: screen`. Subtle — it reads as "live" without being distracting.
- **Pulse dot** (`.dl-pulse`): 7px accent dot with an outward-pulsing `box-shadow` ring (1.4s ease-out infinite). Visual cue that the network transfer is active.
- **Tabular numerics** on all values (`font-variant-numeric: tabular-nums`) so the percentage and speed don't jitter as digits roll over.
### Card layout (`.dl-md`, replaces the 32px action button)
A single row. Two values, separated by `justify-content: space-between`:
- **Left:** `<pulse> <pct>%` — 12px / 600, `var(--t-1)`. `%` glyph at 0.55 opacity. e.g. `• 32%`.
- **Right:** `<speed>` — 11px / 500, `var(--t-2)`. Short format: `49 MB/s` (no decimals at card scale).
Heights match the action button per density: 30px compact / 32px normal / 34px large. Padding `0 10px` (9 compact / 12 large). Font sizes scale similarly (see `styles.css`).
**Container-query graceful degradation** — this is the important part, it has to fit every aspect/density combo:
```css
@container (max-width: 132px) { .dl-md .dl-speed { display: none; } .dl-md-row { justify-content: center; gap: 6px; } }
@container (max-width: 96px) { .dl-md .dl-pulse { display: none; } }
```
At 132 px and below, the speed disappears and the percentage centres. At 96 px and below, the pulse dot also drops, leaving just the percentage. This is what guarantees `compact` density + `box` aspect (the narrowest combination) still reads cleanly.
The state chip in the cover corner still says "Downloading" — we are deliberately NOT repeating that label inside the progress bar.
### Detail-overlay layout (`.dl-lg`, replaces the 44px modal action button)
Fixed 56px height. CSS-grid with three columns and two rows:
```
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-areas:
"primary pct cancel"
"secondary pct cancel";
```
- **Primary row** (`.dl-lg-primary`, top-left) — pulse dot + the uppercase live label `DOWNLOADING` in `color-mix(in srgb, var(--accent) 80%, white)`, 13px / 600, `letter-spacing: 0.02em`. This is the only place the word "Downloading" appears in the component.
- **Secondary row** (`.dl-lg-secondary`, bottom-left) — the live stats. 12px, four groups separated by `·` (0.45 opacity):
1. `<strong>11.4 GB</strong> / 35 GB` (`var(--t-1)` strong + `var(--t-2)` rest)
2. `47.6 MB/s` (`var(--t-1)`)
3. `[users-icon] 5``.dl-peers`, inline-flex with 4px gap, icon at 0.7 opacity, count in `var(--t-1)` 600 tabular-nums. Hidden entirely when `game.peers` is falsy. Communicates this is a LAN swarm transfer; the full sentence lives in the `title` tooltip.
4. `8 min left` (`var(--t-2)`)
- **pct column** — large percentage, 20px / 700, `letter-spacing: -0.01em`, `var(--t-1)`. `%` glyph at 12px / 600 / 0.55 opacity.
- **cancel column** — 28×28 square, `1px solid var(--bd-2)`, `border-radius: 6px`, X icon. Hover: bg `rgba(239,68,68,0.12)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`. Cancelling reverts the game to its prior state (`local` if any data was kept, `none` otherwise) — dev decides the underlying behavior.
**Graceful degradation in narrow modals:**
```css
@container (max-width: 320px) { .dl-lg-secondary .dl-eta, .dl-lg-secondary .dl-sep-eta { display: none; } }
@container (max-width: 240px) { .dl-lg-secondary .dl-peers, .dl-lg-secondary .dl-sep-peers { display: none; } }
```
ETA drops first, then peers; bytes + speed always stay (they're the actionable numbers). The pct/cancel column never collapses.
### Number formatting
All helpers live in `data.jsx`:
- `fmtSpeed(mbps)``49.4 MB/s` below 100, `MM MB/s` (rounded) at/above 100. Used in `.dl-lg`.
- `fmtSpeedShort(mbps)` — always rounded: `49 MB/s`. Used in `.dl-md` so the card stays compact.
- `fmtBytes(gb)``<1 GB → MB rounded`, `<10 GB → up to 2 decimals` (trailing zeros stripped: `2.35 GB`, `2.3 GB`, `2 GB`), `≥10 GB → 1 decimal max` (`11.4 GB`, `35 GB`).
- `fmtEta(seconds)``< 60s → "N s"`, `< 60min → "N min"`, else `"H h M min"`.
Keep these formats; they're tuned so the secondary row never wraps at normal modal width.
### Data shape
The `Game` type gains a `downloading` state plus two transient fields:
```ts
type Game = {
// … existing fields …
state: 'installed' | 'local' | 'downloading' | 'none';
progress?: number; // 01, only when state === 'downloading'
speed?: number; // current throughput in MB/s
peers?: number; // number of LAN peers currently seeding
};
```
In the real app, `progress`, `speed`, and `peers` come from the download worker (Tauri command emitting events). The mock's `useLiveDownload(game)` hook (in `components.jsx`) is just a placeholder — 600ms `setInterval` advancing `progress` proportional to `speed`, with `speed` smoothed via a low-pass filter and small random drift so the number doesn't look fake. `peers` is read straight off the game object (static in the mock); in production, push updates as peers join/leave the swarm — the `.dl-peers` chip re-renders silently. Replace the hook with a `useEffect` that subscribes to your real progress events; the rendering layer needs nothing else.
Filter changes:
- `Local` filter includes `installed` + `local` + `downloading` (in-flight downloads belong on the Local tab — you're managing them).
- Sort by `state` orders `installed < local < downloading < none`.
### State chip
Add a fourth entry to `STATE_META`:
```js
downloading: { label: 'Downloading', dot: 'var(--accent)' }
```
Dot uses the live accent so it visually ties to the progress fill.
---
## Call to Play
A coordination layer that sits on top of the library: **rally the people on the
LAN around a specific game at a specific time.** It answers the actual social
question at a LAN party ("what are we all playing, and when?") that a plain
library grid can't.
Source: `calltoplay.jsx` (the feature) + `ctp-chat.jsx` (per-call chat + shared
`ctpAvatarColor` / `ctpFmtClock` helpers). Styles are the `ctp-*` classes in
`styles.css`. Backed by the mock LAN **peer roster** `PEERS` in `data.jsx`.
### Two flavors of call
| | **Play now** | **Scheduled** |
|---|---|---|
| Set up with | game + max players + **duration** (5/10/15/30/60 min) | game + max players + **clock time** (24h, + which day) |
| Others respond | `Ready now` or `+N minutes` | `I'm in` (RSVP), then check in later |
| Resolves | when the roster fills **or** the timer runs out | at the scheduled time, after a check-in window |
| Who starts it | the **caller** decides the actual launch | the **caller**, once people have checked in |
**Check-in window.** `CHECKIN_LEAD_MS = 15 min`. A scheduled call sits in the
`scheduled` phase collecting RSVPs until 15 minutes before its start time, then
flips into the `checkin` phase: it lights up everywhere and everyone who said
"I'm in" is nudged to answer with the same `Ready now` / `+N minutes` states as
a play-now call. A play-now call is effectively "always in its check-in window."
**Every call carries a small group chat** (see "Per-call chat" below).
### Three surfaces
1. **Top-bar button** (`CallToPlayButton` / `.ctp-btn`) — flag icon + label +
an accent badge counting active (non-`started`) calls. Opens the overlay.
2. **Quick bars** (`CallToPlayTicker` / `.ctp-ticker-stack`) — a persistent
stack rendered at the top of the grid area, **one row per active call**.
Sorted **ready → starting-soon → the rest**, ties broken by whichever
resolves/starts soonest (`TICKER_RANK` = ready 0, soon 1, call/scheduled 2;
then by `deadline`). Clicking a row opens the overlay focused on that call.
3. **Overlay** (`CallToPlayOverlay`) — a modal (same scrim/panel treatment as
the other dialogs) with a header, a **Call a new match** button, the create
form, and a list of **nomination cards** (started calls sink to the bottom).
### Status model
Two derived values drive everything (`calltoplay.jsx`):
- `phaseOf(call)``'now'` (no `scheduledFor`) · `'scheduled'` (>15 min out) ·
`'checkin'` (within the 15-min lead).
- `statusOf(call)` (quick-bar/label status) → `'started'` · `'ready'`
(`readyCount >= maxPlayers`, or state `done`) · `'soon'` (`deadline - now ≤
15 min`) · `'scheduled'` (has a clock time) · `'call'` (a plain play-now call).
Quick-bar labels + LED colors: **SCHEDULED**, **CALL TO PLAY**, **STARTING
SOON**, **READY** (`TICKER_LABEL`), each with its own dot color via
`.ctp-ticker-dot[data-status]`.
**Per-participant ready state:** `ready` (explicitly readied, or their `readyAt`
countdown has elapsed) · `in` (RSVP'd to a scheduled call but not checked in
yet) · `pending` (checked in with a `+N minutes` buffer, counting down). Shown
as compact **ready-bubbles** in the quick bars (`MiniBubbles`: initials +
green ring/check when ready, `+Nm` tag when pending, dimmed when just "in") and
as larger `AvatarChip`s in the nomination card roster.
### Nomination card (`NominationCard`)
Top to bottom:
1. **Header** — square game cover + title + a sub-line: `Called by <creator> · N/M peers have it installed` (or `Scheduled by <creator> · starts at HH:MM · …`), and a **timer** on the right: a live `M:SS` countdown for play-now/check-in, the **clock time + "in N min"** for a scheduled call, `Ready`, or `Launching…`. Countdown urgency (`data-urgency` high/mid/low) tints it as time runs low.
2. **Check-in note** — only in the `checkin` phase: a clock icon + "Starting soon — check-in is open" (or a personalized nudge if you RSVP'd).
3. **Progress bar** — time remaining as a fill (accent, → green when done); hidden while a call is still in the far-out `scheduled` phase.
4. **Roster**`readyCount/maxPlayers ready` (scheduled shows `N in · up to M players`; check-in adds `· K not checked in yet`), then avatar chips for each participant plus empty slots up to `maxPlayers`.
5. **Actions** — context-dependent on your role (**creator** / **participant** / **outsider**) and phase: `Ready now` + `+5/10/15/30m` buffer buttons, `I'm in` (RSVP), `Start now` / `Add 5 more minutes` (creator once resolved), `Leave` / `Can't make it`, or a status note.
6. **Chat** — the collapsible per-call chat panel.
7. **Cancel** — creators get a `Cancel this call` link with an inline confirm.
### Create form (`CreateNominationForm`)
Game search (typeahead over the catalog) → on pick, max-players defaults to the
game's parsed player cap (`parseMaxPlayers`). **When** toggles `Now` vs
`Schedule`. `Now` reveals the **Give people** duration chips. `Schedule` reveals
a **24-hour time picker** (hour/minute steppers **and** a "Type a time" free-text
field accepting `20:00` / `2000` / `9:30`) plus **day chips** (Today + next two
days). The confirm button reads `Call it — <game>` or `Schedule it — <game> ·
<day> <time>`.
### Per-call chat (`CtpChat`, `ctp-chat.jsx`)
Collapsed to a one-line toggle showing an unread badge + a preview of the last
message; expands to a compact IRC-style list (colored usernames via
`ctpAvatarColor`, `HH:MM` timestamps) + an input. All chat state is local to the
card. Usernames are colored deterministically by a hash of the name.
### Data shape
```ts
type Nomination = {
id: string;
gameId: string;
creator: string; // username of the caller
maxPlayers: number;
createdAt: number; // ms epoch
scheduledFor: number | null; // ms epoch clock time; null = play-now
deadline: number; // play-now: createdAt + durationMin; scheduled: === scheduledFor
participants: Record<string, { // keyed by username
status: 'ready' | 'in' | 'pending';
joinedAt: number;
readyAt?: number; // ms epoch a 'pending' buffer elapses
}>;
messages: { id: string; from: string; text: string; at: number }[];
state: 'open' | 'done' | 'started';
startedAt?: number;
};
```
The `useNominations({ username, seed })` hook owns the list and exposes
`createNomination`, `respond`, `rsvp`, `sendMessage`, `leave`, `cancel`,
`startNow`, `addTime`.
### Mock vs production
The mock **simulates other people** with a 1-second `setInterval`
(`tickNomination`): bots RSVP to scheduled calls, ready-up during check-in, walk
in late, and occasionally post a chat line — purely so the demo resolves
visibly (bots use second-scale buffers; real people use the minute-scale ones).
The mock also seeds a few representative calls on mount (a live call with chat,
a fresh call you started, a scheduled call whose check-in window just opened,
and one scheduled for later collecting RSVPs).
**In production, replace the simulation with a real-time LAN transport:** the
launcher instances broadcast nominations, responses, RSVPs, and chat messages to
each other (whatever the app uses — a small pub/sub over the LAN, a lightweight
signaling server, or Tauri-side networking). Swap the guts of `useNominations`
(the `setInterval` + local `setNoms`) for your networking layer that pushes the
same state shape; the rendering components need nothing else. The `username` that
identifies "you" comes from `settings.username` (the Profile setting), not a
prop default.
---
## Filter controls — variant B (not used, kept for reference)
The two-row chrome has a different filter style — **underlined tabs with counts**, like browser tabs:
- Buttons: no background, `padding: 10px 14px 12px`, font `13.5px / 600`. Color `--t-2` inactive, `--t-1` active.
- Count chip after label: 11.5px / 600, `padding 1px 7px`, rounded pill. Inactive bg `rgba(255,255,255,0.06)`, text `--t-3`. Active bg `rgba(255,255,255,0.10)`, text `--t-1`.
- Active tab has a 2px underline at the bottom (`left: 12px, right: 12px`) in `--accent`, animated in via opacity + scaleX (220ms cubic-bezier).
Implement only if you decide variant A doesn't work after building.
---
## Interactions & behavior
- **Click game card** (anywhere except the action button) → open detail overlay.
- **Click action button on card** → trigger the state-appropriate action without opening the overlay. `e.stopPropagation()` on the button.
- **Press / (slash)** → focus the search input.
- **Type in search** → live-filter the visible grid by title or tag (case-insensitive substring).
- **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 "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).
### Transitions / animations
- Card hover: `180ms cubic-bezier(.4,1.2,.5,1)` on transform/border, `350ms cubic-bezier(.4,1.2,.5,1)` on cover scale.
- Modal fade-in: scrim `opacity 0 → 1` over 180ms ease; modal `transform: scale(.96) translateY(8px) → scale(1) translateY(0)` and opacity over 250ms `cubic-bezier(.3,1.3,.4,1)`.
- Segmented filter thumb: `220ms cubic-bezier(.4,1.2,.5,1)` on `left` and `width`.
- Underline tab indicator (variant B): `200ms` on opacity, `250ms cubic-bezier(.4,1.2,.5,1)` on `transform: scaleX`.
- Animated background option: subtle 18s ease-in-out infinite alternate background-position shift on two accent-tinted radial gradients.
---
## State management
Recommend Zustand or a single React context for global launcher state; Tauri commands for filesystem and process operations.
**Library state** (rebuilt on `refresh`):
```ts
type Game = {
id: string;
title: string;
size: number; // GB
version: string; // "YYYY.MM.DD"
desc: string;
state: 'installed' | 'local' | 'downloading' | 'none';
progress?: number; // 01 — present only when state === 'downloading'
speed?: number; // MB/s — present only when state === 'downloading'
peers?: number; // LAN peers currently seeding
players: string; // e.g. "232"
tags: string[];
cover: { c1: string; c2: string; accent: string; mood?: string };
canHostServer?: boolean; // true if the game ships with a dedicated-server binary
};
```
**Server-capable games** in the mock catalog (`canHostServer: true`): BF1942, BF2, CoD2, CoD4, CoD:UO, CS 1.6, CS:Source, Cube 2/Sauerbraten, Doom 3, L4D2, Minecraft, Quake III, TF2, UT2004. RTS / social-deduction / co-op-only-P2P games (AoE II HD, RA3, Generals ZH, Among Us, Portal 2, StarCraft, Warcraft III, AvP, 8-Bit Armies, BlazeRush) are not flagged — they host in-game. In production the flag should come from the same per-game manifest that drives titles / sizes / cover art. Wire each entry to whatever launch command the dedicated server uses (`hldsexec`, `srcds`, `minecraft_server.jar`, etc.); the IPC stub looks like `startServer(gameId)` returning a handle or process id.
**UI state:**
```ts
type LauncherUI = {
filter: 'all' | 'local' | 'installed';
sort: 'az' | 'size' | 'recent' | 'state';
query: string;
openGameId: string | null;
settingsOpen: boolean;
};
```
**Persisted settings** (mirror of Settings dialog state):
```ts
type LauncherSettings = {
username: string;
language: 'en' | 'de';
accent: string; // hex from the curated 6-color palette
bg: 'flat' | 'gradient' | 'animated';
density: 'compact' | 'normal' | 'large';
aspect: 'box' | 'square' | 'banner';
gameFolder: string | null; // v3: moved out of top bar, persists actual path
};
```
Persist via Tauri's plugin-store or a local JSON file in app data dir. Changes from the Settings dialog should write through immediately (no Apply button).
**Storage figures:** computed by summing game sizes per state, plus free-space query via Tauri.
---
## Design tokens
### Color
| token | value | usage |
|---|---|---|
| `--bg-0` | `#0a0e13` | launcher background |
| `--bg-1` | `#0f151c` | card bottom gradient stop |
| `--bg-2` | `#131b25` | top bar / card top / search bg |
| `--bg-3` | `#1a2330` | settings segmented bg / cover fallback |
| `--bg-4` | `#232f3e` | (reserved) |
| `--bd-1` | `rgba(255,255,255,0.06)` | subtle border |
| `--bd-2` | `rgba(255,255,255,0.10)` | stronger border |
| `--bd-3` | `rgba(255,255,255,0.16)` | scrollbar thumb |
| `--t-1` | `#e6edf3` | primary text |
| `--t-2` | `#9aa6b4` | secondary text |
| `--t-3` | `#6b7785` | muted text / metadata |
| `--t-4` | `#4a5663` | (reserved) |
| `--ok` | `#22c55e` | "installed" dot |
| `--warn` | `#f59e0b` | "local" dot |
| `--danger` | `#ef4444` | destructive actions |
| `--accent` | user-selected, default `#3b82f6` | primary actions, focus rings, brand mark |
### Typography
- **UI font** — system sans stack: `-apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", system-ui, sans-serif`
- **Cover-art display font** — `"Bebas Neue"` (Google Fonts, weight 400) with fallback `"Oswald", Impact, "Arial Narrow Bold", sans-serif`
- **Monospace** — `ui-monospace, "SF Mono", Menlo, Consolas, monospace` (used for: directory path, version field in detail overlay)
Sizing reference:
- Brand wordmark: 15 / 700
- Modal title: 32 / 700 / -0.015em
- Card title: 13.5 / 600
- Filter pill label: 12.5 / 600
- Settings row label: 14 / 600
- Section title (settings): 10.5 / 700 / 0.12em uppercase
- Meta (size · genre, etc.): 11.5 / tabular-nums / `--t-3`
- Hint text: 12 / `--t-3`
### Spacing & radii
- Card radius: 10px
- Modal radius: 14px
- Pill/control radius: 8px (search, sort, dir button), 999px (filter segmented), 7px (action button)
- Common gaps: 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 28
- Card body padding: 11 12 12
### Shadows
- Card hover: `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)`
- Modal: `0 30px 80px -10px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.04)`
- Brand mark: `0 6px 20px -6px color-mix(var(--accent), 60%, black), inset 0 1px 0 rgba(255,255,255,0.22)`
- Action button (filled): `0 6px 16px -8px <color>, inset 0 1px 0 rgba(255,255,255,0.22)`
---
## Assets
Cover art in the design files is **stylized placeholder art** — generated entirely from the game's metadata (color pair + accent color + id hash for angle/blob position) plus the title typeset in Bebas Neue. There are no real game cover image assets in this design.
In the production app, the launcher should ideally use real cover-art when available (fetch from IGDB / Steam / local game folder) and fall back to the placeholder generator for games without art. The placeholder generator is in `design_reference/components.jsx → GameCover`.
The icon set (search, play, **server**, install, download, folder, kebab, sort, users, close, check, chevron, trash, **flag**, **clock**, **chat**, **send**, **caretUp**, **caretDown**) is in `design_reference/components.jsx → Icon`. They are 12-14px inline SVGs using `currentColor`. Reuse as-is or substitute with the codebase's existing icon library at the same visual weight. The `server` glyph drives the Start Server button; `flag` / `clock` / `chat` / `send` / `caretUp` / `caretDown` are used by Call to Play.
Fonts to load:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap">
```
---
## File reference
```
design_reference/
├── SoftLAN Launcher.html ← entry; wires React + Babel, mounts <App>
├── styles.css ← all visual styles (CSS custom props + components)
├── data.jsx ← mock GAMES array + PEERS roster + filter/sort helpers + STORAGE mock
├── components.jsx ← Icon, GameCover, StateChip, ActionButton, GameCard,
│ SegmentedFilters, UnderlineFilters, SearchField,
│ SortMenu, StorageMeter, KebabMenu,
│ GameDetailModal, SettingsDialog (incl. GameFolderField)
├── ctp-chat.jsx ← CtpChat panel + ctpAvatarColor / ctpFmtClock helpers
├── calltoplay.jsx ← Call to Play: useNominations hook, quick bars,
│ overlay, nomination cards, create form
└── launcher.jsx ← <Launcher> component composing chrome + grid +
modals + Call to Play
```
To preview the design in a browser:
1. Open `SoftLAN Launcher.html` in a static-server (e.g. `python -m http.server` from the folder).
2. You'll see a design canvas with all variants side-by-side. Click an artboard's expand button to view it full-screen.
- **Call to Play** (top section) — the quick-bar main view, and the same with the overlay open (live call + chat, your call, a check-in nudge, and a scheduled RSVP). Click anything to interact live — create a call, ready up, RSVP, chat.
- **A / B** — chrome variants (A is the chosen direction)
- **C** — detail overlay for an installed, server-capable game (Counter-Strike 1.6) → shows **Play + Start Server + Uninstall**
- **D** — detail overlay for a downloaded-but-not-installed game (CoD 4) → shows **Install + Delete from disk**
- **E** — detail overlay for a downloading game (AvP) → shows the live progress component + **Cancel**
- **F** — Settings dialog open, with the new **Profile** section at the top
3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you live-change every persisted setting (username / language / accent / background / density / aspect / game folder). In the production app these all live in the Settings dialog.
---
## Out of scope / open questions for the developer
- **Unpack logs viewer** — referenced from kebab menu but not designed. Surface it as a separate window or a slide-in panel, dev's choice.
- **Empty state** — when filter returns 0 games (e.g. nothing installed yet). Show a centered message with a CTA to install the first game.
- **Error state on action** — if a Download / Install fails, show inline error on the affected card (red border + retry button), and a toast.
- **Progress state** — designed. See "Download progress" section above. The action-button slot is swapped for a live `DownloadProgress` component (card + modal variants with container-query fallback for narrow tiles). Wire it to your real progress events; the rendering layer is dev-ready.
- **Keyboard arrow nav** — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal.
- **"Server running" state** — once Start Server actually spawns a process, the button should switch to a *running* state (live indicator dot + "Server running" label + click-to-stop). Not designed this round — flag for follow-up alongside whatever server-status panel the app grows.
- **Call to Play real-time transport** — the feature is fully designed and interactive, but the mock fakes other players with a local `setInterval` simulation. Production needs a real LAN transport that broadcasts nominations / responses / RSVPs / chat between launcher instances and pushes the same state shape into `useNominations`. Notifications when a call you're in enters its check-in window (OS notification / tray) are also a follow-up.
- **German translations** — the language toggle is wired in Settings, but the catalog of translated UI strings hasn't been compiled. Stand up `react-i18next` (or equivalent) and seed `en.json` from the existing copy; `de.json` is a translation task for whoever owns localization.
@@ -1,814 +0,0 @@
// calltoplay.jsx — "Call to Play": rally the LAN around a game + a time.
//
// Two flavors of call, one feature:
// · Play NOW — pick a game, max players, and how long to give people.
// Others respond "Ready now" or "+N minutes". Resolves early if the
// roster fills, or when the clock runs out — the CALLER starts it.
// · SCHEDULED — pick a game and a clock time ("Among Us at 20:00").
// People RSVP with "I'm in". 15 minutes before start the call enters
// its CHECK-IN window: it gets highlighted everywhere and everyone
// who's in is nudged to answer with the same ready / +N-minute states.
//
// Every call also carries a small group chat (see ctp-chat.jsx).
//
// Loaded after data.jsx + components.jsx + ctp-chat.jsx, before launcher.jsx.
const { useState, useEffect, useMemo, useRef, useLayoutEffect } = React;
const GAMES_ = window.GAMES;
const PEERS_ = window.PEERS;
const installedPeersFor_ = window.installedPeersFor;
const parseMaxPlayers_ = window.parseMaxPlayers;
const Icon_ = window.Icon;
const GameCover_ = window.GameCover;
const CtpChat_ = window.CtpChat;
const avatarColor = window.ctpAvatarColor;
const fmtClock = window.ctpFmtClock;
// Check-in opens this long before a scheduled call's start time — and any
// call (scheduled or not) counts as "starting soon" inside this window.
const CHECKIN_LEAD_MS = 15 * 60000;
function phaseOf(n, now) {
if (!n.scheduledFor) return 'now';
return now < n.scheduledFor - CHECKIN_LEAD_MS ? 'scheduled' : 'checkin';
}
// Quick-bar status: READY > STARTING SOON > CALL TO PLAY / SCHEDULED.
function statusOf(n, now) {
if (n.state === 'started') return 'started';
if (n.state === 'done' || readyCountOf(n, now) >= n.maxPlayers) return 'ready';
if (n.deadline - now <= CHECKIN_LEAD_MS) return 'soon';
return n.scheduledFor ? 'scheduled' : 'call';
}
function fmtCountdown(ms) {
if (ms <= 0) return '0:00';
const total = Math.ceil(ms / 1000);
const m = Math.floor(total / 60), s = total % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function fmtCountdownShort(ms) {
if (ms <= 0) return 'now';
const total = Math.ceil(ms / 1000);
if (total < 60) return `${total}s`;
return `${Math.ceil(total / 60)}m`;
}
function fmtUntil(ms) {
if (ms <= 60000) return 'in <1 min';
const min = Math.round(ms / 60000);
if (min < 60) return `in ${min} min`;
const h = Math.floor(min / 60), m = min % 60;
return m ? `in ${h}h ${m}m` : `in ${h}h`;
}
function isReady(p, now) {
if (!p) return false;
return p.status === 'ready' || (p.readyAt != null && now >= p.readyAt);
}
function readyCountOf(n, now) {
return Object.values(n.participants).filter(p => isReady(p, now)).length;
}
function inCountOf(n, now) {
return Object.values(n.participants).filter(p => !isReady(p, now) && p.status === 'in').length;
}
// ────────────────────────────────────────────────────────────────────
// Simulation — bots trickle into open calls (RSVP on scheduled ones,
// ready-up during check-in / now-calls) and occasionally post a chat
// line. Bots use second-scale buffers purely so the demo resolves
// visibly; real people would use minute-scale ones.
// ────────────────────────────────────────────────────────────────────
const CTP_BOT_LINES = [
'anyone need the game files? I can seed',
'gimme 5, grabbing food',
'who hosts?',
'voice is up — channel 2',
'ok, back at my desk',
'poke your neighbor, we need one more',
'default settings, no mods — right?',
'glhf',
];
function maybeBotChat(n, now) {
const msgs = n.messages || [];
const speakers = Object.keys(n.participants).filter(p => PEERS_.includes(p));
if (!speakers.length || msgs.length > 40 || Math.random() >= 0.03) return msgs;
const from = speakers[Math.floor(Math.random() * speakers.length)];
const text = CTP_BOT_LINES[Math.floor(Math.random() * CTP_BOT_LINES.length)];
return [...msgs, { id: `bm${now}${Math.random().toString(36).slice(2, 6)}`, from, text, at: now }];
}
function tickNomination(n, now) {
if (n.state !== 'open') return n;
const phase = phaseOf(n, now);
let participants = n.participants;
const joined = Object.keys(participants);
const available = PEERS_.filter(p => p !== n.creator && !joined.includes(p));
if (phase === 'scheduled') {
// RSVP trickle — far from start, people just say "I'm in".
if (available.length && joined.length < n.maxPlayers && Math.random() < 0.06) {
const bot = available[Math.floor(Math.random() * available.length)];
participants = { ...participants, [bot]: { status: 'in', joinedAt: now } };
}
return { ...n, participants, messages: maybeBotChat({ ...n, participants }, now) };
}
// Check-in window: RSVP'd bots convert to ready / "+N minutes".
const inBots = joined.filter(p => PEERS_.includes(p) && participants[p].status === 'in');
if (phase === 'checkin' && inBots.length && Math.random() < 0.22) {
const bot = inBots[Math.floor(Math.random() * inBots.length)];
participants = {
...participants,
[bot]: Math.random() < 0.6
? { ...participants[bot], status: 'ready' }
: { ...participants[bot], status: 'pending', readyAt: now + (10 + Math.random() * 70) * 1000 },
};
} else if (available.length && joined.length < n.maxPlayers && Math.random() < 0.10) {
// Walk-ins can still join late.
const bot = available[Math.floor(Math.random() * available.length)];
const readyNow = Math.random() < 0.55;
participants = {
...participants,
[bot]: readyNow
? { status: 'ready', joinedAt: now }
: { status: 'pending', joinedAt: now, readyAt: now + (10 + Math.random() * 70) * 1000 },
};
}
const messages = maybeBotChat({ ...n, participants }, now);
const readyCount = readyCountOf({ participants }, now);
const state = (readyCount >= n.maxPlayers || now >= n.deadline) ? 'done' : 'open';
return { ...n, participants, messages, state };
}
function makeSeedNomination({ gameId, creator, maxPlayers, ageSec = 0, durationMin = 10, scheduledInMin = null, participants = {}, messages = [] }) {
const now = Date.now() - ageSec * 1000;
const scheduledFor = scheduledInMin != null ? Date.now() + scheduledInMin * 60000 : null;
return {
id: `seed-${gameId}-${creator}`,
gameId, creator, maxPlayers,
createdAt: now,
scheduledFor,
deadline: scheduledFor != null ? scheduledFor : now + durationMin * 60000,
participants: { [creator]: { status: scheduledFor ? 'in' : 'ready', joinedAt: now }, ...participants },
messages: messages.map((m, i) => ({ id: `sm-${gameId}-${i}`, from: m.from, text: m.text, at: Date.now() - (m.agoSec || 0) * 1000 })),
state: 'open',
};
}
function useNominations({ username, seed = [] }) {
const [noms, setNoms] = useState(() => seed.map(makeSeedNomination));
useEffect(() => {
const id = setInterval(() => {
setNoms(prev => {
const now = Date.now();
return prev
.map(n => tickNomination(n, now))
.filter(n => !(n.state === 'started' && now - (n.startedAt || 0) > 2600));
});
}, 1000);
return () => clearInterval(id);
}, []);
const createNomination = (gameId, maxPlayers, durationMin, scheduledFor = null) => {
const now = Date.now();
const nom = {
id: `n${now}${Math.random().toString(36).slice(2, 7)}`,
gameId, creator: username, maxPlayers,
createdAt: now,
scheduledFor,
deadline: scheduledFor != null ? scheduledFor : now + durationMin * 60000,
participants: { [username]: { status: scheduledFor ? 'in' : 'ready', joinedAt: now } },
messages: [],
state: 'open',
};
setNoms(prev => [nom, ...prev]);
};
const respond = (id, choice) => {
setNoms(prev => prev.map(n => {
if (n.id !== id) return n;
const now = Date.now();
const participants = { ...n.participants };
participants[username] = choice === 'ready'
? { status: 'ready', joinedAt: (participants[username] || {}).joinedAt || now }
: { status: 'pending', joinedAt: (participants[username] || {}).joinedAt || now, readyAt: now + choice * 60000 };
return { ...n, participants };
}));
};
// RSVP for a scheduled call: "I'm in".
const rsvp = (id) => {
setNoms(prev => prev.map(n => n.id === id
? { ...n, participants: { ...n.participants, [username]: { status: 'in', joinedAt: Date.now() } } }
: n));
};
const sendMessage = (id, text) => {
const t = String(text || '').trim();
if (!t) return;
setNoms(prev => prev.map(n => n.id === id
? { ...n, messages: [...(n.messages || []), { id: `m${Date.now()}${Math.random().toString(36).slice(2, 6)}`, from: username, text: t, at: Date.now() }] }
: n));
};
const leave = (id) => {
setNoms(prev => prev.map(n => {
if (n.id !== id) return n;
const participants = { ...n.participants };
delete participants[username];
return { ...n, participants };
}));
};
const cancel = (id) => setNoms(prev => prev.filter(n => n.id !== id));
const startNow = (id) => setNoms(prev => prev.map(n => n.id === id ? { ...n, state: 'started', startedAt: Date.now() } : n));
const addTime = (id, minutes = 5) => setNoms(prev => prev.map(n => n.id === id ? { ...n, deadline: Date.now() + minutes * 60000, state: 'open' } : n));
return { nominations: noms, createNomination, respond, rsvp, sendMessage, leave, cancel, startNow, addTime };
}
// ────────────────────────────────────────────────────────────────────
// Top-bar entry point
// ────────────────────────────────────────────────────────────────────
function CallToPlayButton({ nominations, onClick }) {
const active = nominations.filter(n => n.state !== 'started');
return (
<button className="ctp-btn" onClick={onClick}>
<Icon_.flag/>
<span>Call to Play</span>
{active.length > 0 && <span className="ctp-badge">{active.length}</span>}
</button>
);
}
// ────────────────────────────────────────────────────────────────────
// Mini ready-bubbles — the per-player state, compressed for the
// quick bars: green ring + check = ready, "+Nm" tag = needs minutes,
// dimmed = RSVP'd but not checked in yet.
// ────────────────────────────────────────────────────────────────────
function MiniBubbles({ nomination: n, now, max = 6 }) {
const entries = Object.entries(n.participants);
const shown = entries.slice(0, max);
return (
<span className="ctp-ticker-bubbles">
{shown.map(([name, p]) => {
const ready = isReady(p, now);
const st = ready ? 'ready' : p.status === 'in' ? 'in' : 'pending';
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
const title = ready ? `${name} — ready`
: st === 'in' ? `${name} — in, not checked in yet`
: `${name} — ready in ${fmtCountdownShort(p.readyAt - now)}`;
return (
<span key={name} className="ctp-mini" data-state={st} title={title} style={{ background: avatarColor(name) }}>
{initials}
{ready && <span className="ctp-mini-check"><Icon_.check/></span>}
{st === 'pending' && <i className="ctp-mini-tag">{fmtCountdownShort(p.readyAt - now)}</i>}
</span>
);
})}
{entries.length > max && <span className="ctp-mini ctp-mini-more">+{entries.length - max}</span>}
</span>
);
}
// ────────────────────────────────────────────────────────────────────
// Persistent quick bars — one row per active call, soonest first.
// Scheduled calls show their clock time; once check-in opens the row
// lights up to nudge everyone who said "I'm in".
// ────────────────────────────────────────────────────────────────────
const TICKER_LABEL = { scheduled: 'Scheduled', call: 'Call to Play', soon: 'Starting soon', ready: 'Ready' };
// Sort rank: ready first, starting-soon next, everything else after —
// ties broken by whichever resolves/starts soonest.
const TICKER_RANK = { ready: 0, soon: 1, call: 2, scheduled: 2 };
function CallToPlayTickerRow({ nomination: n, accent, onOpen }) {
const [, setTick] = useState(0);
useEffect(() => { const id = setInterval(() => setTick(t => t + 1), 1000); return () => clearInterval(id); }, []);
const game = GAMES_.find(g => g.id === n.gameId);
if (!game) return null;
const now = Date.now();
const status = statusOf(n, now);
const ready = readyCountOf(n, now);
const total = Object.keys(n.participants).length;
const remaining = Math.max(0, n.deadline - now);
const msgs = n.messages || [];
const last = msgs[msgs.length - 1];
const count = status === 'scheduled' ? `${total} in` : `${ready}/${n.maxPlayers} ready`;
const time = status === 'ready' ? 'waiting to start'
: status === 'scheduled' ? `${fmtClock(n.scheduledFor)} · ${fmtUntil(n.scheduledFor - now)}`
: n.scheduledFor ? `starts ${fmtClock(n.scheduledFor)} · ${fmtCountdown(remaining)}`
: fmtCountdown(remaining);
return (
<button className="ctp-ticker" data-status={status} style={{ '--accent': accent }} onClick={() => onOpen(n.id)}>
<span className="ctp-ticker-dot" data-status={status}/>
<span className="ctp-ticker-label" data-status={status}>{TICKER_LABEL[status]}</span>
<span className="ctp-ticker-game">{game.title}</span>
<span className="ctp-ticker-by">by {n.creator}</span>
<span className="ctp-ticker-ready">{count}</span>
<span className="ctp-ticker-time">{time}</span>
{last ? (
<span className="ctp-ticker-chat" title={`${last.from}: ${last.text}`}>
<Icon_.chat/>
<b style={{ color: avatarColor(last.from) }}>{last.from}:</b>
<span className="ctp-ticker-chat-text">{last.text}</span>
</span>
) : <span className="ctp-ticker-chat"/>}
<MiniBubbles nomination={n} now={now}/>
<span className="ctp-ticker-cta">{status === 'soon' ? 'Check in' : 'View'}<Icon_.chevron/></span>
</button>
);
}
function CallToPlayTicker({ nominations, accent, onOpen }) {
const now = Date.now();
const active = nominations.filter(n => n.state !== 'started')
.sort((a, b) => TICKER_RANK[statusOf(a, now)] - TICKER_RANK[statusOf(b, now)] || a.deadline - b.deadline);
if (!active.length) return null;
return (
<div className="ctp-ticker-stack">
{active.map(n => <CallToPlayTickerRow key={n.id} nomination={n} accent={accent} onOpen={onOpen}/>)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Avatar chip — ready / counting-down / RSVP'd ("in")
// ────────────────────────────────────────────────────────────────────
function AvatarChip({ name, participant, now }) {
const ready = isReady(participant, now);
const isIn = !ready && participant.status === 'in';
const remaining = participant.readyAt ? Math.max(0, participant.readyAt - now) : 0;
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
const title = ready ? `${name} — ready`
: isIn ? `${name} — in, not checked in yet`
: `${name} — ready in ${fmtCountdown(remaining)}`;
return (
<div className={`ctp-avatar ${ready ? 'is-ready' : isIn ? 'is-in' : 'is-pending'}`} title={title}>
<span className="ctp-avatar-dot" style={{ background: avatarColor(name) }}>{initials}</span>
{ready
? <span className="ctp-avatar-check"><Icon_.check/></span>
: isIn
? <span className="ctp-avatar-in">in</span>
: <span className="ctp-avatar-pending">{fmtCountdownShort(remaining)}</span>}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Nomination card
// ────────────────────────────────────────────────────────────────────
function NominationCard({ nomination: n, username, accent, actions, focused }) {
const [, setTick] = useState(0);
useEffect(() => { const id = setInterval(() => setTick(t => t + 1), 1000); return () => clearInterval(id); }, []);
const [confirmCancel, setConfirmCancel] = useState(false);
const game = GAMES_.find(g => g.id === n.gameId);
if (!game) return null;
const now = Date.now();
const entries = Object.entries(n.participants);
const readyCount = readyCountOf(n, now);
const inCount = inCountOf(n, now);
const isMe = username in n.participants;
const myStatus = n.participants[username];
const isCreator = n.creator === username;
const isDone = n.state === 'done';
const isStarted = n.state === 'started';
const phase = phaseOf(n, now);
const isSched = phase === 'scheduled' && !isDone && !isStarted;
const isCheckin = phase === 'checkin' && !isDone && !isStarted;
const windowStart = n.scheduledFor ? n.scheduledFor - CHECKIN_LEAD_MS : n.createdAt;
const totalMs = Math.max(1, n.deadline - windowStart);
const remaining = Math.max(0, n.deadline - now);
const pct = Math.max(0, Math.min(100, (remaining / totalMs) * 100));
const urgency = pct < 20 ? 'high' : pct < 50 ? 'mid' : 'low';
const peerCount = installedPeersFor_(game).length;
let actionsBody;
if (isStarted) {
actionsBody = <div className="ctp-note ctp-note-launch">Launching {game.title} for {n.maxPlayers} players</div>;
} else if (isSched) {
if (isCreator) {
actionsBody = <div className="ctp-note">Scheduled for {fmtClock(n.scheduledFor)} check-in opens 15 min before start.</div>;
} else if (isMe) {
actionsBody = (
<React.Fragment>
<div className="ctp-me-status">Youre in well nudge you to check in 15 min before start</div>
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(n.id)}>Cant make it</button>
</React.Fragment>
);
} else {
actionsBody = (
<React.Fragment>
<button className="act-btn act-play" onClick={() => actions.rsvp(n.id)}><Icon_.check/><span>Im in</span></button>
<div className="ctp-note">Check-in opens 15 min before start</div>
</React.Fragment>
);
}
} else if (isCreator) {
if (isDone) {
actionsBody = (
<React.Fragment>
<button className="act-btn act-play" onClick={() => actions.startNow(n.id)}><Icon_.play/><span>Start now</span></button>
<button className="ghost-btn" onClick={() => actions.addTime(n.id, 5)}>Add 5 more minutes</button>
</React.Fragment>
);
} else if (myStatus && myStatus.status === 'in') {
actionsBody = (
<React.Fragment>
<button className="act-btn act-play" onClick={() => actions.respond(n.id, 'ready')}><Icon_.play/><span>Ready now</span></button>
<div className="ctp-buffer-group">
{[5, 10, 15].map(min => (
<button key={min} className="ctp-buffer-btn" onClick={() => actions.respond(n.id, min)}>+{min}m</button>
))}
</div>
</React.Fragment>
);
} else {
actionsBody = <div className="ctp-note">Waiting for players to ready up</div>;
}
} else if (isDone) {
actionsBody = <div className="ctp-note">{readyCount >= n.maxPlayers ? 'Everyones ready.' : `Its time — waiting for ${n.creator} to start.`}</div>;
} else if (isMe) {
if (myStatus.status === 'in') {
actionsBody = (
<React.Fragment>
<div className="ctp-me-status">You said youre in check in:</div>
<button className="act-btn act-play" onClick={() => actions.respond(n.id, 'ready')}><Icon_.play/><span>Ready now</span></button>
<div className="ctp-buffer-group">
{[5, 10, 15].map(min => (
<button key={min} className="ctp-buffer-btn" onClick={() => actions.respond(n.id, min)}>+{min}m</button>
))}
</div>
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(n.id)}>Leave</button>
</React.Fragment>
);
} else {
const ready = isReady(myStatus, now);
actionsBody = (
<React.Fragment>
<div className="ctp-me-status">{ready ? 'Youre in — ready' : `You: ready in ${fmtCountdown(myStatus.readyAt - now)}`}</div>
{!ready && <button className="ghost-btn" onClick={() => actions.respond(n.id, 'ready')}>Im ready now</button>}
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(n.id)}>Leave</button>
</React.Fragment>
);
}
} else {
actionsBody = (
<React.Fragment>
<button className="act-btn act-play" onClick={() => actions.respond(n.id, 'ready')}><Icon_.play/><span>Ready now</span></button>
<div className="ctp-buffer-group">
{[5, 10, 15, 30].map(min => (
<button key={min} className="ctp-buffer-btn" onClick={() => actions.respond(n.id, min)}>+{min}m</button>
))}
</div>
</React.Fragment>
);
}
const timerEl = isStarted ? <div className="ctp-card-timer" data-urgency="off">Launching</div>
: isDone ? <div className="ctp-card-timer" data-urgency="off">Ready</div>
: isSched ? (
<div className="ctp-card-timer is-sched" data-urgency="off">
<span className="ctp-card-clock">{fmtClock(n.scheduledFor)}</span>
<span className="ctp-card-until">{fmtUntil(n.scheduledFor - now)}</span>
</div>
)
: isCheckin ? (
<div className="ctp-card-timer is-sched" data-urgency={urgency}>
<span className="ctp-card-clock">{fmtCountdown(remaining)}</span>
<span className="ctp-card-until">starts {fmtClock(n.scheduledFor)}</span>
</div>
)
: <div className="ctp-card-timer" data-urgency={urgency}>{fmtCountdown(remaining)}</div>;
const subEl = n.scheduledFor
? <React.Fragment>Scheduled by <strong>{n.creator}</strong> · starts at {fmtClock(n.scheduledFor)} · {peerCount}/{PEERS_.length} peers have it installed</React.Fragment>
: <React.Fragment>Called by <strong>{n.creator}</strong> · {peerCount}/{PEERS_.length} peers have it installed</React.Fragment>;
const rosterLabel = isSched
? `${entries.length} in · up to ${n.maxPlayers} players`
: isCheckin && inCount > 0
? `${readyCount}/${n.maxPlayers} ready · ${inCount} not checked in yet`
: `${readyCount}/${n.maxPlayers} ready`;
return (
<div className={`ctp-card ${isDone ? 'is-done' : ''} ${isStarted ? 'is-started' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}>
<div className="ctp-card-top">
<div className="ctp-card-cover"><GameCover_ game={game} aspect="square"/></div>
<div className="ctp-card-info">
<div className="ctp-card-title">{game.title}</div>
<div className="ctp-card-sub">{subEl}</div>
</div>
{timerEl}
</div>
{isCheckin && (
<div className="ctp-checkin-note">
<Icon_.clock/>
<span>{isMe && myStatus && myStatus.status === 'in'
? 'Starting soon — you said youre in. Check in below.'
: 'Starting soon — check-in is open.'}</span>
</div>
)}
{!isSched && (
<div className="ctp-progress">
<div className="ctp-progress-fill"
style={{ width: `${isStarted || isDone ? 100 : pct}%`, background: isDone || isStarted ? 'var(--ok)' : accent }}/>
</div>
)}
<div className="ctp-roster">
<div className="ctp-roster-count">{rosterLabel}</div>
<div className="ctp-avatars">
{entries.map(([name, p]) => <AvatarChip key={name} name={name} participant={p} now={now}/>)}
{Array.from({ length: Math.max(0, n.maxPlayers - entries.length) }).map((_, i) => (
<div key={`empty${i}`} className="ctp-avatar ctp-avatar-empty"/>
))}
</div>
</div>
<div className="ctp-actions">{actionsBody}</div>
<CtpChat_ nomination={n} username={username} disabled={isStarted}
onSend={(text) => actions.sendMessage(n.id, text)}/>
{isCreator && !isStarted && (
confirmCancel ? (
<div className="ctp-cancel-confirm">
<span>Cancel this call for everyone?</span>
<div className="ctp-cancel-confirm-btns">
<button className="ghost-btn ghost-danger" onClick={() => actions.cancel(n.id)}>Yes, cancel it</button>
<button className="ghost-btn" onClick={() => setConfirmCancel(false)}>No, keep it</button>
</div>
</div>
) : (
<button className="ctp-cancel-link" onClick={() => setConfirmCancel(true)}>
<Icon_.trash/><span>Cancel this call</span>
</button>
)
)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Create-nomination form — "Now" or a scheduled clock time
// ────────────────────────────────────────────────────────────────────
function CreateNominationForm({ accent, onCancel, onCreate }) {
const [query, setQuery] = useState('');
const [gameId, setGameId] = useState(null);
const [maxPlayers, setMaxPlayers] = useState(8);
const [duration, setDuration] = useState(10);
const [when, setWhen] = useState('now');
const [dayOffset, setDayOffset] = useState(0);
const nextSlot = () => {
const d = new Date(Date.now() + 40 * 60000);
d.setMinutes(d.getMinutes() <= 30 ? 30 : 60, 0, 0);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
const [timeStr, setTimeStr] = useState(nextSlot); // committed "HH:MM" (24h)
const [editing, setEditing] = useState(false);
const [timeDraft, setTimeDraft] = useState(timeStr);
const timeInputRef = useRef(null);
// Select-all when the type-a-time field appears, so the user can overtype
// immediately. Done in a layout effect (not onFocus+autoFocus) so it fires
// after the browser places the autofocus caret, and re-asserts if the
// per-second nominations tick re-renders the overlay.
useLayoutEffect(() => {
if (editing && timeInputRef.current) {
timeInputRef.current.focus();
timeInputRef.current.select();
}
}, [editing]);
const [hStr, mStr] = timeStr.split(':');
// Nudge the time by whole hours or minute-steps, wrapping within 023:59.
const bump = (field, delta) => {
let [h, m] = timeStr.split(':').map(Number);
if (field === 'h') h = (h + delta + 24) % 24;
else {
let total = h * 60 + m + delta;
total = ((total % 1440) + 1440) % 1440;
h = Math.floor(total / 60); m = total % 60;
}
setTimeStr(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
};
// Constrain free-text entry: digits + at most one colon, ≤2 digits per side
// (≤5 chars with a colon), ≤4 digits without one. Anything else is rejected
// (returns the previous draft) — so a colon can't be typed after 3 digits.
const sanitizeTime = (raw, prev) => {
const v = String(raw).replace(/[^\d:]/g, '');
if ((v.match(/:/g) || []).length > 1) return prev;
const ci = v.indexOf(':');
if (ci !== -1) {
if (v.slice(0, ci).length > 2 || v.slice(ci + 1).length > 2) return prev;
return v;
}
return v.length > 4 ? prev : v;
};
// Accepts "20:00", "2000", "20", "9:30" — normalizes or reverts.
const commitDraft = () => {
const mm = String(timeDraft).trim().match(/^(\d{1,2})(?:[:.\s]?(\d{2}))?$/);
if (mm) {
const h = Number(mm[1]), min = Number(mm[2] || 0);
if (h <= 23 && min <= 59) setTimeStr(`${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`);
}
setEditing(false);
};
const DE_DAYS = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const dayChips = [0, 1, 2].map(off => {
const d = new Date();
d.setDate(d.getDate() + off);
const label = off === 0 ? 'Today' : DE_DAYS[d.getDay()];
return { off, label };
});
const matches = useMemo(() => {
if (!query.trim()) return [];
const q = query.toLowerCase();
return GAMES_.filter(g => g.title.toLowerCase().includes(q)).slice(0, 6);
}, [query]);
const selected = GAMES_.find(g => g.id === gameId);
const pickGame = (g) => {
setGameId(g.id);
setMaxPlayers(parseMaxPlayers_(g.players));
setQuery(g.title);
};
const scheduledTs = () => {
const [h, m] = timeStr.split(':').map(Number);
const d = new Date();
d.setDate(d.getDate() + dayOffset);
d.setHours(h || 0, m || 0, 0, 0);
if (d.getTime() < Date.now() + 60000) d.setDate(d.getDate() + 1);
return d.getTime();
};
return (
<div className="ctp-create">
<div className="ctp-create-row">
<label className="ctp-create-label">Game</label>
<div className="search ctp-create-search" style={{ '--accent': accent }}>
<Icon_.search/>
<input type="text" placeholder="Search the catalog…" value={query}
onChange={(e) => { setQuery(e.target.value); setGameId(null); }}/>
</div>
{!selected && query.trim() && (
<div className="ctp-create-matches">
{matches.map(g => (
<button key={g.id} onClick={() => pickGame(g)}>
<span>{g.title}</span>
<span className="ctp-create-match-meta">{g.players} players</span>
</button>
))}
{matches.length === 0 && <div className="ctp-create-nomatch">No games match "{query}"</div>}
</div>
)}
</div>
{selected && (
<React.Fragment>
<div className="ctp-create-row ctp-create-row-inline">
<label className="ctp-create-label">Max players</label>
<input type="number" className="ctp-create-num" min={2} max={64} value={maxPlayers}
onChange={(e) => setMaxPlayers(Math.max(2, Number(e.target.value) || 2))}/>
<span className="ctp-create-hint">Suggested for {selected.title}: {parseMaxPlayers_(selected.players)}</span>
</div>
<div className="ctp-create-row">
<label className="ctp-create-label">When</label>
<div className="ctp-duration-opts">
<button className={`ctp-duration-btn ${when === 'now' ? 'is-active' : ''}`}
style={when === 'now' ? { background: accent, borderColor: accent } : undefined}
onClick={() => setWhen('now')}>Now</button>
<button className={`ctp-duration-btn ${when === 'later' ? 'is-active' : ''}`}
style={when === 'later' ? { background: accent, borderColor: accent } : undefined}
onClick={() => setWhen('later')}>Schedule</button>
</div>
{when === 'later' && (
<React.Fragment>
<div className="ctp-timepick">
{editing ? (
<input ref={timeInputRef} type="text" inputMode="numeric" maxLength={5} placeholder="20:00"
className="ctp-time-editinput" value={timeDraft}
onChange={(e) => setTimeDraft(prev => sanitizeTime(e.target.value, prev))}
onBlur={commitDraft}
onKeyDown={(e) => { if (e.key === 'Enter') commitDraft(); if (e.key === 'Escape') setEditing(false); }}/>
) : (
<React.Fragment>
<div className="ctp-timepick-field">
<button type="button" className="ctp-time-step" aria-label="Hour up" onClick={() => bump('h', 1)}><Icon_.caretUp/></button>
<span className="ctp-time-cell">{hStr}</span>
<button type="button" className="ctp-time-step" aria-label="Hour down" onClick={() => bump('h', -1)}><Icon_.caretDown/></button>
</div>
<span className="ctp-time-colon">:</span>
<div className="ctp-timepick-field">
<button type="button" className="ctp-time-step" aria-label="Minute up" onClick={() => bump('m', 15)}><Icon_.caretUp/></button>
<span className="ctp-time-cell">{mStr}</span>
<button type="button" className="ctp-time-step" aria-label="Minute down" onClick={() => bump('m', -15)}><Icon_.caretDown/></button>
</div>
<button type="button" className="ctp-time-type"
onClick={() => { setTimeDraft(timeStr); setEditing(true); }}>Type a time</button>
</React.Fragment>
)}
</div>
<div className="ctp-time-row ctp-day-row">
<span className="ctp-day-label">Day</span>
{dayChips.map(c => (
<button key={c.off} className={`ctp-duration-btn ctp-day-btn ${dayOffset === c.off ? 'is-active' : ''}`}
style={dayOffset === c.off ? { background: accent, borderColor: accent } : undefined}
onClick={() => setDayOffset(c.off)}>{c.label}</button>
))}
</div>
<span className="ctp-create-hint">24-hour clock. People RSVP with Im in check-in to ready up opens 15 min before start.</span>
</React.Fragment>
)}
</div>
{when === 'now' && (
<div className="ctp-create-row">
<label className="ctp-create-label">Give people</label>
<div className="ctp-duration-opts">
{[5, 10, 15, 30, 60].map(min => (
<button key={min} className={`ctp-duration-btn ${duration === min ? 'is-active' : ''}`}
style={duration === min ? { background: accent, borderColor: accent } : undefined}
onClick={() => setDuration(min)}>{min}m</button>
))}
</div>
</div>
)}
<div className="ctp-create-foot">
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
<button className="act-btn act-play"
onClick={() => onCreate(selected.id, maxPlayers, duration, when === 'later' ? scheduledTs() : null)}>
{when === 'later'
? `Schedule it — ${selected.title} · ${dayOffset > 0 ? `${dayChips[dayOffset].label} ` : ''}${timeStr}`
: `Call it — ${selected.title}`}
</button>
</div>
</React.Fragment>
)}
{!selected && (
<div className="ctp-create-foot">
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
</div>
)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Full overlay
// ────────────────────────────────────────────────────────────────────
function CallToPlayOverlay({ nominations, username, accent, onClose, actions, focusId }) {
const [showCreate, setShowCreate] = useState(false);
const sorted = [...nominations].sort((a, b) => (a.state === 'started' ? 1 : 0) - (b.state === 'started' ? 1 : 0) || a.deadline - b.deadline);
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal ctp-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose} aria-label="Close"><Icon_.close/></button>
<div className="ctp-head">
<h2>Call to Play</h2>
<p className="ctp-head-sub">Rally the LAN around a game and a time right now, or scheduled for later with an Im in RSVP. The caller decides when it actually starts.</p>
{!showCreate && (
<button className="act-btn act-play ctp-head-new" onClick={() => setShowCreate(true)}>
<Icon_.flag/><span>Call a new match</span>
</button>
)}
</div>
<div className="ctp-body">
{showCreate && (
<CreateNominationForm accent={accent}
onCancel={() => setShowCreate(false)}
onCreate={(gid, max, dur, sched) => { actions.createNomination(gid, max, dur, sched); setShowCreate(false); }}/>
)}
{sorted.length === 0 && !showCreate && (
<div className="ctp-empty">No active calls right now be the one to start something.</div>
)}
{sorted.map(n => (
<NominationCard key={n.id} nomination={n} username={username} accent={accent} actions={actions}
focused={n.id === focusId}/>
))}
</div>
</div>
</div>
);
}
Object.assign(window, {
useNominations, makeSeedNomination,
CallToPlayButton, CallToPlayTicker, CallToPlayOverlay,
NominationCard, CreateNominationForm,
});
@@ -1,86 +0,0 @@
// ctp-chat.jsx — shared Call-to-Play helpers (avatar colors, clock formatting)
// plus the per-call chat panel. Loaded after components.jsx, before calltoplay.jsx.
const { useState: useChatState, useEffect: useChatEffect, useRef: useChatRef } = React;
const CTP_AVATAR_COLORS = ['#60a5fa', '#34d399', '#c084fc', '#fbbf24', '#f472b6', '#38bdf8', '#a3e635', '#fb7185'];
const ctpHashName = (s) => [...String(s)].reduce((a, c) => a + c.charCodeAt(0), 0);
const ctpAvatarColor = (name) => CTP_AVATAR_COLORS[ctpHashName(name) % CTP_AVATAR_COLORS.length];
// 24h clock, LAN-party style: "20:00"
const ctpFmtClock = (ts) => {
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
// ────────────────────────────────────────────────────────────────────
// Per-call chat — collapsed to a one-line toggle with an unread badge
// and a preview of the last message; expands to a compact IRC-style
// message list + input. All state is local to the card.
// ────────────────────────────────────────────────────────────────────
function CtpChat({ nomination: n, username, onSend, disabled }) {
const IconX = window.Icon;
const msgs = n.messages || [];
const [open, setOpen] = useChatState(false);
const [seen, setSeen] = useChatState(msgs.length);
const [draft, setDraft] = useChatState('');
const listRef = useChatRef(null);
useChatEffect(() => { if (open) setSeen(msgs.length); }, [open, msgs.length]);
useChatEffect(() => {
if (open && listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
}, [open, msgs.length]);
const unread = Math.max(0, msgs.length - seen);
const last = msgs[msgs.length - 1];
const submit = () => {
const t = draft.trim();
if (!t) return;
onSend(t);
setDraft('');
};
return (
<div className={`ctp-chat ${open ? 'is-open' : ''}`}>
<button className="ctp-chat-toggle" onClick={() => setOpen(o => !o)}>
<IconX.chat/>
<span>Chat</span>
{msgs.length > 0 && <span className="ctp-chat-count">{msgs.length}</span>}
{!open && unread > 0 && <span className="ctp-chat-unread">{unread}</span>}
{!open && last && (
<span className="ctp-chat-preview">
<b style={{ color: ctpAvatarColor(last.from) }}>{last.from}:</b> {last.text}
</span>
)}
<span className="ctp-chat-chevron"><IconX.chevron/></span>
</button>
{open && (
<React.Fragment>
<div className="ctp-chat-list" ref={listRef}>
{msgs.length === 0 && <div className="ctp-chat-empty">No messages yet say hi.</div>}
{msgs.map(m => (
<div key={m.id} className={`ctp-chat-msg ${m.from === username ? 'is-me' : ''}`}>
<b style={{ color: ctpAvatarColor(m.from) }}>{m.from}</b>
<span className="ctp-chat-time">{ctpFmtClock(m.at)}</span>
<span className="ctp-chat-text"> {m.text}</span>
</div>
))}
</div>
{!disabled && (
<div className="ctp-chat-form">
<input type="text" className="ctp-chat-input" placeholder="Message the group…"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}/>
<button className="ctp-chat-send" onClick={submit} aria-label="Send"><IconX.send/></button>
</div>
)}
</React.Fragment>
)}
</div>
);
}
Object.assign(window, { CtpChat, ctpAvatarColor, ctpFmtClock });
@@ -1,966 +0,0 @@
// DesignCanvas.jsx — Figma-ish design canvas wrapper
// Warm gray grid bg + Sections + Artboards + PostIt notes.
// Artboards are reorderable (grip-drag), deletable, labels/titles are
// inline-editable, and any artboard can be opened in a fullscreen focus
// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
// via the host bridge. No assets, no deps.
//
// Usage:
// <DesignCanvas>
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
// </DCSection>
// </DesignCanvas>
const DC = {
bg: '#f0eee9',
grid: 'rgba(0,0,0,0.06)',
label: 'rgba(60,50,40,0.7)',
title: 'rgba(40,30,20,0.85)',
subtitle: 'rgba(60,50,40,0.6)',
postitBg: '#fef4a8',
postitText: '#5a4a2a',
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
};
// One-time CSS injection (classes are dc-prefixed so they don't collide with
// the hosted design's own styles).
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
const s = document.createElement('style');
s.id = 'dc-styles';
s.textContent = [
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
// isolation:isolate contains artboard content's z-indexes so a
// z-indexed child (sticky navbar etc.) can't paint over .dc-header or
// the .dc-menu popover that drops into the top of the card.
'.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
'.dc-card *{scrollbar-width:none}',
'.dc-card *::-webkit-scrollbar{display:none}',
// Per-artboard header: grip + label on the left, delete/expand on the
// right. Single flex row; when the artboard's on-screen width is too
// narrow for both the label yields (ellipsis, then hidden entirely below
// ~4ch via the container query) and the buttons stay on the row.
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
' display:flex;align-items:center;container-type:inline-size}',
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
'.dc-grip:active{cursor:grabbing}',
'.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
// Below ~4ch of label room: hide the label entirely, and drop the grip to
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
// until the card is moused.
'@container (max-width: 110px){',
' .dc-labeltext{display:none}',
' .dc-grip{opacity:0}',
' [data-dc-slot]:hover .dc-grip{opacity:1}',
'}',
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
'.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
'.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
' font:inherit;transition:background .12s,color .12s}',
'.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
// Slot hosting an open menu floats above later siblings (which otherwise
// paint on top — same z-index:auto, later DOM order) so the popup isn't
// clipped by the next card.
'[data-dc-slot]:has(.dc-menu){z-index:10}',
'.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}',
'.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
'.dc-menu button:hover{background:rgba(0,0,0,.05)}',
'.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
'.dc-menu .dc-danger{color:#c96442}',
'.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
// Chrome (titles / labels / buttons) counter-scales against the viewport
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
// DCViewport on every transform update and inherits to all descendants —
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
// it the same way.
//
// The header uses transform:scale (out-of-flow, so layout impact doesn't
// matter) with its world-space width set to card-width / inv-zoom so that
// after counter-scaling its on-screen width exactly matches the card's —
// that's what lets the container query + text-overflow behave against the
// card's visible edge at every zoom level.
//
// The section head uses CSS zoom instead of transform so its layout box
// grows with the counter-scale, pushing the card row down — otherwise the
// constant-screen-size title would overflow into the (shrinking) world-
// space gap and overlap the artboard headers at low zoom.
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
].join('\n');
document.head.appendChild(s);
}
const DCCtx = React.createContext(null);
// Recursively unwrap React.Fragment so <>…</> grouping doesn't hide
// DCSection/DCArtboard children from the type-based walks below.
function dcFlatten(children) {
const out = [];
React.Children.forEach(children, (c) => {
if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children));
else out.push(c);
});
return out;
}
// ─────────────────────────────────────────────────────────────
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
// Owns runtime state (per-section order, renamed titles/labels, hidden
// artboards, focused artboard). Order/titles/labels/hidden persist to a
// .design-canvas.state.json
// sidecar next to the HTML. Reads go via plain fetch() so the saved
// arrangement is visible anywhere the HTML + sidecar are served together
// (omelette preview, direct link, downloaded zip). Writes go through the
// host's window.omelette bridge — editing requires the omelette runtime.
// Focus is ephemeral.
// ─────────────────────────────────────────────────────────────
const DC_STATE_FILE = '.design-canvas.state.json';
function DesignCanvas({ children, minScale, maxScale, style }) {
const [state, setState] = React.useState({ sections: {}, focus: null });
// Hold rendering until the sidecar read settles so the saved order/titles
// appear on first paint (no source-order flash). didRead gates writes until
// the read settles so the empty initial state can't clobber a slow read;
// skipNextWrite suppresses the one echo-write that would otherwise follow
// hydration.
const [ready, setReady] = React.useState(false);
const didRead = React.useRef(false);
const skipNextWrite = React.useRef(false);
React.useEffect(() => {
let off = false;
fetch('./' + DC_STATE_FILE)
.then((r) => (r.ok ? r.json() : null))
.then((saved) => {
if (off || !saved || !saved.sections) return;
skipNextWrite.current = true;
setState((s) => ({ ...s, sections: saved.sections }));
})
.catch(() => {})
.finally(() => { didRead.current = true; if (!off) setReady(true); });
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
return () => { off = true; clearTimeout(t); };
}, []);
React.useEffect(() => {
if (!didRead.current) return;
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
const t = setTimeout(() => {
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
}, 250);
return () => clearTimeout(t);
}, [state.sections]);
// Build registries synchronously from children so FocusOverlay can read
// them in the same render. Fragments are flattened; wrapping in other
// elements still opts out of focus/reorder.
const registry = {}; // slotId -> { sectionId, artboard }
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
const sectionOrder = [];
dcFlatten(children).forEach((sec) => {
if (!sec || sec.type !== DCSection) return;
const sid = sec.props.id ?? sec.props.title;
if (!sid) return;
sectionOrder.push(sid);
const persisted = state.sections[sid] || {};
const abs = [];
dcFlatten(sec.props.children).forEach((ab) => {
if (!ab || ab.type !== DCArtboard) return;
const aid = ab.props.id ?? ab.props.label;
if (aid) abs.push([aid, ab]);
});
// hidden is scoped to one source revision — when the agent regenerates
// (artboard-ID set changes), prior deletes don't apply to new content.
const srcKey = abs.map(([k]) => k).join('\x1f');
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
const srcIds = [];
abs.forEach(([aid, ab]) => {
if (hidden.includes(aid)) return;
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
srcIds.push(aid);
});
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
sectionMeta[sid] = {
title: persisted.title ?? sec.props.title,
subtitle: sec.props.subtitle,
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
};
});
const api = React.useMemo(() => ({
state,
section: (id) => state.sections[id] || {},
patchSection: (id, p) => setState((s) => ({
...s,
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
})),
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
}), [state]);
// Esc exits focus; any outside pointerdown commits an in-progress rename.
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
const onPd = (e) => {
const ae = document.activeElement;
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
};
document.addEventListener('keydown', onKey);
document.addEventListener('pointerdown', onPd, true);
return () => {
document.removeEventListener('keydown', onKey);
document.removeEventListener('pointerdown', onPd, true);
};
}, [api]);
return (
<DCCtx.Provider value={api}>
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
{state.focus && registry[state.focus] && (
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
)}
</DCCtx.Provider>
);
}
// ─────────────────────────────────────────────────────────────
// DCViewport — transform-based pan/zoom (internal)
//
// Input mapping (Figma-style):
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
// • trackpad scroll → pan (two-finger)
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
// • middle-drag / primary-drag-on-bg → pan
//
// Transform state lives in a ref and is written straight to the DOM
// (translate3d + will-change) so wheel ticks don't go through React —
// keeps pans at 60fps on dense canvases.
// ─────────────────────────────────────────────────────────────
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
const vpRef = React.useRef(null);
const worldRef = React.useRef(null);
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
// Persist viewport across reloads so the user lands back where they were
// after an agent edit or browser refresh. The sandbox origin is already
// per-project; pathname keeps multiple canvas files in one project apart.
const tfKey = 'dc-viewport:' + location.pathname;
const saveT = React.useRef(0);
const lastPostedScale = React.useRef();
const apply = React.useCallback(() => {
const { x, y, scale } = tf.current;
const el = worldRef.current;
if (!el) return;
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
// ticks leave scale unchanged — skip the cross-frame post for those.
if (lastPostedScale.current !== scale) {
lastPostedScale.current = scale;
window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
}
clearTimeout(saveT.current);
saveT.current = setTimeout(() => {
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
}, 200);
}, [tfKey]);
React.useLayoutEffect(() => {
const flush = () => {
clearTimeout(saveT.current);
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
};
try {
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
apply();
}
} catch {}
// Flush on pagehide and unmount so a reload within the 200ms debounce
// window doesn't drop the last pan/zoom.
window.addEventListener('pagehide', flush);
return () => { window.removeEventListener('pagehide', flush); flush(); };
}, []);
React.useEffect(() => {
const vp = vpRef.current;
if (!vp) return;
const zoomAt = (cx, cy, factor) => {
const r = vp.getBoundingClientRect();
const px = cx - r.left, py = cy - r.top;
const t = tf.current;
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
const k = next / t.scale;
// --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
// marginBottom) reflow on every scale change, vertically shifting the
// world layout — so a world point mathematically pinned under the cursor
// drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
// Anchor the DOM element under the cursor instead: record its screen Y,
// apply the transform + --dc-inv-zoom, then cancel whatever vertical
// drift the reflow introduced so it stays put on screen.
let marker = null, markerY0 = 0;
if (k !== 1) {
const hit = document.elementFromPoint(cx, cy);
marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
if (marker) markerY0 = marker.getBoundingClientRect().top;
}
// keep the world point under the cursor fixed
t.x = px - (px - t.x) * k;
t.y = py - (py - t.y) * k;
t.scale = next;
apply();
if (marker) {
// A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any
// departure after the --dc-inv-zoom reflow is the layout drift.
const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); }
}
};
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
// line-mode deltas (Firefox) or large integer pixel deltas with no X
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
// two-finger scroll sends small/fractional pixel deltas, often with
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
const isMouseWheel = (e) =>
e.deltaMode !== 0 ||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
const onWheel = (e) => {
e.preventDefault();
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
// trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
// wheels fall through to the fixed-step branch below.
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
} else if (isMouseWheel(e)) {
// notched mouse wheel — fixed-ratio step per click
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
} else {
// trackpad two-finger scroll — pan
tf.current.x -= e.deltaX;
tf.current.y -= e.deltaY;
apply();
}
};
// Safari sends native gesture* events for trackpad pinch with a smooth
// e.scale; preferring these over the ctrl+wheel fallback gives a much
// better feel there. No-ops on other browsers. Safari also fires
// ctrlKey wheel events during the same pinch — isGesturing makes
// onWheel drop those entirely so they neither zoom nor pan.
let gsBase = 1;
let isGesturing = false;
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
const onGestureChange = (e) => {
e.preventDefault();
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
};
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
// Drag-pan: middle button anywhere, or primary button on canvas
// background (anything that isn't an artboard or an inline editor).
let drag = null;
const onPointerDown = (e) => {
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
e.preventDefault();
vp.setPointerCapture(e.pointerId);
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
vp.style.cursor = 'grabbing';
};
const onPointerMove = (e) => {
if (!drag || e.pointerId !== drag.id) return;
tf.current.x += e.clientX - drag.lx;
tf.current.y += e.clientY - drag.ly;
drag.lx = e.clientX; drag.ly = e.clientY;
apply();
};
const onPointerUp = (e) => {
if (!drag || e.pointerId !== drag.id) return;
vp.releasePointerCapture(e.pointerId);
drag = null;
vp.style.cursor = '';
};
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
const onHostMsg = (e) => {
const d = e.data;
if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
const r = vp.getBoundingClientRect();
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
} else if (d && d.type === '__dc_probe') {
// Host's [readyGen] reset asks whether a canvas is present; it
// fires on the iframe's native 'load', which for canvases with
// images/fonts is after our mount-time announce, so re-announce.
// Clear the pan-tick guard so apply() re-posts the current scale
// even if it's unchanged — the host just reset dcScale to 1.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
}
};
window.addEventListener('message', onHostMsg);
// Announce canvas mode so the host toolbar proxies its % control here
// instead of scaling the iframe element (which would just shrink the
// viewport window of an infinite canvas). The apply() that follows emits
// the initial __dc_zoom so the toolbar % is correct before first pinch.
// lastPostedScale reset mirrors the __dc_probe handler: the layout
// effect's restore-path apply() may already have posted the restored
// scale (before __dc_present), so clear the guard to re-post it in order.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
vp.addEventListener('wheel', onWheel, { passive: false });
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
vp.addEventListener('pointerdown', onPointerDown);
vp.addEventListener('pointermove', onPointerMove);
vp.addEventListener('pointerup', onPointerUp);
vp.addEventListener('pointercancel', onPointerUp);
return () => {
window.removeEventListener('message', onHostMsg);
vp.removeEventListener('wheel', onWheel);
vp.removeEventListener('gesturestart', onGestureStart);
vp.removeEventListener('gesturechange', onGestureChange);
vp.removeEventListener('gestureend', onGestureEnd);
vp.removeEventListener('pointerdown', onPointerDown);
vp.removeEventListener('pointermove', onPointerMove);
vp.removeEventListener('pointerup', onPointerUp);
vp.removeEventListener('pointercancel', onPointerUp);
};
}, [apply, minScale, maxScale]);
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
return (
<div
ref={vpRef}
className="design-canvas"
style={{
height: '100vh', width: '100vw',
background: DC.bg,
overflow: 'hidden',
overscrollBehavior: 'none',
touchAction: 'none',
position: 'relative',
fontFamily: DC.font,
boxSizing: 'border-box',
...style,
}}
>
<div
ref={worldRef}
style={{
position: 'absolute', top: 0, left: 0,
transformOrigin: '0 0',
willChange: 'transform',
width: 'max-content', minWidth: '100%',
minHeight: '100%',
padding: '60px 0 80px',
}}
>
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
{children}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// DCSection — editable title + h-row of artboards in persisted order
// ─────────────────────────────────────────────────────────────
function DCSection({ id, title, subtitle, children, gap = 48 }) {
const ctx = React.useContext(DCCtx);
const sid = id ?? title;
const all = React.Children.toArray(dcFlatten(children));
const artboards = all.filter((c) => c && c.type === DCArtboard);
const rest = all.filter((c) => !(c && c.type === DCArtboard));
const sec = (ctx && sid && ctx.section(sid)) || {};
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
const srcKey = allIds.join('\x1f');
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
const srcOrder = allIds.filter((k) => !hidden.includes(k));
const order = React.useMemo(() => {
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
}, [sec.order, srcOrder.join('|')]);
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
// marginBottom counter-scales so the on-screen gap between sections stays
// constant — otherwise at low zoom the (world-space) gap collapses while
// the screen-constant sectionhead below it doesn't, and the title reads as
// belonging to the section above. paddingBottom below is just enough for
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
// the title sits tight against its own row at every zoom.
return (
<div data-dc-section={sid}
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
<div style={{ padding: '0 60px' }}>
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
<DCEditable tag="div" value={sec.title ?? title}
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
</div>
</div>
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
{order.map((k) => (
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
label={(sec.labels || {})[k] ?? byId[k].props.label}
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
srcKey,
}))}
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
))}
</div>
{rest}
</div>
);
}
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
function DCArtboard() { return null; }
// Per-artboard export (kind: 'png' | 'html'). Both paths share the same
// self-contained clone: computed styles baked in, @font-face / <img> /
// inline-style background-image urls inlined as data URIs. PNG wraps the
// clone in foreignObject→canvas at 3× the artboard's natural width×height
// (same pipeline the host uses for page captures); HTML wraps it in a
// minimal standalone document. Both are independent of viewport zoom.
async function dcExport(node, w, h, name, kind) {
try { await document.fonts.ready; } catch {}
const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
})).catch(() => url);
// Collect @font-face rules. ss.cssRules throws SecurityError on
// cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
// the CSS text directly (those endpoints send ACAO:*) and regex-extract
// the blocks. @import and @media/@supports are walked so nested
// @font-face rules aren't missed.
const fontRules = [], pending = [], seen = new Set();
const scrapeCss = (href) => {
if (seen.has(href)) return; seen.add(href);
pending.push(fetch(href).then((r) => r.text()).then((css) => {
for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
scrapeCss(new URL(m[1], href).href);
}).catch(() => {}));
};
const walk = (rules, base) => {
for (const r of rules) {
if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
const ibase = r.styleSheet.href || base;
try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
} else if (r.cssRules) walk(r.cssRules, base);
}
};
for (const ss of document.styleSheets) {
const base = ss.href || location.href;
try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
}
while (pending.length) await pending.shift();
const fontCss = (await Promise.all(fontRules.map(async (rule) => {
let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
while ((m = re.exec(rule.css))) {
if (m[2].indexOf('data:') === 0) continue;
let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
}
return out;
}))).join('\n');
const cloneStyled = (src) => {
if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
const dst = src.cloneNode(false);
if (src.nodeType === 1) {
const cs = getComputedStyle(src); let txt = '';
for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
dst.setAttribute('style', txt + 'animation:none;transition:none;');
if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
}
for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
return dst;
};
const clone = cloneStyled(node);
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
// Drop the card's own shadow/radius so the export is a flush w×h rect;
// the artboard's own background (if any) is already in the computed style.
clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
const jobs = [];
clone.querySelectorAll('img').forEach((el) => {
const s = el.getAttribute('src');
if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
});
[clone, ...clone.querySelectorAll('*')].forEach((el) => {
const bg = el.style.backgroundImage; if (!bg) return;
let m; const re = /url\(["']?([^"')]+)["']?\)/g;
while ((m = re.exec(bg))) {
const tok = m[0], url = m[1];
if (url.indexOf('data:') === 0) continue;
jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
}
});
await Promise.all(jobs);
const xml = new XMLSerializer().serializeToString(clone);
const save = (blob, ext) => {
if (!blob) return;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
};
if (kind === 'html') {
const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
(fontCss ? '<style>' + fontCss + '</style>' : '') +
'</head><body style="margin:0">' + xml + '</body></html>';
return save(new Blob([html], { type: 'text/html' }), 'html');
}
// PNG: the SVG's own width/height must be the output resolution — an
// <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
// and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
// w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
// the HTML at full resolution.
const px = 3;
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
const img = new Image();
await new Promise((res, rej) => {
img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
});
const cv = document.createElement('canvas');
cv.width = w * px; cv.height = h * px;
cv.getContext('2d').drawImage(img, 0, 0);
cv.toBlob((blob) => save(blob, 'png'), 'image/png');
}
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
const id = rawId ?? rawLabel;
const ref = React.useRef(null);
const cardRef = React.useRef(null);
const menuRef = React.useRef(null);
const [menuOpen, setMenuOpen] = React.useState(false);
const [confirming, setConfirming] = React.useState(false);
// ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
// the menu — first click arms the row, second commits; closing disarms.
React.useEffect(() => {
if (!menuOpen) { setConfirming(false); return; }
const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
document.addEventListener('pointerdown', off, true);
return () => document.removeEventListener('pointerdown', off, true);
}, [menuOpen]);
const doExport = (kind) => {
setMenuOpen(false);
if (!cardRef.current) return;
const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
dcExport(cardRef.current, width, height, name, kind)
.catch((e) => console.error('[design-canvas] export failed:', e));
};
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
// their would-be slots in real time via transforms. DOM order only
// changes on drop.
const onGripDown = (e) => {
e.preventDefault(); e.stopPropagation();
const me = ref.current;
// translateX is applied in local (pre-scale) space but pointer deltas and
// getBoundingClientRect().left are screen-space — divide by the viewport's
// current scale so the dragged card tracks the cursor at any zoom level.
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
const slotXs = homes.map((h) => h.x);
const startIdx = order.indexOf(id);
const startX = e.clientX;
let liveOrder = order.slice();
me.classList.add('dc-dragging');
const layout = () => {
for (const h of homes) {
if (h.id === id) continue;
const slot = liveOrder.indexOf(h.id);
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
}
};
const move = (ev) => {
const dx = ev.clientX - startX;
me.style.transform = `translateX(${dx / scale}px)`;
const cur = homes[startIdx].x + dx;
let nearest = 0, best = Infinity;
for (let i = 0; i < slotXs.length; i++) {
const d = Math.abs(slotXs[i] - cur);
if (d < best) { best = d; nearest = i; }
}
if (liveOrder.indexOf(id) !== nearest) {
liveOrder = order.filter((k) => k !== id);
liveOrder.splice(nearest, 0, id);
layout();
}
};
const up = () => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
const finalSlot = liveOrder.indexOf(id);
me.classList.remove('dc-dragging');
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
// After the settle transition, kill transitions + clear transforms +
// commit the reorder in the same frame so there's no visual snap-back.
setTimeout(() => {
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
requestAnimationFrame(() => requestAnimationFrame(() => {
for (const h of homes) h.el.style.transition = '';
}));
}, 180);
};
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
};
return (
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
<div className="dc-header" data-noncommentable="" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
<div className="dc-labelrow">
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
</div>
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
</div>
</div>
<div className="dc-btns">
<div ref={menuRef} style={{ position: 'relative' }}>
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
</button>
{menuOpen && (
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
<button onClick={() => doExport('png')}>Download PNG</button>
<button onClick={() => doExport('html')}>Download HTML</button>
<hr />
<button className="dc-danger"
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
{confirming ? 'Click again to delete' : 'Delete'}
</button>
</div>
)}
</div>
<button className="dc-expand" onClick={onFocus} title="Focus">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
</button>
</div>
</div>
<div ref={cardRef} className="dc-card"
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
</div>
</div>
);
}
// Inline rename — commits on blur or Enter.
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
const T = tag;
return (
<T className="dc-editable" contentEditable suppressContentEditableWarning
onClick={onClick}
onPointerDown={(e) => e.stopPropagation()}
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
style={style}>{value}</T>
);
}
// ─────────────────────────────────────────────────────────────
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
// sections, Esc or backdrop click to exit.
// ─────────────────────────────────────────────────────────────
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
const ctx = React.useContext(DCCtx);
const { sectionId, artboard } = entry;
const sec = ctx.section(sectionId);
const meta = sectionMeta[sectionId];
const peers = meta.slotIds;
const aid = artboard.props.id ?? artboard.props.label;
const idx = peers.indexOf(aid);
const secIdx = sectionOrder.indexOf(sectionId);
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
const goSection = (d) => {
// Sections whose artboards are all deleted have slotIds:[] — step past
// them to the next non-empty section so ↑/↓ doesn't dead-end.
const n = sectionOrder.length;
for (let i = 1; i < n; i++) {
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
}
};
React.useEffect(() => {
const k = (e) => {
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
};
document.addEventListener('keydown', k);
return () => document.removeEventListener('keydown', k);
});
const { width = 260, height = 480, children } = artboard.props;
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
const [ddOpen, setDd] = React.useState(false);
const Arrow = ({ dir, onClick }) => (
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
</button>
);
// Portal to body so position:fixed is the real viewport regardless of any
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
return ReactDOM.createPortal(
<div onClick={() => ctx.setFocus(null)}
onWheel={(e) => e.preventDefault()}
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
fontFamily: DC.font, color: '#fff' }}>
{/* top bar: section dropdown (left) · close (right) */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
<div style={{ position: 'relative' }}>
<button onClick={() => setDd((o) => !o)}
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
</span>
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
</button>
{ddOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
{sectionMeta[sid].title}
</button>
))}
</div>
)}
</div>
<div style={{ flex: 1 }} />
<button onClick={() => ctx.setFocus(null)}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
</div>
{/* card centered, label + index below — only the card itself stops
propagation so any backdrop click (including the margins around
the card) exits focus */}
<div
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
</div>
</div>
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
{(sec.labels || {})[aid] ?? artboard.props.label}
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
</div>
</div>
<Arrow dir="left" onClick={() => go(-1)} />
<Arrow dir="right" onClick={() => go(1)} />
{/* dots */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
{peers.map((p, i) => (
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
))}
</div>
</div>,
document.body,
);
}
// ─────────────────────────────────────────────────────────────
// Post-it — absolute-positioned sticky note
// ─────────────────────────────────────────────────────────────
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
return (
<div style={{
position: 'absolute', top, left, right, bottom, width,
background: DC.postitBg, padding: '14px 16px',
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
transform: `rotate(${rotate}deg)`,
zIndex: 5,
}}>{children}</div>
);
}
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
@@ -1,568 +0,0 @@
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakColor label="Palette" value={t.palette}
// options={[['#D97757', '#29261b', '#f6f4ef'],
// ['#475569', '#0f172a', '#f1f5f9']]}
// onChange={(v) => setTweak('palette', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
.twk-chips{display:flex;gap:6px}
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
.twk-chip:hover{transform:translateY(-1px);
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
0 2px 6px rgba(0,0,0,.15)}
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
.twk-chip>span>i:first-child{box-shadow:none}
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
// can react — the parent message only reaches the host, not peers.
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', noDeckControls = false, children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
// Auto-inject a rail toggle when a <deck-stage> is on the page. The
// toggle drives the deck's per-viewer _railVisible via window message;
// state is mirrored from the same localStorage key the deck reads so
// the control reflects reality across reloads. The mechanism is the
// message — authors who want custom placement can post it directly
// and pass noDeckControls to suppress this one.
const hasDeckStage = React.useMemo(
() => typeof document !== 'undefined' && !!document.querySelector('deck-stage'),
[],
);
// deck-stage enables its rail in connectedCallback, but this panel can
// mount before that element has upgraded. The initial read catches the
// common case; the listener covers mounting first. (Older deck-stage.js
// copies still wait for the host's __omelette_rail_enabled postMessage —
// same listener handles those.)
const [railEnabled, setRailEnabled] = React.useState(
() => hasDeckStage && !!document.querySelector('deck-stage')?._railEnabled,
);
React.useEffect(() => {
if (!hasDeckStage || railEnabled) return undefined;
const onMsg = (e) => {
if (e.data && e.data.type === '__omelette_rail_enabled') setRailEnabled(true);
};
window.addEventListener('message', onMsg);
return () => window.removeEventListener('message', onMsg);
}, [hasDeckStage, railEnabled]);
const [railVisible, setRailVisible] = React.useState(() => {
try { return localStorage.getItem('deck-stage.railVisible') !== '0'; } catch (e) { return true; }
});
const toggleRail = (on) => {
setRailVisible(on);
window.postMessage({ type: '__deck_rail_visible', on }, '*');
};
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-noncommentable=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">
{children}
{hasDeckStage && railEnabled && !noDeckControls && (
<TweakSection label="Deck">
<TweakToggle label="Thumbnail rail" value={railVisible} onChange={toggleRail} />
</TweakSection>
)}
</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
// Segments wrap mid-word once per-segment width runs out. The track is
// ~248px (280 panel 28 body pad 4 seg pad), each button loses 12px
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
// back to a dropdown rather than wrap.
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
if (!fitsAsSegments) {
// <select> emits strings — map back to the original option value so the
// fallback stays type-preserving (numbers, booleans) like the segment path.
const resolve = (s) => {
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
return m === undefined ? s : typeof m === 'object' ? m.value : m;
};
return <TweakSelect label={label} value={value} options={options}
onChange={(s) => onChange(resolve(s))} />;
}
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
// read on both #111 and #fafafa without per-option configuration. Hex input
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
function __twkIsLight(hex) {
const h = String(hex).replace('#', '');
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
const n = parseInt(x.slice(0, 6), 16);
if (Number.isNaN(n)) return true;
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return r * 299 + g * 587 + b * 114 > 148000;
}
const __TwkCheck = ({ light }) => (
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
</svg>
);
// TweakColor — curated color/palette picker. Each option is either a single
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
// rest stacked in a sharp column on the right. onChange emits the
// option in the shape it was passed (string stays string, array stays array).
// Without options it falls back to the native color input for back-compat.
function TweakColor({ label, value, options, onChange }) {
if (!options || !options.length) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
// Native <input type=color> emits lowercase hex per the HTML spec, so
// compare case-insensitively. String() guards JSON.stringify(undefined),
// which returns the primitive undefined (no .toLowerCase).
const key = (o) => String(JSON.stringify(o)).toLowerCase();
const cur = key(value);
return (
<TweakRow label={label}>
<div className="twk-chips" role="radiogroup">
{options.map((o, i) => {
const colors = Array.isArray(o) ? o : [o];
const [hero, ...rest] = colors;
const sup = rest.slice(0, 4);
const on = key(o) === cur;
return (
<button key={i} type="button" className="twk-chip" role="radio"
aria-checked={on} data-on={on ? '1' : '0'}
aria-label={colors.join(', ')} title={colors.join(' · ')}
style={{ background: hero }}
onClick={() => onChange(o)}>
{sup.length > 0 && (
<span>
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
</span>
)}
{on && <__TwkCheck light={__twkIsLight(hero)} />}
</button>
);
})}
</div>
</TweakRow>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});
-212
View File
@@ -1,212 +0,0 @@
# SoftLAN — Logo handoff
The SoftLAN mark is a pixelated **“S”** (5×5 grid) that, at rest, is a static
icon — but periodically and on hover it **comes alive**: it dissolves into a
single segment that slithers across the board like the game *Snake*, then
re-lays itself back into the S. Two shorter “glitch” flickers add variety.
This folder is everything an engineer/agent needs to ship it.
```
logo_handoff/
├── pixel-live.jsx ← the live React component (the deliverable)
├── demo.html ← open in a browser to see it in motion + in context
├── INTEGRATION.md ← this file
└── assets/
├── softlan-mark.svg static S, fill="currentColor" (inline use)
├── softlan-mark-blue.svg static S, fixed #3b82f6 (img/favicon use)
├── softlan-tile.svg rounded app-icon tile, gradient + white S
├── softlan-lockup.svg full lockup (tile + SoftLAN + LAUNCHER), on dark
├── softlan-lockup-ink.svg full lockup for light backgrounds
└── favicon.svg = tile (drop-in favicon)
```
---
## 1. The live component — `pixel-live.jsx`
A single React component, `LiveLogo`. No build step assumed in the demo (its
loaded via Babel-in-browser), but the source is plain JSX — drop it into your
normal React/TS toolchain and it compiles as-is.
### Props
| prop | type | default | notes |
|-------------|----------|-------------|-------|
| `accent` | string | `#3b82f6` | the S color (any CSS color). Wire this to your theme accent. |
| `size` | number | `140` | rendered width/height in px (its a square SVG). |
| `idleAuto` | boolean | `true` | when true, plays a random trick by itself every `idleMin``idleMax` ms. |
| `idleMin` | number | `5000` | min ms between idle auto-plays. |
| `idleMax` | number | `11000` | max ms between idle auto-plays. |
### Behavior (built in)
- **Rest:** renders the static pixel S in `accent`.
- **Hover:** plays a random trick (snake-weighted).
- **Click:** plays the snake trick.
- **Idle:** if `idleAuto`, fires a random trick on a 511s jitter — but only
while the tab is visible (`document.hidden` guard), so background tabs stay quiet.
- It never overlaps plays (a `playing` guard ignores triggers mid-animation).
### Imperative API (ref)
```jsx
const logo = useRef(null);
// ...
<LiveLogo ref={logo} accent="#3b82f6" size={32} />
// trigger a specific trick on demand:
logo.current.play('snake'); // 'snake' | 'rgb' | 'glitch'
logo.current.isPlaying(); // boolean
```
### Tricks
- `snake` (~2.6s) — the headline animation. S → slither across the 5×5 board → S.
- `rgb` (~1.35s) — chromatic-aberration split that settles back to clean.
- `glitch` (~1.1s) — rows tear/kick sideways, then snap back.
Edit the `POOL` array near the bottom of the file to change which tricks the
random picker uses (and their weighting), and `TRICK_DUR` to retune durations.
---
## 2. Wiring it into the launcher
The current brand mark in `launcher.jsx` is a placeholder div:
```jsx
// BEFORE — both the 'single' and 'two' topbar variants:
<div className="brand-mark" style={{ background: accent }}>S</div>
```
Replace each with the live component:
```jsx
// AFTER:
<LiveLogo accent={accent} size={28} />
```
- Pass the existing `accent` tweak straight through — the mark recolors live.
- `size={28}` matches the current 28px brand-mark box; adjust to taste.
- Drop the old `.brand-mark` background/letter CSS (the SVG is self-contained).
- The component is small and animates only on hover/idle, so its fine to mount
one instance per topbar. If you want the whole header to react to a single
event, share one `ref` and call `.play()`.
Import at the top of the file (or via your bundler):
```jsx
import { LiveLogo } from './pixel-live'; // if you convert exports to ES modules
```
The file currently attaches `LiveLogo` to `window` for the no-build demo —
swap the final `Object.assign(window, …)` line for `export { LiveLogo }` in a
module build.
---
## 3. Static assets (`assets/`)
For places that must be static — favicons, OS app icons, store listings,
loading splash, OG images, anywhere JS isnt running:
- **`softlan-tile.svg`** — the rounded app-icon tile (gradient + white S). Use
this for the favicon, dock/taskbar icon, and installer art. `favicon.svg` is
an identical copy for convenient `<link rel="icon">`.
- **`softlan-mark-blue.svg`** — just the S in brand blue, transparent bg. Use in
`<img>` tags and anywhere you need a fixed color.
- **`softlan-mark.svg`** — the S with `fill="currentColor"`. **Only inherits
color when inlined into the DOM** (inline `<svg>` or an SVG-as-component); via
`<img src>` it wont pick up `currentColor`. Best for inline React/JSX use
where you want it to follow text/theme color.
Geometry note: all static marks use the exact same grid as the live component
(viewBox `0 0 100 100`, cell 17, offset 7.5, gap 2.4, radius 3), so the static
and animated S are pixel-identical — no jump when the live one mounts.
### Generating raster PNGs (if your pipeline needs them)
```bash
# requires librsvg (rsvg-convert) or Inkscape
rsvg-convert -w 512 -h 512 assets/softlan-tile.svg > icon-512.png
rsvg-convert -w 256 -h 256 assets/softlan-tile.svg > icon-256.png
rsvg-convert -w 32 -h 32 assets/favicon.svg > favicon-32.png
```
---
## 4. The logo lockup (icon + wordmark)
The full logo is the **gradient app-tile + the “SoftLAN” wordmark**, with a
tracked-out “LAUNCHER” beneath. This is the canonical lockup — use it for the
top bar, About screen, installer, store header, splash, etc.
### Exact spec
| part | value |
|------|-------|
| tile | rounded square, `radius = round(size·0.225)`, the accent gradient (`linear-gradient(155deg, mix(accent,white 22%) → accent 52% → mix(accent,black 28%))`), white pixel S at `round(size·0.62)` |
| gap tile → text | `size·0.32` |
| wordmark | system font (`-apple-system, "Segoe UI", system-ui`), **700**, `font-size ≈ tile·0.568` (25px at a 44px tile), `letter-spacing -0.01em` |
| “Soft” color | `#e6edf3` on dark UI · `#0a0e13` on light UI |
| “LAN” color | the accent (`#3b82f6`) — always |
| “LAUNCHER” | system font, **700**, `font-size = wordmark·0.42`, `letter-spacing 0.34em`, UPPERCASE, `#6b7785` (dark) / `#8b97a6` (light) |
> The wordmark is set in the **system UI sans** on purpose (it sits inline in the
> chrome). If you need a fixed, OS-independent render — store art, OG images,
> anywhere the system font isnt guaranteed — use the SVG assets below, which
> carry the same metrics. For pixel-perfect raster, set the wordmark in your
> design tool and export, since `system-ui` varies by platform.
### Drop-in React (from `pixel-live.jsx`)
```jsx
import { Lockup, Wordmark } from './pixel-live';
// full lockup, mark stays alive inside the tile:
<Lockup accent="#3b82f6" tile={44} /> // on dark (default)
<Lockup accent="#3b82f6" tile={64} light={false} /> // on light bg
<Lockup accent="#3b82f6" tile={30} sub={false} /> // compact, no "LAUNCHER"
<Lockup live={false} /> // static S in the tile
// just the lettering (e.g. next to the bare LiveLogo in a slim top bar):
<Wordmark accent="#3b82f6" size={19} />
```
`Lockup` props: `accent`, `tile` (px), `light` (true=dark UI), `sub` (show
“LAUNCHER”), `live` (animate the mark). `Wordmark` props: `accent`, `size`,
`light`, `sub`.
### Plain CSS/HTML (no React)
```html
<span class="sl-wm">Soft<b>LAN</b></span>
<style>
.sl-wm { font: 700 25px/1 -apple-system, "Segoe UI", system-ui, sans-serif;
letter-spacing: -0.01em; color: #e6edf3; } /* #0a0e13 on light */
.sl-wm b { color: #3b82f6; font-weight: 700; } /* the accent */
</style>
```
### Static SVG
- **`assets/softlan-lockup.svg`** — full lockup for **dark** backgrounds.
- **`assets/softlan-lockup-ink.svg`** — same lockup for **light** backgrounds
(“Soft” goes ink-dark; “LAN” stays accent).
Both are self-contained, baked to brand blue `#3b82f6`. To re-tint, edit the
three `linearGradient` stops and the “LAN” `fill`.
---
## 5. Accessibility & motion
- The static SVGs carry `role="img"` + `aria-label="SoftLAN"`. Give the live
component an accessible label in its host (e.g. wrap with `aria-label`), since
its decorative motion over a brand mark.
- The idle auto-play already pauses in hidden tabs. If you want to fully respect
`prefers-reduced-motion`, gate the triggers:
```jsx
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
<LiveLogo idleAuto={!reduce} /* and skip the hover/click play when reduce */ />
```
At rest its a clean static S, so reduced-motion users simply get the icon.
---
## 6. Quick check
Open `demo.html` in any browser: hover the big mark (or wait), use the trick
buttons, and try the accent swatches. The small mark in the mock top bar is the
same component at `size={30}` — thats exactly how it looks in the launcher.
The **logo lockup** card shows the full icon-plus-wordmark on dark and light,
at several sizes, and recolors live with the accent swatches.
-29
View File
@@ -1,29 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100" role="img" aria-label="SoftLAN">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#7dabfb"></stop>
<stop offset="0.55" stop-color="#3b82f6"></stop>
<stop offset="1" stop-color="#2456a8"></stop>
</linearGradient>
</defs>
<rect x="0" y="0" width="100" height="100" rx="22.5" fill="url(#g)"></rect>
<g transform="translate(8 8) scale(0.84)">
<rect x="76.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="59.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="42.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="25.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="8.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="8.7" y="25.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="8.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="25.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="42.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="59.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="76.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="76.7" y="59.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="76.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="59.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="42.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="25.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="8.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

-43
View File
@@ -1,43 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="296" height="64" viewBox="0 0 296 64" fill="none" role="img" aria-label="SoftLAN Launcher">
<title>SoftLAN Launcher</title>
<defs>
<linearGradient id="slTileInk" x1="0.16" y1="0" x2="0.84" y2="1">
<stop offset="0" stop-color="#669ef8"></stop>
<stop offset="0.52" stop-color="#3b82f6"></stop>
<stop offset="1" stop-color="#2b5eb1"></stop>
</linearGradient>
</defs>
<rect x="0" y="0" width="64" height="64" rx="14" fill="url(#slTileInk)"></rect>
<rect x="0.5" y="0.5" width="63" height="63" rx="13.5" fill="none" stroke="#000000" stroke-opacity="0.10"></rect>
<svg x="12" y="12" width="40" height="40" viewBox="0 0 100 100">
<g fill="#ffffff">
<rect x="8.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="25.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="42.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="59.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="76.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="8.7" y="25.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="8.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="25.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="42.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="59.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="76.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="76.7" y="59.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="8.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="25.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="42.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="59.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="76.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
</g>
</svg>
<text x="82" y="35" font-family="-apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, system-ui, sans-serif" font-size="32" font-weight="700" letter-spacing="-0.3">
<tspan fill="#0a0e13">Soft</tspan><tspan fill="#3b82f6">LAN</tspan>
</text>
<text x="83" y="52" font-family="-apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, system-ui, sans-serif" font-size="11.5" font-weight="700" letter-spacing="3.8" fill="#8b97a6">LAUNCHER</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

-43
View File
@@ -1,43 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="296" height="64" viewBox="0 0 296 64" fill="none" role="img" aria-label="SoftLAN Launcher">
<title>SoftLAN Launcher</title>
<defs>
<linearGradient id="slTile" x1="0.16" y1="0" x2="0.84" y2="1">
<stop offset="0" stop-color="#669ef8"></stop>
<stop offset="0.52" stop-color="#3b82f6"></stop>
<stop offset="1" stop-color="#2b5eb1"></stop>
</linearGradient>
</defs>
<rect x="0" y="0" width="64" height="64" rx="14" fill="url(#slTile)"></rect>
<rect x="0.5" y="0.5" width="63" height="63" rx="13.5" fill="none" stroke="#ffffff" stroke-opacity="0.18"></rect>
<svg x="12" y="12" width="40" height="40" viewBox="0 0 100 100">
<g fill="#ffffff">
<rect x="8.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="25.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="42.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="59.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="76.7" y="8.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="8.7" y="25.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="8.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="25.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="42.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="59.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="76.7" y="42.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="76.7" y="59.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="8.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="25.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="42.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="59.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
<rect x="76.7" y="76.7" width="14.6" height="14.6" rx="3"></rect>
</g>
</svg>
<text x="82" y="35" font-family="-apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, system-ui, sans-serif" font-size="32" font-weight="700" letter-spacing="-0.3">
<tspan fill="#e6edf3">Soft</tspan><tspan fill="#3b82f6">LAN</tspan>
</text>
<text x="83" y="52" font-family="-apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, system-ui, sans-serif" font-size="11.5" font-weight="700" letter-spacing="3.8" fill="#6b7785">LAUNCHER</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

-19
View File
@@ -1,19 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100" fill="none" role="img" aria-label="SoftLAN">
<rect x="76.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#3b82f6"></rect>
<rect x="59.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#3b82f6"></rect>
<rect x="42.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#3b82f6"></rect>
<rect x="25.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#3b82f6"></rect>
<rect x="8.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#3b82f6"></rect>
<rect x="8.7" y="25.7" width="14.6" height="14.6" rx="3" fill="#3b82f6"></rect>
<rect x="8.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#3b82f6"></rect>
<rect x="25.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#3b82f6"></rect>
<rect x="42.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#3b82f6"></rect>
<rect x="59.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#3b82f6"></rect>
<rect x="76.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#3b82f6"></rect>
<rect x="76.7" y="59.7" width="14.6" height="14.6" rx="3" fill="#3b82f6"></rect>
<rect x="76.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#3b82f6"></rect>
<rect x="59.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#3b82f6"></rect>
<rect x="42.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#3b82f6"></rect>
<rect x="25.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#3b82f6"></rect>
<rect x="8.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#3b82f6"></rect>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

-19
View File
@@ -1,19 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100" fill="none" role="img" aria-label="SoftLAN">
<rect x="76.7" y="8.7" width="14.6" height="14.6" rx="3" fill="currentColor"></rect>
<rect x="59.7" y="8.7" width="14.6" height="14.6" rx="3" fill="currentColor"></rect>
<rect x="42.7" y="8.7" width="14.6" height="14.6" rx="3" fill="currentColor"></rect>
<rect x="25.7" y="8.7" width="14.6" height="14.6" rx="3" fill="currentColor"></rect>
<rect x="8.7" y="8.7" width="14.6" height="14.6" rx="3" fill="currentColor"></rect>
<rect x="8.7" y="25.7" width="14.6" height="14.6" rx="3" fill="currentColor"></rect>
<rect x="8.7" y="42.7" width="14.6" height="14.6" rx="3" fill="currentColor"></rect>
<rect x="25.7" y="42.7" width="14.6" height="14.6" rx="3" fill="currentColor"></rect>
<rect x="42.7" y="42.7" width="14.6" height="14.6" rx="3" fill="currentColor"></rect>
<rect x="59.7" y="42.7" width="14.6" height="14.6" rx="3" fill="currentColor"></rect>
<rect x="76.7" y="42.7" width="14.6" height="14.6" rx="3" fill="currentColor"></rect>
<rect x="76.7" y="59.7" width="14.6" height="14.6" rx="3" fill="currentColor"></rect>
<rect x="76.7" y="76.7" width="14.6" height="14.6" rx="3" fill="currentColor"></rect>
<rect x="59.7" y="76.7" width="14.6" height="14.6" rx="3" fill="currentColor"></rect>
<rect x="42.7" y="76.7" width="14.6" height="14.6" rx="3" fill="currentColor"></rect>
<rect x="25.7" y="76.7" width="14.6" height="14.6" rx="3" fill="currentColor"></rect>
<rect x="8.7" y="76.7" width="14.6" height="14.6" rx="3" fill="currentColor"></rect>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-29
View File
@@ -1,29 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100" role="img" aria-label="SoftLAN">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#7dabfb"></stop>
<stop offset="0.55" stop-color="#3b82f6"></stop>
<stop offset="1" stop-color="#2456a8"></stop>
</linearGradient>
</defs>
<rect x="0" y="0" width="100" height="100" rx="22.5" fill="url(#g)"></rect>
<g transform="translate(8 8) scale(0.84)">
<rect x="76.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="59.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="42.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="25.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="8.7" y="8.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="8.7" y="25.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="8.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="25.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="42.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="59.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="76.7" y="42.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="76.7" y="59.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="76.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="59.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="42.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="25.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
<rect x="8.7" y="76.7" width="14.6" height="14.6" rx="3" fill="#ffffff"></rect>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

-192
View File
@@ -1,192 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SoftLAN Launcher — Live Logo</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap">
<style>
:root { --accent: #3b82f6; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
background: radial-gradient(ellipse 90% 70% at 50% 0%, #141c26 0%, #0a0e13 60%, #070a0e 100%);
color: #e6edf3;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
min-height: 100%;
display: flex; flex-direction: column; align-items: center;
gap: 34px; padding: 30px 24px 60px;
box-sizing: border-box;
}
/* in-context launcher top bar */
.topbar {
width: min(960px, 100%);
height: 58px; border-radius: 12px;
background: linear-gradient(180deg, #161d27 0%, #10151c 100%);
border: 1px solid rgba(255,255,255,0.07);
box-shadow: 0 12px 40px -16px rgba(0,0,0,0.8), inset 0 1px 0 rgba(255,255,255,0.05);
display: flex; align-items: center; gap: 22px; padding: 0 18px;
}
.brand { display: flex; align-items: center; gap: 11px; }
.brand .wm { font-size: 19px; font-weight: 700; letter-spacing: -0.01em; line-height: 1; }
.brand .wm b { color: var(--accent); font-weight: 700; }
.nav { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
.nav a {
font-size: 12.5px; font-weight: 600; letter-spacing: 0.02em; color: #8b97a6;
padding: 7px 12px; border-radius: 7px; text-decoration: none;
}
.nav a.on { color: #fff; background: rgba(255,255,255,0.07); }
.topbar .spacer { flex: 1; }
.pill {
font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em; color: #9fe6c4;
background: rgba(33,227,168,0.12); border: 1px solid rgba(33,227,168,0.25);
padding: 6px 11px; border-radius: 999px;
}
/* hero stage */
.stage {
width: min(960px, 100%);
border-radius: 20px;
background:
radial-gradient(ellipse 60% 55% at 50% 42%, color-mix(in srgb, var(--accent) 16%, transparent) 0%, transparent 70%),
linear-gradient(180deg, #0e141b 0%, #0a0e13 100%);
border: 1px solid rgba(255,255,255,0.07);
padding: 56px 32px 48px; display: flex; flex-direction: column; align-items: center; gap: 30px;
}
.hero-mark {
width: 220px; height: 220px; display: grid; place-items: center;
filter: drop-shadow(0 10px 30px color-mix(in srgb, var(--accent) 45%, transparent));
}
.hint { font-size: 13px; color: #6b7785; letter-spacing: 0.02em; }
.hint b { color: #9aa6b4; font-weight: 600; }
.controls { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
.controls button {
font: inherit; font-size: 13px; font-weight: 600; letter-spacing: 0.01em;
color: #cdd6e0; background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1); border-radius: 9px;
padding: 10px 16px; cursor: pointer; transition: background .15s, border-color .15s, transform .05s;
}
.controls button:hover { background: rgba(255,255,255,0.09); border-color: rgba(255,255,255,0.2); }
.controls button:active { transform: translateY(1px); }
.controls button.primary {
color: #fff; background: color-mix(in srgb, var(--accent) 88%, black);
border-color: color-mix(in srgb, var(--accent) 70%, white);
}
.controls button.primary:hover { background: var(--accent); }
.caption { max-width: 640px; text-align: center; font-size: 14px; line-height: 1.6; color: #8b97a6; text-wrap: pretty; }
.caption .k { color: #cdd6e0; font-weight: 600; }
h1 { margin: 0; font-family: 'Bebas Neue', sans-serif; font-weight: 400; font-size: 30px; letter-spacing: 0.03em; color: #e6edf3; }
.swatches { display: flex; gap: 8px; align-items: center; margin-top: 2px; }
.swatches span { font-size: 12px; color: #6b7785; margin-right: 4px; }
.swatches button { width: 22px; height: 22px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.18); cursor: pointer; padding: 0; }
/* lockup card */
.lockcard {
width: min(960px, 100%); border-radius: 20px;
background: linear-gradient(180deg, #0e141b 0%, #0a0e13 100%);
border: 1px solid rgba(255,255,255,0.07);
padding: 40px 36px; display: flex; flex-direction: column; gap: 28px;
}
.lockcard h2 { margin: 0; font-family: 'Bebas Neue', sans-serif; font-weight: 400; font-size: 26px; letter-spacing: 0.03em; }
.lockrow { display: flex; align-items: center; gap: 40px; flex-wrap: wrap; }
.lockrow.light { background: #f0eee9; border-radius: 14px; padding: 24px 28px; }
.lab { font-size: 11px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: #5b6675; margin-bottom: 14px; }
.lab.dark { color: #94908a; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="pixel-live.jsx"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useRef, useState } = React;
function App() {
const [accent, setAccent] = useState('#3b82f6');
const heroRef = useRef(null);
const barRef = useRef(null);
const playBoth = (trick) => { heroRef.current && heroRef.current.play(trick); barRef.current && barRef.current.play(trick); };
const setAcc = (c) => { setAccent(c); document.documentElement.style.setProperty('--accent', c); };
return (
<>
{/* in-context: the launcher top bar */}
<div className="topbar">
<div className="brand">
<LiveLogo ref={barRef} accent={accent} size={30} idleAuto={true} />
<span className="wm">Soft<b>LAN</b></span>
</div>
<nav className="nav">
<a className="on">Library</a>
<a>Store</a>
<a>Downloads</a>
<a>Servers</a>
</nav>
<span className="spacer" style={{ flex: 1 }}></span>
<span className="pill">3 PEERS ONLINE</span>
</div>
{/* the canonical logo lockup */}
<div className="lockcard">
<h2>The logo lockup</h2>
<div>
<div className="lab">On dark primary</div>
<div className="lockrow">
<Lockup accent={accent} tile={64} />
<Lockup accent={accent} tile={44} />
<Lockup accent={accent} tile={30} sub={false} />
</div>
</div>
<div>
<div className="lab dark">On light ink variant</div>
<div className="lockrow light">
<Lockup accent={accent} tile={64} light={false} />
<Lockup accent={accent} tile={44} light={false} />
</div>
</div>
</div>
{/* hero stage */}
<div className="stage">
<h1>The mark, alive</h1>
<div className="hero-mark">
<LiveLogo ref={heroRef} accent={accent} size={190} idleAuto={true} />
</div>
<div className="hint"><b>Hover the mark</b> or just wait. It comes alive on its own every few seconds.</div>
<div className="controls">
<button className="primary" onClick={() => playBoth('snake')}> Snake</button>
<button onClick={() => playBoth('rgb')}>RGB datamosh</button>
<button onClick={() => playBoth('glitch')}>Signal glitch</button>
</div>
<div className="swatches">
<span>Accent</span>
{['#3b82f6','#21e3a8','#f59e0b','#ec4899','#a855f7'].map(c => (
<button key={c} style={{ background: c }} onClick={() => setAcc(c)} title={c}></button>
))}
</div>
</div>
<p className="caption">
At rest it's the baseline pixel <span className="k">S</span>. The <span className="k">Snake</span> trick dissolves
the letter into a single segment that slithers across the 5×5 board — a nod to the LAN-party era — then re-lays
itself back into the S. <span className="k">RGB datamosh</span> and <span className="k">Signal glitch</span> are
shorter flickers for a quicker hit. Both the big mark and the one in the top bar are the same live component.
</p>
</>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
</script>
</body>
</html>
-225
View File
@@ -1,225 +0,0 @@
// pixel-live.jsx — the LIVE animated pixel "S" for SoftLAN.
// At rest it's the baseline concept-5 S. On hover (and at random idle moments)
// it "comes alive": slithers like the game Snake across the grid then reforms,
// or glitches with an RGB datamosh. Exposes play(trick) via ref.
//
// Grid geometry matches the baseline MarkPixel exactly so the swap is seamless.
const { useState, useEffect, useRef, useImperativeHandle, forwardRef } = React;
// ── shared grid geometry (identical to baseline MarkPixel) ──
const LCELL = 17, LOFF = 7.5, LGAP = 2.4, LR = 3;
const LSIZE = LCELL - LGAP; // 14.6
// the "S" as an ORDERED walk (tail → head). Each step is grid-adjacent.
// head = (0,4) bottom-left, tail = (4,0) top-right.
const SORD = [
[4,0],[3,0],[2,0],[1,0],[0,0], // top bar (R→L)
[0,1], // left drop
[0,2],[1,2],[2,2],[3,2],[4,2], // middle bar (L→R)
[4,3], // right drop
[4,4],[3,4],[2,4],[1,4],[0,4], // bottom bar (R→L)
];
// boustrophedon sweep of the whole 5×5 board, (0,4) → (4,0) — the Snake "playfield".
const BOUS = (() => {
const b = [];
for (let col = 0; col < 5; col++) {
const rows = col % 2 === 0 ? [4,3,2,1,0] : [0,1,2,3,4];
rows.forEach(r => b.push([col, r]));
}
return b; // 25 cells, b[0]=(0,4), b[24]=(4,0)
})();
// full head path P: lay the S, sweep the board, re-lay the S.
const PATH = (() => {
const p = SORD.map(c => c.slice()); // 0..16 (full S)
for (let i = 1; i < BOUS.length; i++) p.push(BOUS[i].slice()); // 17..40
for (let i = 1; i < SORD.length; i++) p.push(SORD[i].slice()); // 41..56
return p; // length 57
})();
const I0 = 16, I1 = PATH.length - 1; // animate head index 16 → 56
// snake length schedule: full S → short snake → full S
function lenAt(i) {
if (i <= 16) return 17;
if (i <= 26) return Math.round(17 + (7 - 17) * ((i - 16) / 10));
if (i <= 40) return 7;
if (i <= 56) return Math.round(7 + (17 - 7) * ((i - 40) / 16));
return 17;
}
// hex → rgb mixed toward white(t>0)/black(t<0)
function lshade(hex, t) {
let h = (hex || '#000').replace('#', '');
if (h.length === 3) h = h.split('').map(x => x + x).join('');
let r = parseInt(h.slice(0,2),16), g = parseInt(h.slice(2,4),16), b = parseInt(h.slice(4,6),16);
const tgt = t >= 0 ? 255 : 0, a = Math.min(1, Math.abs(t));
r = Math.round(r+(tgt-r)*a); g = Math.round(g+(tgt-g)*a); b = Math.round(b+(tgt-b)*a);
return `rgb(${r},${g},${b})`;
}
function px(col, row) { return { x: LOFF + col * LCELL + LGAP / 2, y: LOFF + row * LCELL + LGAP / 2 }; }
function Cell({ col, row, fill, opacity = 1, dx = 0, dy = 0, w = LSIZE, h = LSIZE, rx = LR }) {
const { x, y } = px(col, row);
return <rect x={x + dx} y={y + dy} width={w} height={h} rx={rx} fill={fill} opacity={opacity} />;
}
// ── renderers ──
function renderStatic(accent) {
return SORD.map(([c, r], i) => <Cell key={i} col={c} row={r} fill={accent} />);
}
function renderSnake(p, accent) {
const i = Math.min(I1, Math.max(I0, I0 + Math.round(p * (I1 - I0))));
const L = lenAt(i);
const start = Math.max(0, i - L + 1);
const body = PATH.slice(start, i + 1);
const n = body.length;
// over the final stretch, ease the per-cell gradient/opacity/head-grow toward
// the flat, uniform static S so the hand-off to renderStatic is seamless.
const settle = Math.max(0, Math.min(1, (p - 0.82) / 0.18));
return body.map((cell, j) => {
const t = n > 1 ? j / (n - 1) : 1; // 0 tail → 1 head
const fill = lshade(accent, (t - 0.45) * 0.5 * (1 - settle)); // head brighter, tail darker
const opacity = (0.42 + 0.58 * t) + (1 - (0.42 + 0.58 * t)) * settle;
const isHead = j === n - 1;
const grow = (isHead ? 1.06 : 1) * (1 - settle) + 1 * settle;
const s = LSIZE * grow, d = (LSIZE - s) / 2;
return <Cell key={j} col={cell[0]} row={cell[1]} fill={fill} opacity={opacity} dx={d} dy={d} w={s} h={s} />;
});
}
const GLX = [11, -7, 13, -5, 9]; // per-row horizontal kick
function renderGlitch(p, accent) {
const burst = Math.max(0, 1 - p) * (0.45 + 0.55 * Math.abs(Math.sin(p * 26)));
return SORD.map(([c, r], i) => {
const dx = GLX[r] * burst;
const drop = burst > 0.55 && Math.sin(i * 12.9 + p * 50) > 0.82; // occasional dropout
const flick = 0.7 + 0.3 * Math.abs(Math.sin(p * 70 + i));
return <Cell key={i} col={c} row={r} fill={accent} opacity={drop ? 0.12 : flick} dx={dx} />;
});
}
function renderRGB(p, accent) {
const burst = Math.pow(Math.max(0, 1 - p), 1.25);
const jit = Math.sin(p * 52) * 0.5 + Math.sin(p * 31) * 0.5;
const off = (4.5 + 2.5 * jit) * burst;
const voff = 1.6 * jit * burst;
const layers = [
{ fill: '#ff2d55', dx: -off, dy: -voff, op: 0.8 },
{ fill: '#21e3a8', dx: off, dy: voff, op: 0.8 },
{ fill: accent, dx: 0, dy: 0, op: 1 },
];
return layers.flatMap((ly, li) =>
SORD.map(([c, r], i) => (
<g key={li + '-' + i} style={{ mixBlendMode: li < 2 ? 'screen' : 'normal' }}>
<Cell col={c} row={r} fill={ly.fill} opacity={ly.op} dx={ly.dx} dy={ly.dy} />
</g>
))
);
}
const RENDER = { snake: renderSnake, glitch: renderGlitch, rgb: renderRGB };
const TRICK_DUR = { snake: 2600, glitch: 1100, rgb: 1350 };
const POOL = ['snake', 'snake', 'snake', 'rgb', 'glitch']; // snake-weighted
function randomTrick() { return POOL[Math.floor(Math.random() * POOL.length)]; }
const LiveLogo = forwardRef(function LiveLogo(
{ accent = '#3b82f6', size = 140, idleAuto = true, idleMin = 5000, idleMax = 11000 }, ref
) {
const [anim, setAnim] = useState(null); // {trick, p} | null
const rafRef = useRef(0);
const playingRef = useRef(false);
const play = (trick) => {
if (playingRef.current) return;
playingRef.current = true;
const dur = TRICK_DUR[trick] || 2000;
const t0 = performance.now();
const step = (now) => {
const p = (now - t0) / dur;
if (p >= 1) { setAnim(null); playingRef.current = false; return; }
setAnim({ trick, p });
rafRef.current = requestAnimationFrame(step);
};
setAnim({ trick, p: 0 });
rafRef.current = requestAnimationFrame(step);
};
useImperativeHandle(ref, () => ({ play, isPlaying: () => playingRef.current }));
useEffect(() => {
if (!idleAuto) return;
let timer;
const schedule = () => {
timer = setTimeout(() => {
if (!document.hidden && !playingRef.current) play(randomTrick());
schedule();
}, idleMin + Math.random() * (idleMax - idleMin));
};
schedule();
return () => clearTimeout(timer);
}, [idleAuto, idleMin, idleMax]);
useEffect(() => () => cancelAnimationFrame(rafRef.current), []);
const content = anim ? RENDER[anim.trick](anim.p, accent) : renderStatic(accent);
return (
<svg viewBox="0 0 100 100" width={size} height={size} fill="none"
style={{ cursor: 'pointer', overflow: 'visible' }}
onMouseEnter={() => play(randomTrick())}
onClick={() => play('snake')}>
{content}
</svg>
);
});
// ── Wordmark: "SoftLAN" + "LAUNCHER", the exact brand lettering ──
// `light` controls the "Soft" ink: true → #e6edf3 (dark UI), false → #0a0e13 (light UI).
// "LAN" is always the accent. Scales off `size` (the cap-height-ish wordmark px).
function Wordmark({ accent = '#3b82f6', size = 25, light = true, sub = true }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: size * 0.08, lineHeight: 1 }}>
<div style={{
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif',
fontSize: size, fontWeight: 700, letterSpacing: '-0.01em', lineHeight: 1,
}}>
<span style={{ color: light ? '#e6edf3' : '#0a0e13' }}>Soft</span>
<span style={{ color: accent }}>LAN</span>
</div>
{sub && (
<div style={{
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif',
fontSize: size * 0.42, fontWeight: 700, letterSpacing: '0.34em',
textTransform: 'uppercase', color: light ? '#6b7785' : '#8b97a6',
}}>Launcher</div>
)}
</div>
);
}
// ── Lockup: the gradient app-tile (with the live or static S) + the Wordmark ──
// This is the canonical horizontal logo lockup. `tile` is the tile px size; the
// wordmark scales with it. `live` keeps the mark animated inside the tile.
function Lockup({ accent = '#3b82f6', tile = 44, light = true, sub = true, live = true }) {
const rad = Math.round(tile * 0.225);
const markSize = Math.round(tile * 0.62);
return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: tile * 0.32 }}>
<div style={{
width: tile, height: tile, borderRadius: rad, display: 'grid', placeItems: 'center',
background: `linear-gradient(155deg, color-mix(in srgb, ${accent} 78%, white) 0%, ${accent} 52%, color-mix(in srgb, ${accent} 72%, black) 100%)`,
boxShadow: `0 ${tile * 0.14}px ${tile * 0.34}px -${tile * 0.12}px color-mix(in srgb, ${accent} 70%, black), inset 0 1px 0 rgba(255,255,255,0.35), inset 0 0 0 1px rgba(255,255,255,0.08)`,
}}>
{live
? <LiveLogo accent="#ffffff" size={markSize} idleAuto={true} />
: <svg viewBox="0 0 100 100" width={markSize} height={markSize}>{renderStatic('#ffffff')}</svg>}
</div>
<Wordmark accent={accent} size={tile * 0.568} light={light} sub={sub} />
</div>
);
}
Object.assign(window, { LiveLogo, Wordmark, Lockup, renderSnake, renderGlitch, renderRGB, renderStatic });
+4 -9
View File
@@ -2,8 +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
@@ -23,16 +21,16 @@ fmt:
just --fmt
_fix:
cargo fix --workspace --all-targets --all-features
cargo clippy --workspace --all-targets --all-features --fix
cargo fix
cargo clippy --fix
fix: _fix fmt
clippy:
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo clippy
test:
cargo test --workspace --all-targets --all-features
cargo test --workspace
frontend-test:
cd crates/lanspread-tauri-deno-ts && deno test --unstable-sloppy-imports tests
@@ -47,9 +45,6 @@ peer-cli-image:
mkdir -p "$DOCKER_CONFIG"
docker build -f crates/lanspread-peer-cli/Dockerfile -t lanspread-peer-cli:dev .
peer-cli-tests *SCENARIOS: peer-cli-image
python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py {{ SCENARIOS }}
# macvlan: each peer container gets its own MAC/IP on the real LAN.
# Override on the command line if your LAN differs, e.g.
# just LANSPREAD_PARENT_IFACE=enp4s0 LANSPREAD_SUBNET=10.0.0.0/24 LANSPREAD_GATEWAY=10.0.0.1 peer-cli-net