Compare commits
27
Commits
main
..
calltoplay
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9268de2371
|
||
|
|
18dd3b7e07
|
||
|
|
f4a6259cf3
|
||
|
|
fe3c3c6520
|
||
|
|
49159988a3
|
||
|
|
a02c5b3c85
|
||
|
|
96c66875b5
|
||
|
|
b929ad16a5
|
||
|
|
8dae9dfe75
|
||
|
|
a886e64fc7
|
||
|
|
8d1e1a13c5
|
||
|
|
f608eaa6b1
|
||
|
|
716564bc7c
|
||
|
|
2c204ac258
|
||
|
|
8d3affe19c
|
||
|
|
9c34efa705
|
||
|
|
e5d70ae56f
|
||
|
|
be7ad2e560
|
||
|
|
872692e3f4
|
||
|
|
e141229805
|
||
|
|
d58307c328
|
||
|
|
383e8f4855
|
||
|
|
cc7dacf6c3
|
||
|
|
640d81d919
|
||
|
|
4b7725db16
|
||
|
|
29eacabcc0
|
||
|
|
0f53bc4b78
|
@@ -1,6 +1,7 @@
|
|||||||
.git
|
.git
|
||||||
.agents
|
.agents
|
||||||
.codex
|
.codex
|
||||||
|
.lanspread-peer-cli
|
||||||
target
|
target
|
||||||
**/target
|
**/target
|
||||||
**/node_modules
|
**/node_modules
|
||||||
|
|||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
# Backlog
|
|
||||||
|
|
||||||
Smells and small inconsistencies found during post-PLAN.md review. None of
|
|
||||||
these block merging — they are tracked here so they aren't forgotten and so
|
|
||||||
they don't reopen as "new findings" the next time someone reads the code.
|
|
||||||
|
|
||||||
**Rule of engagement:** items in this file get touched only when (a)
|
|
||||||
someone hits the symptom in practice, or (b) work in a nearby area makes
|
|
||||||
fixing the smell incidental. No batch refactor passes. No "while we're
|
|
||||||
here" cleanups that grow beyond the in-scope change.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
No open backlog items.
|
|
||||||
|
|
||||||
## How items leave this file
|
|
||||||
|
|
||||||
- Closed by fix → delete the entry, mention it in the commit.
|
|
||||||
- Closed by decision ("we're not doing this") → delete the entry, no
|
|
||||||
commit message ceremony needed.
|
|
||||||
- Promoted to active work → move back to `FINDINGS.md` only when there's
|
|
||||||
a concrete plan to fix it now.
|
|
||||||
|
|
||||||
This file does not grow unboundedly. If it does, that's a signal to
|
|
||||||
either close items or stop adding to it.
|
|
||||||
@@ -1,21 +1,27 @@
|
|||||||
# lanspread
|
# lanspread
|
||||||
|
|
||||||
Peer-to-peer game library sharing for LAN parties. Peers discover each other on the local network via mDNS, exchange library metadata over QUIC, and let users browse and download games from each other. Ships as a Tauri desktop app.
|
Peer-to-peer game library sharing for LAN parties. Peers discover each other on
|
||||||
|
the local network via mDNS, exchange library metadata over QUIC, and let users
|
||||||
|
browse and download games from each other. Ships as a Tauri desktop app.
|
||||||
|
|
||||||
## Workspace layout
|
## Workspace layout
|
||||||
|
|
||||||
Cargo workspace under `crates/`:
|
Cargo workspace under `crates/`:
|
||||||
|
|
||||||
- `lanspread-peer` — core peer: networking, library, downloads, services. Start here for behavior changes. See `crates/lanspread-peer/ARCHITECTURE.md`.
|
- `lanspread-peer` — core peer: networking, library, downloads, services. Start
|
||||||
|
here for behavior changes. See `crates/lanspread-peer/ARCHITECTURE.md`.
|
||||||
- `lanspread-proto` — wire protocol types shared across peers.
|
- `lanspread-proto` — wire protocol types shared across peers.
|
||||||
- `lanspread-mdns` — mDNS-SD discovery wrapper.
|
- `lanspread-mdns` — mDNS-SD discovery wrapper.
|
||||||
- `lanspread-db` — database/schema types (sqlx + sqlite).
|
- `lanspread-db` — database/schema types (sqlx + sqlite).
|
||||||
- `lanspread-compat` — compatibility/migration glue between db and other crates.
|
- `lanspread-compat` — compatibility/migration glue between db and other crates.
|
||||||
- `lanspread-utils` — small shared helpers.
|
- `lanspread-utils` — small shared helpers.
|
||||||
- `lanspread-peer-cli` — JSONL peer harness for scripted and containerized tests.
|
- `lanspread-peer-cli` — JSONL peer harness for scripted and containerized
|
||||||
- `lanspread-tauri-deno-ts/` — frontend (Vite + Deno + TS in `src/`) and Tauri shell (`src-tauri/`). This is the GUI client.
|
tests.
|
||||||
|
- `lanspread-tauri-deno-ts/` — frontend (Vite + Deno + TS in `src/`) and Tauri
|
||||||
|
shell (`src-tauri/`). This is the GUI client.
|
||||||
|
|
||||||
Top-level `Cargo.toml` pins workspace dependency versions; per-crate `Cargo.toml`s set lints (pedantic clippy, `unsafe_code = forbid` on most).
|
Top-level `Cargo.toml` pins workspace dependency versions; per-crate
|
||||||
|
`Cargo.toml`s set lints (pedantic clippy, `unsafe_code = forbid` on most).
|
||||||
|
|
||||||
## Commands (justfile)
|
## Commands (justfile)
|
||||||
|
|
||||||
@@ -31,16 +37,22 @@ Never use normal cargo ... commands, use the just ... commands instead.
|
|||||||
- `just clean` — wipe the build cache.
|
- `just clean` — wipe the build cache.
|
||||||
- `just peer-cli-build` — build the scripted peer harness.
|
- `just peer-cli-build` — build the scripted peer harness.
|
||||||
- `just peer-cli-image` — build the peer harness Docker image.
|
- `just peer-cli-image` — build the peer harness Docker image.
|
||||||
- `just peer-cli-run NAME` — run one named harness container with persistent state under `.lanspread-peer-cli/NAME/`.
|
- `just peer-cli-run NAME` — run one named harness container with persistent
|
||||||
|
state under `.lanspread-peer-cli/NAME/`.
|
||||||
|
|
||||||
## Protocol policy
|
## Protocol policy
|
||||||
|
|
||||||
There is only one wire version — the current one. No legacy peers, no compatibility shims, no fallback paths for older builds. Anyone who wants to interop must run the current build; everyone else is out. Do not add backward-compat code, `#[serde(other)]` escape hatches, or "what if an old peer sends X" defenses.
|
There is only one wire version — the current one. No legacy peers, no
|
||||||
|
compatibility shims, no fallback paths for older builds. Anyone who wants to
|
||||||
|
interop must run the current build; everyone else is out. Do not add
|
||||||
|
backward-compat code, `#[serde(other)]` escape hatches, or "what if an old peer
|
||||||
|
sends X" defenses.
|
||||||
|
|
||||||
## Manual CLI testing (docker container)
|
## Manual CLI testing (docker container)
|
||||||
|
|
||||||
Start `just peer-cli-alpha`, `just peer-cli-bravo` and `just peer-cli-charlie` each in its own terminal. You then have 3 peers that you can interact with via stdin/stdout (JSONL).
|
Start `just peer-cli-alpha`, `just peer-cli-bravo` and `just peer-cli-charlie`
|
||||||
Use this setup to manually test peer functionality.
|
each in its own terminal. You then have 3 peers that you can interact with via
|
||||||
|
stdin/stdout (JSONL). Use this setup to manually test peer functionality.
|
||||||
|
|
||||||
## General info
|
## General info
|
||||||
|
|
||||||
|
|||||||
Generated
+153
-245
@@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
@@ -40,9 +40,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -99,9 +99,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aws-lc-rs"
|
name = "aws-lc-rs"
|
||||||
version = "1.17.3"
|
version = "1.18.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1"
|
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-sys",
|
"aws-lc-sys",
|
||||||
"untrusted 0.7.1",
|
"untrusted 0.7.1",
|
||||||
@@ -110,9 +110,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aws-lc-sys"
|
name = "aws-lc-sys"
|
||||||
version = "0.43.0"
|
version = "0.44.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c"
|
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"cmake",
|
"cmake",
|
||||||
@@ -133,6 +133,12 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.23.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -265,9 +271,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "camino"
|
name = "camino"
|
||||||
version = "1.2.4"
|
version = "1.2.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0"
|
checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
@@ -292,7 +298,7 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -307,9 +313,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.3.0"
|
version = "1.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
|
checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
"jobserver",
|
||||||
@@ -358,7 +364,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.3.0",
|
"cpufeatures 0.3.0",
|
||||||
"rand_core 0.10.1",
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -392,20 +398,11 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "concurrent-queue"
|
|
||||||
version = "2.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
|
||||||
dependencies = [
|
|
||||||
"crossbeam-utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cookie"
|
name = "cookie"
|
||||||
version = "0.18.1"
|
version = "0.18.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"time",
|
"time",
|
||||||
"version_check",
|
"version_check",
|
||||||
@@ -566,17 +563,6 @@ version = "0.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cuckoofilter"
|
|
||||||
version = "0.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b810a8449931679f64cd7eef1bbd0fa315801b6d5d9cdc1ace2804d6529eee18"
|
|
||||||
dependencies = [
|
|
||||||
"byteorder",
|
|
||||||
"fnv",
|
|
||||||
"rand 0.7.3",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.23.0"
|
version = "0.23.0"
|
||||||
@@ -697,13 +683,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.6"
|
version = "0.2.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -803,9 +789,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.16.0"
|
version = "1.17.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
@@ -819,7 +805,7 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
"memchr",
|
"memchr",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
"toml 1.1.3+spec-1.1.0",
|
"toml 1.1.4+spec-1.1.0",
|
||||||
"vswhom",
|
"vswhom",
|
||||||
"winreg",
|
"winreg",
|
||||||
]
|
]
|
||||||
@@ -868,11 +854,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "event-listener"
|
name = "event-listener"
|
||||||
version = "5.4.1"
|
version = "5.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"concurrent-queue",
|
|
||||||
"parking",
|
"parking",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
@@ -914,9 +899,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flate2"
|
name = "flate2"
|
||||||
@@ -963,13 +948,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types-macros"
|
name = "foreign-types-macros"
|
||||||
version = "0.2.3"
|
version = "0.2.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
|
checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1220,17 +1205,6 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "getrandom"
|
|
||||||
version = "0.1.16"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"libc",
|
|
||||||
"wasi 0.9.0+wasi-snapshot-preview1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1239,7 +1213,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
"wasi",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1263,7 +1237,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
"rand_core 0.10.1",
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1414,12 +1388,6 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hash_hasher"
|
|
||||||
version = "2.0.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1b4b9ebce26001bad2e6366295f64e381c1e9c479109202149b9e15e154973e9"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1493,9 +1461,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.2"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"itoa",
|
"itoa",
|
||||||
@@ -1786,18 +1754,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "intrusive-collections"
|
name = "intrusive-collections"
|
||||||
version = "0.10.2"
|
version = "0.10.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4b719c59241cfaac1042a6d26787e28ed7ee4a4e21a5a907786f54222d1b0062"
|
checksum = "4275b20e6057cd7733fd8df8a5a31701e4fe44497dad0f3fa0e1c4fb971506be"
|
||||||
dependencies = [
|
|
||||||
"memoffset",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is-docker"
|
name = "is-docker"
|
||||||
@@ -1903,9 +1868,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.103"
|
version = "0.3.104"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -1947,9 +1912,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kqueue"
|
name = "kqueue"
|
||||||
version = "1.2.0"
|
version = "1.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
|
checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kqueue-sys",
|
"kqueue-sys",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2049,7 +2014,7 @@ dependencies = [
|
|||||||
name = "lanspread-tauri-deno-ts"
|
name = "lanspread-tauri-deno-ts"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.23.1",
|
||||||
"eyre",
|
"eyre",
|
||||||
"lanspread-compat",
|
"lanspread-compat",
|
||||||
"lanspread-db",
|
"lanspread-db",
|
||||||
@@ -2109,9 +2074,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.188"
|
version = "0.2.189"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b"
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libdbus-sys"
|
name = "libdbus-sys"
|
||||||
@@ -2149,9 +2114,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.18"
|
version = "0.1.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
|
checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -2207,9 +2172,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mdns-sd"
|
name = "mdns-sd"
|
||||||
version = "0.20.2"
|
version = "0.20.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f18d8ec9d1869796fb2910d95f4d957072df0b6a22e247a1d760d8b4c805e17a"
|
checksum = "86dbb9f00c8c367f75ed3a775d3eb31d0375a72f58275ef64a1bc53c255a2ce2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"flume",
|
"flume",
|
||||||
@@ -2268,7 +2233,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
"wasi",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2289,7 +2254,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2629,9 +2594,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "open"
|
name = "open"
|
||||||
version = "5.4.0"
|
version = "5.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5"
|
checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dunce",
|
"dunce",
|
||||||
"is-wsl",
|
"is-wsl",
|
||||||
@@ -2833,15 +2798,6 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ppv-lite86"
|
|
||||||
version = "0.2.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
|
||||||
dependencies = [
|
|
||||||
"zerocopy",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "precomputed-hash"
|
name = "precomputed-hash"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2940,19 +2896,6 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand"
|
|
||||||
version = "0.7.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.1.16",
|
|
||||||
"libc",
|
|
||||||
"rand_chacha",
|
|
||||||
"rand_core 0.5.1",
|
|
||||||
"rand_hc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -2961,26 +2904,7 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chacha20",
|
"chacha20",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"rand_core 0.10.1",
|
"rand_core",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_chacha"
|
|
||||||
version = "0.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
|
|
||||||
dependencies = [
|
|
||||||
"ppv-lite86",
|
|
||||||
"rand_core 0.5.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_core"
|
|
||||||
version = "0.5.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.1.16",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2989,15 +2913,6 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_hc"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
|
|
||||||
dependencies = [
|
|
||||||
"rand_core 0.5.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -3021,7 +2936,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"libredox",
|
"libredox",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3041,7 +2956,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.2",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3058,9 +2973,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.16"
|
version = "0.4.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
|
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -3175,9 +3090,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.42"
|
version = "0.23.43"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
|
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"log",
|
"log",
|
||||||
@@ -3190,9 +3105,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.15.0"
|
version = "1.15.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -3217,9 +3132,9 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "s2n-codec"
|
name = "s2n-codec"
|
||||||
version = "0.83.0"
|
version = "0.85.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5323db3697b61f4f5161c346573a9089cf6b4e13656991a5e84c18dc91bc17d7"
|
checksum = "66aa14280ad931e7048e32dd2501966423ba5f9ec3fa8650fd31ad098fa5e303"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3228,16 +3143,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "s2n-quic"
|
name = "s2n-quic"
|
||||||
version = "1.83.0"
|
version = "1.85.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "54598beca69a3970deaa988edcb2a00f5812433db085f23b08f3ebe40a3bf86d"
|
checksum = "d791203713d76de21c8e0396095667ce34bb560fc44af46d0d6e7fff1adb15be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cuckoofilter",
|
|
||||||
"futures",
|
"futures",
|
||||||
"hash_hasher",
|
"rand",
|
||||||
"rand 0.10.2",
|
|
||||||
"s2n-codec",
|
"s2n-codec",
|
||||||
"s2n-quic-core",
|
"s2n-quic-core",
|
||||||
"s2n-quic-crypto",
|
"s2n-quic-crypto",
|
||||||
@@ -3252,9 +3165,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "s2n-quic-core"
|
name = "s2n-quic-core"
|
||||||
version = "0.83.0"
|
version = "0.85.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ebf9897515906f85528b3301f6f54d9d7b02827a9f5a2e13fcd5c965161571e"
|
checksum = "350907401b44da761ae7c2eb25252aceaaf6d47bd72826662d025bf6853106bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
@@ -3274,9 +3187,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "s2n-quic-crypto"
|
name = "s2n-quic-crypto"
|
||||||
version = "0.83.0"
|
version = "0.85.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc67b81d13e30e2b5d69a154fa312bfe523ec3a95c81a694c15c902a210b80de"
|
checksum = "a687178dfcb7a19c4d58a7867169039b6d07cc8d9fab9395218bde19e209f93d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
@@ -3288,9 +3201,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "s2n-quic-platform"
|
name = "s2n-quic-platform"
|
||||||
version = "0.83.0"
|
version = "0.85.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6a488f217c6dec4eac1a0c61d25af5235a2abcd03af31ae2f3bc047901caad80"
|
checksum = "6dfb8b66f6f5a0b65e965505555d4830d1f559c858d6f84df86cf17eb1f57119"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -3303,9 +3216,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "s2n-quic-rustls"
|
name = "s2n-quic-rustls"
|
||||||
version = "0.83.0"
|
version = "0.85.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7349312d82172938036b92119f666ba93ce9bed7cbfeedc968115574932398fc"
|
checksum = "82ac21eb7d17f40c236ca6bb22bd36aa1fdff3af4d2ab29fe4f46ad90aa4c756"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -3317,9 +3230,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "s2n-quic-tls"
|
name = "s2n-quic-tls"
|
||||||
version = "0.83.0"
|
version = "0.85.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "093191572f12842106d8e37326d1a99dd5d6c96bfcdd14c1718e6b6ae51730cb"
|
checksum = "bf73b03c8a4d14821fe4b7882508cc0134da3678360938da28b03b65560c2c97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"errno",
|
"errno",
|
||||||
@@ -3332,9 +3245,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "s2n-quic-tls-default"
|
name = "s2n-quic-tls-default"
|
||||||
version = "0.83.0"
|
version = "0.85.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3488ac2d7fab0b4921e412dbc8599419f8d1bfd6f9c4422fdb4ef8b5e4a33c68"
|
checksum = "d0b7498aeb71298f1a1dd04f5609116eb1255fcd2c621957aa8611fe49eab209"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"s2n-quic-rustls",
|
"s2n-quic-rustls",
|
||||||
"s2n-quic-tls",
|
"s2n-quic-tls",
|
||||||
@@ -3342,9 +3255,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "s2n-quic-transport"
|
name = "s2n-quic-transport"
|
||||||
version = "0.83.0"
|
version = "0.85.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "899b3ffff460a7d4e8b8ed0933aca1b4d309a1e52bfffdd9fb54bd78e3c922b0"
|
checksum = "39307614b59b4262689604176f58a914ab14dc94a1087611ac3f1190213d1d81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
@@ -3360,9 +3273,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "s2n-tls"
|
name = "s2n-tls"
|
||||||
version = "0.3.40"
|
version = "0.3.42"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "db76c77f8c280a581031ad88f8bc09c18581b11d785f530025d135dd22c03b9e"
|
checksum = "b20cf2736f71fa3ee0783fbef55eaf93702f3fd8da5b0ac3d56fd9ae54e899c4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"errno",
|
"errno",
|
||||||
"hex",
|
"hex",
|
||||||
@@ -3373,13 +3286,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "s2n-tls-sys"
|
name = "s2n-tls-sys"
|
||||||
version = "0.3.40"
|
version = "0.3.42"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ce900e83d6cb0dee6623e6e0c955ce1973ad2994ce76c5ed577e27fa564cb82b"
|
checksum = "d6b51c89b30aafcb9b0135478d3e920c1a463636ae5b7209d4baa2f526ce211f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"cc",
|
"cc",
|
||||||
"libc",
|
"libc",
|
||||||
|
"rustc_version",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3420,9 +3334,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "1.2.1"
|
version = "1.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
|
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dyn-clone",
|
"dyn-clone",
|
||||||
"ref-cast",
|
"ref-cast",
|
||||||
@@ -3516,7 +3430,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.2",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3551,7 +3465,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.2",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3585,7 +3499,7 @@ dependencies = [
|
|||||||
"indexmap 1.9.3",
|
"indexmap 1.9.3",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"schemars 0.9.0",
|
"schemars 0.9.0",
|
||||||
"schemars 1.2.1",
|
"schemars 1.2.2",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_with_macros",
|
"serde_with_macros",
|
||||||
@@ -3729,9 +3643,9 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket-pktinfo"
|
name = "socket-pktinfo"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3e8e43b4bdce7cff8a4d3f8025ee38fce5ca138fab868ebbf9529c81328fbf9d"
|
checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"socket2",
|
"socket2",
|
||||||
@@ -3842,7 +3756,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"sha2",
|
"sha2",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -3904,7 +3818,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"serde",
|
"serde",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
@@ -4006,9 +3920,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.2"
|
version = "3.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
|
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -4090,9 +4004,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tao-macros"
|
name = "tao-macros"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
|
checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -4146,7 +4060,7 @@ dependencies = [
|
|||||||
"tauri-runtime",
|
"tauri-runtime",
|
||||||
"tauri-runtime-wry",
|
"tauri-runtime-wry",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tray-icon",
|
"tray-icon",
|
||||||
"url",
|
"url",
|
||||||
@@ -4197,7 +4111,7 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"time",
|
"time",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -4248,7 +4162,7 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4271,8 +4185,8 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"toml 1.1.3+spec-1.1.0",
|
"toml 1.1.4+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4293,7 +4207,7 @@ dependencies = [
|
|||||||
"shared_child",
|
"shared_child",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4308,7 +4222,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
@@ -4331,7 +4245,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
@@ -4394,8 +4308,8 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_with",
|
"serde_with",
|
||||||
"swift-rs",
|
"swift-rs",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"toml 1.1.3+spec-1.1.0",
|
"toml 1.1.4+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
"urlpattern",
|
"urlpattern",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -4410,7 +4324,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"dunce",
|
"dunce",
|
||||||
"embed-resource",
|
"embed-resource",
|
||||||
"toml 1.1.3+spec-1.1.0",
|
"toml 1.1.4+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4433,11 +4347,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.19"
|
version = "2.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl 2.0.19",
|
"thiserror-impl 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4453,13 +4367,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror-impl"
|
name = "thiserror-impl"
|
||||||
version = "2.0.19"
|
version = "2.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.2",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4473,9 +4387,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.54"
|
version = "0.3.55"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
|
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -4547,20 +4461,20 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-macros"
|
name = "tokio-macros"
|
||||||
version = "2.7.1"
|
version = "2.7.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
|
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.18"
|
version = "0.1.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
@@ -4611,9 +4525,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "1.1.3+spec-1.1.0"
|
version = "1.1.4+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
|
checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -4689,9 +4603,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_parser"
|
name = "toml_parser"
|
||||||
version = "1.1.2+spec-1.1.0"
|
version = "1.1.3+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow 1.0.4",
|
"winnow 1.0.4",
|
||||||
]
|
]
|
||||||
@@ -4807,9 +4721,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tray-icon"
|
name = "tray-icon"
|
||||||
version = "0.24.1"
|
version = "0.24.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc"
|
checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs",
|
"dirs",
|
||||||
@@ -4823,7 +4737,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5016,12 +4930,6 @@ dependencies = [
|
|||||||
"try-lock",
|
"try-lock",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasi"
|
|
||||||
version = "0.9.0+wasi-snapshot-preview1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
@@ -5039,9 +4947,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.126"
|
version = "0.2.127"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -5052,9 +4960,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-futures"
|
name = "wasm-bindgen-futures"
|
||||||
version = "0.4.76"
|
version = "0.4.77"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
|
checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -5062,9 +4970,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.126"
|
version = "0.2.127"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@@ -5072,9 +4980,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.126"
|
version = "0.2.127"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -5085,9 +4993,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.126"
|
version = "0.2.127"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -5107,9 +5015,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.103"
|
version = "0.3.104"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
|
checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -5202,7 +5110,7 @@ version = "0.38.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"windows 0.61.3",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
@@ -5771,7 +5679,7 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"soup3",
|
"soup3",
|
||||||
"tao-macros",
|
"tao-macros",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webkit2gtk-sys",
|
"webkit2gtk-sys",
|
||||||
@@ -5828,18 +5736,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.55"
|
version = "0.8.56"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
|
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.55"
|
version = "0.8.56"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
|
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
base64 = "0.22"
|
base64 = "0.23"
|
||||||
bytes = { version = "1", features = ["serde"] }
|
bytes = { version = "1", features = ["serde"] }
|
||||||
crc32fast = "1"
|
crc32fast = "1"
|
||||||
eyre = "0.6"
|
eyre = "0.6"
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
# Streamed Install Next Steps
|
|
||||||
|
|
||||||
I’d treat the prototype as proof of the hard part: “can we stream
|
|
||||||
archive-derived install bytes into `local/` without making the receiver a
|
|
||||||
source?” Yes. Next I’d harden the pieces that decide whether this is
|
|
||||||
product-ready.
|
|
||||||
|
|
||||||
1. **Done — Move from CLI-only to real app integration**
|
|
||||||
|
|
||||||
The GUI now has an explicit “Low disk install” action in the game detail
|
|
||||||
modal for remote-only games. The Tauri backend queues that path through
|
|
||||||
`stream_install_game`, injects the shared external `unrar` stream provider,
|
|
||||||
and hands fetched file details to `StreamInstallGame` instead of the normal
|
|
||||||
download command.
|
|
||||||
|
|
||||||
2. **Done — Replace per-file `unrar p` with a final archive provider**
|
|
||||||
|
|
||||||
The shared external `unrar` stream provider now runs `unrar lt` once for the
|
|
||||||
archive metadata and one sequential `unrar p` pass per archive for payload
|
|
||||||
bytes. It frames directories, file starts, file chunks, and file ends from
|
|
||||||
the technical listing, so CLI and GUI callers use one purpose-built provider
|
|
||||||
instead of a per-file extraction loop.
|
|
||||||
|
|
||||||
3. **Done — Handle solid archives deliberately**
|
|
||||||
|
|
||||||
The provider exposes the RAR `solid` flag in `ArchiveBegin` and always uses
|
|
||||||
one sequential payload pass per archive, which is the safe path for solid
|
|
||||||
archives. S41 now verifies a real solid RAR fixture through the Docker
|
|
||||||
peer-cli flow, including local-only final state, absent root archive/sentinel,
|
|
||||||
byte count, and extracted payload SHA-256 hashes.
|
|
||||||
|
|
||||||
4. **Done — Decide the integrity model**
|
|
||||||
|
|
||||||
Streamed installs intentionally verify against sender archive metadata for
|
|
||||||
now: each file must match the RAR-advertised size and CRC32. That catches
|
|
||||||
transport corruption, truncation, and provider bugs, but does not claim
|
|
||||||
malicious-peer protection. Trusted content remains a separate catalog schema
|
|
||||||
step: add catalog-owned archive or extracted-file SHA-256 hashes, then verify
|
|
||||||
those at the receiver before commit.
|
|
||||||
|
|
||||||
5. **Done — Upgrade retry/resume semantics**
|
|
||||||
|
|
||||||
Streamed install attempts now use the same majority-validated peer set as
|
|
||||||
normal downloads, and each failed attempt rolls back its staging transaction
|
|
||||||
before trying the next peer. S42 pins the policy: retry the whole stream from
|
|
||||||
another validated peer, keep no partial files across attempts, and do not add
|
|
||||||
byte-offset resume until there is a strong reason.
|
|
||||||
|
|
||||||
6. **Done — Expand scenario coverage**
|
|
||||||
|
|
||||||
S43-S47 cover the remaining streamed-install edges: already-installed
|
|
||||||
rejection, corrupt archive rollback, sender disconnect mid-stream, receiver
|
|
||||||
cancel mid-stream, and multi-archive `.eti` roots streamed in sorted order.
|
|
||||||
The peer-cli harness now exposes `cancel-download` so cancellation scenarios
|
|
||||||
exercise the same runtime path as the GUI.
|
|
||||||
|
|
||||||
7. **Done — Clean product semantics**
|
|
||||||
|
|
||||||
The UI now keeps streamed installs in the installed visual state while making
|
|
||||||
the sharing limitation explicit: cards show `Not shareable`, and the detail
|
|
||||||
modal status shows `Installed, not shareable`. Downloaded-and-installed games
|
|
||||||
keep the normal `Installed` label.
|
|
||||||
|
|
||||||
The remaining production-readiness step is additive: move from sender-owned RAR
|
|
||||||
metadata to catalog-owned archive or extracted-file hashes, then verify those
|
|
||||||
at the receiver before committing the streamed install.
|
|
||||||
@@ -1,570 +0,0 @@
|
|||||||
# Peer CLI P2P Scenarios
|
|
||||||
|
|
||||||
This matrix tracks the headless peer-to-peer contract exercised through
|
|
||||||
`lanspread-peer-cli`. It intentionally avoids the GUI and uses direct connect
|
|
||||||
for deterministic local runs; mDNS/macvlan remains an environment smoke path.
|
|
||||||
|
|
||||||
## Scenario Matrix
|
|
||||||
|
|
||||||
| ID | Scenario | Setup | Expected result |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| S1 | Startup scan | Start one peer with `fixture-alpha`. | Peer emits `local-peer-ready` and `local-library-changed`; catalog fixture games are `downloaded=true`, `installed=false`, `availability=Ready`. |
|
|
||||||
| S2 | Direct connect handshake | Start alpha and bravo, send alpha `connect` to bravo's ready address. | Both peers record one remote peer, no self-peer entry appears, and each peer receives the other's library. |
|
|
||||||
| S3 | Remote aggregation | Empty client connects to alpha and bravo. | `list-games` shows remote-only games once; shared `ggoo` has `peer_count=2`, unique games have `peer_count=1`. |
|
|
||||||
| S4 | Single-source download, no install | Empty client connected to bravo downloads `bfbc2` with `install=false`. | Client emits `got-game-files`, `download-begin`, `download-finished`, then local `bfbc2` is `downloaded=true`, `installed=false`; root files exist and `local/` does not. |
|
|
||||||
| S5 | Auto-install download | Empty client connected to bravo downloads `cnctw` with default install. | Download finishes, install begins and finishes, and local `cnctw` is `downloaded=true`, `installed=true` with `local/fixture-payload.txt`. |
|
|
||||||
| S6 | Manual install and uninstall | After S4, client sends `install bfbc2`, then `uninstall bfbc2`. | Install marks `bfbc2` installed and creates `local/`; uninstall removes `local/` while preserving downloaded root files. |
|
|
||||||
| S7 | Duplicate-source majority download | Empty client connects to alpha and bravo, then downloads shared `ggoo`. | Metadata from both peers validates by majority/plurality, download completes once, and installed state matches the install flag. |
|
|
||||||
| 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. |
|
|
||||||
| 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. |
|
|
||||||
| 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. |
|
|
||||||
| 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. |
|
|
||||||
| 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. |
|
|
||||||
| 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. |
|
|
||||||
| 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:
|
|
||||||
|
|
||||||
- The receiver's catalog is authoritative. A remote root whose `version.ini`
|
|
||||||
does not match the catalog's expected version for that game ID is not
|
|
||||||
downloadable.
|
|
||||||
- `list-games` aggregates by game ID. The game appears once; `peer_count`
|
|
||||||
counts only ready peers with that ID and the catalog version.
|
|
||||||
- The aggregated `eti_game_version` must be the catalog version.
|
|
||||||
- The descriptor set emitted to the download path, file-size validation, and
|
|
||||||
transfer planning are catalog-version-only. Stale peers must not supply
|
|
||||||
download descriptors, majority votes, or chunks.
|
|
||||||
- If exactly one peer has the catalog version, that peer is the only transfer
|
|
||||||
source. If several peers match the catalog version, validation and chunk
|
|
||||||
fanout happen among that catalog-version set only.
|
|
||||||
- Capture proof with the `list-games` row, `got-game-files` descriptors,
|
|
||||||
`download-chunk-finished` source addresses, and source/receiver SHA-256
|
|
||||||
manifests.
|
|
||||||
|
|
||||||
## Extended Failure And Mutation Contracts
|
|
||||||
|
|
||||||
Use S18-S36 to pin down operational behavior that is awkward to prove with the
|
|
||||||
GUI:
|
|
||||||
|
|
||||||
- A failed download must not commit the root `version.ini` sentinel. Partial
|
|
||||||
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.
|
|
||||||
- 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
|
|
||||||
game is already active is rejected instead of starting another writer.
|
|
||||||
- Unknown remote game IDs are filtered by the receiver's current catalog and
|
|
||||||
are not downloadable.
|
|
||||||
|
|
||||||
For a manual run, prefer a catalog game ID already served by the fixture lab,
|
|
||||||
such as `cnc4`, then create temporary `just peer-cli-run` game roots where some
|
|
||||||
peers match the catalog version and others deliberately use stale
|
|
||||||
`version.ini` contents. The existing alpha/bravo/charlie fixtures cover
|
|
||||||
duplicate-source and shared-game cases; S15-S17 add the focused skew cases.
|
|
||||||
|
|
||||||
## First-Play Launch-Setting Contract
|
|
||||||
|
|
||||||
Use S38 to pin down how launcher settings are stamped into an installed game:
|
|
||||||
|
|
||||||
- Stamping happens on the first `play`, not during install/update. The install
|
|
||||||
transaction only clears the `games/<id>/launch_settings_applied` marker so the
|
|
||||||
next play reapplies settings to a freshly (re)created `local/`.
|
|
||||||
- The first play stamps the username into the first `account_name.txt` and the
|
|
||||||
first `SmartSteamEmu.ini` `PersonaName` line, and the language into the first
|
|
||||||
`language.txt`, searching the whole `local/` tree. The matched `PersonaName`
|
|
||||||
line keeps its existing line ending (`\n` or `\r\n`).
|
|
||||||
- The marker records only that we *tried*: it is written unconditionally after
|
|
||||||
the first play, so a game with none of these files is still marked done.
|
|
||||||
- S38 needs a real archive expanded with `--unrar`; the Docker matrix image now
|
|
||||||
carries the Linux sidecar for streamed-install coverage, while the peer
|
|
||||||
crate's `launch_settings` unit tests cover the rewrite, line-ending, and
|
|
||||||
marker logic deterministically.
|
|
||||||
|
|
||||||
## Streamed Install Archive Contract
|
|
||||||
|
|
||||||
Use S39-S41 to pin down low-disk streamed installs:
|
|
||||||
|
|
||||||
- The stream provider performs one archive metadata pass and one payload pass
|
|
||||||
per `.eti`, then frames entry boundaries for the receiver.
|
|
||||||
- Non-solid and solid archives both install into `local/` without committing a
|
|
||||||
root archive or root `version.ini`, so the receiver is installed but not a
|
|
||||||
downloadable source.
|
|
||||||
- Streamed install integrity is currently sender archive integrity: size and
|
|
||||||
RAR CRC32 must match the sender's archive metadata. The SHA-256 checks in the
|
|
||||||
scenarios prove the Docker/provider path matches the source fixture; they are
|
|
||||||
not catalog-owned trust anchors.
|
|
||||||
- S41 verifies the fixture is actually solid inside the source container, so
|
|
||||||
solid handling stays covered by the same Docker harness as the existing
|
|
||||||
streamed-install scenarios.
|
|
||||||
- S42 verifies retry/resume semantics: failed streamed attempts roll back their
|
|
||||||
staging directory and retry the whole stream from another validated peer.
|
|
||||||
There is no byte-offset resume contract.
|
|
||||||
- S43-S47 cover the remaining streamed-install failure and archive-shape edges:
|
|
||||||
already-installed rejection, corrupt archive rollback, sender disconnect,
|
|
||||||
receiver cancel, and multi-archive root sorting.
|
|
||||||
|
|
||||||
## 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
|
|
||||||
the install transaction and into a single first-play step (shared with the new
|
|
||||||
`SmartSteamEmu.ini` `PersonaName` rewrite) gated by the
|
|
||||||
`games/<id>/launch_settings_applied` marker.
|
|
||||||
- `just test` passed the whole workspace, including the new
|
|
||||||
`lanspread_peer::launch_settings` unit tests and
|
|
||||||
`install::transaction::install_resets_launch_settings_marker`.
|
|
||||||
- S38 host run: built `crates/lanspread-peer-cli/fixtures/fixture-persona/css`
|
|
||||||
with a stored RAR `.eti` (verified by `unrar t`) burying a CRLF
|
|
||||||
`SmartSteamEmu.ini` plus stub `account_name.txt`/`language.txt`. A host peer
|
|
||||||
installed `css` with `--unrar /usr/bin/unrar`, then `play css` stamped the
|
|
||||||
username into the deep `PersonaName` line (CRLF preserved, sibling lines
|
|
||||||
intact) and `account_name.txt`, the language into `language.txt`, and created
|
|
||||||
the marker. A second `play css` returned `already_applied=true` and rewrote
|
|
||||||
nothing even after the value was reset externally.
|
|
||||||
|
|
||||||
### 2026-05-19 - Snapshot Status Fix Docker Matrix Pass
|
|
||||||
|
|
||||||
- Code under test included `5c4976d` (`fix(peer): settle local state before
|
|
||||||
clearing operations`) and `6651f02` (`fix(ui): derive operation status from
|
|
||||||
snapshots`).
|
|
||||||
- Gates before the matrix: `just fmt`, `just test`, `just frontend-test`, and
|
|
||||||
`just build` passed. The peer harness image was rebuilt with
|
|
||||||
`just peer-cli-image`.
|
|
||||||
- Runner: `python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
|
||||||
passed S1-S36 against the rebuilt `lanspread-peer-cli:dev` image.
|
|
||||||
- Auto-install coverage remained good: S5 downloaded and installed `cnctw`, saw
|
|
||||||
the fixture payload under `local/`, and the downloaded root diffed cleanly
|
|
||||||
against `fixture-bravo/cnctw` excluding local metadata.
|
|
||||||
- Large/exact transfer coverage remained good: S13 small and large downloads
|
|
||||||
diffed cleanly; S14 split `alienswarm` between two sources with chunk totals
|
|
||||||
`67,108,864` and `58,721,049` bytes and the final root diffed cleanly.
|
|
||||||
- Failure and mutation coverage remained good: S17 latest-version conflict,
|
|
||||||
S19 sole-source drop, S20 write failure, S26 duplicate operation, and S35
|
|
||||||
unknown catalog filtering all failed safely without advertising bad local
|
|
||||||
state; S21-S23 propagation, S24-S25 concurrency, S29-S31 bootstrapping, S32
|
|
||||||
reinstall, S33 mutation install, S34 many-small-files, and S36 latest
|
|
||||||
singleton all passed.
|
|
||||||
|
|
||||||
### 2026-05-18 - Full Automated Docker Matrix Pass
|
|
||||||
|
|
||||||
- Runner: `python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
|
||||||
passed S1-S36 against the current `lanspread-peer-cli:dev` image.
|
|
||||||
- S1-S17 rerun highlights: startup, direct connect, aggregation, download,
|
|
||||||
install/uninstall, duplicate-source, ambiguous metadata, missing game,
|
|
||||||
shutdown cleanup, identity reconnect, serve gates, exact equality, large
|
|
||||||
multi-peer chunking, and latest-version selection/conflict all passed. Exact
|
|
||||||
transfer scenarios used `diff -r`/SHA-256 manifest checks; S14 chunk totals
|
|
||||||
were `58,721,049` and `67,108,864` bytes, balanced within one `32 MiB` chunk.
|
|
||||||
- S18-S36 rerun highlights: source-drop, disk-full, live mutation, concurrency,
|
|
||||||
duplicate-operation rejection, self-connect rejection, empty-peer sourcing,
|
|
||||||
5-peer aggregation, bootstrapped sourcing, reinstall, external mutation,
|
|
||||||
many-small-files, unknown catalog filtering, and stale-majority/latest
|
|
||||||
singleton cases all passed. File-copy scenarios used diff/manifests or `cmp`
|
|
||||||
for the mutated install payload.
|
|
||||||
|
|
||||||
### 2026-05-18 - Extended Scenario Docker Pass
|
|
||||||
|
|
||||||
- Runner: `python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
|
||||||
passed for S18-S36 after rebuilding `lanspread-peer-cli:dev` with
|
|
||||||
`just peer-cli-image`.
|
|
||||||
- S18 redundant source drop: one `alienswarm` source was killed after
|
|
||||||
`download-begin`; the client emitted `download-finished`, no
|
|
||||||
`download-failed`, and `diff -r`/SHA-256 manifest comparison matched the
|
|
||||||
surviving source. Recorded large-file chunk bytes from the surviving source:
|
|
||||||
`58,721,049`.
|
|
||||||
- S19 sole-source drop: killing the only source after `download-begin` emitted
|
|
||||||
`download-failed`; the receiver had no committed `alienswarm/version.ini`, no
|
|
||||||
ready local row, and no active operation left.
|
|
||||||
- S20 receiver write failure: a client with `/games` constrained to a `32m`
|
|
||||||
tmpfs emitted `download-failed`; `/games/alienswarm/version.ini` was absent
|
|
||||||
inside the container and active operations were empty.
|
|
||||||
- S21-S23 live mutation propagation: a connected peer observed `cod5` added,
|
|
||||||
`cod5` removed, and `cnc4` bumped from `20250101` to `20260501` without
|
|
||||||
reconnecting or dropping the peer.
|
|
||||||
- S24-S25 concurrency: two clients downloaded `alienswarm` from one source at
|
|
||||||
the same time and both diffed cleanly; one client downloaded `bfbc2` and
|
|
||||||
`cnctw` concurrently and both roots diffed cleanly.
|
|
||||||
- S26 duplicate same-game download: the second `alienswarm` download command
|
|
||||||
returned `operation already in progress for game alienswarm`; the first
|
|
||||||
download still finished and diffed cleanly.
|
|
||||||
- S27 self-connect rejection: connecting a peer to its own listener returned
|
|
||||||
`cannot connect peer to itself ...`; `list-peers` stayed empty and the peer
|
|
||||||
stayed responsive.
|
|
||||||
- S28 address-change invariant: `just test` passed and included
|
|
||||||
`peer_db::tests::address_update_preserves_peer_identity_and_library`.
|
|
||||||
- S29 empty-library peer: an observer first saw the empty peer with zero games;
|
|
||||||
after that peer downloaded `alienswarm`, the downloaded root diffed cleanly
|
|
||||||
and the observer's peer snapshot for that same peer contained `alienswarm`.
|
|
||||||
- S30 5-peer aggregation: a sixth client connected to five peers and aggregated
|
|
||||||
six game IDs with expected `peer_count` and latest versions, with no duplicate
|
|
||||||
game rows and no self-peer entry.
|
|
||||||
- S31 bootstrapped source: after the original source was killed, a third peer
|
|
||||||
downloaded `alienswarm` from the bootstrapped client and diffed cleanly
|
|
||||||
against the original fixture.
|
|
||||||
- S32 reinstall: reinstall after uninstall recreated `local/`, reported
|
|
||||||
`installed=true`, and produced no transfer chunk events during reinstall.
|
|
||||||
- S33 external root mutation: after mutating the downloaded `bfbc2.eti` inside
|
|
||||||
the client container, `install` wrote `local/fixture-payload.txt` that matched
|
|
||||||
the mutated archive exactly by `cmp`.
|
|
||||||
- S34 many-small-files transfer: a `bf1942` fixture with 20 small regular files
|
|
||||||
and no `.eti` downloaded with `install=false`; 21 file chunks were observed
|
|
||||||
including `version.ini`, and the receiver diffed cleanly against the source.
|
|
||||||
- S35 unknown game ID: a source advertised `mystery-game` via `--fixture`; the
|
|
||||||
receiver filtered it out of `list-games`, `download mystery-game` returned
|
|
||||||
`game mystery-game is not in the local catalog`, and no local files were
|
|
||||||
created.
|
|
||||||
- S36 latest singleton: with one peer on `20260501` and four peers on
|
|
||||||
`20250101`, the client reported `peer_count=5` and latest `20260501`; only
|
|
||||||
the singleton latest peer sent chunks and the final root diffed cleanly.
|
|
||||||
|
|
||||||
### 2026-05-18 - Full Matrix Manual Docker Pass
|
|
||||||
|
|
||||||
- Build/setup: `just peer-cli-image` passed. Local `just peer-cli-build`
|
|
||||||
needed `RUSTC_WRAPPER=` because the host `kache` wrapper failed with a
|
|
||||||
read-only filesystem error; `RUSTC_WRAPPER= just peer-cli-build` passed.
|
|
||||||
- Temporary skew/conflict fixtures were created under the ignored
|
|
||||||
`.lanspread-peer-cli/full-fixtures/` tree using `rar a -idq -m0` against
|
|
||||||
`/dev/urandom` payloads and then renaming the archives to `.eti`.
|
|
||||||
`find .lanspread-peer-cli/full-fixtures -name '*.eti' -exec unrar t -idq {} \;`
|
|
||||||
passed.
|
|
||||||
- S1 startup scan: `just peer-cli-alpha` emitted `cli-started`,
|
|
||||||
`local-library-changed`, and `local-peer-ready`; `alienswarm`, `bf1942`, and
|
|
||||||
`ggoo` were `downloaded=true`, `installed=false`, `availability=Ready`.
|
|
||||||
- S2 clean direct connect: with only alpha and bravo running, alpha connected to
|
|
||||||
bravo at `10.66.0.3:42776`; `wait-peers` returned `peer_count=1`, and
|
|
||||||
`list-peers` showed exactly one bravo peer with four games.
|
|
||||||
- S3 clean remote aggregation: an empty `clean-s3-client` saw exactly alpha and
|
|
||||||
bravo. `list-games` showed `ggoo peer_count=2`; `alienswarm`, `bf1942`,
|
|
||||||
`bfbc2`, `cnc4`, and `cnctw` each had `peer_count=1`.
|
|
||||||
- S4 single-source no-install: `full-empty-client` downloaded `bfbc2` from
|
|
||||||
bravo with `install=false`. Events included `got-game-files`,
|
|
||||||
`download-begin`, `download-finished`, and local `installed=false`. Host
|
|
||||||
verification: `diff -r crates/lanspread-peer-cli/fixtures/fixture-bravo/bfbc2
|
|
||||||
.lanspread-peer-cli/full-empty-client/games/bfbc2` passed and `local/` was
|
|
||||||
absent.
|
|
||||||
- S5 auto-install: `full-empty-client` downloaded `cnctw` with default install.
|
|
||||||
Events included download finish, `install-begin`, and `install-finished`;
|
|
||||||
`local/fixture-payload.txt` existed. Host verification diffed the downloaded
|
|
||||||
files against `fixture-bravo/cnctw` excluding `local/` and `.lanspread.json`.
|
|
||||||
- S6 manual install/uninstall: after S4, `install bfbc2` created `local/` and
|
|
||||||
marked `installed=true`; `uninstall bfbc2` removed `local/` and preserved the
|
|
||||||
downloaded root files. Host verification diffed the preserved files against
|
|
||||||
`fixture-bravo/bfbc2` excluding `.lanspread.json`.
|
|
||||||
- S7 duplicate-source download: `full-empty-client` downloaded shared `ggoo`
|
|
||||||
from alpha/bravo with `install=false`. Chunk events used alpha for
|
|
||||||
`version.ini` and bravo for `ggoo.eti`; host `diff -r` matched both
|
|
||||||
`fixture-alpha/ggoo` and `fixture-bravo/ggoo`.
|
|
||||||
- S8 ambiguous metadata rejection: `full-s8-a` and `full-s8-b` both advertised
|
|
||||||
`ggoo` version `20260101` but with different `.eti` sizes (`1,048,746` and
|
|
||||||
`2,097,323` bytes). The client saw `peer_count=2`, then `download ggoo`
|
|
||||||
emitted `download-failed`; no target `ggoo/version.ini` was committed.
|
|
||||||
- S9 missing game: `download does-not-exist` emitted `no-peers-have-game` and
|
|
||||||
returned a command error; `.lanspread-peer-cli/full-empty-client/games` had no
|
|
||||||
`does-not-exist` directory.
|
|
||||||
- S10 shutdown cleanup: alpha saw bravo before shutdown with one remote peer and
|
|
||||||
bravo-only remote games. After bravo `shutdown`, alpha emitted `peer-lost`;
|
|
||||||
`list-peers` returned `[]` and `list-games` returned an empty remote list.
|
|
||||||
- S11 same identity reconnect: restarting bravo reused peer ID
|
|
||||||
`019e347d901e70c19adf5b9fd313fce4` at new address `10.66.0.3:41764`.
|
|
||||||
Alpha `list-peers` showed exactly one bravo entry at the new address.
|
|
||||||
- S12 transfer serving gates: this remains covered by unit tests because the
|
|
||||||
CLI cannot stably race raw transfer requests against non-catalog, missing
|
|
||||||
sentinel, active-operation, and `local/` path states. `RUSTC_WRAPPER= just
|
|
||||||
test` passed, including `local_download_available_gates_on_catalog_operation_and_sentinel`,
|
|
||||||
`get_game_response_respects_serve_gates`,
|
|
||||||
`file_transfer_dispatch_respects_serve_gates`, and
|
|
||||||
`local_relative_paths_are_never_transferable`.
|
|
||||||
- S13 exact transferred-file equality: the S4 small transfer and S14 large
|
|
||||||
transfer both passed host `diff -r` against the original source game
|
|
||||||
directories, proving exact file equality beyond event flow.
|
|
||||||
- S14 large multi-peer chunked download: `full-empty-client` first downloaded
|
|
||||||
`alienswarm` from alpha and diffed cleanly against `fixture-alpha/alienswarm`.
|
|
||||||
A fresh `full-s14-client` then saw `alienswarm peer_count=2` and downloaded
|
|
||||||
from both alpha and `full-empty-client`. Large `.eti` chunk totals were
|
|
||||||
`67,108,864` bytes from alpha and `58,721,049` bytes from the staged peer,
|
|
||||||
balanced within one `32 MiB` chunk. Final host `diff -r` against
|
|
||||||
`fixture-alpha/alienswarm` passed.
|
|
||||||
- S15 three-way version skew: peers A/B/C advertised `cnc4` versions
|
|
||||||
`20250101`, `20250201`, and `20250301`. The client saw one row with
|
|
||||||
`peer_count=3` and `eti_game_version=20250301`; all chunks came only from C
|
|
||||||
at `10.66.0.4:60290`. Host `diff -r` against C passed.
|
|
||||||
- S16 latest-version fanout with stale peer present: A advertised stale
|
|
||||||
`20250101`; B/C both advertised latest `20250301` with a `134,217,906` byte
|
|
||||||
`.eti`. The client saw `peer_count=3`; chunks came only from B/C
|
|
||||||
(`67,108,873` and `67,109,042` bytes respectively), with stale A contributing
|
|
||||||
zero. Host `diff -r` matched both B and C.
|
|
||||||
- S17 latest-version conflict rejection: A advertised stale `20250101`; B/C
|
|
||||||
both advertised latest `20250301` but with conflicting `.eti` sizes
|
|
||||||
(`1,048,748` and `2,097,325` bytes). The client saw `peer_count=3` and latest
|
|
||||||
`20250301`, then `download cnc4` emitted `download-failed`; no target
|
|
||||||
`cnc4/version.ini` was committed.
|
|
||||||
- Gates after manual runs: `just fmt`, `RUSTC_WRAPPER= just test`, and
|
|
||||||
`RUSTC_WRAPPER= just clippy` passed.
|
|
||||||
|
|
||||||
### 2026-05-17 - Exact Transfer And Large Multi-Peer Chunking
|
|
||||||
|
|
||||||
- Fixture update: `fixture-alpha/alienswarm/alienswarm.eti` was rebuilt with
|
|
||||||
`rar a -idq -m0` from three random 40 MiB payload files, then renamed to
|
|
||||||
`.eti`. Final archive size: `125,829,913` bytes. `unrar t -idq` passed.
|
|
||||||
- Gates before manual runs: `just fmt`, `just test`, `just peer-cli-build`,
|
|
||||||
`just clippy`, and `just peer-cli-image` passed.
|
|
||||||
- S13 small exact transfer: `deep-small-client` downloaded `bfbc2` from
|
|
||||||
`fixture-bravo` with `install=false`. SHA-256 manifests matched exactly:
|
|
||||||
`bfbc2/bfbc2.eti`
|
|
||||||
`f7accef0833f29481acdeaac58261bc4fc23ebb58b7197049024d354f60daabc`;
|
|
||||||
`bfbc2/version.ini`
|
|
||||||
`f3d94f70edcebbbc7d8ce38fdf076412fb95114ce1ecf071b26c9c2f93586372`.
|
|
||||||
- S13 large exact transfer: `deep-stage-b` downloaded `alienswarm` from
|
|
||||||
`fixture-alpha` with `install=false`. SHA-256 manifests matched exactly:
|
|
||||||
`alienswarm/alienswarm.eti`
|
|
||||||
`8a4fb1fd458e731affb175134b7b99efc8d8a5eda80e978ba81f721d01aecc43`;
|
|
||||||
`alienswarm/notes.txt`
|
|
||||||
`3832bcb7057a4453981e975d2d2d528bfd9a26671423352f4a8527362d5b9810`;
|
|
||||||
`alienswarm/version.ini`
|
|
||||||
`8dfdc51d4dbfb06015b41a85a5f5d47f44144139e4a12db2b17eb040773082a3`.
|
|
||||||
- S14 multi-peer setup: `deep-stage-c` connected to alpha
|
|
||||||
(`10.66.0.3:53514`) and `deep-stage-b` (`10.66.0.2:58491`). `list-games`
|
|
||||||
showed `alienswarm` with `peer_count=2` before the download.
|
|
||||||
- S14 chunk-source evidence for `alienswarm/alienswarm.eti`: `deep-stage-c`
|
|
||||||
received chunks from `deep-stage-b` at offsets `0` and `67,108,864`
|
|
||||||
(`67,108,864` bytes total) and from alpha at offsets `33,554,432` and
|
|
||||||
`100,663,296` (`58,721,049` bytes total). The source-byte difference was
|
|
||||||
`8,387,815` bytes, below one `32 MiB` chunk.
|
|
||||||
- S14 final exactness: `deep-stage-c`'s `alienswarm` SHA-256 manifest matched
|
|
||||||
`fixture-alpha` exactly for `alienswarm.eti`, `notes.txt`, and `version.ini`.
|
|
||||||
@@ -17,13 +17,14 @@ Useful flags:
|
|||||||
|
|
||||||
- `--games-dir PATH` stores local archives and installs.
|
- `--games-dir PATH` stores local archives and installs.
|
||||||
- `--state-dir PATH` stores the generated peer identity.
|
- `--state-dir PATH` stores the generated peer identity.
|
||||||
- `--fixture GAME_ID` seeds a tiny archive that the fixture unpacker can install.
|
- `--fixture GAME_ID` seeds a tiny archive that the fixture unpacker can
|
||||||
|
install.
|
||||||
|
|
||||||
## Fixture Game Directories
|
## Fixture Game Directories
|
||||||
|
|
||||||
`fixtures/fixture-alpha`, `fixtures/fixture-bravo`, and
|
`fixtures/fixture-alpha`, `fixtures/fixture-bravo`, and
|
||||||
`fixtures/fixture-charlie` are ready-to-use game directories for local CLI
|
`fixtures/fixture-charlie` are ready-to-use game directories for local CLI smoke
|
||||||
smoke tests. Point `--games-dir` at one of them to start a peer with several
|
tests. Point `--games-dir` at one of them to start a peer with several
|
||||||
catalog-backed fake games. Each game includes `version.ini` and a real RAR
|
catalog-backed fake games. Each game includes `version.ini` and a real RAR
|
||||||
archive renamed to `.eti`; `fixture-alpha` and `fixture-bravo` share `ggoo`,
|
archive renamed to `.eti`; `fixture-alpha` and `fixture-bravo` share `ggoo`,
|
||||||
while `fixture-bravo` and `fixture-charlie` share `cnc4`.
|
while `fixture-bravo` and `fixture-charlie` share `cnc4`.
|
||||||
@@ -44,6 +45,6 @@ echoed back on the result or error line.
|
|||||||
{"id":"q1","cmd":"shutdown"}
|
{"id":"q1","cmd":"shutdown"}
|
||||||
```
|
```
|
||||||
|
|
||||||
The `status` result includes receiver-side `active_operations` and
|
The `status` result includes receiver-side `active_operations` and sender-side
|
||||||
sender-side `active_outbound_transfers` counts by game ID, which the scenario
|
`active_outbound_transfers` counts by game ID, which the scenario runner uses to
|
||||||
runner uses to verify transfer lifecycle cleanup.
|
verify transfer lifecycle cleanup.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Run the peer-cli scenarios S1-S47 through Docker."""
|
"""Run the peer-cli scenarios S1-S49 through Docker."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -242,6 +242,12 @@ class Peer:
|
|||||||
def status(self) -> dict[str, Any]:
|
def status(self) -> dict[str, Any]:
|
||||||
return self.send({"cmd": "status"})["data"]
|
return self.send({"cmd": "status"})["data"]
|
||||||
|
|
||||||
|
def call_to_play_events(self) -> list[dict[str, Any]]:
|
||||||
|
return self.send({"cmd": "list-call-to-play"})["data"]["events"]
|
||||||
|
|
||||||
|
def publish_call_to_play(self, event: dict[str, Any]) -> None:
|
||||||
|
self.send({"cmd": "publish-call-to-play", "event": event})
|
||||||
|
|
||||||
def connect_to(self, other: "Peer") -> None:
|
def connect_to(self, other: "Peer") -> None:
|
||||||
if other.ready_addr is None:
|
if other.ready_addr is None:
|
||||||
raise ScenarioError(f"{other.name} is not ready")
|
raise ScenarioError(f"{other.name} is not ready")
|
||||||
@@ -350,6 +356,8 @@ class Runner:
|
|||||||
("S45", self.s45_sender_disconnect_mid_stream),
|
("S45", self.s45_sender_disconnect_mid_stream),
|
||||||
("S46", self.s46_receiver_cancel_mid_stream),
|
("S46", self.s46_receiver_cancel_mid_stream),
|
||||||
("S47", self.s47_multi_archive_streams_in_sorted_order),
|
("S47", self.s47_multi_archive_streams_in_sorted_order),
|
||||||
|
("S48", self.s48_call_to_play_replication_and_late_join),
|
||||||
|
("S49", self.s49_terminal_call_to_play_late_join),
|
||||||
]
|
]
|
||||||
|
|
||||||
for scenario_id, scenario in scenarios:
|
for scenario_id, scenario in scenarios:
|
||||||
@@ -1757,6 +1765,139 @@ class Runner:
|
|||||||
|
|
||||||
return f"multi-archive cnctw streamed in sorted order: {chunk_paths}"
|
return f"multi-archive cnctw streamed in sorted order: {chunk_paths}"
|
||||||
|
|
||||||
|
def s48_call_to_play_replication_and_late_join(self) -> str:
|
||||||
|
alice = self.peer("s48-alice")
|
||||||
|
bob = self.peer("s48-bob")
|
||||||
|
bob.connect_to(alice)
|
||||||
|
|
||||||
|
now = int(time.time() * 1000)
|
||||||
|
create = {
|
||||||
|
"id": "s48-create",
|
||||||
|
"call_id": "s48-call",
|
||||||
|
"actor_id": "",
|
||||||
|
"actor_name": "Alice",
|
||||||
|
"at": now,
|
||||||
|
"action": {
|
||||||
|
"Create": {
|
||||||
|
"game_id": "cnctw",
|
||||||
|
"max_players": 4,
|
||||||
|
"scheduled_for": None,
|
||||||
|
"deadline": now + 600_000,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rsvp = {
|
||||||
|
"id": "s48-rsvp",
|
||||||
|
"call_id": "s48-call",
|
||||||
|
"actor_id": "",
|
||||||
|
"actor_name": "Bob",
|
||||||
|
"at": now + 1,
|
||||||
|
"action": "Rsvp",
|
||||||
|
}
|
||||||
|
message = {
|
||||||
|
"id": "s48-message-event",
|
||||||
|
"call_id": "s48-call",
|
||||||
|
"actor_id": "",
|
||||||
|
"actor_name": "Bob",
|
||||||
|
"at": now + 2,
|
||||||
|
"action": {
|
||||||
|
"SendMessage": {
|
||||||
|
"message_id": "s48-message",
|
||||||
|
"text": "I am in",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
alice.publish_call_to_play(create)
|
||||||
|
wait_call_to_play_events(bob, {"s48-create"})
|
||||||
|
bob.publish_call_to_play(rsvp)
|
||||||
|
bob.publish_call_to_play(message)
|
||||||
|
wait_call_to_play_events(alice, {"s48-create", "s48-rsvp", "s48-message-event"})
|
||||||
|
|
||||||
|
charlie = self.peer("s48-charlie")
|
||||||
|
charlie.connect_to(alice)
|
||||||
|
events = wait_call_to_play_events(
|
||||||
|
charlie,
|
||||||
|
{"s48-create", "s48-rsvp", "s48-message-event"},
|
||||||
|
)
|
||||||
|
if len(events) != 3:
|
||||||
|
raise ScenarioError(f"late join history contains duplicates: {events}")
|
||||||
|
|
||||||
|
return "live create/RSVP/chat replicated and a late joiner received deduplicated history"
|
||||||
|
|
||||||
|
def s49_terminal_call_to_play_late_join(self) -> str:
|
||||||
|
alice = self.peer("s49-alice")
|
||||||
|
bob = self.peer("s49-bob")
|
||||||
|
bob.connect_to(alice)
|
||||||
|
|
||||||
|
now = int(time.time() * 1000)
|
||||||
|
create = {
|
||||||
|
"id": "s49-create",
|
||||||
|
"call_id": "s49-call",
|
||||||
|
"actor_id": "",
|
||||||
|
"actor_name": "Alice",
|
||||||
|
"at": now,
|
||||||
|
"action": {
|
||||||
|
"Create": {
|
||||||
|
"game_id": "cnctw",
|
||||||
|
"max_players": 4,
|
||||||
|
"scheduled_for": None,
|
||||||
|
"deadline": now + 600_000,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ready = {
|
||||||
|
"id": "s49-ready",
|
||||||
|
"call_id": "s49-call",
|
||||||
|
"actor_id": "",
|
||||||
|
"actor_name": "Bob",
|
||||||
|
"at": now + 1,
|
||||||
|
"action": {"Respond": {"ready_at": None}},
|
||||||
|
}
|
||||||
|
message = {
|
||||||
|
"id": "s49-message-event",
|
||||||
|
"call_id": "s49-call",
|
||||||
|
"actor_id": "",
|
||||||
|
"actor_name": "Bob",
|
||||||
|
"at": now + 2,
|
||||||
|
"action": {
|
||||||
|
"SendMessage": {
|
||||||
|
"message_id": "s49-message",
|
||||||
|
"text": "Ready to launch",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
start = {
|
||||||
|
"id": "s49-start",
|
||||||
|
"call_id": "s49-call",
|
||||||
|
"actor_id": "",
|
||||||
|
"actor_name": "Alice",
|
||||||
|
"at": now + 3,
|
||||||
|
"action": "Start",
|
||||||
|
}
|
||||||
|
|
||||||
|
alice.publish_call_to_play(create)
|
||||||
|
wait_call_to_play_events(bob, {"s49-create"})
|
||||||
|
bob.publish_call_to_play(ready)
|
||||||
|
bob.publish_call_to_play(message)
|
||||||
|
wait_call_to_play_events(alice, {"s49-create", "s49-ready", "s49-message-event"})
|
||||||
|
alice.publish_call_to_play(start)
|
||||||
|
wait_call_to_play_events(
|
||||||
|
bob,
|
||||||
|
{"s49-create", "s49-ready", "s49-message-event", "s49-start"},
|
||||||
|
)
|
||||||
|
|
||||||
|
charlie = self.peer("s49-charlie")
|
||||||
|
charlie.connect_to(alice)
|
||||||
|
events = wait_call_to_play_events(
|
||||||
|
charlie,
|
||||||
|
{"s49-create", "s49-ready", "s49-message-event", "s49-start"},
|
||||||
|
)
|
||||||
|
if len(events) != 4:
|
||||||
|
raise ScenarioError(f"terminal late-join history is incomplete: {events}")
|
||||||
|
|
||||||
|
return "late joiner reconstructed terminal call outcome, roster, and chat"
|
||||||
|
|
||||||
|
|
||||||
def run(command: list[str], description: str) -> subprocess.CompletedProcess[str]:
|
def run(command: list[str], description: str) -> subprocess.CompletedProcess[str]:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -2034,6 +2175,24 @@ def wait_no_outbound_transfer(peer: Peer, game_id: str, timeout: float = 20) ->
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_call_to_play_events(
|
||||||
|
peer: Peer,
|
||||||
|
expected_ids: set[str],
|
||||||
|
timeout: float = 20,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
last_events: list[dict[str, Any]] = []
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
events = peer.call_to_play_events()
|
||||||
|
last_events = events
|
||||||
|
if expected_ids <= {event.get("id") for event in events}:
|
||||||
|
return events
|
||||||
|
time.sleep(0.2)
|
||||||
|
raise ScenarioError(
|
||||||
|
f"{peer.name} never received Call to Play events {expected_ids}: {last_events}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def assert_game_state(
|
def assert_game_state(
|
||||||
game: dict[str, Any],
|
game: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use eyre::{Context, OptionExt};
|
use eyre::{Context, OptionExt};
|
||||||
use lanspread_peer::{UnpackFuture, Unpacker};
|
use lanspread_peer::{CallToPlayEvent, UnpackFuture, Unpacker};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
@@ -26,6 +26,10 @@ pub enum CliCommand {
|
|||||||
Status,
|
Status,
|
||||||
ListPeers,
|
ListPeers,
|
||||||
ListGames,
|
ListGames,
|
||||||
|
ListCallToPlay,
|
||||||
|
PublishCallToPlay {
|
||||||
|
event: CallToPlayEvent,
|
||||||
|
},
|
||||||
SetGameDir {
|
SetGameDir {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
@@ -67,6 +71,8 @@ impl CliCommand {
|
|||||||
Self::Status => "status",
|
Self::Status => "status",
|
||||||
Self::ListPeers => "list-peers",
|
Self::ListPeers => "list-peers",
|
||||||
Self::ListGames => "list-games",
|
Self::ListGames => "list-games",
|
||||||
|
Self::ListCallToPlay => "list-call-to-play",
|
||||||
|
Self::PublishCallToPlay { .. } => "publish-call-to-play",
|
||||||
Self::SetGameDir { .. } => "set-game-dir",
|
Self::SetGameDir { .. } => "set-game-dir",
|
||||||
Self::Download { .. } => "download",
|
Self::Download { .. } => "download",
|
||||||
Self::StreamInstall { .. } => "stream-install",
|
Self::StreamInstall { .. } => "stream-install",
|
||||||
@@ -102,6 +108,16 @@ pub fn parse_command_value(value: &Value) -> eyre::Result<CommandEnvelope> {
|
|||||||
"status" => CliCommand::Status,
|
"status" => CliCommand::Status,
|
||||||
"list-peers" => CliCommand::ListPeers,
|
"list-peers" => CliCommand::ListPeers,
|
||||||
"list-games" => CliCommand::ListGames,
|
"list-games" => CliCommand::ListGames,
|
||||||
|
"list-call-to-play" => CliCommand::ListCallToPlay,
|
||||||
|
"publish-call-to-play" => CliCommand::PublishCallToPlay {
|
||||||
|
event: serde_json::from_value(
|
||||||
|
object
|
||||||
|
.get("event")
|
||||||
|
.cloned()
|
||||||
|
.ok_or_eyre("publish-call-to-play must include event")?,
|
||||||
|
)
|
||||||
|
.wrap_err("invalid Call to Play event")?,
|
||||||
|
},
|
||||||
"set-game-dir" => CliCommand::SetGameDir {
|
"set-game-dir" => CliCommand::SetGameDir {
|
||||||
path: PathBuf::from(required_str(object, "path")?),
|
path: PathBuf::from(required_str(object, "path")?),
|
||||||
},
|
},
|
||||||
@@ -384,6 +400,21 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_call_to_play_event_command() {
|
||||||
|
let parsed = parse_command_line(
|
||||||
|
r#"{"cmd":"publish-call-to-play","event":{"id":"event-1","call_id":"call-1","actor_id":"","actor_name":"Alice","at":1000,"action":{"Create":{"game_id":"game-1","max_players":4,"scheduled_for":null,"deadline":61000}}}}"#,
|
||||||
|
)
|
||||||
|
.expect("command should parse");
|
||||||
|
|
||||||
|
let CliCommand::PublishCallToPlay { event } = parsed.command else {
|
||||||
|
panic!("expected PublishCallToPlay");
|
||||||
|
};
|
||||||
|
assert_eq!(event.id, "event-1");
|
||||||
|
assert_eq!(event.call_id, "call-1");
|
||||||
|
assert_eq!(event.actor_name, "Alice");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn fixture_unpacker_creates_install_payload() {
|
async fn fixture_unpacker_creates_install_payload() {
|
||||||
let temp = TempDir::new("lanspread-peer-cli-fixture");
|
let temp = TempDir::new("lanspread-peer-cli-fixture");
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
|
|||||||
use lanspread_peer::{
|
use lanspread_peer::{
|
||||||
ActiveOperation,
|
ActiveOperation,
|
||||||
ActiveOperationKind,
|
ActiveOperationKind,
|
||||||
|
CallToPlayEvent,
|
||||||
ExternalUnrarStreamProvider,
|
ExternalUnrarStreamProvider,
|
||||||
NoopStreamInstallProvider,
|
NoopStreamInstallProvider,
|
||||||
OutboundTransfers,
|
OutboundTransfers,
|
||||||
@@ -46,7 +47,7 @@ use lanspread_peer_cli::{
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use tokio::{
|
use tokio::{
|
||||||
io::{AsyncBufReadExt, BufReader},
|
io::{AsyncBufReadExt, BufReader},
|
||||||
sync::{Notify, RwLock, mpsc},
|
sync::{Notify, RwLock, mpsc, oneshot},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -101,6 +102,7 @@ struct CliState {
|
|||||||
game_files: HashMap<String, Vec<GameFileDescription>>,
|
game_files: HashMap<String, Vec<GameFileDescription>>,
|
||||||
unavailable_games: HashSet<String>,
|
unavailable_games: HashSet<String>,
|
||||||
downloads: HashMap<String, DownloadMeasurement>,
|
downloads: HashMap<String, DownloadMeasurement>,
|
||||||
|
call_to_play_events: Vec<CallToPlayEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, serde::Serialize)]
|
#[derive(Clone, serde::Serialize)]
|
||||||
@@ -243,6 +245,27 @@ async fn handle_command(
|
|||||||
CliCommand::Status => status(shared).await,
|
CliCommand::Status => status(shared).await,
|
||||||
CliCommand::ListPeers => list_peers(shared).await,
|
CliCommand::ListPeers => list_peers(shared).await,
|
||||||
CliCommand::ListGames => list_games(shared).await,
|
CliCommand::ListGames => list_games(shared).await,
|
||||||
|
CliCommand::ListCallToPlay => {
|
||||||
|
let (reply, result) = oneshot::channel();
|
||||||
|
sender.send(PeerCommand::GetCallToPlayEvents { reply: Some(reply) })?;
|
||||||
|
let events = tokio::time::timeout(Duration::from_secs(1), result)
|
||||||
|
.await
|
||||||
|
.wrap_err("timed out waiting for Call to Play history")?
|
||||||
|
.wrap_err("peer stopped before returning Call to Play history")?;
|
||||||
|
Ok(json!({ "events": events }))
|
||||||
|
}
|
||||||
|
CliCommand::PublishCallToPlay { event } => {
|
||||||
|
let (reply, result) = oneshot::channel();
|
||||||
|
sender.send(PeerCommand::PublishCallToPlay {
|
||||||
|
event: event.clone(),
|
||||||
|
reply,
|
||||||
|
})?;
|
||||||
|
result
|
||||||
|
.await
|
||||||
|
.wrap_err("peer stopped before publishing Call to Play event")?
|
||||||
|
.map_err(eyre::Report::msg)?;
|
||||||
|
Ok(json!({"published": true, "event_id": event.id}))
|
||||||
|
}
|
||||||
CliCommand::SetGameDir { path } => {
|
CliCommand::SetGameDir { path } => {
|
||||||
sender.send(PeerCommand::SetGameDir(path.clone()))?;
|
sender.send(PeerCommand::SetGameDir(path.clone()))?;
|
||||||
Ok(json!({"queued": true, "path": path}))
|
Ok(json!({"queued": true, "path": path}))
|
||||||
@@ -498,6 +521,21 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
|
|||||||
json!({ "active_operations": active_operations_json(&active_operations) }),
|
json!({ "active_operations": active_operations_json(&active_operations) }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
PeerEvent::CallToPlayEvents(events) => {
|
||||||
|
let mut state = shared.state.write().await;
|
||||||
|
let mut known = state
|
||||||
|
.call_to_play_events
|
||||||
|
.iter()
|
||||||
|
.map(|event| event.id.clone())
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
state.call_to_play_events.extend(
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| known.insert(event.id.clone()))
|
||||||
|
.cloned(),
|
||||||
|
);
|
||||||
|
("call-to-play-events", json!({ "events": events }))
|
||||||
|
}
|
||||||
PeerEvent::GotGameFiles {
|
PeerEvent::GotGameFiles {
|
||||||
id,
|
id,
|
||||||
file_descriptions,
|
file_descriptions,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# lanspread-peer proposed protocol and architecture
|
# lanspread-peer proposed protocol and architecture
|
||||||
|
|
||||||
This document proposes a tighter, more fault-tolerant protocol while keeping
|
This document proposes a tighter, more fault-tolerant protocol while keeping the
|
||||||
the current idea: mDNS discovery, QUIC transport, on-demand metadata, and
|
current idea: mDNS discovery, QUIC transport, on-demand metadata, and chunked
|
||||||
chunked file transfers.
|
file transfers.
|
||||||
|
|
||||||
## Goals (unchanged)
|
## Goals (unchanged)
|
||||||
|
|
||||||
@@ -26,14 +26,15 @@ chunked file transfers.
|
|||||||
|
|
||||||
When a peer is discovered:
|
When a peer is discovered:
|
||||||
|
|
||||||
1. Connect and send `Hello { peer_id, proto_ver, listen_addr, library_rev,
|
1. Connect and send
|
||||||
library_digest, features }`. `listen_addr` is mandatory; the QUIC source port
|
`Hello { peer_id, proto_ver, listen_addr, library_rev, library_digest, features }`.
|
||||||
is only a temporary transport port and must not be recorded as the peer's
|
`listen_addr` is mandatory; the QUIC source port is only a temporary
|
||||||
listener.
|
transport port and must not be recorded as the peer's listener.
|
||||||
2. Receive `HelloAck { peer_id, proto_ver, listen_addr, library_rev,
|
2. Receive
|
||||||
library_digest, features }`.
|
`HelloAck { peer_id, proto_ver, listen_addr, library_rev, library_digest, features }`.
|
||||||
3. If the remote `peer_id` is already known but the address changed, update it.
|
3. If the remote `peer_id` is already known but the address changed, update it.
|
||||||
4. If protocol versions are incompatible, drop the peer (and keep mDNS watching).
|
4. If protocol versions are incompatible, drop the peer (and keep mDNS
|
||||||
|
watching).
|
||||||
5. If library digests match, do nothing else.
|
5. If library digests match, do nothing else.
|
||||||
6. If digests differ:
|
6. If digests differ:
|
||||||
- If we have a known `library_rev` for that peer, request `LibraryDelta`.
|
- If we have a known `library_rev` for that peer, request `LibraryDelta`.
|
||||||
@@ -44,6 +45,53 @@ When a peer is discovered:
|
|||||||
- Any message updates `last_seen`.
|
- Any message updates `last_seen`.
|
||||||
- Pings run only when idle (or on a longer interval), not every 5 seconds.
|
- Pings run only when idle (or on a longer interval), not every 5 seconds.
|
||||||
- Library updates are pushed as deltas, debounced and coalesced.
|
- Library updates are pushed as deltas, debounced and coalesced.
|
||||||
|
- Call to Play actions are broadcast as immutable, uniquely identified events.
|
||||||
|
|
||||||
|
### Call to Play replication
|
||||||
|
|
||||||
|
Call to Play is transient peer-session state rather than database state. The
|
||||||
|
peer keeps a bounded event history, deduplicated by event ID. Every event and
|
||||||
|
chat message remains in the snapshot for the full lifetime of an active call, so
|
||||||
|
a peer joining mid-call receives the complete context. A creator's `Start` or
|
||||||
|
`Cancel` makes the call terminal and read-only, but its complete roster and chat
|
||||||
|
history remain in snapshots for 15 minutes so late joiners can see the outcome.
|
||||||
|
After that display window the history compacts to the Start or Cancel tombstone
|
||||||
|
for the rest of the peer session. Active and recently terminal calls are never
|
||||||
|
partially trimmed. If genuinely active history reaches the bound, local
|
||||||
|
publishes return an error to the caller instead of appearing to succeed;
|
||||||
|
terminal histories and tombstones do not consume that active-history capacity. A
|
||||||
|
call whose deadline elapses remains available for five minutes so the creator
|
||||||
|
can start or extend it, then its unresolved history is evicted as a unit. A
|
||||||
|
local action is applied to that history, sent to the UI, and broadcast to every
|
||||||
|
currently known peer. An incoming live event is applied once and sent to the UI
|
||||||
|
without being rebroadcast, which prevents forwarding loops.
|
||||||
|
|
||||||
|
Live Call to Play delivery is acknowledged by the receiver. Applied, duplicate,
|
||||||
|
and obsolete events need no follow-up. An unknown envelope peer, missing call
|
||||||
|
root, transport failure, or malformed acknowledgement makes the sender perform
|
||||||
|
one normal `Hello` / `HelloAck` exchange with that peer. The handshake carries
|
||||||
|
the full retained history in both directions, so a transient request failure
|
||||||
|
heals without waiting for mDNS rediscovery or a later reconnect. A rejected
|
||||||
|
event is logged without retry. Local publication remains successful while this
|
||||||
|
healing happens asynchronously, so an offline peer cannot block an action.
|
||||||
|
|
||||||
|
Actors are keyed by the peer's stable ID and carry a separate display name. The
|
||||||
|
origin peer overwrites the actor ID on local actions. A live-event envelope must
|
||||||
|
name a peer already in the receiver's roster, and every enclosed actor ID must
|
||||||
|
match that envelope. This prevents accidental identity mixing and protects
|
||||||
|
creator controls from other normal clients. It is not authentication against a
|
||||||
|
hostile LAN peer: all peers use the shared application TLS identity, and stable
|
||||||
|
peer IDs are self-asserted under the project's trusted-LAN model.
|
||||||
|
|
||||||
|
`Hello` and `HelloAck` include each side's event history. This lets peers that
|
||||||
|
join after a call was created reconstruct the same nominations, responses,
|
||||||
|
RSVPs, chat, and terminal actions. The launcher reducer sorts the event stream
|
||||||
|
deterministically and derives deadlines and check-in phases from timestamps.
|
||||||
|
Those phases compare creator-supplied wall-clock timestamps with each viewer's
|
||||||
|
local wall clock, so LAN machines are assumed to be synchronized closely enough
|
||||||
|
for human-scale minute countdowns; clock skew shifts the displayed boundary by
|
||||||
|
the same amount. There is deliberately no compatibility path for older protocol
|
||||||
|
versions.
|
||||||
|
|
||||||
### 4) Shutdown
|
### 4) Shutdown
|
||||||
|
|
||||||
@@ -90,8 +138,8 @@ When a peer is discovered:
|
|||||||
|
|
||||||
1. Maintain a persistent on-disk index (per game):
|
1. Maintain a persistent on-disk index (per game):
|
||||||
- `manifest_hash`, total size, file list (optional), and a fingerprint
|
- `manifest_hash`, total size, file list (optional), and a fingerprint
|
||||||
(root-level `version.ini` mtime, root-level `.eti` mtime/size, and
|
(root-level `version.ini` mtime, root-level `.eti` mtime/size, and `local/`
|
||||||
`local/` directory presence).
|
directory presence).
|
||||||
2. Use filesystem watchers to update only changed games.
|
2. Use filesystem watchers to update only changed games.
|
||||||
3. Keep a 300-second fallback scan to recover from missed events.
|
3. Keep a 300-second fallback scan to recover from missed events.
|
||||||
|
|
||||||
@@ -117,8 +165,8 @@ Downloaded and installed are independent predicates:
|
|||||||
`local/` are user-owned and are skipped by manifests, fingerprints, and file
|
`local/` are user-owned and are skipped by manifests, fingerprints, and file
|
||||||
serving.
|
serving.
|
||||||
- Install and update transactions unpack into staging, then overwrite the first
|
- Install and update transactions unpack into staging, then overwrite the first
|
||||||
discovered game-provided `account_name.txt` and `language.txt` files under
|
discovered game-provided `account_name.txt` and `language.txt` files under the
|
||||||
the staged tree from launcher settings before promoting it to `local/`.
|
staged tree from launcher settings before promoting it to `local/`.
|
||||||
|
|
||||||
Reserved per-game paths:
|
Reserved per-game paths:
|
||||||
|
|
||||||
@@ -192,8 +240,8 @@ Most scans become O(number of game dirs), with full recursion only when needed.
|
|||||||
1. Protocol updates in `lanspread-proto`:
|
1. Protocol updates in `lanspread-proto`:
|
||||||
- Define `Hello`, `HelloAck`, `LibrarySummary`, `LibrarySnapshot`,
|
- Define `Hello`, `HelloAck`, `LibrarySummary`, `LibrarySnapshot`,
|
||||||
`LibraryDelta`, and optional `Goodbye` messages.
|
`LibraryDelta`, and optional `Goodbye` messages.
|
||||||
- Thread `peer_id`, `library_rev`, and `manifest_hash` through all
|
- Thread `peer_id`, `library_rev`, and `manifest_hash` through all library
|
||||||
library and manifest-bearing types.
|
and manifest-bearing types.
|
||||||
- Make `Hello` and `HelloAck` carry the sender's `listen_addr`,
|
- Make `Hello` and `HelloAck` carry the sender's `listen_addr`,
|
||||||
`library_rev`, and `library_digest` so both sides can record stable
|
`library_rev`, and `library_digest` so both sides can record stable
|
||||||
listener addresses and immediately select `LibraryDelta` vs
|
listener addresses and immediately select `LibraryDelta` vs
|
||||||
@@ -201,11 +249,11 @@ Most scans become O(number of game dirs), with full recursion only when needed.
|
|||||||
2. Peer identity:
|
2. Peer identity:
|
||||||
- Persist a stable `peer_id` (UUID) in the peer config and inject it into
|
- Persist a stable `peer_id` (UUID) in the peer config and inject it into
|
||||||
`PeerInfo` and `PeerGameDB` at startup.
|
`PeerInfo` and `PeerGameDB` at startup.
|
||||||
- Track `peer_id -> SocketAddr` in the discovery table and update the
|
- Track `peer_id -> SocketAddr` in the discovery table and update the address
|
||||||
address on any incoming handshake or mDNS refresh.
|
on any incoming handshake or mDNS refresh.
|
||||||
3. Discovery handshake:
|
3. Discovery handshake:
|
||||||
- Publish `peer_id` and `library_rev` in mDNS TXT records to avoid
|
- Publish `peer_id` and `library_rev` in mDNS TXT records to avoid immediate
|
||||||
immediate TCP/QUIC roundtrips when nothing changed.
|
TCP/QUIC roundtrips when nothing changed.
|
||||||
- Add a lightweight handshake in `run_peer_discovery` that exchanges
|
- Add a lightweight handshake in `run_peer_discovery` that exchanges
|
||||||
`Hello`/`HelloAck` before any library sync.
|
`Hello`/`HelloAck` before any library sync.
|
||||||
- Ignore peers that do not advertise the current protocol version.
|
- Ignore peers that do not advertise the current protocol version.
|
||||||
@@ -214,8 +262,8 @@ Most scans become O(number of game dirs), with full recursion only when needed.
|
|||||||
successful index refresh completes.
|
successful index refresh completes.
|
||||||
- Apply `LibraryDelta` when `library_rev` matches; reject stale or future
|
- Apply `LibraryDelta` when `library_rev` matches; reject stale or future
|
||||||
revisions and request `LibrarySnapshot` instead.
|
revisions and request `LibrarySnapshot` instead.
|
||||||
- Cache the last accepted `manifest_hash` per peer to short-circuit
|
- Cache the last accepted `manifest_hash` per peer to short-circuit manifest
|
||||||
manifest requests when unchanged.
|
requests when unchanged.
|
||||||
5. Local index + scan optimizations:
|
5. Local index + scan optimizations:
|
||||||
- Use the cached `local_library/index.json` file in the configured state
|
- Use the cached `local_library/index.json` file in the configured state
|
||||||
directory to store per-root fingerprints and computed manifests.
|
directory to store per-root fingerprints and computed manifests.
|
||||||
|
|||||||
@@ -23,19 +23,20 @@ It is designed to run headless – other crates (most notably
|
|||||||
`Game` definitions, tracks the latest ETI version per title, and keeps the
|
`Game` definitions, tracks the latest ETI version per title, and keeps the
|
||||||
last seen list of `GameFileDescription` entries for each peer.
|
last seen list of `GameFileDescription` entries for each peer.
|
||||||
|
|
||||||
Internally the peer runtime owns four long-lived tasks that run for the
|
Internally the peer runtime owns four long-lived tasks that run for the lifetime
|
||||||
lifetime of the process:
|
of the process:
|
||||||
|
|
||||||
1. **Server component** (`run_server_component`) – listens for QUIC connections,
|
1. **Server component** (`run_server_component`) – listens for QUIC connections,
|
||||||
advertises via mDNS, and serves `Request::ListGames`, `Request::GetGame`,
|
advertises via mDNS, and serves `Request::ListGames`, `Request::GetGame`,
|
||||||
`Request::GetGameFileData`, `Request::GetGameFileChunk`, and
|
`Request::GetGameFileData`, `Request::GetGameFileChunk`, and
|
||||||
`Request::StreamInstall` by reading from the local game directory.
|
`Request::StreamInstall` by reading from the local game directory.
|
||||||
2. **Discovery loop** (`run_peer_discovery`) – uses the `lanspread-mdns`
|
2. **Discovery loop** (`run_peer_discovery`) – uses the `lanspread-mdns` helper
|
||||||
helper to discover other peers. The blocking mDNS work is executed on a
|
to discover other peers. The blocking mDNS work is executed on a dedicated
|
||||||
dedicated thread via `tokio::task::spawn_blocking` so that the Tokio runtime
|
thread via `tokio::task::spawn_blocking` so that the Tokio runtime remains
|
||||||
remains responsive.
|
responsive.
|
||||||
3. **Ping service** (`run_ping_service`) – periodically issues QUIC ping requests
|
3. **Ping service** (`run_ping_service`) – periodically issues QUIC ping
|
||||||
to keep peer liveness up to date and prunes stale entries from `PeerGameDB`.
|
requests to keep peer liveness up to date and prunes stale entries from
|
||||||
|
`PeerGameDB`.
|
||||||
4. **Local game monitor** (`run_local_game_monitor`) – watches the configured
|
4. **Local game monitor** (`run_local_game_monitor`) – watches the configured
|
||||||
game directory and each game root non-recursively, gates per-ID rescans while
|
game directory and each game root non-recursively, gates per-ID rescans while
|
||||||
operations are active, emits local-library changes separately from active
|
operations are active, emits local-library changes separately from active
|
||||||
@@ -61,8 +62,9 @@ When the UI asks to download a game:
|
|||||||
|
|
||||||
1. The UI first issues `PeerCommand::GetGame` for a new download, or
|
1. The UI first issues `PeerCommand::GetGame` for a new download, or
|
||||||
`PeerCommand::FetchLatestFromPeers` for an update that must bypass local
|
`PeerCommand::FetchLatestFromPeers` for an update that must bypass local
|
||||||
archives. The selected peers are queried via `request_game_details_from_peer`,
|
archives. The selected peers are queried via
|
||||||
and their file manifests are merged inside `PeerGameDB`.
|
`request_game_details_from_peer`, and their file manifests are merged inside
|
||||||
|
`PeerGameDB`.
|
||||||
2. Once the UI receives `PeerEvent::GotGameFiles`, it forwards the selected file
|
2. Once the UI receives `PeerEvent::GotGameFiles`, it forwards the selected file
|
||||||
list back with `PeerCommand::DownloadGameFiles`.
|
list back with `PeerCommand::DownloadGameFiles`.
|
||||||
3. `download_game_files` starts a version-sentinel transaction, parks any old
|
3. `download_game_files` starts a version-sentinel transaction, parks any old
|
||||||
@@ -84,8 +86,8 @@ When the UI asks to download a game:
|
|||||||
sweep `.version.ini.tmp` and `.version.ini.discarded` without restoring the
|
sweep `.version.ini.tmp` and `.version.ini.discarded` without restoring the
|
||||||
previous sentinel. Cancelled downloads also discard the peer-owned download
|
previous sentinel. Cancelled downloads also discard the peer-owned download
|
||||||
payload while preserving `local/` and install transaction metadata.
|
payload while preserving `local/` and install transaction metadata.
|
||||||
7. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished`
|
7. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished` is
|
||||||
is emitted and the peer auto-runs the install transaction.
|
emitted and the peer auto-runs the install transaction.
|
||||||
|
|
||||||
### Streamed Install Pipeline
|
### Streamed Install Pipeline
|
||||||
|
|
||||||
@@ -108,25 +110,24 @@ renamed to `local/`, post-promote intent or launch-settings cleanup failures are
|
|||||||
logged for startup recovery rather than reported as a failed install.
|
logged for startup recovery rather than reported as a failed install.
|
||||||
|
|
||||||
`PeerCommand::CancelDownload` cancels the tracked download token for an active
|
`PeerCommand::CancelDownload` cancels the tracked download token for an active
|
||||||
transfer. The transfer task remains responsible for clearing `active_operations`,
|
transfer. The transfer task remains responsible for clearing
|
||||||
discarding partial payload files, and refreshing the settled local snapshot, so
|
`active_operations`, discarding partial payload files, and refreshing the
|
||||||
the UI continues to treat active-operation snapshots as the single source of
|
settled local snapshot, so the UI continues to treat active-operation snapshots
|
||||||
truth for whether a download is still running.
|
as the single source of truth for whether a download is still running.
|
||||||
|
|
||||||
### Install Transactions
|
### Install Transactions
|
||||||
|
|
||||||
Install, update, uninstall, downloaded-file removal, and startup recovery live
|
Install, update, uninstall, downloaded-file removal, and startup recovery live
|
||||||
under `src/install/`.
|
under `src/install/`. Install-side operation intent is stored atomically under
|
||||||
Install-side operation intent is stored atomically under the configured peer
|
the configured peer state directory, at `games/<game_id>/install_intent.json`.
|
||||||
state directory, at `games/<game_id>/install_intent.json`. Game roots still use
|
Game roots still use Lanspread-owned `.local.installing/` and `.local.backup/`
|
||||||
Lanspread-owned `.local.installing/` and `.local.backup/` directories marked by
|
directories marked by `.lanspread_owned`. Startup recovery combines the recorded
|
||||||
`.lanspread_owned`. Startup recovery combines the recorded intent with the
|
intent with the observed filesystem state and only deletes reserved directories
|
||||||
observed filesystem state and only deletes reserved directories when intent or
|
when intent or marker ownership proves they belong to Lanspread. Downloaded-file
|
||||||
marker ownership proves they belong to Lanspread.
|
removal is deliberately separate from uninstall: it only accepts catalog IDs
|
||||||
Downloaded-file removal is deliberately separate from uninstall: it only accepts
|
that are direct children of the configured game directory, refuses installed or
|
||||||
catalog IDs that are direct children of the configured game directory, refuses
|
in-flight roots, and deletes the whole game root only after finding a regular
|
||||||
installed or in-flight roots, and deletes the whole game root only after finding
|
root-level `version.ini` sentinel.
|
||||||
a regular root-level `version.ini` sentinel.
|
|
||||||
|
|
||||||
Legacy launcher-owned files in game directories are migrated by a dedicated
|
Legacy launcher-owned files in game directories are migrated by a dedicated
|
||||||
pre-start phase. Normal install, recovery, scan, and transfer paths use only the
|
pre-start phase. Normal install, recovery, scan, and transfer paths use only the
|
||||||
@@ -142,11 +143,10 @@ The Tauri application embeds this crate in
|
|||||||
game directory.
|
game directory.
|
||||||
- The Tauri commands (`request_games`, `install_game`, `update_game`,
|
- The Tauri commands (`request_games`, `install_game`, `update_game`,
|
||||||
`remove_downloaded_game`, and `update_game_directory`) translate UI actions
|
`remove_downloaded_game`, and `update_game_directory`) translate UI actions
|
||||||
into `PeerCommand`s. In
|
into `PeerCommand`s. In particular, `update_game_directory` validates the
|
||||||
particular, `update_game_directory` validates the filesystem path before
|
filesystem path before storing it, loads the bundled catalog on first use,
|
||||||
storing it, loads the bundled catalog on first use, kicks off the peer runtime
|
kicks off the peer runtime on demand, and mirrors the installed/uninstalled
|
||||||
on demand, and mirrors the installed/uninstalled state into the UI-facing
|
state into the UI-facing database.
|
||||||
database.
|
|
||||||
- A background task consumes `PeerEvent`s and fans them out to the front-end via
|
- A background task consumes `PeerEvent`s and fans them out to the front-end via
|
||||||
Tauri publish/subscribe events (`games-list-updated`, `game-download-*`,
|
Tauri publish/subscribe events (`games-list-updated`, `game-download-*`,
|
||||||
`game-install-*`, `game-uninstall-*`, `peer-*`). The Tauri crate now only
|
`game-install-*`, `game-uninstall-*`, `peer-*`). The Tauri crate now only
|
||||||
|
|||||||
@@ -0,0 +1,913 @@
|
|||||||
|
//! Replicated event history for Call to Play coordination.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::{BTreeSet, HashMap, HashSet},
|
||||||
|
fmt,
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use lanspread_proto::{CallToPlayAck, CallToPlayAction, CallToPlayEvent};
|
||||||
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
PeerEvent,
|
||||||
|
context::Ctx,
|
||||||
|
events,
|
||||||
|
network::send_call_to_play_events,
|
||||||
|
services::{HandshakeCtx, perform_handshake_with_peer},
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_EVENTS: usize = 4_096;
|
||||||
|
const MAX_ID_CHARS: usize = 128;
|
||||||
|
const MAX_GAME_ID_CHARS: usize = 256;
|
||||||
|
const MAX_USERNAME_CHARS: usize = 24;
|
||||||
|
const MAX_MESSAGE_CHARS: usize = 500;
|
||||||
|
const EXPIRED_RETENTION_MS: i64 = 5 * 60_000;
|
||||||
|
const TERMINAL_RETENTION_MS: i64 = 15 * 60_000;
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(crate) struct CallToPlayStore {
|
||||||
|
events: Vec<CallToPlayEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) struct BatchMerge {
|
||||||
|
pub(crate) applied: Vec<CallToPlayEvent>,
|
||||||
|
pub(crate) duplicates: usize,
|
||||||
|
pub(crate) obsolete: usize,
|
||||||
|
pub(crate) missing_call_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BatchMerge {
|
||||||
|
pub(crate) fn needs_history(&self) -> bool {
|
||||||
|
!self.missing_call_ids.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum MergeError {
|
||||||
|
Invalid(&'static str),
|
||||||
|
ConflictingEvent(String),
|
||||||
|
HistoryFull,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MergeError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Invalid(reason) => formatter.write_str(reason),
|
||||||
|
Self::ConflictingEvent(event_id) => {
|
||||||
|
write!(formatter, "conflicting Call to Play event ID {event_id}")
|
||||||
|
}
|
||||||
|
Self::HistoryFull => formatter.write_str("Call to Play event history is full"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CallToPlayStore {
|
||||||
|
pub(crate) fn snapshot(&mut self) -> Vec<CallToPlayEvent> {
|
||||||
|
self.snapshot_at(now_ms())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_at(&mut self, now: i64) -> Vec<CallToPlayEvent> {
|
||||||
|
compact_history(&mut self.events, now);
|
||||||
|
self.events.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn merge_batch(
|
||||||
|
&mut self,
|
||||||
|
incoming: Vec<CallToPlayEvent>,
|
||||||
|
) -> Result<BatchMerge, MergeError> {
|
||||||
|
self.merge_batch_at(incoming, now_ms())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_batch_at(
|
||||||
|
&mut self,
|
||||||
|
incoming: Vec<CallToPlayEvent>,
|
||||||
|
now: i64,
|
||||||
|
) -> Result<BatchMerge, MergeError> {
|
||||||
|
for event in &incoming {
|
||||||
|
validate_event(event).map_err(MergeError::Invalid)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (incoming, mut duplicates) = deduplicate_batch(incoming)?;
|
||||||
|
|
||||||
|
// Expiry is evaluated before accepting new actions. An action cannot keep
|
||||||
|
// an already-retired root alive by itself; the sender must provide the
|
||||||
|
// complete history in the same batch.
|
||||||
|
let mut retained = self.events.clone();
|
||||||
|
compact_history(&mut retained, now);
|
||||||
|
|
||||||
|
let retained_by_id = retained
|
||||||
|
.iter()
|
||||||
|
.map(|event| (event.id.as_str(), event))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
let mut new_events = Vec::with_capacity(incoming.len());
|
||||||
|
for event in incoming {
|
||||||
|
if let Some(existing) = retained_by_id.get(event.id.as_str()) {
|
||||||
|
if *existing != &event {
|
||||||
|
return Err(MergeError::ConflictingEvent(event.id));
|
||||||
|
}
|
||||||
|
duplicates += 1;
|
||||||
|
} else {
|
||||||
|
new_events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(retained_by_id);
|
||||||
|
|
||||||
|
let rooted_calls = retained
|
||||||
|
.iter()
|
||||||
|
.chain(&new_events)
|
||||||
|
.filter(|event| matches!(event.action, CallToPlayAction::Create { .. }))
|
||||||
|
.map(|event| event.call_id.clone())
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
let retained_tombstones = terminal_tombstone_call_ids(&retained);
|
||||||
|
let retained_history = HistoryIndex::build(&retained);
|
||||||
|
let mut applicable = Vec::with_capacity(new_events.len());
|
||||||
|
let mut missing_call_ids = BTreeSet::new();
|
||||||
|
let mut obsolete = 0;
|
||||||
|
|
||||||
|
for event in new_events {
|
||||||
|
if retained_tombstones.contains(event.call_id.as_str()) {
|
||||||
|
obsolete += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(terminal) = retained_history.terminal_events.get(&event.call_id)
|
||||||
|
&& (matches!(event.action, CallToPlayAction::Create { .. })
|
||||||
|
|| (event.at, event.id.as_str()) > terminal.order_key())
|
||||||
|
{
|
||||||
|
obsolete += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !matches!(event.action, CallToPlayAction::Create { .. })
|
||||||
|
&& !rooted_calls.contains(event.call_id.as_str())
|
||||||
|
{
|
||||||
|
missing_call_ids.insert(event.call_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
applicable.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let applicable_ids = applicable
|
||||||
|
.iter()
|
||||||
|
.map(|event| event.id.clone())
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
retained.extend(applicable);
|
||||||
|
compact_history(&mut retained, now);
|
||||||
|
|
||||||
|
if unresolved_event_count(&retained) > MAX_EVENTS {
|
||||||
|
return Err(MergeError::HistoryFull);
|
||||||
|
}
|
||||||
|
|
||||||
|
let retained_ids = retained
|
||||||
|
.iter()
|
||||||
|
.map(|event| event.id.clone())
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
let applied = retained
|
||||||
|
.iter()
|
||||||
|
.filter(|event| applicable_ids.contains(&event.id))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
obsolete += applicable_ids
|
||||||
|
.iter()
|
||||||
|
.filter(|event_id| !retained_ids.contains(*event_id))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
self.events = retained;
|
||||||
|
Ok(BatchMerge {
|
||||||
|
applied,
|
||||||
|
duplicates,
|
||||||
|
obsolete,
|
||||||
|
missing_call_ids: missing_call_ids.into_iter().collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deduplicate_batch(
|
||||||
|
incoming: Vec<CallToPlayEvent>,
|
||||||
|
) -> Result<(Vec<CallToPlayEvent>, usize), MergeError> {
|
||||||
|
let mut unique = Vec::<CallToPlayEvent>::with_capacity(incoming.len());
|
||||||
|
let mut indexes = HashMap::<String, usize>::with_capacity(incoming.len());
|
||||||
|
let mut duplicates = 0;
|
||||||
|
|
||||||
|
for event in incoming {
|
||||||
|
if let Some(index) = indexes.get(&event.id).copied() {
|
||||||
|
if unique[index] != event {
|
||||||
|
return Err(MergeError::ConflictingEvent(event.id));
|
||||||
|
}
|
||||||
|
duplicates += 1;
|
||||||
|
} else {
|
||||||
|
indexes.insert(event.id.clone(), unique.len());
|
||||||
|
unique.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((unique, duplicates))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct HistoryIndex {
|
||||||
|
creators: HashMap<String, CreateRecord>,
|
||||||
|
terminal_events: HashMap<String, EventRecord>,
|
||||||
|
extensions: HashMap<String, ExtensionRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct CreateRecord {
|
||||||
|
at: i64,
|
||||||
|
event_id: String,
|
||||||
|
actor_id: String,
|
||||||
|
deadline: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateRecord {
|
||||||
|
fn order_key(&self) -> (i64, &str) {
|
||||||
|
(self.at, &self.event_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct EventRecord {
|
||||||
|
at: i64,
|
||||||
|
event_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventRecord {
|
||||||
|
fn order_key(&self) -> (i64, &str) {
|
||||||
|
(self.at, &self.event_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ExtensionRecord {
|
||||||
|
event: EventRecord,
|
||||||
|
deadline: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HistoryIndex {
|
||||||
|
fn build(events: &[CallToPlayEvent]) -> Self {
|
||||||
|
let mut creators = HashMap::<String, CreateRecord>::new();
|
||||||
|
for event in events {
|
||||||
|
let CallToPlayAction::Create { deadline, .. } = event.action else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let candidate = CreateRecord {
|
||||||
|
at: event.at,
|
||||||
|
event_id: event.id.clone(),
|
||||||
|
actor_id: event.actor_id.clone(),
|
||||||
|
deadline,
|
||||||
|
};
|
||||||
|
creators
|
||||||
|
.entry(event.call_id.clone())
|
||||||
|
.and_modify(|current| {
|
||||||
|
if candidate.order_key() < current.order_key() {
|
||||||
|
current.clone_from(&candidate);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_insert(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut terminal_events = HashMap::<String, EventRecord>::new();
|
||||||
|
let mut extensions = HashMap::<String, ExtensionRecord>::new();
|
||||||
|
for event in events {
|
||||||
|
let Some(creator) = creators.get(&event.call_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if event.actor_id != creator.actor_id
|
||||||
|
|| (event.at, event.id.as_str()) <= creator.order_key()
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(
|
||||||
|
event.action,
|
||||||
|
CallToPlayAction::Cancel | CallToPlayAction::Start
|
||||||
|
) {
|
||||||
|
let candidate = EventRecord {
|
||||||
|
at: event.at,
|
||||||
|
event_id: event.id.clone(),
|
||||||
|
};
|
||||||
|
terminal_events
|
||||||
|
.entry(event.call_id.clone())
|
||||||
|
.and_modify(|current| {
|
||||||
|
if candidate.order_key() < current.order_key() {
|
||||||
|
current.clone_from(&candidate);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_insert(candidate);
|
||||||
|
} else if let CallToPlayAction::AddTime { deadline } = event.action {
|
||||||
|
let candidate = ExtensionRecord {
|
||||||
|
event: EventRecord {
|
||||||
|
at: event.at,
|
||||||
|
event_id: event.id.clone(),
|
||||||
|
},
|
||||||
|
deadline,
|
||||||
|
};
|
||||||
|
extensions
|
||||||
|
.entry(event.call_id.clone())
|
||||||
|
.and_modify(|current| {
|
||||||
|
if candidate.event.order_key() > current.event.order_key() {
|
||||||
|
current.clone_from(&candidate);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_insert(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
creators,
|
||||||
|
terminal_events,
|
||||||
|
extensions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expired_call_ids(&self, now: i64) -> HashSet<&str> {
|
||||||
|
self.creators
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(call_id, creator)| {
|
||||||
|
if self.terminal_events.contains_key(call_id) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let deadline = self
|
||||||
|
.extensions
|
||||||
|
.get(call_id)
|
||||||
|
.map_or(creator.deadline, |extension| extension.deadline);
|
||||||
|
(now - deadline > EXPIRED_RETENTION_MS).then_some(call_id.as_str())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_history(events: &mut Vec<CallToPlayEvent>, now: i64) {
|
||||||
|
let index = HistoryIndex::build(events);
|
||||||
|
let expired_calls = index.expired_call_ids(now);
|
||||||
|
|
||||||
|
events.retain(|event| {
|
||||||
|
if expired_calls.contains(event.call_id.as_str()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(terminal) = index.terminal_events.get(&event.call_id) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if (event.at, event.id.as_str()) > terminal.order_key() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
now - terminal.at <= TERMINAL_RETENTION_MS || event.id == terminal.event_id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminal_tombstone_call_ids(events: &[CallToPlayEvent]) -> HashSet<String> {
|
||||||
|
let rooted_calls = events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| matches!(event.action, CallToPlayAction::Create { .. }))
|
||||||
|
.map(|event| event.call_id.clone())
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| {
|
||||||
|
matches!(
|
||||||
|
event.action,
|
||||||
|
CallToPlayAction::Cancel | CallToPlayAction::Start
|
||||||
|
) && !rooted_calls.contains(event.call_id.as_str())
|
||||||
|
})
|
||||||
|
.map(|event| event.call_id.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unresolved_event_count(events: &[CallToPlayEvent]) -> usize {
|
||||||
|
let index = HistoryIndex::build(events);
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| {
|
||||||
|
index.creators.contains_key(&event.call_id)
|
||||||
|
&& !index.terminal_events.contains_key(&event.call_id)
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ms() -> i64 {
|
||||||
|
i64::try_from(
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis(),
|
||||||
|
)
|
||||||
|
.unwrap_or(i64::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn publish(
|
||||||
|
ctx: &Ctx,
|
||||||
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
|
mut event: CallToPlayEvent,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
event.actor_id.clone_from(ctx.peer_id.as_ref());
|
||||||
|
let merged = ctx
|
||||||
|
.call_to_play
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.merge_batch(vec![event.clone()])
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
if merged.needs_history() {
|
||||||
|
return Err("Call to Play history is missing".to_string());
|
||||||
|
}
|
||||||
|
if merged.applied.is_empty() {
|
||||||
|
if merged.obsolete > 0 {
|
||||||
|
return Err("Call to Play event is obsolete".to_string());
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(merged.applied));
|
||||||
|
|
||||||
|
let peer_addresses = ctx.peer_game_db.read().await.get_peer_addresses();
|
||||||
|
let peer_id = ctx.peer_id.clone();
|
||||||
|
let handshake_ctx = HandshakeCtx::from_ctx(ctx, tx_notify_ui);
|
||||||
|
ctx.task_tracker.spawn(async move {
|
||||||
|
let deliveries = peer_addresses.into_iter().map(|peer_addr| {
|
||||||
|
let event = event.clone();
|
||||||
|
let peer_id = peer_id.clone();
|
||||||
|
let handshake_ctx = handshake_ctx.clone();
|
||||||
|
async move {
|
||||||
|
deliver_to_peer(handshake_ctx, peer_addr, peer_id.as_ref(), event).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
futures::future::join_all(deliveries).await;
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn deliver_to_peer(
|
||||||
|
handshake_ctx: HandshakeCtx,
|
||||||
|
peer_addr: std::net::SocketAddr,
|
||||||
|
peer_id: &str,
|
||||||
|
event: CallToPlayEvent,
|
||||||
|
) {
|
||||||
|
let delivery = send_call_to_play_events(peer_addr, peer_id, vec![event]).await;
|
||||||
|
match &delivery {
|
||||||
|
Ok(CallToPlayAck::Rejected { reason }) => {
|
||||||
|
log::warn!("Peer {peer_addr} rejected a Call to Play event: {reason}");
|
||||||
|
}
|
||||||
|
Ok(CallToPlayAck::Obsolete) => {
|
||||||
|
log::debug!("Peer {peer_addr} already retired the Call to Play event");
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("Failed to deliver a Call to Play event to {peer_addr}: {err}");
|
||||||
|
}
|
||||||
|
Ok(
|
||||||
|
CallToPlayAck::Applied
|
||||||
|
| CallToPlayAck::Duplicate
|
||||||
|
| CallToPlayAck::NeedHandshake
|
||||||
|
| CallToPlayAck::NeedHistory,
|
||||||
|
) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(reason) = delivery_resync_reason(delivery.as_ref().map_err(|_| ())) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Err(err) = perform_handshake_with_peer(handshake_ctx, peer_addr, None).await {
|
||||||
|
log::warn!("Failed to {reason} with {peer_addr}: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delivery_resync_reason(delivery: Result<&CallToPlayAck, ()>) -> Option<&'static str> {
|
||||||
|
match delivery {
|
||||||
|
Err(()) => Some("heal a failed Call to Play delivery"),
|
||||||
|
Ok(CallToPlayAck::NeedHandshake) => Some("complete a requested Call to Play handshake"),
|
||||||
|
Ok(CallToPlayAck::NeedHistory) => Some("restore missing Call to Play history"),
|
||||||
|
Ok(
|
||||||
|
CallToPlayAck::Applied
|
||||||
|
| CallToPlayAck::Duplicate
|
||||||
|
| CallToPlayAck::Obsolete
|
||||||
|
| CallToPlayAck::Rejected { .. },
|
||||||
|
) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_event(event: &CallToPlayEvent) -> Result<(), &'static str> {
|
||||||
|
validate_nonempty(&event.id, MAX_ID_CHARS, "invalid event id")?;
|
||||||
|
validate_nonempty(&event.call_id, MAX_ID_CHARS, "invalid call id")?;
|
||||||
|
validate_nonempty(&event.actor_id, MAX_ID_CHARS, "invalid actor id")?;
|
||||||
|
validate_nonempty(&event.actor_name, MAX_USERNAME_CHARS, "invalid actor name")?;
|
||||||
|
if event.at <= 0 {
|
||||||
|
return Err("invalid event timestamp");
|
||||||
|
}
|
||||||
|
|
||||||
|
match &event.action {
|
||||||
|
CallToPlayAction::Create {
|
||||||
|
game_id,
|
||||||
|
max_players,
|
||||||
|
scheduled_for,
|
||||||
|
deadline,
|
||||||
|
} => {
|
||||||
|
validate_nonempty(game_id, MAX_GAME_ID_CHARS, "invalid game id")?;
|
||||||
|
if !(2..=64).contains(max_players) {
|
||||||
|
return Err("max players must be between 2 and 64");
|
||||||
|
}
|
||||||
|
if *deadline <= event.at {
|
||||||
|
return Err("deadline must be after creation");
|
||||||
|
}
|
||||||
|
if scheduled_for.is_some_and(|scheduled| scheduled != *deadline) {
|
||||||
|
return Err("scheduled call deadline must match its start time");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CallToPlayAction::Respond { ready_at } => {
|
||||||
|
if ready_at.is_some_and(|ready| ready < event.at) {
|
||||||
|
return Err("ready time cannot be before the response");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CallToPlayAction::SendMessage { message_id, text } => {
|
||||||
|
validate_nonempty(message_id, MAX_ID_CHARS, "invalid message id")?;
|
||||||
|
validate_nonempty(text, MAX_MESSAGE_CHARS, "invalid message")?;
|
||||||
|
}
|
||||||
|
CallToPlayAction::AddTime { deadline } => {
|
||||||
|
if *deadline <= event.at {
|
||||||
|
return Err("extended deadline must be in the future");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CallToPlayAction::Rsvp
|
||||||
|
| CallToPlayAction::Leave
|
||||||
|
| CallToPlayAction::Cancel
|
||||||
|
| CallToPlayAction::Start => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_nonempty(
|
||||||
|
value: &str,
|
||||||
|
max_chars: usize,
|
||||||
|
error: &'static str,
|
||||||
|
) -> Result<(), &'static str> {
|
||||||
|
if value.trim().is_empty() || value.chars().count() > max_chars {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use lanspread_proto::{CallToPlayAck, CallToPlayAction, CallToPlayEvent};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
CallToPlayStore,
|
||||||
|
MAX_EVENTS,
|
||||||
|
MergeError,
|
||||||
|
TERMINAL_RETENTION_MS,
|
||||||
|
delivery_resync_reason,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_NOW: i64 = 8_000_000_000_000;
|
||||||
|
|
||||||
|
fn create_event(id: &str) -> CallToPlayEvent {
|
||||||
|
create_event_for("call-1", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_event_for(call_id: &str, id: &str) -> CallToPlayEvent {
|
||||||
|
CallToPlayEvent {
|
||||||
|
id: id.to_string(),
|
||||||
|
call_id: call_id.to_string(),
|
||||||
|
actor_id: "peer-alice".to_string(),
|
||||||
|
actor_name: "Alice".to_string(),
|
||||||
|
at: TEST_NOW,
|
||||||
|
action: CallToPlayAction::Create {
|
||||||
|
game_id: "game-1".to_string(),
|
||||||
|
max_players: 4,
|
||||||
|
scheduled_for: None,
|
||||||
|
deadline: TEST_NOW + 60_000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action_event(id: &str, call_id: &str, action: CallToPlayAction) -> CallToPlayEvent {
|
||||||
|
CallToPlayEvent {
|
||||||
|
id: id.to_string(),
|
||||||
|
call_id: call_id.to_string(),
|
||||||
|
actor_id: "peer-alice".to_string(),
|
||||||
|
actor_name: "Alice".to_string(),
|
||||||
|
at: TEST_NOW + 1_000,
|
||||||
|
action,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deduplicates_events_without_reordering_new_history() {
|
||||||
|
let mut store = CallToPlayStore::default();
|
||||||
|
let first = create_event("event-1");
|
||||||
|
let second = create_event("event-2");
|
||||||
|
let merged = store
|
||||||
|
.merge_batch_at(vec![first.clone(), first.clone(), second.clone()], TEST_NOW)
|
||||||
|
.expect("valid batch should merge");
|
||||||
|
|
||||||
|
assert_eq!(merged.applied, [first, second]);
|
||||||
|
assert_eq!(merged.duplicates, 1);
|
||||||
|
assert_eq!(merged.obsolete, 0);
|
||||||
|
assert!(!merged.needs_history());
|
||||||
|
|
||||||
|
let duplicate = store
|
||||||
|
.merge_batch_at(vec![create_event("event-1")], TEST_NOW)
|
||||||
|
.expect("stored duplicate should be harmless");
|
||||||
|
assert!(duplicate.applied.is_empty());
|
||||||
|
assert_eq!(duplicate.duplicates, 1);
|
||||||
|
|
||||||
|
let ids = event_ids(store.snapshot_at(TEST_NOW));
|
||||||
|
assert_eq!(ids, ["event-1", "event-2"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_batch_leaves_store_unchanged() {
|
||||||
|
let mut store = CallToPlayStore::default();
|
||||||
|
store
|
||||||
|
.merge_batch_at(vec![create_event("create")], TEST_NOW)
|
||||||
|
.expect("create should fit");
|
||||||
|
let before = store.snapshot_at(TEST_NOW);
|
||||||
|
|
||||||
|
let mut invalid = action_event(
|
||||||
|
"invalid",
|
||||||
|
"call-1",
|
||||||
|
CallToPlayAction::SendMessage {
|
||||||
|
message_id: "message-1".to_string(),
|
||||||
|
text: "valid before corruption".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
invalid.action = CallToPlayAction::SendMessage {
|
||||||
|
message_id: "message-1".to_string(),
|
||||||
|
text: " ".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.merge_batch_at(
|
||||||
|
vec![
|
||||||
|
action_event("rsvp", "call-1", CallToPlayAction::Rsvp),
|
||||||
|
invalid,
|
||||||
|
],
|
||||||
|
TEST_NOW,
|
||||||
|
),
|
||||||
|
Err(MergeError::Invalid("invalid message"))
|
||||||
|
);
|
||||||
|
assert_eq!(store.snapshot_at(TEST_NOW), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conflicting_event_id_leaves_store_unchanged() {
|
||||||
|
let mut store = CallToPlayStore::default();
|
||||||
|
store
|
||||||
|
.merge_batch_at(vec![create_event("create")], TEST_NOW)
|
||||||
|
.expect("create should fit");
|
||||||
|
let before = store.snapshot_at(TEST_NOW);
|
||||||
|
let mut conflicting = create_event("create");
|
||||||
|
conflicting.actor_name = "Mallory".to_string();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.merge_batch_at(vec![conflicting], TEST_NOW),
|
||||||
|
Err(MergeError::ConflictingEvent("create".to_string()))
|
||||||
|
);
|
||||||
|
assert_eq!(store.snapshot_at(TEST_NOW), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_and_extension_revive_expired_history_in_any_batch_order() {
|
||||||
|
let extended_deadline = TEST_NOW + 20 * 60_000;
|
||||||
|
let extension = action_event(
|
||||||
|
"extend",
|
||||||
|
"call-1",
|
||||||
|
CallToPlayAction::AddTime {
|
||||||
|
deadline: extended_deadline,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let after_recovery_window = TEST_NOW + 6 * 60_000 + 1;
|
||||||
|
|
||||||
|
for history in [
|
||||||
|
vec![create_event("create"), extension.clone()],
|
||||||
|
vec![extension.clone(), create_event("create")],
|
||||||
|
] {
|
||||||
|
let mut store = CallToPlayStore::default();
|
||||||
|
store
|
||||||
|
.merge_batch_at(vec![create_event("create")], TEST_NOW)
|
||||||
|
.expect("initial create should fit");
|
||||||
|
|
||||||
|
let missing = store
|
||||||
|
.merge_batch_at(vec![extension.clone()], after_recovery_window)
|
||||||
|
.expect("missing history is an acknowledged merge outcome");
|
||||||
|
assert!(missing.applied.is_empty());
|
||||||
|
assert_eq!(missing.missing_call_ids, ["call-1"]);
|
||||||
|
assert!(store.snapshot_at(after_recovery_window).is_empty());
|
||||||
|
|
||||||
|
let revived = store
|
||||||
|
.merge_batch_at(history, after_recovery_window)
|
||||||
|
.expect("complete history should revive the call atomically");
|
||||||
|
assert_eq!(revived.applied.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
event_ids(store.snapshot_at(after_recovery_window)),
|
||||||
|
event_ids(revived.applied)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn immediately_obsolete_event_is_not_applied() {
|
||||||
|
let mut store = CallToPlayStore::default();
|
||||||
|
let after_recovery_window = TEST_NOW + 6 * 60_000 + 1;
|
||||||
|
let merged = store
|
||||||
|
.merge_batch_at(vec![create_event("create")], after_recovery_window)
|
||||||
|
.expect("obsolete is an acknowledged merge outcome");
|
||||||
|
|
||||||
|
assert!(merged.applied.is_empty());
|
||||||
|
assert_eq!(merged.obsolete, 1);
|
||||||
|
assert!(store.snapshot_at(after_recovery_window).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_tombstone_prevents_stale_history_resurrection() {
|
||||||
|
let mut store = CallToPlayStore::default();
|
||||||
|
let start = action_event("start", "call-1", CallToPlayAction::Start);
|
||||||
|
store
|
||||||
|
.merge_batch_at(vec![create_event("create"), start.clone()], TEST_NOW)
|
||||||
|
.expect("terminal history should merge");
|
||||||
|
let after_terminal_retention = start.at + TERMINAL_RETENTION_MS + 1;
|
||||||
|
assert_eq!(
|
||||||
|
store.snapshot_at(after_terminal_retention).as_slice(),
|
||||||
|
std::slice::from_ref(&start)
|
||||||
|
);
|
||||||
|
|
||||||
|
let stale = store
|
||||||
|
.merge_batch_at(
|
||||||
|
vec![
|
||||||
|
create_event("create"),
|
||||||
|
action_event(
|
||||||
|
"extend",
|
||||||
|
"call-1",
|
||||||
|
CallToPlayAction::AddTime {
|
||||||
|
deadline: TEST_NOW + 20 * 60_000,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
after_terminal_retention,
|
||||||
|
)
|
||||||
|
.expect("stale history is an obsolete merge outcome");
|
||||||
|
|
||||||
|
assert!(stale.applied.is_empty());
|
||||||
|
assert_eq!(stale.obsolete, 2);
|
||||||
|
assert_eq!(store.snapshot_at(after_terminal_retention), [start]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_actions_succeed_at_the_active_history_cap() {
|
||||||
|
for terminal_action in [CallToPlayAction::Start, CallToPlayAction::Cancel] {
|
||||||
|
let mut store = full_active_store();
|
||||||
|
let terminal = action_event("terminal", "call-1", terminal_action);
|
||||||
|
|
||||||
|
let merged = store
|
||||||
|
.merge_batch_at(vec![terminal.clone()], TEST_NOW)
|
||||||
|
.expect("terminal action should settle the full call");
|
||||||
|
assert_eq!(merged.applied.as_slice(), std::slice::from_ref(&terminal));
|
||||||
|
assert_eq!(store.snapshot_at(TEST_NOW).len(), MAX_EVENTS + 1);
|
||||||
|
assert_eq!(
|
||||||
|
store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS + 1),
|
||||||
|
[terminal]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_histories_do_not_consume_active_history_capacity() {
|
||||||
|
let mut store = full_active_store();
|
||||||
|
let mut terminal = action_event("call-1-start", "call-1", CallToPlayAction::Start);
|
||||||
|
terminal.at = TEST_NOW + 2_000;
|
||||||
|
store
|
||||||
|
.merge_batch_at(vec![terminal], TEST_NOW)
|
||||||
|
.expect("start should settle active history");
|
||||||
|
|
||||||
|
let created = create_event_for("call-2", "call-2-create");
|
||||||
|
let merged = store
|
||||||
|
.merge_batch_at(vec![created.clone()], TEST_NOW)
|
||||||
|
.expect("terminal history must not block a new active call");
|
||||||
|
assert_eq!(merged.applied, [created]);
|
||||||
|
assert_eq!(store.snapshot_at(TEST_NOW).len(), MAX_EVENTS + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_active_history_returns_an_error() {
|
||||||
|
let mut store = full_active_store();
|
||||||
|
let before = store.snapshot_at(TEST_NOW);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.merge_batch_at(
|
||||||
|
vec![action_event("overflow", "call-1", CallToPlayAction::Rsvp,)],
|
||||||
|
TEST_NOW,
|
||||||
|
),
|
||||||
|
Err(MergeError::HistoryFull)
|
||||||
|
);
|
||||||
|
assert_eq!(store.snapshot_at(TEST_NOW), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visible_call_retains_complete_chat_history() {
|
||||||
|
let mut store = CallToPlayStore::default();
|
||||||
|
let message = action_event(
|
||||||
|
"message-event",
|
||||||
|
"call-1",
|
||||||
|
CallToPlayAction::SendMessage {
|
||||||
|
message_id: "message-1".to_string(),
|
||||||
|
text: "Ready when you are".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let merged = store
|
||||||
|
.merge_batch_at(
|
||||||
|
vec![
|
||||||
|
create_event("create"),
|
||||||
|
action_event("rsvp", "call-1", CallToPlayAction::Rsvp),
|
||||||
|
message.clone(),
|
||||||
|
],
|
||||||
|
TEST_NOW,
|
||||||
|
)
|
||||||
|
.expect("visible history should merge");
|
||||||
|
|
||||||
|
assert_eq!(merged.applied.len(), 3);
|
||||||
|
assert!(store.snapshot_at(TEST_NOW).contains(&message));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_history_preserves_roster_and_chat_for_display_window() {
|
||||||
|
let mut store = CallToPlayStore::default();
|
||||||
|
let message = action_event(
|
||||||
|
"message",
|
||||||
|
"call-1",
|
||||||
|
CallToPlayAction::SendMessage {
|
||||||
|
message_id: "message-1".to_string(),
|
||||||
|
text: "Launching now".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let terminal = action_event("terminal", "call-1", CallToPlayAction::Start);
|
||||||
|
let history = vec![
|
||||||
|
create_event("create"),
|
||||||
|
action_event("rsvp", "call-1", CallToPlayAction::Rsvp),
|
||||||
|
message,
|
||||||
|
terminal.clone(),
|
||||||
|
];
|
||||||
|
store
|
||||||
|
.merge_batch_at(history.clone(), TEST_NOW)
|
||||||
|
.expect("terminal history should merge");
|
||||||
|
|
||||||
|
let mut post_terminal = action_event(
|
||||||
|
"post-terminal",
|
||||||
|
"call-1",
|
||||||
|
CallToPlayAction::SendMessage {
|
||||||
|
message_id: "message-2".to_string(),
|
||||||
|
text: "Too late".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
post_terminal.at = terminal.at + 1;
|
||||||
|
let obsolete = store
|
||||||
|
.merge_batch_at(
|
||||||
|
vec![create_event("different-create"), post_terminal],
|
||||||
|
TEST_NOW,
|
||||||
|
)
|
||||||
|
.expect("terminal updates should be obsolete");
|
||||||
|
assert!(obsolete.applied.is_empty());
|
||||||
|
assert_eq!(obsolete.obsolete, 2);
|
||||||
|
assert_eq!(
|
||||||
|
store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS),
|
||||||
|
history
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS + 1),
|
||||||
|
[terminal]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_failed_or_incomplete_deliveries_request_resync() {
|
||||||
|
assert!(delivery_resync_reason(Err(())).is_some());
|
||||||
|
assert!(delivery_resync_reason(Ok(&CallToPlayAck::NeedHandshake)).is_some());
|
||||||
|
assert!(delivery_resync_reason(Ok(&CallToPlayAck::NeedHistory)).is_some());
|
||||||
|
|
||||||
|
for ack in [
|
||||||
|
CallToPlayAck::Applied,
|
||||||
|
CallToPlayAck::Duplicate,
|
||||||
|
CallToPlayAck::Obsolete,
|
||||||
|
CallToPlayAck::Rejected {
|
||||||
|
reason: "invalid".to_string(),
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
assert!(delivery_resync_reason(Ok(&ack)).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn full_active_store() -> CallToPlayStore {
|
||||||
|
let mut history = Vec::with_capacity(MAX_EVENTS);
|
||||||
|
history.push(create_event("create"));
|
||||||
|
history.extend((1..MAX_EVENTS).map(|index| {
|
||||||
|
action_event(&format!("event-{index}"), "call-1", CallToPlayAction::Rsvp)
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mut store = CallToPlayStore::default();
|
||||||
|
store
|
||||||
|
.merge_batch_at(history, TEST_NOW)
|
||||||
|
.expect("active history should fit through the cap");
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_ids(events: Vec<CallToPlayEvent>) -> Vec<String> {
|
||||||
|
events.into_iter().map(|event| event.id).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ use crate::{
|
|||||||
PeerEvent,
|
PeerEvent,
|
||||||
StreamInstallProvider,
|
StreamInstallProvider,
|
||||||
Unpacker,
|
Unpacker,
|
||||||
|
call_to_play::CallToPlayStore,
|
||||||
events,
|
events,
|
||||||
library::LocalLibraryState,
|
library::LocalLibraryState,
|
||||||
peer_db::PeerGameDB,
|
peer_db::PeerGameDB,
|
||||||
@@ -51,6 +52,7 @@ pub struct Ctx {
|
|||||||
pub shutdown: CancellationToken,
|
pub shutdown: CancellationToken,
|
||||||
pub task_tracker: TaskTracker,
|
pub task_tracker: TaskTracker,
|
||||||
pub active_outbound_transfers: OutboundTransfers,
|
pub active_outbound_transfers: OutboundTransfers,
|
||||||
|
pub call_to_play: Arc<RwLock<CallToPlayStore>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Context for peer connection handling.
|
/// Context for peer connection handling.
|
||||||
@@ -69,6 +71,7 @@ pub struct PeerCtx {
|
|||||||
pub shutdown: CancellationToken,
|
pub shutdown: CancellationToken,
|
||||||
pub task_tracker: TaskTracker,
|
pub task_tracker: TaskTracker,
|
||||||
pub active_outbound_transfers: OutboundTransfers,
|
pub active_outbound_transfers: OutboundTransfers,
|
||||||
|
pub call_to_play: Arc<RwLock<CallToPlayStore>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for PeerCtx {
|
impl std::fmt::Debug for PeerCtx {
|
||||||
@@ -113,6 +116,7 @@ impl Ctx {
|
|||||||
shutdown,
|
shutdown,
|
||||||
task_tracker,
|
task_tracker,
|
||||||
active_outbound_transfers,
|
active_outbound_transfers,
|
||||||
|
call_to_play: Arc::new(RwLock::new(CallToPlayStore::default())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +139,7 @@ impl Ctx {
|
|||||||
shutdown: self.shutdown.clone(),
|
shutdown: self.shutdown.clone(),
|
||||||
task_tracker: self.task_tracker.clone(),
|
task_tracker: self.task_tracker.clone(),
|
||||||
active_outbound_transfers: self.active_outbound_transfers.clone(),
|
active_outbound_transfers: self.active_outbound_transfers.clone(),
|
||||||
|
call_to_play: self.call_to_play.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use crate::state_paths::peer_id_path;
|
|||||||
|
|
||||||
pub const FEATURE_LIBRARY_DELTA: &str = "library-delta-v1";
|
pub const FEATURE_LIBRARY_DELTA: &str = "library-delta-v1";
|
||||||
pub const FEATURE_LIBRARY_SNAPSHOT: &str = "library-snapshot-v1";
|
pub const FEATURE_LIBRARY_SNAPSHOT: &str = "library-snapshot-v1";
|
||||||
|
pub const FEATURE_CALL_TO_PLAY: &str = "call-to-play-v1";
|
||||||
|
|
||||||
pub fn load_or_create_peer_id(state_dir: &Path) -> eyre::Result<String> {
|
pub fn load_or_create_peer_id(state_dir: &Path) -> eyre::Result<String> {
|
||||||
let path = peer_id_path(state_dir);
|
let path = peer_id_path(state_dir);
|
||||||
@@ -28,5 +29,6 @@ pub fn default_features() -> Vec<String> {
|
|||||||
vec![
|
vec![
|
||||||
FEATURE_LIBRARY_DELTA.to_string(),
|
FEATURE_LIBRARY_DELTA.to_string(),
|
||||||
FEATURE_LIBRARY_SNAPSHOT.to_string(),
|
FEATURE_LIBRARY_SNAPSHOT.to_string(),
|
||||||
|
FEATURE_CALL_TO_PLAY.to_string(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
// Module declarations
|
// Module declarations
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
mod call_to_play;
|
||||||
mod config;
|
mod config;
|
||||||
mod context;
|
mod context;
|
||||||
mod download;
|
mod download;
|
||||||
@@ -46,6 +47,7 @@ pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT};
|
|||||||
pub use error::PeerError;
|
pub use error::PeerError;
|
||||||
pub use install::{UnpackFuture, Unpacker};
|
pub use install::{UnpackFuture, Unpacker};
|
||||||
use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
|
use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
|
||||||
|
pub use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
|
||||||
pub use migration::{MigrationReport, migrate_legacy_state};
|
pub use migration::{MigrationReport, migrate_legacy_state};
|
||||||
pub use peer_db::{
|
pub use peer_db::{
|
||||||
MajorityValidationResult,
|
MajorityValidationResult,
|
||||||
@@ -58,6 +60,7 @@ pub use peer_db::{
|
|||||||
use tokio::sync::{
|
use tokio::sync::{
|
||||||
RwLock,
|
RwLock,
|
||||||
mpsc::{UnboundedReceiver, UnboundedSender},
|
mpsc::{UnboundedReceiver, UnboundedSender},
|
||||||
|
oneshot,
|
||||||
};
|
};
|
||||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||||
|
|
||||||
@@ -159,6 +162,8 @@ pub enum PeerEvent {
|
|||||||
ActiveOperationsChanged {
|
ActiveOperationsChanged {
|
||||||
active_operations: Vec<ActiveOperation>,
|
active_operations: Vec<ActiveOperation>,
|
||||||
},
|
},
|
||||||
|
/// New or requested Call to Play events in replication order.
|
||||||
|
CallToPlayEvents(Vec<CallToPlayEvent>),
|
||||||
/// A required peer runtime component failed.
|
/// A required peer runtime component failed.
|
||||||
RuntimeFailed {
|
RuntimeFailed {
|
||||||
component: PeerRuntimeComponent,
|
component: PeerRuntimeComponent,
|
||||||
@@ -224,7 +229,7 @@ pub enum ActiveOperationKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Commands sent to the peer system from the UI.
|
/// Commands sent to the peer system from the UI.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Debug)]
|
||||||
pub enum PeerCommand {
|
pub enum PeerCommand {
|
||||||
/// Request a list of all available games.
|
/// Request a list of all available games.
|
||||||
ListGames,
|
ListGames,
|
||||||
@@ -259,6 +264,15 @@ pub enum PeerCommand {
|
|||||||
GetPeerCount,
|
GetPeerCount,
|
||||||
/// Connect directly to a peer address without waiting for mDNS discovery.
|
/// Connect directly to a peer address without waiting for mDNS discovery.
|
||||||
ConnectPeer(SocketAddr),
|
ConnectPeer(SocketAddr),
|
||||||
|
/// Publish one local Call to Play action to this peer and the LAN.
|
||||||
|
PublishCallToPlay {
|
||||||
|
event: CallToPlayEvent,
|
||||||
|
reply: oneshot::Sender<Result<(), String>>,
|
||||||
|
},
|
||||||
|
/// Request the complete in-memory Call to Play history.
|
||||||
|
GetCallToPlayEvents {
|
||||||
|
reply: Option<oneshot::Sender<Vec<CallToPlayEvent>>>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Optional startup settings for non-GUI callers and tests.
|
/// Optional startup settings for non-GUI callers and tests.
|
||||||
@@ -489,6 +503,17 @@ async fn handle_peer_commands(
|
|||||||
PeerCommand::ConnectPeer(addr) => {
|
PeerCommand::ConnectPeer(addr) => {
|
||||||
handle_connect_peer_command(ctx, tx_notify_ui, addr).await;
|
handle_connect_peer_command(ctx, tx_notify_ui, addr).await;
|
||||||
}
|
}
|
||||||
|
PeerCommand::PublishCallToPlay { event, reply } => {
|
||||||
|
let result = call_to_play::publish(ctx, tx_notify_ui, event).await;
|
||||||
|
let _ = reply.send(result);
|
||||||
|
}
|
||||||
|
PeerCommand::GetCallToPlayEvents { reply } => {
|
||||||
|
let events = ctx.call_to_play.write().await.snapshot();
|
||||||
|
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(events.clone()));
|
||||||
|
if let Some(reply) = reply {
|
||||||
|
let _ = reply.send(events);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,16 @@ use bytes::BytesMut;
|
|||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use if_addrs::{IfAddr, Interface, get_if_addrs};
|
use if_addrs::{IfAddr, Interface, get_if_addrs};
|
||||||
use lanspread_db::db::GameFileDescription;
|
use lanspread_db::db::GameFileDescription;
|
||||||
use lanspread_proto::{Hello, HelloAck, LibraryDelta, Message, Request, Response};
|
use lanspread_proto::{
|
||||||
|
CallToPlayAck,
|
||||||
|
CallToPlayEvent,
|
||||||
|
Hello,
|
||||||
|
HelloAck,
|
||||||
|
LibraryDelta,
|
||||||
|
Message,
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
};
|
||||||
use s2n_quic::{
|
use s2n_quic::{
|
||||||
Client as QuicClient,
|
Client as QuicClient,
|
||||||
Connection,
|
Connection,
|
||||||
@@ -132,6 +141,14 @@ pub async fn send_oneway_request(peer_addr: SocketAddr, request: Request) -> eyr
|
|||||||
|
|
||||||
/// Performs a hello/ack handshake with a peer.
|
/// Performs a hello/ack handshake with a peer.
|
||||||
pub async fn exchange_hello(peer_addr: SocketAddr, hello: Hello) -> eyre::Result<HelloAck> {
|
pub async fn exchange_hello(peer_addr: SocketAddr, hello: Hello) -> eyre::Result<HelloAck> {
|
||||||
|
let response = exchange_request(peer_addr, Request::Hello(hello)).await?;
|
||||||
|
match response {
|
||||||
|
Response::HelloAck(ack) => Ok(ack),
|
||||||
|
other => eyre::bail!("Unexpected response from peer {peer_addr}: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exchange_request(peer_addr: SocketAddr, request: Request) -> eyre::Result<Response> {
|
||||||
let mut conn = connect_to_peer(peer_addr).await?;
|
let mut conn = connect_to_peer(peer_addr).await?;
|
||||||
|
|
||||||
let stream = conn.open_bidirectional_stream().await?;
|
let stream = conn.open_bidirectional_stream().await?;
|
||||||
@@ -139,19 +156,15 @@ pub async fn exchange_hello(peer_addr: SocketAddr, hello: Hello) -> eyre::Result
|
|||||||
let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new());
|
let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new());
|
||||||
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
|
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||||
|
|
||||||
framed_tx.send(Request::Hello(hello).encode()).await?;
|
framed_tx.send(request.encode()).await?;
|
||||||
let _ = framed_tx.close().await;
|
framed_tx.close().await?;
|
||||||
|
|
||||||
let mut data = BytesMut::new();
|
let mut data = BytesMut::new();
|
||||||
while let Some(Ok(bytes)) = framed_rx.next().await {
|
while let Some(frame) = framed_rx.next().await {
|
||||||
data.extend_from_slice(&bytes);
|
data.extend_from_slice(&frame?);
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = Response::decode(data.freeze());
|
Ok(Response::decode(data.freeze()))
|
||||||
match response {
|
|
||||||
Response::HelloAck(ack) => Ok(ack),
|
|
||||||
other => eyre::bail!("Unexpected response from peer {peer_addr}: {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_library_delta(
|
pub async fn send_library_delta(
|
||||||
@@ -173,6 +186,25 @@ pub async fn send_goodbye(peer_addr: SocketAddr, peer_id: String) -> eyre::Resul
|
|||||||
send_oneway_request(peer_addr, Request::Goodbye { peer_id }).await
|
send_oneway_request(peer_addr, Request::Goodbye { peer_id }).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn send_call_to_play_events(
|
||||||
|
peer_addr: SocketAddr,
|
||||||
|
peer_id: &str,
|
||||||
|
events: Vec<CallToPlayEvent>,
|
||||||
|
) -> eyre::Result<CallToPlayAck> {
|
||||||
|
let response = exchange_request(
|
||||||
|
peer_addr,
|
||||||
|
Request::CallToPlayEvents {
|
||||||
|
peer_id: peer_id.to_string(),
|
||||||
|
events,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
match response {
|
||||||
|
Response::CallToPlayAck(ack) => Ok(ack),
|
||||||
|
other => eyre::bail!("Unexpected Call to Play response from peer {peer_addr}: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Requests game file details from a peer.
|
/// Requests game file details from a peer.
|
||||||
pub async fn request_game_details_from_peer(
|
pub async fn request_game_details_from_peer(
|
||||||
peer_addr: SocketAddr,
|
peer_addr: SocketAddr,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
PeerEvent,
|
PeerEvent,
|
||||||
|
call_to_play::CallToPlayStore,
|
||||||
context::{Ctx, PeerCtx},
|
context::{Ctx, PeerCtx},
|
||||||
events,
|
events,
|
||||||
identity::default_features,
|
identity::default_features,
|
||||||
@@ -24,6 +25,7 @@ pub(crate) struct HandshakeCtx {
|
|||||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||||
catalog: Arc<RwLock<GameCatalog>>,
|
catalog: Arc<RwLock<GameCatalog>>,
|
||||||
|
call_to_play: Arc<RwLock<CallToPlayStore>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HandshakeCtx {
|
impl HandshakeCtx {
|
||||||
@@ -35,6 +37,7 @@ impl HandshakeCtx {
|
|||||||
peer_game_db: ctx.peer_game_db.clone(),
|
peer_game_db: ctx.peer_game_db.clone(),
|
||||||
tx_notify_ui: tx_notify_ui.clone(),
|
tx_notify_ui: tx_notify_ui.clone(),
|
||||||
catalog: ctx.catalog.clone(),
|
catalog: ctx.catalog.clone(),
|
||||||
|
call_to_play: ctx.call_to_play.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +49,7 @@ impl HandshakeCtx {
|
|||||||
peer_game_db: ctx.peer_game_db.clone(),
|
peer_game_db: ctx.peer_game_db.clone(),
|
||||||
tx_notify_ui: ctx.tx_notify_ui.clone(),
|
tx_notify_ui: ctx.tx_notify_ui.clone(),
|
||||||
catalog: ctx.catalog.clone(),
|
catalog: ctx.catalog.clone(),
|
||||||
|
call_to_play: ctx.call_to_play.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,28 +62,36 @@ async fn required_listen_addr(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn build_hello_ack(ctx: &PeerCtx) -> eyre::Result<HelloAck> {
|
pub(super) async fn build_hello_ack(ctx: &PeerCtx) -> eyre::Result<HelloAck> {
|
||||||
let library_guard = ctx.local_library.read().await;
|
|
||||||
let listen_addr = required_listen_addr(&ctx.local_peer_addr).await?;
|
let listen_addr = required_listen_addr(&ctx.local_peer_addr).await?;
|
||||||
let library = build_library_snapshot(&library_guard);
|
let library = {
|
||||||
|
let library_guard = ctx.local_library.read().await;
|
||||||
|
build_library_snapshot(&library_guard)
|
||||||
|
};
|
||||||
|
let call_to_play_events = ctx.call_to_play.write().await.snapshot();
|
||||||
Ok(HelloAck {
|
Ok(HelloAck {
|
||||||
peer_id: ctx.peer_id.as_ref().clone(),
|
peer_id: ctx.peer_id.as_ref().clone(),
|
||||||
proto_ver: PROTOCOL_VERSION,
|
proto_ver: PROTOCOL_VERSION,
|
||||||
listen_addr,
|
listen_addr,
|
||||||
library,
|
library,
|
||||||
features: default_features(),
|
features: default_features(),
|
||||||
|
call_to_play_events,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn build_hello_from_state(ctx: &HandshakeCtx) -> eyre::Result<Hello> {
|
async fn build_hello_from_state(ctx: &HandshakeCtx) -> eyre::Result<Hello> {
|
||||||
let library_guard = ctx.local_library.read().await;
|
|
||||||
let listen_addr = required_listen_addr(&ctx.local_peer_addr).await?;
|
let listen_addr = required_listen_addr(&ctx.local_peer_addr).await?;
|
||||||
let library = build_library_snapshot(&library_guard);
|
let library = {
|
||||||
|
let library_guard = ctx.local_library.read().await;
|
||||||
|
build_library_snapshot(&library_guard)
|
||||||
|
};
|
||||||
|
let call_to_play_events = ctx.call_to_play.write().await.snapshot();
|
||||||
Ok(Hello {
|
Ok(Hello {
|
||||||
peer_id: ctx.peer_id.as_ref().clone(),
|
peer_id: ctx.peer_id.as_ref().clone(),
|
||||||
proto_ver: PROTOCOL_VERSION,
|
proto_ver: PROTOCOL_VERSION,
|
||||||
listen_addr,
|
listen_addr,
|
||||||
library,
|
library,
|
||||||
features: default_features(),
|
features: default_features(),
|
||||||
|
call_to_play_events,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +126,13 @@ pub(crate) async fn perform_handshake_with_peer(
|
|||||||
let _ = ctx.peer_game_db.write().await.remove_peer(expected);
|
let _ = ctx.peer_game_db.write().await.remove_peer(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
merge_call_to_play_events(
|
||||||
|
&ctx.call_to_play,
|
||||||
|
&ctx.tx_notify_ui,
|
||||||
|
ack.call_to_play_events,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let record_addr = ack.listen_addr;
|
let record_addr = ack.listen_addr;
|
||||||
let upsert = record_remote_library(
|
let upsert = record_remote_library(
|
||||||
&ctx.peer_game_db,
|
&ctx.peer_game_db,
|
||||||
@@ -149,6 +168,12 @@ pub(super) async fn accept_inbound_hello(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let addr = hello.listen_addr;
|
let addr = hello.listen_addr;
|
||||||
|
merge_call_to_play_events(
|
||||||
|
&ctx.call_to_play,
|
||||||
|
&ctx.tx_notify_ui,
|
||||||
|
hello.call_to_play_events,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let handshake_ctx = HandshakeCtx::from_peer_ctx(ctx);
|
let handshake_ctx = HandshakeCtx::from_peer_ctx(ctx);
|
||||||
let upsert = record_remote_library(
|
let upsert = record_remote_library(
|
||||||
&ctx.peer_game_db,
|
&ctx.peer_game_db,
|
||||||
@@ -165,6 +190,29 @@ pub(super) async fn accept_inbound_hello(
|
|||||||
build_hello_ack(ctx).await
|
build_hello_ack(ctx).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn merge_call_to_play_events(
|
||||||
|
store: &Arc<RwLock<CallToPlayStore>>,
|
||||||
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
|
incoming: Vec<lanspread_proto::CallToPlayEvent>,
|
||||||
|
) {
|
||||||
|
match store.write().await.merge_batch(incoming) {
|
||||||
|
Ok(merged) => {
|
||||||
|
if merged.needs_history() {
|
||||||
|
log::warn!(
|
||||||
|
"Call to Play handshake omitted roots for calls: {}",
|
||||||
|
merged.missing_call_ids.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !merged.applied.is_empty() {
|
||||||
|
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(merged.applied));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("Rejecting Call to Play handshake history: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn spawn_library_resync(
|
pub(super) fn spawn_library_resync(
|
||||||
ctx: HandshakeCtx,
|
ctx: HandshakeCtx,
|
||||||
peer_addr: SocketAddr,
|
peer_addr: SocketAddr,
|
||||||
@@ -212,7 +260,15 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
use lanspread_db::db::GameCatalog;
|
use lanspread_db::db::GameCatalog;
|
||||||
use lanspread_proto::{Availability, GameSummary, Hello, LibrarySnapshot, PROTOCOL_VERSION};
|
use lanspread_proto::{
|
||||||
|
Availability,
|
||||||
|
CallToPlayAction,
|
||||||
|
CallToPlayEvent,
|
||||||
|
GameSummary,
|
||||||
|
Hello,
|
||||||
|
LibrarySnapshot,
|
||||||
|
PROTOCOL_VERSION,
|
||||||
|
};
|
||||||
use tokio::sync::{RwLock, mpsc};
|
use tokio::sync::{RwLock, mpsc};
|
||||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||||
|
|
||||||
@@ -248,6 +304,7 @@ mod tests {
|
|||||||
peer_game_db,
|
peer_game_db,
|
||||||
tx_notify_ui,
|
tx_notify_ui,
|
||||||
catalog: Arc::new(RwLock::new(GameCatalog::empty())),
|
catalog: Arc::new(RwLock::new(GameCatalog::empty())),
|
||||||
|
call_to_play: Arc::new(RwLock::new(crate::call_to_play::CallToPlayStore::default())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +321,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn call_to_play_event() -> CallToPlayEvent {
|
||||||
|
CallToPlayEvent {
|
||||||
|
id: "event-1".to_string(),
|
||||||
|
call_id: "call-1".to_string(),
|
||||||
|
actor_id: "peer-alice".to_string(),
|
||||||
|
actor_name: "Alice".to_string(),
|
||||||
|
at: 8_000_000_000_000,
|
||||||
|
action: CallToPlayAction::Create {
|
||||||
|
game_id: "game".to_string(),
|
||||||
|
max_players: 4,
|
||||||
|
scheduled_for: None,
|
||||||
|
deadline: 8_000_000_060_000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn outbound_hello_requires_local_listener_addr() {
|
async fn outbound_hello_requires_local_listener_addr() {
|
||||||
let ctx = test_handshake_ctx(None);
|
let ctx = test_handshake_ctx(None);
|
||||||
@@ -304,6 +377,22 @@ mod tests {
|
|||||||
assert_eq!(hello.library.games[0].id, "game");
|
assert_eq!(hello.library.games[0].id, "game");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn outbound_hello_carries_call_to_play_history() {
|
||||||
|
let ctx = test_handshake_ctx(Some(addr([10, 66, 0, 2], 40000)));
|
||||||
|
ctx.call_to_play
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.merge_batch(vec![call_to_play_event()])
|
||||||
|
.expect("valid event should be merged");
|
||||||
|
|
||||||
|
let hello = build_hello_from_state(&ctx)
|
||||||
|
.await
|
||||||
|
.expect("listener address is present");
|
||||||
|
|
||||||
|
assert_eq!(hello.call_to_play_events, [call_to_play_event()]);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn inbound_hello_applies_remote_library_snapshot() {
|
async fn inbound_hello_applies_remote_library_snapshot() {
|
||||||
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
||||||
@@ -335,6 +424,7 @@ mod tests {
|
|||||||
games: vec![summary("remote-game")],
|
games: vec![summary("remote-game")],
|
||||||
},
|
},
|
||||||
features: Vec::new(),
|
features: Vec::new(),
|
||||||
|
call_to_play_events: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let ack = accept_inbound_hello(&peer_ctx, None, hello)
|
let ack = accept_inbound_hello(&peer_ctx, None, hello)
|
||||||
@@ -406,6 +496,7 @@ mod tests {
|
|||||||
games: vec![summary("self-game")],
|
games: vec![summary("self-game")],
|
||||||
},
|
},
|
||||||
features: Vec::new(),
|
features: Vec::new(),
|
||||||
|
call_to_play_events: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let ack = accept_inbound_hello(&peer_ctx, None, self_hello)
|
let ack = accept_inbound_hello(&peer_ctx, None, self_hello)
|
||||||
@@ -422,4 +513,49 @@ mod tests {
|
|||||||
"self hello must emit no peer discovery events"
|
"self hello must emit no peer discovery events"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn inbound_hello_merges_call_to_play_history_once() {
|
||||||
|
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
||||||
|
let ctx = Ctx::new(
|
||||||
|
peer_game_db,
|
||||||
|
"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 remote_addr = addr([127, 0, 0, 1], 5000);
|
||||||
|
let hello = Hello {
|
||||||
|
peer_id: "remote-peer".to_string(),
|
||||||
|
proto_ver: PROTOCOL_VERSION,
|
||||||
|
listen_addr: remote_addr,
|
||||||
|
library: LibrarySnapshot {
|
||||||
|
library_rev: 0,
|
||||||
|
games: Vec::new(),
|
||||||
|
},
|
||||||
|
features: Vec::new(),
|
||||||
|
call_to_play_events: vec![call_to_play_event(), call_to_play_event()],
|
||||||
|
};
|
||||||
|
|
||||||
|
accept_inbound_hello(&peer_ctx, None, hello)
|
||||||
|
.await
|
||||||
|
.expect("current protocol hello should be accepted");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ctx.call_to_play.write().await.snapshot(),
|
||||||
|
[call_to_play_event()]
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
rx_notify_ui.recv().await,
|
||||||
|
Some(PeerEvent::CallToPlayEvents(events)) if events == [call_to_play_event()]
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::net::SocketAddr;
|
|||||||
|
|
||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use lanspread_db::db::{Game, GameFileDescription};
|
use lanspread_db::db::{Game, GameFileDescription};
|
||||||
use lanspread_proto::{LibraryDelta, Message, Request, Response};
|
use lanspread_proto::{CallToPlayAck, LibraryDelta, Message, Request, Response};
|
||||||
use s2n_quic::stream::{BidirectionalStream, SendStream};
|
use s2n_quic::stream::{BidirectionalStream, SendStream};
|
||||||
use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
|
use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
|
||||||
|
|
||||||
@@ -90,6 +90,13 @@ async fn dispatch_request(
|
|||||||
handle_library_delta(ctx, peer_id, delta).await;
|
handle_library_delta(ctx, peer_id, delta).await;
|
||||||
framed_tx
|
framed_tx
|
||||||
}
|
}
|
||||||
|
Request::CallToPlayEvents {
|
||||||
|
peer_id,
|
||||||
|
events: incoming,
|
||||||
|
} => {
|
||||||
|
let ack = handle_call_to_play_events(ctx, &peer_id, incoming).await;
|
||||||
|
send_response(framed_tx, Response::CallToPlayAck(ack), "CallToPlayAck").await
|
||||||
|
}
|
||||||
Request::GetGame { id } => handle_get_game(ctx, id, framed_tx).await,
|
Request::GetGame { id } => handle_get_game(ctx, id, framed_tx).await,
|
||||||
Request::GetGameFileData(desc) => handle_file_data_request(ctx, desc, framed_tx).await,
|
Request::GetGameFileData(desc) => handle_file_data_request(ctx, desc, framed_tx).await,
|
||||||
Request::GetGameFileChunk {
|
Request::GetGameFileChunk {
|
||||||
@@ -114,6 +121,60 @@ async fn dispatch_request(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_call_to_play_events(
|
||||||
|
ctx: &PeerCtx,
|
||||||
|
peer_id: &str,
|
||||||
|
incoming: Vec<lanspread_proto::CallToPlayEvent>,
|
||||||
|
) -> CallToPlayAck {
|
||||||
|
let peer_id = peer_id.to_string();
|
||||||
|
if ctx.peer_game_db.read().await.peer_addr(&peer_id).is_none() {
|
||||||
|
log::debug!("Requesting a handshake before accepting Call to Play events from {peer_id}");
|
||||||
|
return CallToPlayAck::NeedHandshake;
|
||||||
|
}
|
||||||
|
if incoming.iter().any(|event| event.actor_id != peer_id) {
|
||||||
|
let reason = format!("event actor does not match envelope peer {peer_id}");
|
||||||
|
log::warn!("Rejecting Call to Play events: {reason}");
|
||||||
|
return CallToPlayAck::Rejected { reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
match ctx.call_to_play.write().await.merge_batch(incoming) {
|
||||||
|
Ok(merged) => {
|
||||||
|
let ack = if merged.needs_history() {
|
||||||
|
CallToPlayAck::NeedHistory
|
||||||
|
} else if !merged.applied.is_empty() {
|
||||||
|
CallToPlayAck::Applied
|
||||||
|
} else if merged.obsolete > 0 {
|
||||||
|
CallToPlayAck::Obsolete
|
||||||
|
} else if merged.duplicates > 0 {
|
||||||
|
CallToPlayAck::Duplicate
|
||||||
|
} else {
|
||||||
|
CallToPlayAck::Rejected {
|
||||||
|
reason: "empty Call to Play event batch".to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if merged.needs_history() {
|
||||||
|
log::warn!(
|
||||||
|
"Ignoring Call to Play actions without history from {peer_id}: {}",
|
||||||
|
merged.missing_call_ids.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !merged.applied.is_empty() {
|
||||||
|
events::send(
|
||||||
|
&ctx.tx_notify_ui,
|
||||||
|
crate::PeerEvent::CallToPlayEvents(merged.applied),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ack
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("Rejecting Call to Play events from {peer_id}: {err}");
|
||||||
|
CallToPlayAck::Rejected {
|
||||||
|
reason: err.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn note_peer_activity(ctx: &PeerCtx, remote_addr: Option<SocketAddr>) {
|
async fn note_peer_activity(ctx: &PeerCtx, remote_addr: Option<SocketAddr>) {
|
||||||
if let Some(addr) = remote_addr {
|
if let Some(addr) = remote_addr {
|
||||||
ctx.peer_game_db
|
ctx.peer_game_db
|
||||||
@@ -450,6 +511,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
use lanspread_db::db::GameCatalog;
|
use lanspread_db::db::GameCatalog;
|
||||||
|
use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
|
||||||
use tokio::sync::{RwLock, mpsc};
|
use tokio::sync::{RwLock, mpsc};
|
||||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||||
|
|
||||||
@@ -495,6 +557,29 @@ mod tests {
|
|||||||
.to_peer_ctx(tx_notify_ui)
|
.to_peer_ctx(tx_notify_ui)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn call_to_play_event(actor_id: &str, action: CallToPlayAction) -> CallToPlayEvent {
|
||||||
|
CallToPlayEvent {
|
||||||
|
id: "event-1".to_string(),
|
||||||
|
call_id: "call-1".to_string(),
|
||||||
|
actor_id: actor_id.to_string(),
|
||||||
|
actor_name: "Alice".to_string(),
|
||||||
|
at: 8_000_000_000_000,
|
||||||
|
action,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_to_play_create(actor_id: &str) -> CallToPlayEvent {
|
||||||
|
call_to_play_event(
|
||||||
|
actor_id,
|
||||||
|
CallToPlayAction::Create {
|
||||||
|
game_id: "game".to_string(),
|
||||||
|
max_players: 4,
|
||||||
|
scheduled_for: None,
|
||||||
|
deadline: 8_000_000_060_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_relative_paths_are_never_transferable() {
|
fn local_relative_paths_are_never_transferable() {
|
||||||
assert!(path_points_inside_local("game", "game/local/save.dat"));
|
assert!(path_points_inside_local("game", "game/local/save.dat"));
|
||||||
@@ -517,6 +602,84 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn known_peer_id_accepts_live_events_without_transport_ip_matching() {
|
||||||
|
let temp = TempDir::new("lanspread-call-to-play-known-peer");
|
||||||
|
let ctx = test_ctx(temp.path().to_path_buf(), GameCatalog::empty());
|
||||||
|
ctx.peer_game_db.write().await.upsert_peer(
|
||||||
|
"peer-alice".to_string(),
|
||||||
|
SocketAddr::from(([10, 66, 0, 2], 40000)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let ack =
|
||||||
|
handle_call_to_play_events(&ctx, "peer-alice", vec![call_to_play_create("peer-alice")])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(ack, CallToPlayAck::Applied);
|
||||||
|
assert_eq!(ctx.call_to_play.write().await.snapshot().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_peer_and_mismatched_actor_receive_explicit_acks() {
|
||||||
|
let temp = TempDir::new("lanspread-call-to-play-identity");
|
||||||
|
let ctx = test_ctx(temp.path().to_path_buf(), GameCatalog::empty());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
handle_call_to_play_events(
|
||||||
|
&ctx,
|
||||||
|
"peer-alice",
|
||||||
|
vec![call_to_play_create("peer-alice")],
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
CallToPlayAck::NeedHandshake
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.peer_game_db.write().await.upsert_peer(
|
||||||
|
"peer-alice".to_string(),
|
||||||
|
SocketAddr::from(([10, 66, 0, 2], 40000)),
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
handle_call_to_play_events(
|
||||||
|
&ctx,
|
||||||
|
"peer-alice",
|
||||||
|
vec![call_to_play_create("peer-mallory")],
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
CallToPlayAck::Rejected { reason }
|
||||||
|
if reason.contains("does not match envelope peer")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn live_event_ack_reports_missing_history_and_duplicates() {
|
||||||
|
let temp = TempDir::new("lanspread-call-to-play-outcomes");
|
||||||
|
let ctx = test_ctx(temp.path().to_path_buf(), GameCatalog::empty());
|
||||||
|
ctx.peer_game_db.write().await.upsert_peer(
|
||||||
|
"peer-alice".to_string(),
|
||||||
|
SocketAddr::from(([10, 66, 0, 2], 40000)),
|
||||||
|
);
|
||||||
|
let orphan = call_to_play_event(
|
||||||
|
"peer-alice",
|
||||||
|
CallToPlayAction::AddTime {
|
||||||
|
deadline: 8_000_000_600_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
handle_call_to_play_events(&ctx, "peer-alice", vec![orphan]).await,
|
||||||
|
CallToPlayAck::NeedHistory
|
||||||
|
);
|
||||||
|
|
||||||
|
let create = call_to_play_create("peer-alice");
|
||||||
|
assert_eq!(
|
||||||
|
handle_call_to_play_events(&ctx, "peer-alice", vec![create.clone()]).await,
|
||||||
|
CallToPlayAck::Applied
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
handle_call_to_play_events(&ctx, "peer-alice", vec![create]).await,
|
||||||
|
CallToPlayAck::Duplicate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_game_response_respects_serve_gates() {
|
async fn get_game_response_respects_serve_gates() {
|
||||||
let temp = TempDir::new("lanspread-stream");
|
let temp = TempDir::new("lanspread-stream");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use bytes::Bytes;
|
|||||||
use lanspread_db::db::{Game, GameFileDescription};
|
use lanspread_db::db::{Game, GameFileDescription};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub const PROTOCOL_VERSION: u32 = 5;
|
pub const PROTOCOL_VERSION: u32 = 7;
|
||||||
|
|
||||||
pub use lanspread_db::db::Availability;
|
pub use lanspread_db::db::Availability;
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ pub struct Hello {
|
|||||||
pub listen_addr: SocketAddr,
|
pub listen_addr: SocketAddr,
|
||||||
pub library: LibrarySnapshot,
|
pub library: LibrarySnapshot,
|
||||||
pub features: Vec<String>,
|
pub features: Vec<String>,
|
||||||
|
pub call_to_play_events: Vec<CallToPlayEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
@@ -36,6 +37,51 @@ pub struct HelloAck {
|
|||||||
pub listen_addr: SocketAddr,
|
pub listen_addr: SocketAddr,
|
||||||
pub library: LibrarySnapshot,
|
pub library: LibrarySnapshot,
|
||||||
pub features: Vec<String>,
|
pub features: Vec<String>,
|
||||||
|
pub call_to_play_events: Vec<CallToPlayEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct CallToPlayEvent {
|
||||||
|
pub id: String,
|
||||||
|
pub call_id: String,
|
||||||
|
pub actor_id: String,
|
||||||
|
pub actor_name: String,
|
||||||
|
pub at: i64,
|
||||||
|
pub action: CallToPlayAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum CallToPlayAction {
|
||||||
|
Create {
|
||||||
|
game_id: String,
|
||||||
|
max_players: u16,
|
||||||
|
scheduled_for: Option<i64>,
|
||||||
|
deadline: i64,
|
||||||
|
},
|
||||||
|
Respond {
|
||||||
|
ready_at: Option<i64>,
|
||||||
|
},
|
||||||
|
Rsvp,
|
||||||
|
SendMessage {
|
||||||
|
message_id: String,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
Leave,
|
||||||
|
Cancel,
|
||||||
|
Start,
|
||||||
|
AddTime {
|
||||||
|
deadline: i64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum CallToPlayAck {
|
||||||
|
Applied,
|
||||||
|
Duplicate,
|
||||||
|
NeedHandshake,
|
||||||
|
NeedHistory,
|
||||||
|
Obsolete,
|
||||||
|
Rejected { reason: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
@@ -75,6 +121,10 @@ pub enum Request {
|
|||||||
peer_id: String,
|
peer_id: String,
|
||||||
delta: LibraryDelta,
|
delta: LibraryDelta,
|
||||||
},
|
},
|
||||||
|
CallToPlayEvents {
|
||||||
|
peer_id: String,
|
||||||
|
events: Vec<CallToPlayEvent>,
|
||||||
|
},
|
||||||
Goodbye {
|
Goodbye {
|
||||||
peer_id: String,
|
peer_id: String,
|
||||||
},
|
},
|
||||||
@@ -90,6 +140,7 @@ pub enum Response {
|
|||||||
file_descriptions: Vec<GameFileDescription>,
|
file_descriptions: Vec<GameFileDescription>,
|
||||||
},
|
},
|
||||||
HelloAck(HelloAck),
|
HelloAck(HelloAck),
|
||||||
|
CallToPlayAck(CallToPlayAck),
|
||||||
GameNotFound(String),
|
GameNotFound(String),
|
||||||
InvalidRequest(Bytes, String),
|
InvalidRequest(Bytes, String),
|
||||||
EncodingError(String),
|
EncodingError(String),
|
||||||
|
|||||||
Generated
+251
-175
@@ -1,192 +1,156 @@
|
|||||||
{
|
{
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"specifiers": {
|
"specifiers": {
|
||||||
"npm:@tauri-apps/api@^2.11.0": "2.11.0",
|
"npm:@tauri-apps/api@^2.11.1": "2.11.1",
|
||||||
"npm:@tauri-apps/cli@^2.11.2": "2.11.2",
|
"npm:@tauri-apps/cli@^2.11.4": "2.11.4",
|
||||||
"npm:@tauri-apps/plugin-dialog@^2.7.1": "2.7.1",
|
"npm:@tauri-apps/plugin-dialog@^2.7.2": "2.7.2",
|
||||||
"npm:@tauri-apps/plugin-shell@^2.3.5": "2.3.5",
|
"npm:@tauri-apps/plugin-shell@^2.3.5": "2.3.5",
|
||||||
"npm:@tauri-apps/plugin-store@^2.4.3": "2.4.3",
|
"npm:@tauri-apps/plugin-store@^2.4.4": "2.4.4",
|
||||||
"npm:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.17",
|
"npm:@types/react-dom@^19.2.4": "19.2.4_@types+react@19.2.18",
|
||||||
"npm:@types/react@^19.2.17": "19.2.17",
|
"npm:@types/react@^19.2.18": "19.2.18",
|
||||||
"npm:@vitejs/plugin-react@^6.0.2": "6.0.2_vite@8.0.16",
|
"npm:@vitejs/plugin-react@^6.0.5": "6.0.5_vite@8.2.1",
|
||||||
"npm:react-dom@^19.2.7": "19.2.7_react@19.2.7",
|
"npm:react-dom@^19.2.8": "19.2.8_react@19.2.8",
|
||||||
"npm:react@^19.2.7": "19.2.7",
|
"npm:react@^19.2.8": "19.2.8",
|
||||||
"npm:typescript@^6.0.3": "6.0.3",
|
"npm:typescript@^7.0.2": "7.0.2",
|
||||||
"npm:vite@^8.0.16": "8.0.16"
|
"npm:vite@^8.2.1": "8.2.1"
|
||||||
},
|
},
|
||||||
"npm": {
|
"npm": {
|
||||||
"@emnapi/core@1.10.0": {
|
"@oxc-project/types@0.143.0": {
|
||||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
"integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="
|
||||||
"dependencies": [
|
|
||||||
"@emnapi/wasi-threads",
|
|
||||||
"tslib"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"@emnapi/runtime@1.10.0": {
|
"@rolldown/binding-android-arm64@1.2.3": {
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
"integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==",
|
||||||
"dependencies": [
|
|
||||||
"tslib"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"@emnapi/wasi-threads@1.2.1": {
|
|
||||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
|
||||||
"dependencies": [
|
|
||||||
"tslib"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"@napi-rs/wasm-runtime@1.1.4_@emnapi+core@1.10.0_@emnapi+runtime@1.10.0": {
|
|
||||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
|
||||||
"dependencies": [
|
|
||||||
"@emnapi/core",
|
|
||||||
"@emnapi/runtime",
|
|
||||||
"@tybys/wasm-util"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"@oxc-project/types@0.133.0": {
|
|
||||||
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="
|
|
||||||
},
|
|
||||||
"@rolldown/binding-android-arm64@1.0.3": {
|
|
||||||
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
|
|
||||||
"os": ["android"],
|
"os": ["android"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-darwin-arm64@1.0.3": {
|
"@rolldown/binding-darwin-arm64@1.2.3": {
|
||||||
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
|
"integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==",
|
||||||
"os": ["darwin"],
|
"os": ["darwin"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-darwin-x64@1.0.3": {
|
"@rolldown/binding-darwin-x64@1.2.3": {
|
||||||
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
|
"integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==",
|
||||||
"os": ["darwin"],
|
"os": ["darwin"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-freebsd-x64@1.0.3": {
|
"@rolldown/binding-freebsd-x64@1.2.3": {
|
||||||
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
|
"integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==",
|
||||||
"os": ["freebsd"],
|
"os": ["freebsd"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-linux-arm-gnueabihf@1.0.3": {
|
"@rolldown/binding-linux-arm-gnueabihf@1.2.3": {
|
||||||
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
|
"integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["arm"]
|
"cpu": ["arm"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-linux-arm64-gnu@1.0.3": {
|
"@rolldown/binding-linux-arm64-gnu@1.2.3": {
|
||||||
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
|
"integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-linux-arm64-musl@1.0.3": {
|
"@rolldown/binding-linux-arm64-musl@1.2.3": {
|
||||||
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
|
"integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-linux-ppc64-gnu@1.0.3": {
|
"@rolldown/binding-linux-ppc64-gnu@1.2.3": {
|
||||||
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
|
"integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["ppc64"]
|
"cpu": ["ppc64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-linux-s390x-gnu@1.0.3": {
|
"@rolldown/binding-linux-s390x-gnu@1.2.3": {
|
||||||
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
|
"integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["s390x"]
|
"cpu": ["s390x"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-linux-x64-gnu@1.0.3": {
|
"@rolldown/binding-linux-x64-gnu@1.2.3": {
|
||||||
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
|
"integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-linux-x64-musl@1.0.3": {
|
"@rolldown/binding-linux-x64-musl@1.2.3": {
|
||||||
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
|
"integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-openharmony-arm64@1.0.3": {
|
"@rolldown/binding-openharmony-arm64@1.2.3": {
|
||||||
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
|
"integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==",
|
||||||
"os": ["openharmony"],
|
"os": ["openharmony"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-wasm32-wasi@1.0.3": {
|
"@rolldown/binding-win32-arm64-msvc@1.2.3": {
|
||||||
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
|
"integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==",
|
||||||
"dependencies": [
|
|
||||||
"@emnapi/core",
|
|
||||||
"@emnapi/runtime",
|
|
||||||
"@napi-rs/wasm-runtime"
|
|
||||||
],
|
|
||||||
"cpu": ["wasm32"]
|
|
||||||
},
|
|
||||||
"@rolldown/binding-win32-arm64-msvc@1.0.3": {
|
|
||||||
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
|
|
||||||
"os": ["win32"],
|
"os": ["win32"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-win32-x64-msvc@1.0.3": {
|
"@rolldown/binding-win32-x64-msvc@1.2.3": {
|
||||||
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
|
"integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==",
|
||||||
"os": ["win32"],
|
"os": ["win32"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
"@rolldown/pluginutils@1.0.1": {
|
"@rolldown/pluginutils@1.0.1": {
|
||||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="
|
||||||
},
|
},
|
||||||
"@tauri-apps/api@2.11.0": {
|
"@tauri-apps/api@2.11.1": {
|
||||||
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="
|
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="
|
||||||
},
|
},
|
||||||
"@tauri-apps/cli-darwin-arm64@2.11.2": {
|
"@tauri-apps/cli-darwin-arm64@2.11.4": {
|
||||||
"integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==",
|
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||||
"os": ["darwin"],
|
"os": ["darwin"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"@tauri-apps/cli-darwin-x64@2.11.2": {
|
"@tauri-apps/cli-darwin-x64@2.11.4": {
|
||||||
"integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==",
|
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||||
"os": ["darwin"],
|
"os": ["darwin"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
"@tauri-apps/cli-linux-arm-gnueabihf@2.11.2": {
|
"@tauri-apps/cli-linux-arm-gnueabihf@2.11.4": {
|
||||||
"integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==",
|
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["arm"]
|
"cpu": ["arm"]
|
||||||
},
|
},
|
||||||
"@tauri-apps/cli-linux-arm64-gnu@2.11.2": {
|
"@tauri-apps/cli-linux-arm64-gnu@2.11.4": {
|
||||||
"integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==",
|
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"@tauri-apps/cli-linux-arm64-musl@2.11.2": {
|
"@tauri-apps/cli-linux-arm64-musl@2.11.4": {
|
||||||
"integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==",
|
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"@tauri-apps/cli-linux-riscv64-gnu@2.11.2": {
|
"@tauri-apps/cli-linux-riscv64-gnu@2.11.4": {
|
||||||
"integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==",
|
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["riscv64"]
|
"cpu": ["riscv64"]
|
||||||
},
|
},
|
||||||
"@tauri-apps/cli-linux-x64-gnu@2.11.2": {
|
"@tauri-apps/cli-linux-x64-gnu@2.11.4": {
|
||||||
"integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==",
|
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
"@tauri-apps/cli-linux-x64-musl@2.11.2": {
|
"@tauri-apps/cli-linux-x64-musl@2.11.4": {
|
||||||
"integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==",
|
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
"@tauri-apps/cli-win32-arm64-msvc@2.11.2": {
|
"@tauri-apps/cli-win32-arm64-msvc@2.11.4": {
|
||||||
"integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==",
|
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||||
"os": ["win32"],
|
"os": ["win32"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"@tauri-apps/cli-win32-ia32-msvc@2.11.2": {
|
"@tauri-apps/cli-win32-ia32-msvc@2.11.4": {
|
||||||
"integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==",
|
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||||
"os": ["win32"],
|
"os": ["win32"],
|
||||||
"cpu": ["ia32"]
|
"cpu": ["ia32"]
|
||||||
},
|
},
|
||||||
"@tauri-apps/cli-win32-x64-msvc@2.11.2": {
|
"@tauri-apps/cli-win32-x64-msvc@2.11.4": {
|
||||||
"integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==",
|
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||||
"os": ["win32"],
|
"os": ["win32"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
"@tauri-apps/cli@2.11.2": {
|
"@tauri-apps/cli@2.11.4": {
|
||||||
"integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==",
|
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||||
"optionalDependencies": [
|
"optionalDependencies": [
|
||||||
"@tauri-apps/cli-darwin-arm64",
|
"@tauri-apps/cli-darwin-arm64",
|
||||||
"@tauri-apps/cli-darwin-x64",
|
"@tauri-apps/cli-darwin-x64",
|
||||||
@@ -202,8 +166,8 @@
|
|||||||
],
|
],
|
||||||
"bin": true
|
"bin": true
|
||||||
},
|
},
|
||||||
"@tauri-apps/plugin-dialog@2.7.1": {
|
"@tauri-apps/plugin-dialog@2.7.2": {
|
||||||
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
|
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@tauri-apps/api"
|
"@tauri-apps/api"
|
||||||
]
|
]
|
||||||
@@ -214,32 +178,126 @@
|
|||||||
"@tauri-apps/api"
|
"@tauri-apps/api"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@tauri-apps/plugin-store@2.4.3": {
|
"@tauri-apps/plugin-store@2.4.4": {
|
||||||
"integrity": "sha512-9LWPj9yMphRi9czEtUv87XHbl1b6xgd9EXpPrUnq6nG7+nbtoF84d4Kwz9xhAv/Hf30sr58pq7EOlyI936y8qw==",
|
"integrity": "sha512-oxSMaj/QpVfJcBMYX5aOQV94fWvga0MwQMfD6TLlbK2dh+ShPWAzefd8HWXhvOKjPRJdGVAkW7ZGO76JzzjaDA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@tauri-apps/api"
|
"@tauri-apps/api"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@tybys/wasm-util@0.10.2": {
|
"@types/react-dom@19.2.4_@types+react@19.2.18": {
|
||||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
|
||||||
"dependencies": [
|
|
||||||
"tslib"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"@types/react-dom@19.2.3_@types+react@19.2.17": {
|
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@types/react"
|
"@types/react"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@types/react@19.2.17": {
|
"@types/react@19.2.18": {
|
||||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"csstype"
|
"csstype"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@vitejs/plugin-react@6.0.2_vite@8.0.16": {
|
"@typescript/typescript-aix-ppc64@7.0.2": {
|
||||||
"integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==",
|
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||||
|
"os": ["aix"],
|
||||||
|
"cpu": ["ppc64"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-darwin-arm64@7.0.2": {
|
||||||
|
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||||
|
"os": ["darwin"],
|
||||||
|
"cpu": ["arm64"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-darwin-x64@7.0.2": {
|
||||||
|
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||||
|
"os": ["darwin"],
|
||||||
|
"cpu": ["x64"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-freebsd-arm64@7.0.2": {
|
||||||
|
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||||
|
"os": ["freebsd"],
|
||||||
|
"cpu": ["arm64"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-freebsd-x64@7.0.2": {
|
||||||
|
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||||
|
"os": ["freebsd"],
|
||||||
|
"cpu": ["x64"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-linux-arm64@7.0.2": {
|
||||||
|
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["arm64"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-linux-arm@7.0.2": {
|
||||||
|
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["arm"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-linux-loong64@7.0.2": {
|
||||||
|
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["loong64"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-linux-mips64el@7.0.2": {
|
||||||
|
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["mips64el"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-linux-ppc64@7.0.2": {
|
||||||
|
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["ppc64"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-linux-riscv64@7.0.2": {
|
||||||
|
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["riscv64"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-linux-s390x@7.0.2": {
|
||||||
|
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["s390x"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-linux-x64@7.0.2": {
|
||||||
|
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["x64"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-netbsd-arm64@7.0.2": {
|
||||||
|
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||||
|
"os": ["netbsd"],
|
||||||
|
"cpu": ["arm64"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-netbsd-x64@7.0.2": {
|
||||||
|
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||||
|
"os": ["netbsd"],
|
||||||
|
"cpu": ["x64"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-openbsd-arm64@7.0.2": {
|
||||||
|
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||||
|
"os": ["openbsd"],
|
||||||
|
"cpu": ["arm64"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-openbsd-x64@7.0.2": {
|
||||||
|
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||||
|
"os": ["openbsd"],
|
||||||
|
"cpu": ["x64"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-sunos-x64@7.0.2": {
|
||||||
|
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||||
|
"os": ["sunos"],
|
||||||
|
"cpu": ["x64"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-win32-arm64@7.0.2": {
|
||||||
|
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||||
|
"os": ["win32"],
|
||||||
|
"cpu": ["arm64"]
|
||||||
|
},
|
||||||
|
"@typescript/typescript-win32-x64@7.0.2": {
|
||||||
|
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||||
|
"os": ["win32"],
|
||||||
|
"cpu": ["x64"]
|
||||||
|
},
|
||||||
|
"@vitejs/plugin-react@6.0.5_vite@8.2.1": {
|
||||||
|
"integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@rolldown/pluginutils",
|
"@rolldown/pluginutils",
|
||||||
"vite"
|
"vite"
|
||||||
@@ -251,7 +309,7 @@
|
|||||||
"detect-libc@2.1.2": {
|
"detect-libc@2.1.2": {
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
|
||||||
},
|
},
|
||||||
"fdir@6.5.0_picomatch@4.0.4": {
|
"fdir@6.5.0_picomatch@4.0.5": {
|
||||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"picomatch"
|
"picomatch"
|
||||||
@@ -265,63 +323,63 @@
|
|||||||
"os": ["darwin"],
|
"os": ["darwin"],
|
||||||
"scripts": true
|
"scripts": true
|
||||||
},
|
},
|
||||||
"lightningcss-android-arm64@1.32.0": {
|
"lightningcss-android-arm64@1.33.0": {
|
||||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||||
"os": ["android"],
|
"os": ["android"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"lightningcss-darwin-arm64@1.32.0": {
|
"lightningcss-darwin-arm64@1.33.0": {
|
||||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||||
"os": ["darwin"],
|
"os": ["darwin"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"lightningcss-darwin-x64@1.32.0": {
|
"lightningcss-darwin-x64@1.33.0": {
|
||||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||||
"os": ["darwin"],
|
"os": ["darwin"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
"lightningcss-freebsd-x64@1.32.0": {
|
"lightningcss-freebsd-x64@1.33.0": {
|
||||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||||
"os": ["freebsd"],
|
"os": ["freebsd"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
"lightningcss-linux-arm-gnueabihf@1.32.0": {
|
"lightningcss-linux-arm-gnueabihf@1.33.0": {
|
||||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["arm"]
|
"cpu": ["arm"]
|
||||||
},
|
},
|
||||||
"lightningcss-linux-arm64-gnu@1.32.0": {
|
"lightningcss-linux-arm64-gnu@1.33.0": {
|
||||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"lightningcss-linux-arm64-musl@1.32.0": {
|
"lightningcss-linux-arm64-musl@1.33.0": {
|
||||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"lightningcss-linux-x64-gnu@1.32.0": {
|
"lightningcss-linux-x64-gnu@1.33.0": {
|
||||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
"lightningcss-linux-x64-musl@1.32.0": {
|
"lightningcss-linux-x64-musl@1.33.0": {
|
||||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
"lightningcss-win32-arm64-msvc@1.32.0": {
|
"lightningcss-win32-arm64-msvc@1.33.0": {
|
||||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||||
"os": ["win32"],
|
"os": ["win32"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"lightningcss-win32-x64-msvc@1.32.0": {
|
"lightningcss-win32-x64-msvc@1.33.0": {
|
||||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||||
"os": ["win32"],
|
"os": ["win32"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
"lightningcss@1.32.0": {
|
"lightningcss@1.33.0": {
|
||||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"detect-libc"
|
"detect-libc"
|
||||||
],
|
],
|
||||||
@@ -339,36 +397,36 @@
|
|||||||
"lightningcss-win32-x64-msvc"
|
"lightningcss-win32-x64-msvc"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nanoid@3.3.12": {
|
"nanoid@3.3.18": {
|
||||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||||
"bin": true
|
"bin": true
|
||||||
},
|
},
|
||||||
"picocolors@1.1.1": {
|
"picocolors@1.1.1": {
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||||
},
|
},
|
||||||
"picomatch@4.0.4": {
|
"picomatch@4.0.5": {
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="
|
||||||
},
|
},
|
||||||
"postcss@8.5.15": {
|
"postcss@8.5.26": {
|
||||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"nanoid",
|
"nanoid",
|
||||||
"picocolors",
|
"picocolors",
|
||||||
"source-map-js"
|
"source-map-js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"react-dom@19.2.7_react@19.2.7": {
|
"react-dom@19.2.8_react@19.2.8": {
|
||||||
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"react",
|
"react",
|
||||||
"scheduler"
|
"scheduler"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"react@19.2.7": {
|
"react@19.2.8": {
|
||||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="
|
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="
|
||||||
},
|
},
|
||||||
"rolldown@1.0.3": {
|
"rolldown@1.2.3": {
|
||||||
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
"integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@oxc-project/types",
|
"@oxc-project/types",
|
||||||
"@rolldown/pluginutils"
|
"@rolldown/pluginutils"
|
||||||
@@ -386,7 +444,6 @@
|
|||||||
"@rolldown/binding-linux-x64-gnu",
|
"@rolldown/binding-linux-x64-gnu",
|
||||||
"@rolldown/binding-linux-x64-musl",
|
"@rolldown/binding-linux-x64-musl",
|
||||||
"@rolldown/binding-openharmony-arm64",
|
"@rolldown/binding-openharmony-arm64",
|
||||||
"@rolldown/binding-wasm32-wasi",
|
|
||||||
"@rolldown/binding-win32-arm64-msvc",
|
"@rolldown/binding-win32-arm64-msvc",
|
||||||
"@rolldown/binding-win32-x64-msvc"
|
"@rolldown/binding-win32-x64-msvc"
|
||||||
],
|
],
|
||||||
@@ -405,15 +462,34 @@
|
|||||||
"picomatch"
|
"picomatch"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"tslib@2.8.1": {
|
"typescript@7.0.2": {
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||||
},
|
"optionalDependencies": [
|
||||||
"typescript@6.0.3": {
|
"@typescript/typescript-aix-ppc64",
|
||||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
"@typescript/typescript-darwin-arm64",
|
||||||
|
"@typescript/typescript-darwin-x64",
|
||||||
|
"@typescript/typescript-freebsd-arm64",
|
||||||
|
"@typescript/typescript-freebsd-x64",
|
||||||
|
"@typescript/typescript-linux-arm",
|
||||||
|
"@typescript/typescript-linux-arm64",
|
||||||
|
"@typescript/typescript-linux-loong64",
|
||||||
|
"@typescript/typescript-linux-mips64el",
|
||||||
|
"@typescript/typescript-linux-ppc64",
|
||||||
|
"@typescript/typescript-linux-riscv64",
|
||||||
|
"@typescript/typescript-linux-s390x",
|
||||||
|
"@typescript/typescript-linux-x64",
|
||||||
|
"@typescript/typescript-netbsd-arm64",
|
||||||
|
"@typescript/typescript-netbsd-x64",
|
||||||
|
"@typescript/typescript-openbsd-arm64",
|
||||||
|
"@typescript/typescript-openbsd-x64",
|
||||||
|
"@typescript/typescript-sunos-x64",
|
||||||
|
"@typescript/typescript-win32-arm64",
|
||||||
|
"@typescript/typescript-win32-x64"
|
||||||
|
],
|
||||||
"bin": true
|
"bin": true
|
||||||
},
|
},
|
||||||
"vite@8.0.16": {
|
"vite@8.2.1": {
|
||||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
"integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"lightningcss",
|
"lightningcss",
|
||||||
"picomatch",
|
"picomatch",
|
||||||
@@ -430,18 +506,18 @@
|
|||||||
"workspace": {
|
"workspace": {
|
||||||
"packageJson": {
|
"packageJson": {
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"npm:@tauri-apps/api@^2.11.0",
|
"npm:@tauri-apps/api@^2.11.1",
|
||||||
"npm:@tauri-apps/cli@^2.11.2",
|
"npm:@tauri-apps/cli@^2.11.4",
|
||||||
"npm:@tauri-apps/plugin-dialog@^2.7.1",
|
"npm:@tauri-apps/plugin-dialog@^2.7.2",
|
||||||
"npm:@tauri-apps/plugin-shell@^2.3.5",
|
"npm:@tauri-apps/plugin-shell@^2.3.5",
|
||||||
"npm:@tauri-apps/plugin-store@^2.4.3",
|
"npm:@tauri-apps/plugin-store@^2.4.4",
|
||||||
"npm:@types/react-dom@^19.2.3",
|
"npm:@types/react-dom@^19.2.4",
|
||||||
"npm:@types/react@^19.2.17",
|
"npm:@types/react@^19.2.18",
|
||||||
"npm:@vitejs/plugin-react@^6.0.2",
|
"npm:@vitejs/plugin-react@^6.0.5",
|
||||||
"npm:react-dom@^19.2.7",
|
"npm:react-dom@^19.2.8",
|
||||||
"npm:react@^19.2.7",
|
"npm:react@^19.2.8",
|
||||||
"npm:typescript@^6.0.3",
|
"npm:typescript@^7.0.2",
|
||||||
"npm:vite@^8.0.16"
|
"npm:vite@^8.2.1"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,19 +10,19 @@
|
|||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||||
"@tauri-apps/plugin-store": "^2.4.3",
|
"@tauri-apps/plugin-store": "^2.4.4",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.8",
|
||||||
"@tauri-apps/api": "^2.11.0",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5"
|
"@tauri-apps/plugin-shell": "^2.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^8.0.16",
|
"vite": "^8.2.1",
|
||||||
"@tauri-apps/cli": "^2.11.2"
|
"@tauri-apps/cli": "^2.11.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ walkdir = { workspace = true }
|
|||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target."cfg(windows)".dependencies]
|
||||||
windows = { workspace = true }
|
windows = { workspace = true }
|
||||||
|
|
||||||
[lints.clippy]
|
[lints.clippy]
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use lanspread_db::db::{Availability, Game, GameCatalog, GameDB, GameFileDescript
|
|||||||
use lanspread_peer::{
|
use lanspread_peer::{
|
||||||
ActiveOperation,
|
ActiveOperation,
|
||||||
ActiveOperationKind,
|
ActiveOperationKind,
|
||||||
|
CallToPlayEvent,
|
||||||
ExternalUnrarStreamProvider,
|
ExternalUnrarStreamProvider,
|
||||||
NoopStreamInstallProvider,
|
NoopStreamInstallProvider,
|
||||||
PeerCommand,
|
PeerCommand,
|
||||||
@@ -39,6 +40,7 @@ use tauri_plugin_shell::{
|
|||||||
use tokio::sync::{
|
use tokio::sync::{
|
||||||
RwLock,
|
RwLock,
|
||||||
mpsc::{UnboundedReceiver, UnboundedSender},
|
mpsc::{UnboundedReceiver, UnboundedSender},
|
||||||
|
oneshot,
|
||||||
};
|
};
|
||||||
use tracing::{Event, Level, Metadata, Subscriber, field::Visit};
|
use tracing::{Event, Level, Metadata, Subscriber, field::Visit};
|
||||||
use tracing_subscriber::{
|
use tracing_subscriber::{
|
||||||
@@ -90,6 +92,7 @@ impl OutboundTransferEmitState {
|
|||||||
struct LanSpreadState {
|
struct LanSpreadState {
|
||||||
peer_ctrl: Arc<RwLock<Option<UnboundedSender<PeerCommand>>>>,
|
peer_ctrl: Arc<RwLock<Option<UnboundedSender<PeerCommand>>>>,
|
||||||
peer_runtime: Arc<RwLock<Option<PeerRuntimeHandle>>>,
|
peer_runtime: Arc<RwLock<Option<PeerRuntimeHandle>>>,
|
||||||
|
local_peer_id: Arc<RwLock<Option<String>>>,
|
||||||
games: Arc<RwLock<GameDB>>,
|
games: Arc<RwLock<GameDB>>,
|
||||||
active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
|
active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
|
||||||
games_folder: Arc<RwLock<String>>,
|
games_folder: Arc<RwLock<String>>,
|
||||||
@@ -161,6 +164,7 @@ struct LauncherGame {
|
|||||||
game: Game,
|
game: Game,
|
||||||
can_host_server: bool,
|
can_host_server: bool,
|
||||||
active_outbound_transfers: usize,
|
active_outbound_transfers: usize,
|
||||||
|
installed_peer_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
||||||
@@ -272,6 +276,43 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn request_call_to_play_events(
|
||||||
|
state: tauri::State<'_, LanSpreadState>,
|
||||||
|
) -> tauri::Result<Option<String>> {
|
||||||
|
let peer_ctrl = state.inner().peer_ctrl.read().await.clone();
|
||||||
|
let Some(peer_ctrl) = peer_ctrl else {
|
||||||
|
log::warn!("Peer system not initialized yet");
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
if peer_ctrl
|
||||||
|
.send(PeerCommand::GetCallToPlayEvents { reply: None })
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(state.inner().local_peer_id.read().await.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn publish_call_to_play(
|
||||||
|
event: CallToPlayEvent,
|
||||||
|
state: tauri::State<'_, LanSpreadState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let peer_ctrl = state.inner().peer_ctrl.read().await.clone();
|
||||||
|
let Some(peer_ctrl) = peer_ctrl else {
|
||||||
|
log::warn!("Peer system not initialized yet");
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let (reply, result) = oneshot::channel();
|
||||||
|
peer_ctrl
|
||||||
|
.send(PeerCommand::PublishCallToPlay { event, reply })
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
result.await.map_err(|err| err.to_string())?.map(|()| true)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn install_game(
|
async fn install_game(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -1031,6 +1072,19 @@ fn clear_all_local_game_states(game_db: &mut GameDB) {
|
|||||||
async fn emit_games_list(app_handle: &AppHandle) {
|
async fn emit_games_list(app_handle: &AppHandle) {
|
||||||
let state = app_handle.state::<LanSpreadState>();
|
let state = app_handle.state::<LanSpreadState>();
|
||||||
|
|
||||||
|
let installed_peer_counts = state
|
||||||
|
.peer_game_db
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.peer_snapshots()
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|peer| peer.games)
|
||||||
|
.filter(|game| game.installed)
|
||||||
|
.fold(HashMap::<String, u32>::new(), |mut counts, game| {
|
||||||
|
*counts.entry(game.id).or_default() += 1;
|
||||||
|
counts
|
||||||
|
});
|
||||||
|
|
||||||
let games_db_lock = state.games.clone();
|
let games_db_lock = state.games.clone();
|
||||||
let game_db = games_db_lock.read().await;
|
let game_db = games_db_lock.read().await;
|
||||||
let games_folder = state.games_folder.read().await.clone();
|
let games_folder = state.games_folder.read().await.clone();
|
||||||
@@ -1051,6 +1105,7 @@ async fn emit_games_list(app_handle: &AppHandle) {
|
|||||||
LauncherGame {
|
LauncherGame {
|
||||||
can_host_server: game_can_host_server(&games_folder, &game),
|
can_host_server: game_can_host_server(&games_folder, &game),
|
||||||
active_outbound_transfers,
|
active_outbound_transfers,
|
||||||
|
installed_peer_count: installed_peer_counts.get(&game.id).copied().unwrap_or(0),
|
||||||
game,
|
game,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -2160,6 +2215,11 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|||||||
match event {
|
match event {
|
||||||
PeerEvent::LocalPeerReady { peer_id, addr } => {
|
PeerEvent::LocalPeerReady { peer_id, addr } => {
|
||||||
log::info!("Local peer ready: {peer_id} at {addr}");
|
log::info!("Local peer ready: {peer_id} at {addr}");
|
||||||
|
*app_handle
|
||||||
|
.state::<LanSpreadState>()
|
||||||
|
.local_peer_id
|
||||||
|
.write()
|
||||||
|
.await = Some(peer_id);
|
||||||
}
|
}
|
||||||
PeerEvent::ListGames(games) => {
|
PeerEvent::ListGames(games) => {
|
||||||
log::info!("PeerEvent::ListGames received");
|
log::info!("PeerEvent::ListGames received");
|
||||||
@@ -2178,6 +2238,11 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|||||||
}
|
}
|
||||||
emit_games_list(app_handle).await;
|
emit_games_list(app_handle).await;
|
||||||
}
|
}
|
||||||
|
PeerEvent::CallToPlayEvents(events) => {
|
||||||
|
if let Err(err) = app_handle.emit("call-to-play-events", Some(events)) {
|
||||||
|
log::error!("Failed to emit call-to-play-events event: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
PeerEvent::OutboundTransferCountChanged => {
|
PeerEvent::OutboundTransferCountChanged => {
|
||||||
log::info!("PeerEvent::OutboundTransferCountChanged received");
|
log::info!("PeerEvent::OutboundTransferCountChanged received");
|
||||||
schedule_outbound_transfer_emit(app_handle).await;
|
schedule_outbound_transfer_emit(app_handle).await;
|
||||||
@@ -2348,6 +2413,8 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
request_games,
|
request_games,
|
||||||
|
request_call_to_play_events,
|
||||||
|
publish_call_to_play,
|
||||||
install_game,
|
install_game,
|
||||||
stream_install_game,
|
stream_install_game,
|
||||||
run_game,
|
run_game,
|
||||||
|
|||||||
@@ -105,4 +105,35 @@ export const Icon = {
|
|||||||
<path d="M5 9h2M6 8v2M10 9h.01M11 8h.01" />
|
<path d="M5 9h2M6 8v2M10 9h.01M11 8h.01" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
|
flag: (p: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.6} {...baseStroke} {...p}>
|
||||||
|
<path d="M3 14V2.5M3.5 3h8l-1.4 2.5L11.5 8h-8" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
clock: (p: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
|
||||||
|
<circle cx="8" cy="8" r="5.5" />
|
||||||
|
<path d="M8 4.5V8l2.5 1.5" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
chat: (p: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
|
||||||
|
<path d="M2.5 3.5h11v8h-6L4 14v-2.5H2.5z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
send: (p: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
|
||||||
|
<path d="m2 3 12 5-12 5 2-5zM4 8h6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
caretUp: (p: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="10" height="10" strokeWidth={1.8} {...baseStroke} {...p}>
|
||||||
|
<path d="m4 10 4-4 4 4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
caretDown: (p: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="10" height="10" strokeWidth={1.8} {...baseStroke} {...p}>
|
||||||
|
<path d="m4 6 4 4 4-4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
} satisfies Record<string, (p: Props) => JSX.Element>;
|
} satisfies Record<string, (p: Props) => JSX.Element>;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Icon } from '../Icon';
|
||||||
|
import { activeCallCount } from '../../lib/callToPlay';
|
||||||
|
import { Nomination } from '../../lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nominations: ReadonlyArray<Nomination>;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CallToPlayButton = ({ nominations, onClick }: Props) => {
|
||||||
|
const activeCount = activeCallCount(nominations);
|
||||||
|
return (
|
||||||
|
<button className="ctp-btn" onClick={onClick}>
|
||||||
|
<Icon.flag />
|
||||||
|
<span>Call to Play</span>
|
||||||
|
{activeCount > 0 && <span className="ctp-badge">{activeCount}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Icon } from '../Icon';
|
||||||
|
import { Modal } from '../Modal';
|
||||||
|
import { CreateNominationForm } from './CreateNominationForm';
|
||||||
|
import { NominationCard } from './NominationCard';
|
||||||
|
import { CallToPlayActions } from '../../hooks/useCallToPlay';
|
||||||
|
import { sortNominations } from '../../lib/callToPlay';
|
||||||
|
import { Game, Nomination } from '../../lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nominations: ReadonlyArray<Nomination>;
|
||||||
|
games: ReadonlyArray<Game>;
|
||||||
|
actorId: string | null;
|
||||||
|
actions: CallToPlayActions;
|
||||||
|
focusId: string | null;
|
||||||
|
transportReady: boolean;
|
||||||
|
error: string | null;
|
||||||
|
getThumbnail: (gameId: string) => string | null | undefined;
|
||||||
|
totalPeerCount: number;
|
||||||
|
onLaunch: (game: Game) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CallToPlayOverlay = ({
|
||||||
|
nominations,
|
||||||
|
games,
|
||||||
|
actorId,
|
||||||
|
actions,
|
||||||
|
focusId,
|
||||||
|
transportReady,
|
||||||
|
error,
|
||||||
|
getThumbnail,
|
||||||
|
totalPeerCount,
|
||||||
|
onLaunch,
|
||||||
|
onClose,
|
||||||
|
}: Props) => {
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const gameById = new Map(games.map(game => [game.id, game]));
|
||||||
|
const sorted = sortNominations(nominations);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose} className="ctp-modal">
|
||||||
|
<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 “I’m in” RSVP. The caller decides when it actually starts.
|
||||||
|
</p>
|
||||||
|
{!showCreate && (
|
||||||
|
<button
|
||||||
|
className="act-btn act-play ctp-head-new"
|
||||||
|
disabled={!transportReady}
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
><Icon.flag /><span>Call a new match</span></button>
|
||||||
|
)}
|
||||||
|
{!transportReady && !error && (
|
||||||
|
<div className="ctp-transport-note">Connecting Call to Play to the LAN…</div>
|
||||||
|
)}
|
||||||
|
{error && <div className="ctp-transport-note is-error">{error}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="ctp-body">
|
||||||
|
{showCreate && (
|
||||||
|
<CreateNominationForm
|
||||||
|
games={games}
|
||||||
|
onCancel={() => setShowCreate(false)}
|
||||||
|
onCreate={(gameId, maxPlayers, duration, scheduledFor) => {
|
||||||
|
actions.createNomination(gameId, maxPlayers, duration, scheduledFor);
|
||||||
|
setShowCreate(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{sorted.length === 0 && !showCreate && (
|
||||||
|
<div className="ctp-empty">
|
||||||
|
No active calls right now — be the one to start something.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sorted.map(nomination => {
|
||||||
|
const game = gameById.get(nomination.gameId) ?? null;
|
||||||
|
return (
|
||||||
|
<NominationCard
|
||||||
|
key={nomination.id}
|
||||||
|
nomination={nomination}
|
||||||
|
game={game}
|
||||||
|
actorId={actorId}
|
||||||
|
actions={actions}
|
||||||
|
focused={nomination.id === focusId}
|
||||||
|
thumbnailUrl={game ? getThumbnail(game.id) : null}
|
||||||
|
totalPeerCount={totalPeerCount}
|
||||||
|
onLaunch={onLaunch}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
import { Icon } from '../Icon';
|
||||||
|
import {
|
||||||
|
avatarColor,
|
||||||
|
formatClock,
|
||||||
|
formatCountdown,
|
||||||
|
formatCountdownShort,
|
||||||
|
formatUntil,
|
||||||
|
isReady,
|
||||||
|
readyCountOf,
|
||||||
|
statusOf,
|
||||||
|
type CallToPlayStatus,
|
||||||
|
} from '../../lib/callToPlay';
|
||||||
|
import { Game, Nomination } from '../../lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nominations: ReadonlyArray<Nomination>;
|
||||||
|
games: ReadonlyArray<Game>;
|
||||||
|
accent: string;
|
||||||
|
onOpen: (callId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABEL = {
|
||||||
|
running: 'Running',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
scheduled: 'Scheduled',
|
||||||
|
call: 'Call to Play',
|
||||||
|
soon: 'Starting soon',
|
||||||
|
ready: 'Ready',
|
||||||
|
expired: 'Time’s up',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type TickerStatus = CallToPlayStatus;
|
||||||
|
|
||||||
|
const RANK: Record<TickerStatus, number> = {
|
||||||
|
expired: 0,
|
||||||
|
ready: 1,
|
||||||
|
soon: 2,
|
||||||
|
call: 3,
|
||||||
|
scheduled: 3,
|
||||||
|
running: 4,
|
||||||
|
cancelled: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tickerStatusOf = (nomination: Nomination, now: number): TickerStatus =>
|
||||||
|
statusOf(nomination, now);
|
||||||
|
|
||||||
|
const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
|
||||||
|
const entries = Object.entries(nomination.participants);
|
||||||
|
return (
|
||||||
|
<span className="ctp-ticker-bubbles">
|
||||||
|
{entries.slice(0, 6).map(([participantId, participant]) => {
|
||||||
|
const name = participant.name;
|
||||||
|
const ready = isReady(participant, now);
|
||||||
|
const state = ready ? 'ready' : participant.status === 'in' ? 'in' : 'pending';
|
||||||
|
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
|
||||||
|
const remaining = (participant.readyAt ?? now) - now;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={participantId}
|
||||||
|
className="ctp-mini"
|
||||||
|
data-state={state}
|
||||||
|
title={`${name} — ${ready ? 'ready' : state === 'in' ? 'in' : `ready ${formatCountdownShort(remaining)}`}`}
|
||||||
|
style={{ background: avatarColor(name) }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
{ready && <span className="ctp-mini-check"><Icon.check /></span>}
|
||||||
|
{state === 'pending' && (
|
||||||
|
<i className="ctp-mini-tag">{formatCountdownShort(remaining)}</i>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{entries.length > 6 && <span className="ctp-mini ctp-mini-more">+{entries.length - 6}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CallToPlayTicker = ({ nominations, games, accent, onOpen }: Props) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const gameById = new Map(games.map(game => [game.id, game]));
|
||||||
|
const rows = nominations.map(nomination => ({
|
||||||
|
nomination,
|
||||||
|
status: tickerStatusOf(nomination, now),
|
||||||
|
})).sort((left, right) =>
|
||||||
|
RANK[left.status] - RANK[right.status]
|
||||||
|
|| left.nomination.deadline - right.nomination.deadline
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ctp-ticker-stack">
|
||||||
|
{rows.map(({ nomination, status }) => {
|
||||||
|
const game = gameById.get(nomination.gameId);
|
||||||
|
const ready = readyCountOf(nomination, now);
|
||||||
|
const total = Object.keys(nomination.participants).length;
|
||||||
|
const remaining = Math.max(0, nomination.deadline - now);
|
||||||
|
const last = nomination.messages[nomination.messages.length - 1];
|
||||||
|
const count = status === 'running' || status === 'cancelled'
|
||||||
|
? `${total} players`
|
||||||
|
: status === 'scheduled'
|
||||||
|
? `${total} in`
|
||||||
|
: `${ready}/${nomination.maxPlayers} ready`;
|
||||||
|
const terminalElapsed = now - (nomination.terminalAt ?? now);
|
||||||
|
const terminalAge = terminalElapsed < 1_000
|
||||||
|
? 'just now'
|
||||||
|
: `${formatCountdownShort(terminalElapsed)} ago`;
|
||||||
|
const time = status === 'running'
|
||||||
|
? `started ${terminalAge}`
|
||||||
|
: status === 'cancelled'
|
||||||
|
? `cancelled ${terminalAge}`
|
||||||
|
: status === 'ready'
|
||||||
|
? 'waiting to start'
|
||||||
|
: status === 'expired'
|
||||||
|
? `${formatCountdownShort(now - nomination.deadline)} ago · waiting for caller`
|
||||||
|
: status === 'scheduled'
|
||||||
|
? `${formatClock(nomination.scheduledFor!)} · ${formatUntil(nomination.scheduledFor! - now)}`
|
||||||
|
: nomination.scheduledFor !== null
|
||||||
|
? `starts ${formatClock(nomination.scheduledFor)} · ${formatCountdown(remaining)}`
|
||||||
|
: formatCountdown(remaining);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={nomination.id}
|
||||||
|
className="ctp-ticker"
|
||||||
|
data-status={status}
|
||||||
|
style={{ '--accent': accent } as CSSProperties}
|
||||||
|
onClick={() => onOpen(nomination.id)}
|
||||||
|
>
|
||||||
|
<span className="ctp-ticker-dot" data-status={status} />
|
||||||
|
<span className="ctp-ticker-label" data-status={status}>{LABEL[status]}</span>
|
||||||
|
<span
|
||||||
|
className="ctp-ticker-game"
|
||||||
|
title={game ? undefined : `Game ID: ${nomination.gameId}`}
|
||||||
|
>{game?.name ?? 'Game unavailable here'}</span>
|
||||||
|
<span className="ctp-ticker-by">by {nomination.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={nomination} now={now} />
|
||||||
|
<span className="ctp-ticker-cta">
|
||||||
|
{status === 'soon' ? 'Check in' : 'View'}<Icon.chevron />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { Icon } from '../Icon';
|
||||||
|
import { Game } from '../../lib/types';
|
||||||
|
import { bumpTime, normalizeTimeInput, sanitizeTimeDraft } from '../../lib/callToPlay';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
games: ReadonlyArray<Game>;
|
||||||
|
onCancel: () => void;
|
||||||
|
onCreate: (
|
||||||
|
gameId: string,
|
||||||
|
maxPlayers: number,
|
||||||
|
durationMinutes: number,
|
||||||
|
scheduledFor: number | null,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTimeSlot = (): { time: string; dayOffset: number } => {
|
||||||
|
const now = new Date();
|
||||||
|
const slot = new Date(now.getTime() + 40 * 60_000);
|
||||||
|
slot.setMinutes(slot.getMinutes() <= 30 ? 30 : 60, 0, 0);
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const slotDay = new Date(slot.getFullYear(), slot.getMonth(), slot.getDate());
|
||||||
|
return {
|
||||||
|
time: `${String(slot.getHours()).padStart(2, '0')}:${String(slot.getMinutes()).padStart(2, '0')}`,
|
||||||
|
dayOffset: Math.round((slotDay.getTime() - today.getTime()) / 86_400_000),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestedPlayers = (game: Game): number => Math.max(2, Math.min(64, game.max_players ?? 8));
|
||||||
|
|
||||||
|
const dayChips = (): ReadonlyArray<{ offset: number; label: string }> => {
|
||||||
|
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
return [0, 1, 2].map(offset => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() + offset);
|
||||||
|
return { offset, label: offset === 0 ? 'Today' : days[date.getDay()] };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreateNominationForm = ({ games, onCancel, onCreate }: Props) => {
|
||||||
|
const [initialSchedule] = useState(nextTimeSlot);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [gameId, setGameId] = useState<string | null>(null);
|
||||||
|
const [maxPlayers, setMaxPlayers] = useState(8);
|
||||||
|
const [duration, setDuration] = useState(10);
|
||||||
|
const [when, setWhen] = useState<'now' | 'later'>('now');
|
||||||
|
const [dayOffset, setDayOffset] = useState(initialSchedule.dayOffset);
|
||||||
|
const [time, setTime] = useState(initialSchedule.time);
|
||||||
|
const [editingTime, setEditingTime] = useState(false);
|
||||||
|
const [timeDraft, setTimeDraft] = useState(time);
|
||||||
|
const timeInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (editingTime) {
|
||||||
|
timeInputRef.current?.focus();
|
||||||
|
timeInputRef.current?.select();
|
||||||
|
}
|
||||||
|
}, [editingTime]);
|
||||||
|
|
||||||
|
const selected = games.find(game => game.id === gameId);
|
||||||
|
const matches = useMemo(() => {
|
||||||
|
const needle = query.trim().toLocaleLowerCase();
|
||||||
|
if (!needle) return [];
|
||||||
|
return games.filter(game => game.name.toLocaleLowerCase().includes(needle)).slice(0, 6);
|
||||||
|
}, [games, query]);
|
||||||
|
const days = dayChips();
|
||||||
|
|
||||||
|
const pickGame = (game: Game) => {
|
||||||
|
setGameId(game.id);
|
||||||
|
setMaxPlayers(suggestedPlayers(game));
|
||||||
|
setQuery(game.name);
|
||||||
|
};
|
||||||
|
const scheduledTimestamp = (): number => {
|
||||||
|
const [hours, minutes] = time.split(':').map(Number);
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() + dayOffset);
|
||||||
|
date.setHours(hours, minutes, 0, 0);
|
||||||
|
return date.getTime();
|
||||||
|
};
|
||||||
|
const scheduledFor = scheduledTimestamp();
|
||||||
|
const scheduledTimeIsFuture = scheduledFor >= Date.now() + 60_000;
|
||||||
|
const commitTimeDraft = () => {
|
||||||
|
const normalized = normalizeTimeInput(timeDraft);
|
||||||
|
if (normalized) setTime(normalized);
|
||||||
|
setEditingTime(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ctp-create">
|
||||||
|
<div className="ctp-create-row">
|
||||||
|
<label className="ctp-create-label" htmlFor="ctp-game-search">Game</label>
|
||||||
|
<div className="search ctp-create-search">
|
||||||
|
<Icon.search />
|
||||||
|
<input
|
||||||
|
id="ctp-game-search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search the catalog…"
|
||||||
|
autoComplete="off"
|
||||||
|
value={query}
|
||||||
|
onChange={event => {
|
||||||
|
setQuery(event.target.value);
|
||||||
|
setGameId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!selected && query.trim() && (
|
||||||
|
<div className="ctp-create-matches">
|
||||||
|
{matches.map(game => (
|
||||||
|
<button key={game.id} onClick={() => pickGame(game)}>
|
||||||
|
<span>{game.name}</span>
|
||||||
|
<span className="ctp-create-match-meta">
|
||||||
|
up to {suggestedPlayers(game)} players
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{matches.length === 0 && (
|
||||||
|
<div className="ctp-create-nomatch">No games match “{query}”</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<>
|
||||||
|
<div className="ctp-create-row ctp-create-row-inline">
|
||||||
|
<label className="ctp-create-label" htmlFor="ctp-max-players">Max players</label>
|
||||||
|
<input
|
||||||
|
id="ctp-max-players"
|
||||||
|
type="number"
|
||||||
|
className="ctp-create-num"
|
||||||
|
min={2}
|
||||||
|
max={64}
|
||||||
|
value={maxPlayers}
|
||||||
|
onChange={event => setMaxPlayers(
|
||||||
|
Math.max(2, Math.min(64, Number(event.target.value) || 2)),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="ctp-create-hint">
|
||||||
|
Suggested for {selected.name}: {suggestedPlayers(selected)}
|
||||||
|
</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' : ''}`}
|
||||||
|
onClick={() => setWhen('now')}
|
||||||
|
>Now</button>
|
||||||
|
<button
|
||||||
|
className={`ctp-duration-btn ${when === 'later' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setWhen('later')}
|
||||||
|
>Schedule</button>
|
||||||
|
</div>
|
||||||
|
{when === 'later' && (
|
||||||
|
<>
|
||||||
|
<div className="ctp-timepick">
|
||||||
|
{editingTime ? (
|
||||||
|
<input
|
||||||
|
ref={timeInputRef}
|
||||||
|
className="ctp-time-editinput"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={5}
|
||||||
|
placeholder="20:00"
|
||||||
|
value={timeDraft}
|
||||||
|
onChange={event => setTimeDraft(previous =>
|
||||||
|
sanitizeTimeDraft(event.target.value, previous)
|
||||||
|
)}
|
||||||
|
onBlur={commitTimeDraft}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === 'Enter') commitTimeDraft();
|
||||||
|
if (event.key === 'Escape') setEditingTime(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="ctp-timepick-field">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ctp-time-step"
|
||||||
|
aria-label="Hour up"
|
||||||
|
onClick={() => setTime(value => bumpTime(value, 'hours', 1))}
|
||||||
|
><Icon.caretUp /></button>
|
||||||
|
<span className="ctp-time-cell">{time.slice(0, 2)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ctp-time-step"
|
||||||
|
aria-label="Hour down"
|
||||||
|
onClick={() => setTime(value => bumpTime(value, 'hours', -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={() => setTime(value => bumpTime(value, 'minutes', 15))}
|
||||||
|
><Icon.caretUp /></button>
|
||||||
|
<span className="ctp-time-cell">{time.slice(3)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ctp-time-step"
|
||||||
|
aria-label="Minute down"
|
||||||
|
onClick={() => setTime(value => bumpTime(value, 'minutes', -15))}
|
||||||
|
><Icon.caretDown /></button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ctp-time-type"
|
||||||
|
onClick={() => {
|
||||||
|
setTimeDraft(time);
|
||||||
|
setEditingTime(true);
|
||||||
|
}}
|
||||||
|
>Type a time</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ctp-time-row ctp-day-row">
|
||||||
|
<span className="ctp-day-label">Day</span>
|
||||||
|
{days.map(day => (
|
||||||
|
<button
|
||||||
|
key={day.offset}
|
||||||
|
className={`ctp-duration-btn ctp-day-btn ${dayOffset === day.offset ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setDayOffset(day.offset)}
|
||||||
|
>{day.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="ctp-create-hint">
|
||||||
|
{scheduledTimeIsFuture
|
||||||
|
? '24-hour clock. Check-in to ready up opens 15 min before start.'
|
||||||
|
: 'Choose a time at least one minute from now.'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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(minutes => (
|
||||||
|
<button
|
||||||
|
key={minutes}
|
||||||
|
className={`ctp-duration-btn ${duration === minutes ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setDuration(minutes)}
|
||||||
|
>{minutes}m</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="ctp-create-foot">
|
||||||
|
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
|
||||||
|
<button
|
||||||
|
className="act-btn act-play"
|
||||||
|
disabled={when === 'later' && !scheduledTimeIsFuture}
|
||||||
|
onClick={() => onCreate(
|
||||||
|
selected.id,
|
||||||
|
maxPlayers,
|
||||||
|
duration,
|
||||||
|
when === 'later' ? scheduledFor : null,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{when === 'later'
|
||||||
|
? `Schedule it — ${selected.name} · ${dayOffset > 0 ? `${days[dayOffset].label} ` : ''}${time}`
|
||||||
|
: `Call it — ${selected.name}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!selected && (
|
||||||
|
<div className="ctp-create-foot">
|
||||||
|
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { Icon } from '../Icon';
|
||||||
|
import { avatarColor, formatClock } from '../../lib/callToPlay';
|
||||||
|
import { Nomination } from '../../lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nomination: Nomination;
|
||||||
|
actorId: string | null;
|
||||||
|
disabled: boolean;
|
||||||
|
onSend: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CtpChat = ({ nomination, actorId, disabled, onSend }: Props) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [seen, setSeen] = useState(nomination.messages.length);
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setSeen(nomination.messages.length);
|
||||||
|
}, [open, nomination.messages.length]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||||
|
}, [open, nomination.messages.length]);
|
||||||
|
|
||||||
|
const unread = Math.max(0, nomination.messages.length - seen);
|
||||||
|
const last = nomination.messages[nomination.messages.length - 1];
|
||||||
|
const submit = () => {
|
||||||
|
const text = draft.trim();
|
||||||
|
if (!text) return;
|
||||||
|
onSend(text);
|
||||||
|
setDraft('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`ctp-chat ${open ? 'is-open' : ''}`}>
|
||||||
|
<button className="ctp-chat-toggle" onClick={() => setOpen(value => !value)}>
|
||||||
|
<Icon.chat />
|
||||||
|
<span>Chat</span>
|
||||||
|
{nomination.messages.length > 0 && (
|
||||||
|
<span className="ctp-chat-count">{nomination.messages.length}</span>
|
||||||
|
)}
|
||||||
|
{!open && unread > 0 && <span className="ctp-chat-unread">{unread}</span>}
|
||||||
|
{!open && last && (
|
||||||
|
<span className="ctp-chat-preview">
|
||||||
|
<b style={{ color: avatarColor(last.from) }}>{last.from}:</b> {last.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ctp-chat-chevron"><Icon.chevron /></span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="ctp-chat-list" ref={listRef}>
|
||||||
|
{nomination.messages.length === 0 && (
|
||||||
|
<div className="ctp-chat-empty">No messages yet — say hi.</div>
|
||||||
|
)}
|
||||||
|
{nomination.messages.map(message => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={`ctp-chat-msg ${message.fromId === actorId ? 'is-me' : ''}`}
|
||||||
|
>
|
||||||
|
<b style={{ color: avatarColor(message.from) }}>{message.from}</b>
|
||||||
|
<span className="ctp-chat-time">{formatClock(message.at)}</span>
|
||||||
|
<span className="ctp-chat-text"> {message.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!disabled && (
|
||||||
|
<div className="ctp-chat-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="ctp-chat-input"
|
||||||
|
placeholder="Message the group…"
|
||||||
|
maxLength={500}
|
||||||
|
value={draft}
|
||||||
|
onChange={event => setDraft(event.target.value)}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === 'Enter') submit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="ctp-chat-send" onClick={submit} aria-label="Send">
|
||||||
|
<Icon.send />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { Icon } from '../Icon';
|
||||||
|
import { GameCover } from '../grid/GameCover';
|
||||||
|
import { CtpChat } from './CtpChat';
|
||||||
|
import { CallToPlayActions } from '../../hooks/useCallToPlay';
|
||||||
|
import {
|
||||||
|
avatarColor,
|
||||||
|
CHECKIN_LEAD_MS,
|
||||||
|
formatClock,
|
||||||
|
formatCountdown,
|
||||||
|
formatCountdownShort,
|
||||||
|
formatUntil,
|
||||||
|
inCountOf,
|
||||||
|
isReady,
|
||||||
|
isTerminal,
|
||||||
|
phaseOf,
|
||||||
|
readyCountOf,
|
||||||
|
statusOf,
|
||||||
|
} from '../../lib/callToPlay';
|
||||||
|
import { CallToPlayParticipant, Game, Nomination } from '../../lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nomination: Nomination;
|
||||||
|
game: Game | null;
|
||||||
|
actorId: string | null;
|
||||||
|
actions: CallToPlayActions;
|
||||||
|
focused: boolean;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
|
totalPeerCount: number;
|
||||||
|
onLaunch: (game: Game) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AvatarChip = ({
|
||||||
|
name,
|
||||||
|
participant,
|
||||||
|
now,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
participant: CallToPlayParticipant;
|
||||||
|
now: number;
|
||||||
|
}) => {
|
||||||
|
const ready = isReady(participant, now);
|
||||||
|
const isIn = !ready && participant.status === 'in';
|
||||||
|
const remaining = Math.max(0, (participant.readyAt ?? now) - now);
|
||||||
|
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`ctp-avatar ${ready ? 'is-ready' : isIn ? 'is-in' : 'is-pending'}`}
|
||||||
|
title={`${name} — ${ready ? 'ready' : isIn ? 'in, not checked in' : `ready in ${formatCountdown(remaining)}`}`}
|
||||||
|
>
|
||||||
|
<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">{formatCountdownShort(remaining)}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NominationCard = ({
|
||||||
|
nomination,
|
||||||
|
game,
|
||||||
|
actorId,
|
||||||
|
actions,
|
||||||
|
focused,
|
||||||
|
thumbnailUrl,
|
||||||
|
totalPeerCount,
|
||||||
|
onLaunch,
|
||||||
|
}: Props) => {
|
||||||
|
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (focused) cardRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
}, [focused]);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const entries = Object.entries(nomination.participants);
|
||||||
|
const readyCount = readyCountOf(nomination, now);
|
||||||
|
const inCount = inCountOf(nomination, now);
|
||||||
|
const myStatus = actorId === null ? undefined : nomination.participants[actorId];
|
||||||
|
const isMe = myStatus !== undefined;
|
||||||
|
const isCreator = nomination.creatorId === actorId;
|
||||||
|
const isDone = nomination.state === 'done';
|
||||||
|
const terminal = isTerminal(nomination);
|
||||||
|
const isRunning = nomination.state === 'running';
|
||||||
|
const isCancelled = nomination.state === 'cancelled';
|
||||||
|
const isExpired = statusOf(nomination, now) === 'expired';
|
||||||
|
const phase = phaseOf(nomination, now);
|
||||||
|
const isScheduled = phase === 'scheduled' && !isDone && !terminal && !isExpired;
|
||||||
|
const isCheckin = phase === 'checkin' && !isDone && !terminal && !isExpired;
|
||||||
|
const windowStart = nomination.scheduledFor === null
|
||||||
|
? nomination.createdAt
|
||||||
|
: nomination.scheduledFor - CHECKIN_LEAD_MS;
|
||||||
|
const remaining = Math.max(0, nomination.deadline - now);
|
||||||
|
const percentage = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, (remaining / Math.max(1, nomination.deadline - windowStart)) * 100),
|
||||||
|
);
|
||||||
|
const urgency = percentage < 20 ? 'high' : percentage < 50 ? 'mid' : 'low';
|
||||||
|
const installedCount = game === null
|
||||||
|
? 0
|
||||||
|
: (game.installed_peer_count ?? game.peer_count) + (game.installed ? 1 : 0);
|
||||||
|
const lanCount = Math.max(totalPeerCount + 1, installedCount);
|
||||||
|
|
||||||
|
const timer = isRunning
|
||||||
|
? <div className="ctp-card-timer" data-urgency="off">Running</div>
|
||||||
|
: isCancelled
|
||||||
|
? <div className="ctp-card-timer" data-urgency="off">Cancelled</div>
|
||||||
|
: isExpired
|
||||||
|
? <div className="ctp-card-timer" data-urgency="high">Time’s up</div>
|
||||||
|
: isDone
|
||||||
|
? <div className="ctp-card-timer" data-urgency="off">Ready</div>
|
||||||
|
: isScheduled
|
||||||
|
? (
|
||||||
|
<div className="ctp-card-timer is-sched" data-urgency="off">
|
||||||
|
<span className="ctp-card-clock">{formatClock(nomination.scheduledFor!)}</span>
|
||||||
|
<span className="ctp-card-until">{formatUntil(nomination.scheduledFor! - now)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: isCheckin
|
||||||
|
? (
|
||||||
|
<div className="ctp-card-timer is-sched" data-urgency={urgency}>
|
||||||
|
<span className="ctp-card-clock">{formatCountdown(remaining)}</span>
|
||||||
|
<span className="ctp-card-until">starts {formatClock(nomination.scheduledFor!)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: <div className="ctp-card-timer" data-urgency={urgency}>{formatCountdown(remaining)}</div>;
|
||||||
|
|
||||||
|
const rosterLabel = terminal
|
||||||
|
? `${entries.length} players`
|
||||||
|
: isScheduled
|
||||||
|
? `${entries.length} in · up to ${nomination.maxPlayers} players`
|
||||||
|
: isCheckin && inCount > 0
|
||||||
|
? `${readyCount}/${nomination.maxPlayers} ready · ${inCount} not checked in yet`
|
||||||
|
: `${readyCount}/${nomination.maxPlayers} ready`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={cardRef}
|
||||||
|
className={`ctp-card ${isDone ? 'is-done' : ''} ${isExpired ? 'is-expired' : ''} ${terminal ? 'is-terminal' : ''} ${isRunning ? 'is-running' : ''} ${isCancelled ? 'is-cancelled' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="ctp-card-top">
|
||||||
|
<div className="ctp-card-cover">
|
||||||
|
{game
|
||||||
|
? <GameCover game={game} aspect="square" thumbnailUrl={thumbnailUrl} />
|
||||||
|
: <div className="ctp-card-cover-missing"><Icon.flag /></div>}
|
||||||
|
</div>
|
||||||
|
<div className="ctp-card-info">
|
||||||
|
<div className="ctp-card-title">{game?.name ?? 'Game unavailable here'}</div>
|
||||||
|
<div className="ctp-card-sub">
|
||||||
|
{nomination.scheduledFor === null ? 'Called' : 'Scheduled'} by{' '}
|
||||||
|
<strong>{nomination.creator}</strong>
|
||||||
|
{nomination.scheduledFor !== null && ` · starts at ${formatClock(nomination.scheduledFor)}`}
|
||||||
|
{game
|
||||||
|
? ` · ${installedCount}/${lanCount} peers have it installed`
|
||||||
|
: ` · this game is not in your current library (ID: ${nomination.gameId})`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{timer}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCheckin && (
|
||||||
|
<div className="ctp-checkin-note">
|
||||||
|
<Icon.clock />
|
||||||
|
<span>{isMe && myStatus.status === 'in'
|
||||||
|
? 'Starting soon — you said you’re in. Check in below.'
|
||||||
|
: 'Starting soon — check-in is open.'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isScheduled && (
|
||||||
|
<div className="ctp-progress">
|
||||||
|
<div
|
||||||
|
className="ctp-progress-fill"
|
||||||
|
style={{
|
||||||
|
width: `${terminal || isDone ? 100 : percentage}%`,
|
||||||
|
background: isExpired || isCancelled
|
||||||
|
? 'var(--danger)'
|
||||||
|
: terminal || isDone
|
||||||
|
? 'var(--ok)'
|
||||||
|
: 'var(--accent)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ctp-roster">
|
||||||
|
<div className="ctp-roster-count">{rosterLabel}</div>
|
||||||
|
<div className="ctp-avatars">
|
||||||
|
{entries.map(([participantId, participant]) => (
|
||||||
|
<AvatarChip
|
||||||
|
key={participantId}
|
||||||
|
name={participant.name}
|
||||||
|
participant={participant}
|
||||||
|
now={now}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: Math.max(0, nomination.maxPlayers - entries.length) })
|
||||||
|
.map((_, index) => (
|
||||||
|
<div key={index} className="ctp-avatar ctp-avatar-empty" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ctp-actions">
|
||||||
|
<CardActions
|
||||||
|
nomination={nomination}
|
||||||
|
game={game}
|
||||||
|
actorId={actorId}
|
||||||
|
actions={actions}
|
||||||
|
onLaunch={onLaunch}
|
||||||
|
now={now}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CtpChat
|
||||||
|
nomination={nomination}
|
||||||
|
actorId={actorId}
|
||||||
|
disabled={terminal}
|
||||||
|
onSend={text => actions.sendMessage(nomination.id, text)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isCreator && !terminal && (
|
||||||
|
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(nomination.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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CardActionsProps {
|
||||||
|
nomination: Nomination;
|
||||||
|
game: Game | null;
|
||||||
|
actorId: string | null;
|
||||||
|
actions: CallToPlayActions;
|
||||||
|
onLaunch: (game: Game) => void;
|
||||||
|
now: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
|
||||||
|
nomination: Nomination;
|
||||||
|
actions: CallToPlayActions;
|
||||||
|
includeThirty?: boolean;
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
<button className="act-btn act-play" onClick={() => actions.respond(nomination.id, 'ready')}>
|
||||||
|
<Icon.play /><span>Ready now</span>
|
||||||
|
</button>
|
||||||
|
<div className="ctp-buffer-group">
|
||||||
|
{[5, 10, 15, ...(includeThirty ? [30] : [])].map(minutes => (
|
||||||
|
<button
|
||||||
|
key={minutes}
|
||||||
|
className="ctp-buffer-btn"
|
||||||
|
onClick={() => actions.respond(nomination.id, minutes)}
|
||||||
|
>+{minutes}m</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CardActions = ({
|
||||||
|
nomination,
|
||||||
|
game,
|
||||||
|
actorId,
|
||||||
|
actions,
|
||||||
|
onLaunch,
|
||||||
|
now,
|
||||||
|
}: CardActionsProps) => {
|
||||||
|
const myStatus = actorId === null ? undefined : nomination.participants[actorId];
|
||||||
|
const isMe = myStatus !== undefined;
|
||||||
|
const isCreator = nomination.creatorId === actorId;
|
||||||
|
const isDone = nomination.state === 'done';
|
||||||
|
const terminal = isTerminal(nomination);
|
||||||
|
const isExpired = statusOf(nomination, now) === 'expired';
|
||||||
|
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !terminal;
|
||||||
|
const readyCount = readyCountOf(nomination, now);
|
||||||
|
|
||||||
|
if (terminal) {
|
||||||
|
return (
|
||||||
|
<div className="ctp-note">
|
||||||
|
{nomination.state === 'running'
|
||||||
|
? game
|
||||||
|
? `${game.name} is running.`
|
||||||
|
: 'The match is running.'
|
||||||
|
: 'This call was cancelled.'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (scheduled) {
|
||||||
|
if (isCreator) {
|
||||||
|
return (
|
||||||
|
<div className="ctp-note">
|
||||||
|
Scheduled for {formatClock(nomination.scheduledFor!)} — check-in opens 15 min before start.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isMe) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="ctp-me-status">You’re in — we’ll nudge you when check-in opens</div>
|
||||||
|
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
|
||||||
|
Can’t make it
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className="act-btn act-play" onClick={() => actions.rsvp(nomination.id)}>
|
||||||
|
<Icon.check /><span>I’m in</span>
|
||||||
|
</button>
|
||||||
|
<div className="ctp-note">Check-in opens 15 min before start</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isCreator) {
|
||||||
|
if (isDone) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="act-btn act-play"
|
||||||
|
onClick={() => void actions.startNow(nomination.id).then(accepted => {
|
||||||
|
if (accepted && game) onLaunch(game);
|
||||||
|
})}
|
||||||
|
><Icon.play /><span>{game ? 'Start now' : 'Mark as running'}</span></button>
|
||||||
|
<button
|
||||||
|
className="ghost-btn"
|
||||||
|
onClick={() => actions.addTime(nomination.id, nomination.deadline)}
|
||||||
|
>
|
||||||
|
Add 5 more minutes
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (myStatus?.status === 'in') {
|
||||||
|
return <ReadyButtons nomination={nomination} actions={actions} />;
|
||||||
|
}
|
||||||
|
return <div className="ctp-note">Waiting for players to ready up…</div>;
|
||||||
|
}
|
||||||
|
if (isDone) {
|
||||||
|
return (
|
||||||
|
<div className="ctp-note">
|
||||||
|
{isExpired
|
||||||
|
? `Time’s up — waiting for ${nomination.creator} to start or extend the call.`
|
||||||
|
: readyCount >= nomination.maxPlayers
|
||||||
|
? 'Everyone’s ready.'
|
||||||
|
: `It’s time — waiting for ${nomination.creator} to start.`}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isMe) {
|
||||||
|
if (myStatus.status === 'in') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="ctp-me-status">You said you’re in — check in:</div>
|
||||||
|
<ReadyButtons nomination={nomination} actions={actions} />
|
||||||
|
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
|
||||||
|
Leave
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ready = isReady(myStatus, now);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="ctp-me-status">
|
||||||
|
{ready ? 'You’re in — ready' : `You: ready in ${formatCountdown((myStatus.readyAt ?? now) - now)}`}
|
||||||
|
</div>
|
||||||
|
{!ready && (
|
||||||
|
<button className="ghost-btn" onClick={() => actions.respond(nomination.id, 'ready')}>
|
||||||
|
I’m ready now
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
|
||||||
|
Leave
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <ReadyButtons nomination={nomination} actions={actions} includeThirty />;
|
||||||
|
};
|
||||||
@@ -3,9 +3,10 @@ import { SegmentedFilters } from './SegmentedFilters';
|
|||||||
import { SearchField } from './SearchField';
|
import { SearchField } from './SearchField';
|
||||||
import { SortMenu } from './SortMenu';
|
import { SortMenu } from './SortMenu';
|
||||||
import { KebabMenu, KebabItem } from './KebabMenu';
|
import { KebabMenu, KebabItem } from './KebabMenu';
|
||||||
|
import { CallToPlayButton } from '../calltoplay/CallToPlayButton';
|
||||||
|
|
||||||
import { FilterCounts } from '../../lib/gameState';
|
import { FilterCounts } from '../../lib/gameState';
|
||||||
import { GameFilter, GameSort } from '../../lib/types';
|
import { GameFilter, GameSort, Nomination } from '../../lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
accent: string;
|
accent: string;
|
||||||
@@ -18,6 +19,8 @@ interface Props {
|
|||||||
sort: GameSort;
|
sort: GameSort;
|
||||||
setSort: (value: GameSort) => void;
|
setSort: (value: GameSort) => void;
|
||||||
kebabItems: ReadonlyArray<KebabItem>;
|
kebabItems: ReadonlyArray<KebabItem>;
|
||||||
|
nominations: ReadonlyArray<Nomination>;
|
||||||
|
onOpenCallToPlay: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TopBar = ({
|
export const TopBar = ({
|
||||||
@@ -31,6 +34,8 @@ export const TopBar = ({
|
|||||||
sort,
|
sort,
|
||||||
setSort,
|
setSort,
|
||||||
kebabItems,
|
kebabItems,
|
||||||
|
nominations,
|
||||||
|
onOpenCallToPlay,
|
||||||
}: Props) => (
|
}: Props) => (
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="topbar-left">
|
<div className="topbar-left">
|
||||||
@@ -47,6 +52,7 @@ export const TopBar = ({
|
|||||||
<SortMenu value={sort} onChange={setSort} />
|
<SortMenu value={sort} onChange={setSort} />
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-right-tail">
|
<div className="topbar-right-tail">
|
||||||
|
<CallToPlayButton nominations={nominations} onClick={onOpenCallToPlay} />
|
||||||
<KebabMenu items={kebabItems} />
|
<KebabMenu items={kebabItems} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CALL_TO_PLAY_CONNECTING_MESSAGE,
|
||||||
|
callToPlayEvent,
|
||||||
|
callToPlayPublishErrorMessage,
|
||||||
|
extendDeadline,
|
||||||
|
pruneCallToPlayEvents,
|
||||||
|
reduceCallToPlayEvents,
|
||||||
|
} from '../lib/callToPlay';
|
||||||
|
import { CallToPlayAction, CallToPlayEvent, Nomination } from '../lib/types';
|
||||||
|
|
||||||
|
export interface CallToPlayActions {
|
||||||
|
createNomination: (
|
||||||
|
gameId: string,
|
||||||
|
maxPlayers: number,
|
||||||
|
durationMinutes: number,
|
||||||
|
scheduledFor: number | null,
|
||||||
|
) => void;
|
||||||
|
respond: (callId: string, choice: 'ready' | number) => void;
|
||||||
|
rsvp: (callId: string) => void;
|
||||||
|
sendMessage: (callId: string, text: string) => void;
|
||||||
|
leave: (callId: string) => void;
|
||||||
|
cancel: (callId: string) => void;
|
||||||
|
startNow: (callId: string) => Promise<boolean>;
|
||||||
|
addTime: (callId: string, currentDeadline: number, minutes?: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseCallToPlay {
|
||||||
|
nominations: Nomination[];
|
||||||
|
actions: CallToPlayActions;
|
||||||
|
actorId: string | null;
|
||||||
|
transportReady: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeEvents = (
|
||||||
|
previous: ReadonlyMap<string, CallToPlayEvent>,
|
||||||
|
incoming: ReadonlyArray<CallToPlayEvent>,
|
||||||
|
): Map<string, CallToPlayEvent> => {
|
||||||
|
const next = new Map(previous);
|
||||||
|
for (const event of incoming) next.set(event.id, event);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCallToPlay = (username: string): UseCallToPlay => {
|
||||||
|
const actor = useMemo(() => {
|
||||||
|
const trimmed = username.trim();
|
||||||
|
return trimmed ? Array.from(trimmed).slice(0, 24).join('') : 'Commander';
|
||||||
|
}, [username]);
|
||||||
|
const [events, setEvents] = useState<ReadonlyMap<string, CallToPlayEvent>>(() => new Map());
|
||||||
|
const [actorId, setActorId] = useState<string | null>(null);
|
||||||
|
const [now, setNow] = useState(Date.now());
|
||||||
|
const [transportReady, setTransportReady] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
const current = Date.now();
|
||||||
|
setNow(current);
|
||||||
|
setEvents(previous => pruneCallToPlayEvents(previous, current));
|
||||||
|
}, 1_000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let unlisten: UnlistenFn | undefined;
|
||||||
|
let retry: number | undefined;
|
||||||
|
|
||||||
|
const requestSnapshot = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const peerId = await invoke<string | null>('request_call_to_play_events');
|
||||||
|
if (cancelled) return false;
|
||||||
|
const ready = peerId !== null;
|
||||||
|
setActorId(peerId);
|
||||||
|
setTransportReady(ready);
|
||||||
|
if (ready) {
|
||||||
|
setError(current =>
|
||||||
|
current === CALL_TO_PLAY_CONNECTING_MESSAGE ? null : current
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ready && retry !== undefined) {
|
||||||
|
window.clearInterval(retry);
|
||||||
|
retry = undefined;
|
||||||
|
}
|
||||||
|
return ready;
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setTransportReady(false);
|
||||||
|
console.error('request_call_to_play_events failed:', err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async () => {
|
||||||
|
try {
|
||||||
|
unlisten = await listen<CallToPlayEvent[]>('call-to-play-events', event => {
|
||||||
|
setEvents(previous => mergeEvents(previous, event.payload));
|
||||||
|
});
|
||||||
|
if (cancelled) {
|
||||||
|
unlisten();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ready = await requestSnapshot();
|
||||||
|
if (!cancelled && !ready) {
|
||||||
|
retry = window.setInterval(() => void requestSnapshot(), 2_000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to register Call to Play listener:', err);
|
||||||
|
if (!cancelled) {
|
||||||
|
setTransportReady(false);
|
||||||
|
setError('Call to Play networking is unavailable.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void register();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unlisten?.();
|
||||||
|
if (retry !== undefined) window.clearInterval(retry);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const publish = useCallback(async (
|
||||||
|
callId: string,
|
||||||
|
action: CallToPlayAction,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (actorId === null) {
|
||||||
|
setTransportReady(false);
|
||||||
|
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const event = callToPlayEvent(callId, actorId, actor, action);
|
||||||
|
try {
|
||||||
|
const accepted = await invoke<boolean>('publish_call_to_play', { event });
|
||||||
|
if (!accepted) {
|
||||||
|
setTransportReady(false);
|
||||||
|
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setTransportReady(true);
|
||||||
|
setError(null);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('publish_call_to_play failed:', err);
|
||||||
|
setError(callToPlayPublishErrorMessage(err));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [actor, actorId]);
|
||||||
|
|
||||||
|
const actions = useMemo<CallToPlayActions>(() => ({
|
||||||
|
createNomination: (gameId, maxPlayers, durationMinutes, scheduledFor) => {
|
||||||
|
const createdAt = Date.now();
|
||||||
|
const callId = globalThis.crypto.randomUUID();
|
||||||
|
const deadline = scheduledFor ?? createdAt + durationMinutes * 60_000;
|
||||||
|
void publish(callId, {
|
||||||
|
Create: {
|
||||||
|
game_id: gameId,
|
||||||
|
max_players: Math.max(2, Math.min(64, Math.round(maxPlayers))),
|
||||||
|
scheduled_for: scheduledFor,
|
||||||
|
deadline,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
respond: (callId, choice) => void publish(callId, {
|
||||||
|
Respond: {
|
||||||
|
ready_at: choice === 'ready' ? null : Date.now() + choice * 60_000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
rsvp: callId => void publish(callId, 'Rsvp'),
|
||||||
|
sendMessage: (callId, text) => {
|
||||||
|
const trimmed = text.trim().slice(0, 500);
|
||||||
|
if (!trimmed) return;
|
||||||
|
void publish(callId, {
|
||||||
|
SendMessage: {
|
||||||
|
message_id: globalThis.crypto.randomUUID(),
|
||||||
|
text: trimmed,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
leave: callId => void publish(callId, 'Leave'),
|
||||||
|
cancel: callId => void publish(callId, 'Cancel'),
|
||||||
|
startNow: callId => publish(callId, 'Start'),
|
||||||
|
addTime: (callId, currentDeadline, minutes = 5) => void publish(callId, {
|
||||||
|
AddTime: { deadline: extendDeadline(Date.now(), currentDeadline, minutes) },
|
||||||
|
}),
|
||||||
|
}), [publish]);
|
||||||
|
|
||||||
|
const nominations = useMemo(
|
||||||
|
() => reduceCallToPlayEvents([...events.values()], now),
|
||||||
|
[events, now],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { nominations, actions, actorId, transportReady, error };
|
||||||
|
};
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import {
|
||||||
|
CallToPlayAction,
|
||||||
|
CallToPlayEvent,
|
||||||
|
CallToPlayParticipant,
|
||||||
|
Nomination,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export const CHECKIN_LEAD_MS = 15 * 60_000;
|
||||||
|
export const EXPIRED_RETENTION_MS = 5 * 60_000;
|
||||||
|
export const TERMINAL_RETENTION_MS = 15 * 60_000;
|
||||||
|
export const CALL_TO_PLAY_CONNECTING_MESSAGE =
|
||||||
|
'Call to Play is still connecting to the LAN. Try again in a moment.';
|
||||||
|
|
||||||
|
export const callToPlayPublishErrorMessage = (error: unknown): string => {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
if (detail.includes('Call to Play event is obsolete')
|
||||||
|
|| detail.includes('Call to Play history is missing')
|
||||||
|
) {
|
||||||
|
return 'This Call to Play has expired or already finished.';
|
||||||
|
}
|
||||||
|
if (detail.includes('Call to Play event history is full')) {
|
||||||
|
return 'Call to Play has reached its active update limit. Start or cancel an active call, then try again.';
|
||||||
|
}
|
||||||
|
return 'Could not send this Call to Play update.';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extendDeadline = (
|
||||||
|
now: number,
|
||||||
|
currentDeadline: number,
|
||||||
|
durationMinutes = 5,
|
||||||
|
): number => Math.max(now, currentDeadline) + durationMinutes * 60_000;
|
||||||
|
|
||||||
|
export type CallToPlayPhase = 'now' | 'scheduled' | 'checkin';
|
||||||
|
export type CallToPlayStatus =
|
||||||
|
| 'running'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'expired'
|
||||||
|
| 'ready'
|
||||||
|
| 'soon'
|
||||||
|
| 'scheduled'
|
||||||
|
| 'call';
|
||||||
|
|
||||||
|
interface MutableNomination extends Nomination {
|
||||||
|
messageIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compareEvents = (a: CallToPlayEvent, b: CallToPlayEvent): number =>
|
||||||
|
a.at - b.at || a.id.localeCompare(b.id);
|
||||||
|
|
||||||
|
type CreatePayload = Extract<CallToPlayAction, { Create: unknown }>['Create'];
|
||||||
|
type RespondPayload = Extract<CallToPlayAction, { Respond: unknown }>['Respond'];
|
||||||
|
type MessagePayload = Extract<CallToPlayAction, { SendMessage: unknown }>['SendMessage'];
|
||||||
|
type AddTimePayload = Extract<CallToPlayAction, { AddTime: unknown }>['AddTime'];
|
||||||
|
|
||||||
|
const createPayload = (action: CallToPlayAction): CreatePayload | null =>
|
||||||
|
typeof action === 'object' && 'Create' in action ? action.Create : null;
|
||||||
|
const respondPayload = (action: CallToPlayAction): RespondPayload | null =>
|
||||||
|
typeof action === 'object' && 'Respond' in action ? action.Respond : null;
|
||||||
|
const messagePayload = (action: CallToPlayAction): MessagePayload | null =>
|
||||||
|
typeof action === 'object' && 'SendMessage' in action ? action.SendMessage : null;
|
||||||
|
const addTimePayload = (action: CallToPlayAction): AddTimePayload | null =>
|
||||||
|
typeof action === 'object' && 'AddTime' in action ? action.AddTime : null;
|
||||||
|
|
||||||
|
export const phaseOf = (nomination: Nomination, now: number): CallToPlayPhase => {
|
||||||
|
if (nomination.scheduledFor === null) return 'now';
|
||||||
|
return now < nomination.scheduledFor - CHECKIN_LEAD_MS ? 'scheduled' : 'checkin';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isReady = (
|
||||||
|
participant: CallToPlayParticipant | undefined,
|
||||||
|
now: number,
|
||||||
|
): boolean => Boolean(
|
||||||
|
participant && (
|
||||||
|
participant.status === 'ready'
|
||||||
|
|| (participant.readyAt !== undefined && now >= participant.readyAt)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const readyCountOf = (nomination: Nomination, now: number): number =>
|
||||||
|
Object.values(nomination.participants).filter(participant => isReady(participant, now)).length;
|
||||||
|
|
||||||
|
export const inCountOf = (nomination: Nomination, now: number): number =>
|
||||||
|
Object.values(nomination.participants).filter(participant =>
|
||||||
|
!isReady(participant, now) && participant.status === 'in'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
export const isTerminal = (nomination: Nomination): boolean =>
|
||||||
|
nomination.state === 'running' || nomination.state === 'cancelled';
|
||||||
|
|
||||||
|
export const activeCallCount = (nominations: ReadonlyArray<Nomination>): number =>
|
||||||
|
nominations.filter(nomination => !isTerminal(nomination)).length;
|
||||||
|
|
||||||
|
export const sortNominations = (
|
||||||
|
nominations: ReadonlyArray<Nomination>,
|
||||||
|
): Nomination[] => [...nominations].sort((left, right) => {
|
||||||
|
const terminalRank = Number(isTerminal(left)) - Number(isTerminal(right));
|
||||||
|
if (terminalRank !== 0) return terminalRank;
|
||||||
|
if (isTerminal(left) && isTerminal(right)) {
|
||||||
|
return (right.terminalAt ?? 0) - (left.terminalAt ?? 0)
|
||||||
|
|| left.id.localeCompare(right.id);
|
||||||
|
}
|
||||||
|
return left.deadline - right.deadline
|
||||||
|
|| right.createdAt - left.createdAt
|
||||||
|
|| left.id.localeCompare(right.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus => {
|
||||||
|
if (nomination.state === 'running' || nomination.state === 'cancelled') {
|
||||||
|
return nomination.state;
|
||||||
|
}
|
||||||
|
if (now >= nomination.deadline) return 'expired';
|
||||||
|
if (nomination.state === 'done' || readyCountOf(nomination, now) >= nomination.maxPlayers) {
|
||||||
|
return 'ready';
|
||||||
|
}
|
||||||
|
if (nomination.deadline - now <= CHECKIN_LEAD_MS) return 'soon';
|
||||||
|
return nomination.scheduledFor === null ? 'call' : 'scheduled';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reduceCallToPlayEvents = (
|
||||||
|
input: ReadonlyArray<CallToPlayEvent>,
|
||||||
|
now: number,
|
||||||
|
): Nomination[] => {
|
||||||
|
const nominations = [...groupEvents(input).values()]
|
||||||
|
.map(events => deriveNomination(events, now))
|
||||||
|
.filter((nomination): nomination is Nomination => nomination !== null);
|
||||||
|
return sortNominations(nominations);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pruneCallToPlayEvents = (
|
||||||
|
previous: ReadonlyMap<string, CallToPlayEvent>,
|
||||||
|
now: number,
|
||||||
|
): ReadonlyMap<string, CallToPlayEvent> => {
|
||||||
|
const retiredEventIds = new Set<string>();
|
||||||
|
for (const events of groupEvents([...previous.values()]).values()) {
|
||||||
|
if (deriveNomination(events, now) !== null) continue;
|
||||||
|
|
||||||
|
const hasCreate = events.some(event => createPayload(event.action) !== null);
|
||||||
|
const expiredTombstone = events.some(event =>
|
||||||
|
(event.action === 'Start' || event.action === 'Cancel')
|
||||||
|
&& now - event.at > TERMINAL_RETENTION_MS
|
||||||
|
);
|
||||||
|
if (hasCreate || expiredTombstone) {
|
||||||
|
for (const event of events) retiredEventIds.add(event.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (retiredEventIds.size === 0) return previous;
|
||||||
|
|
||||||
|
const next = new Map(previous);
|
||||||
|
for (const eventId of retiredEventIds) next.delete(eventId);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupEvents = (
|
||||||
|
input: ReadonlyArray<CallToPlayEvent>,
|
||||||
|
): Map<string, CallToPlayEvent[]> => {
|
||||||
|
const unique = new Map(input.map(event => [event.id, event]));
|
||||||
|
const byCall = new Map<string, CallToPlayEvent[]>();
|
||||||
|
for (const event of unique.values()) {
|
||||||
|
const events = byCall.get(event.call_id) ?? [];
|
||||||
|
events.push(event);
|
||||||
|
byCall.set(event.call_id, events);
|
||||||
|
}
|
||||||
|
return byCall;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deriveNomination = (
|
||||||
|
events: CallToPlayEvent[],
|
||||||
|
now: number,
|
||||||
|
): Nomination | null => {
|
||||||
|
events.sort(compareEvents);
|
||||||
|
const create = events.find(event => createPayload(event.action) !== null);
|
||||||
|
if (!create) return null;
|
||||||
|
const payload = createPayload(create.action);
|
||||||
|
if (!payload) return null;
|
||||||
|
|
||||||
|
const nomination: MutableNomination = {
|
||||||
|
id: create.call_id,
|
||||||
|
gameId: payload.game_id,
|
||||||
|
creatorId: create.actor_id,
|
||||||
|
creator: create.actor_name,
|
||||||
|
maxPlayers: payload.max_players,
|
||||||
|
createdAt: create.at,
|
||||||
|
scheduledFor: payload.scheduled_for,
|
||||||
|
deadline: payload.deadline,
|
||||||
|
participants: {
|
||||||
|
[create.actor_id]: {
|
||||||
|
name: create.actor_name,
|
||||||
|
status: payload.scheduled_for === null ? 'ready' : 'in',
|
||||||
|
joinedAt: create.at,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
messages: [],
|
||||||
|
state: 'open',
|
||||||
|
terminalAt: null,
|
||||||
|
messageIds: new Set(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (compareEvents(event, create) > 0) applyEvent(nomination, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nomination.state === 'open'
|
||||||
|
&& (readyCountOf(nomination, now) >= nomination.maxPlayers || now >= nomination.deadline)
|
||||||
|
) {
|
||||||
|
nomination.state = 'done';
|
||||||
|
}
|
||||||
|
if (isTerminal(nomination)) {
|
||||||
|
if (now - (nomination.terminalAt ?? now) > TERMINAL_RETENTION_MS) return null;
|
||||||
|
} else if (now - nomination.deadline > EXPIRED_RETENTION_MS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { messageIds: _, ...result } = nomination;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void => {
|
||||||
|
if (isTerminal(nomination)) return;
|
||||||
|
|
||||||
|
const action = event.action;
|
||||||
|
if (typeof action === 'string') {
|
||||||
|
applyUnitAction(nomination, event, action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = respondPayload(action);
|
||||||
|
if (response) {
|
||||||
|
const existing = nomination.participants[event.actor_id];
|
||||||
|
nomination.participants[event.actor_id] = {
|
||||||
|
name: event.actor_name,
|
||||||
|
status: response.ready_at === null ? 'ready' : 'pending',
|
||||||
|
joinedAt: existing?.joinedAt ?? event.at,
|
||||||
|
...(response.ready_at === null ? {} : { readyAt: response.ready_at }),
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = messagePayload(action);
|
||||||
|
if (message && !nomination.messageIds.has(message.message_id)) {
|
||||||
|
nomination.messageIds.add(message.message_id);
|
||||||
|
nomination.messages.push({
|
||||||
|
id: message.message_id,
|
||||||
|
fromId: event.actor_id,
|
||||||
|
from: event.actor_name,
|
||||||
|
text: message.text,
|
||||||
|
at: event.at,
|
||||||
|
});
|
||||||
|
nomination.messages.sort((a, b) => a.at - b.at || a.id.localeCompare(b.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = addTimePayload(action);
|
||||||
|
if (extension
|
||||||
|
&& event.actor_id === nomination.creatorId
|
||||||
|
) {
|
||||||
|
nomination.deadline = extension.deadline;
|
||||||
|
nomination.state = 'open';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyUnitAction = (
|
||||||
|
nomination: MutableNomination,
|
||||||
|
event: CallToPlayEvent,
|
||||||
|
action: Extract<CallToPlayAction, string>,
|
||||||
|
): void => {
|
||||||
|
switch (action) {
|
||||||
|
case 'Rsvp': {
|
||||||
|
const existing = nomination.participants[event.actor_id];
|
||||||
|
nomination.participants[event.actor_id] = {
|
||||||
|
name: event.actor_name,
|
||||||
|
status: 'in',
|
||||||
|
joinedAt: existing?.joinedAt ?? event.at,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Leave':
|
||||||
|
if (event.actor_id !== nomination.creatorId) {
|
||||||
|
delete nomination.participants[event.actor_id];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Cancel':
|
||||||
|
if (event.actor_id === nomination.creatorId) {
|
||||||
|
nomination.state = 'cancelled';
|
||||||
|
nomination.terminalAt = event.at;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Start':
|
||||||
|
if (event.actor_id === nomination.creatorId) {
|
||||||
|
nomination.state = 'running';
|
||||||
|
nomination.terminalAt = event.at;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const callToPlayEvent = (
|
||||||
|
callId: string,
|
||||||
|
actorId: string,
|
||||||
|
actorName: string,
|
||||||
|
action: CallToPlayAction,
|
||||||
|
at = Date.now(),
|
||||||
|
): CallToPlayEvent => ({
|
||||||
|
id: globalThis.crypto.randomUUID(),
|
||||||
|
call_id: callId,
|
||||||
|
actor_id: actorId,
|
||||||
|
actor_name: actorName,
|
||||||
|
at,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const formatClock = (timestamp: number): string => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatCountdown = (milliseconds: number): string => {
|
||||||
|
if (milliseconds <= 0) return '0:00';
|
||||||
|
const total = Math.ceil(milliseconds / 1_000);
|
||||||
|
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatCountdownShort = (milliseconds: number): string => {
|
||||||
|
if (milliseconds <= 0) return 'now';
|
||||||
|
const total = Math.ceil(milliseconds / 1_000);
|
||||||
|
return total < 60 ? `${total}s` : `${Math.ceil(total / 60)}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatUntil = (milliseconds: number): string => {
|
||||||
|
if (milliseconds <= 60_000) return 'in <1 min';
|
||||||
|
const minutes = Math.round(milliseconds / 60_000);
|
||||||
|
if (minutes < 60) return `in ${minutes} min`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainder = minutes % 60;
|
||||||
|
return remainder ? `in ${hours}h ${remainder}m` : `in ${hours}h`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AVATAR_COLORS = [
|
||||||
|
'#60a5fa', '#34d399', '#c084fc', '#fbbf24',
|
||||||
|
'#f472b6', '#38bdf8', '#a3e635', '#fb7185',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const avatarColor = (name: string): string => {
|
||||||
|
const hash = Array.from(name).reduce((sum, character) => sum + character.charCodeAt(0), 0);
|
||||||
|
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sanitizeTimeDraft = (raw: string, previous: string): string => {
|
||||||
|
const value = raw.replace(/[^\d:]/g, '');
|
||||||
|
if ((value.match(/:/g) ?? []).length > 1) return previous;
|
||||||
|
const colon = value.indexOf(':');
|
||||||
|
if (colon !== -1) {
|
||||||
|
if (value.slice(0, colon).length > 2 || value.slice(colon + 1).length > 2) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value.length > 4 ? previous : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeTimeInput = (raw: string): string | null => {
|
||||||
|
const match = raw.trim().match(/^(\d{1,2})(?:[:.\s]?(\d{2}))?$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const hours = Number(match[1]);
|
||||||
|
const minutes = Number(match[2] ?? 0);
|
||||||
|
if (hours > 23 || minutes > 59) return null;
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bumpTime = (
|
||||||
|
time: string,
|
||||||
|
field: 'hours' | 'minutes',
|
||||||
|
delta: number,
|
||||||
|
): string => {
|
||||||
|
let [hours, minutes] = time.split(':').map(Number);
|
||||||
|
if (field === 'hours') {
|
||||||
|
hours = (hours + delta + 24) % 24;
|
||||||
|
} else {
|
||||||
|
const total = ((hours * 60 + minutes + delta) % 1_440 + 1_440) % 1_440;
|
||||||
|
hours = Math.floor(total / 60);
|
||||||
|
minutes = total % 60;
|
||||||
|
}
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
@@ -60,6 +60,7 @@ export interface Game {
|
|||||||
peer_count: number;
|
peer_count: number;
|
||||||
can_host_server?: boolean;
|
can_host_server?: boolean;
|
||||||
active_outbound_transfers?: number;
|
active_outbound_transfers?: number;
|
||||||
|
installed_peer_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveOperation {
|
export interface ActiveOperation {
|
||||||
@@ -83,3 +84,54 @@ export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'bus
|
|||||||
|
|
||||||
/** Two-character language code passed through to game scripts. */
|
/** Two-character language code passed through to game scripts. */
|
||||||
export type LauncherLanguage = 'en' | 'de';
|
export type LauncherLanguage = 'en' | 'de';
|
||||||
|
|
||||||
|
export type CallToPlayParticipantStatus = 'ready' | 'in' | 'pending';
|
||||||
|
|
||||||
|
export interface CallToPlayParticipant {
|
||||||
|
name: string;
|
||||||
|
status: CallToPlayParticipantStatus;
|
||||||
|
joinedAt: number;
|
||||||
|
readyAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallToPlayMessage {
|
||||||
|
id: string;
|
||||||
|
fromId: string;
|
||||||
|
from: string;
|
||||||
|
text: string;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Nomination {
|
||||||
|
id: string;
|
||||||
|
gameId: string;
|
||||||
|
creatorId: string;
|
||||||
|
creator: string;
|
||||||
|
maxPlayers: number;
|
||||||
|
createdAt: number;
|
||||||
|
scheduledFor: number | null;
|
||||||
|
deadline: number;
|
||||||
|
participants: Record<string, CallToPlayParticipant>;
|
||||||
|
messages: CallToPlayMessage[];
|
||||||
|
state: 'open' | 'done' | 'running' | 'cancelled';
|
||||||
|
terminalAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CallToPlayAction =
|
||||||
|
| { Create: { game_id: string; max_players: number; scheduled_for: number | null; deadline: number } }
|
||||||
|
| { Respond: { ready_at: number | null } }
|
||||||
|
| 'Rsvp'
|
||||||
|
| { SendMessage: { message_id: string; text: string } }
|
||||||
|
| 'Leave'
|
||||||
|
| 'Cancel'
|
||||||
|
| 'Start'
|
||||||
|
| { AddTime: { deadline: number } };
|
||||||
|
|
||||||
|
export interface CallToPlayEvent {
|
||||||
|
id: string;
|
||||||
|
call_id: string;
|
||||||
|
actor_id: string;
|
||||||
|
actor_name: string;
|
||||||
|
at: number;
|
||||||
|
action: CallToPlayAction;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1783,3 +1783,682 @@
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════
|
||||||
|
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-expired { border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2)); }
|
||||||
|
.ctp-card.is-terminal { opacity: 0.72; }
|
||||||
|
.ctp-card.is-running { border-color: color-mix(in srgb, var(--ok) 35%, var(--bd-2)); }
|
||||||
|
.ctp-card.is-cancelled { border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-2)); }
|
||||||
|
.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-cover-missing {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
color: var(--t-3);
|
||||||
|
background: color-mix(in srgb, var(--bd-2) 55%, transparent);
|
||||||
|
border: 1px dashed var(--bd-2);
|
||||||
|
}
|
||||||
|
.ctp-card-cover-missing svg { width: 22px; height: 22px; }
|
||||||
|
.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-card.is-expired .ctp-card-timer { color: var(--danger); }
|
||||||
|
.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) ·
|
||||||
|
TIME'S UP (red, steady) · RUNNING / CANCELLED (muted receipts) ─── */
|
||||||
|
.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[data-status="expired"] {
|
||||||
|
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2));
|
||||||
|
}
|
||||||
|
.ctp-ticker[data-status="expired"]:hover { background: color-mix(in srgb, var(--danger) 13%, var(--bg-2)); }
|
||||||
|
.ctp-ticker[data-status="running"] {
|
||||||
|
background: color-mix(in srgb, var(--ok) 5%, var(--bg-2));
|
||||||
|
border-color: color-mix(in srgb, var(--ok) 25%, var(--bd-2));
|
||||||
|
}
|
||||||
|
.ctp-ticker[data-status="running"]:hover { background: color-mix(in srgb, var(--ok) 9%, var(--bg-2)); }
|
||||||
|
.ctp-ticker[data-status="cancelled"] {
|
||||||
|
background: color-mix(in srgb, var(--danger) 4%, var(--bg-2));
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 22%, var(--bd-2));
|
||||||
|
}
|
||||||
|
.ctp-ticker[data-status="cancelled"]:hover { background: color-mix(in srgb, var(--danger) 8%, 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); }
|
||||||
|
.ctp-ticker-dot[data-status="expired"] { background: var(--danger); animation: none; box-shadow: none; }
|
||||||
|
.ctp-ticker-dot[data-status="running"] { background: var(--ok); animation: none; box-shadow: none; opacity: 0.75; }
|
||||||
|
.ctp-ticker-dot[data-status="cancelled"] { background: var(--danger); animation: none; box-shadow: none; opacity: 0.65; }
|
||||||
|
@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-label[data-status="expired"] { color: var(--danger); }
|
||||||
|
.ctp-ticker-label[data-status="running"] { color: color-mix(in srgb, var(--ok) 75%, var(--t-2)); }
|
||||||
|
.ctp-ticker-label[data-status="cancelled"] { color: color-mix(in srgb, var(--danger) 70%, var(--t-2)); }
|
||||||
|
.ctp-ticker[data-status="soon"] .ctp-ticker-cta { color: var(--warn); }
|
||||||
|
.ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); }
|
||||||
|
.ctp-ticker[data-status="expired"] .ctp-ticker-cta { color: var(--danger); }
|
||||||
|
.ctp-ticker[data-status="running"] .ctp-ticker-cta,
|
||||||
|
.ctp-ticker[data-status="cancelled"] .ctp-ticker-cta { color: var(--t-2); }
|
||||||
|
|
||||||
|
/* ─── 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)); }
|
||||||
|
|
||||||
|
.ctp-transport-note {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--t-3);
|
||||||
|
}
|
||||||
|
.ctp-transport-note.is-error { color: var(--danger); }
|
||||||
|
@container launcher (max-width: 1280px) {
|
||||||
|
.ctp-ticker {
|
||||||
|
grid-template-columns: 10px 86px minmax(120px, 1fr) 78px 130px max-content 70px;
|
||||||
|
}
|
||||||
|
.ctp-ticker-by,
|
||||||
|
.ctp-ticker-chat { display: none; }
|
||||||
|
}
|
||||||
|
@container launcher (max-width: 800px) {
|
||||||
|
.ctp-ticker {
|
||||||
|
grid-template-columns: 10px 78px minmax(100px, 1fr) 76px 64px;
|
||||||
|
}
|
||||||
|
.ctp-ticker-time,
|
||||||
|
.ctp-ticker-bubbles { display: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ import { ConfirmRemoveDownloadModal } from '../components/modals/ConfirmRemoveDo
|
|||||||
import { SettingsDialog } from '../components/modals/SettingsDialog';
|
import { SettingsDialog } from '../components/modals/SettingsDialog';
|
||||||
import { NoDirectoryState } from '../components/empty/NoDirectoryState';
|
import { NoDirectoryState } from '../components/empty/NoDirectoryState';
|
||||||
import { EmptyResultsState } from '../components/empty/EmptyResultsState';
|
import { EmptyResultsState } from '../components/empty/EmptyResultsState';
|
||||||
|
import { CallToPlayTicker } from '../components/calltoplay/CallToPlayTicker';
|
||||||
|
import { CallToPlayOverlay } from '../components/calltoplay/CallToPlayOverlay';
|
||||||
|
|
||||||
import { useGameDirectory } from '../hooks/useGameDirectory';
|
import { useGameDirectory } from '../hooks/useGameDirectory';
|
||||||
import { useGames } from '../hooks/useGames';
|
import { useGames } from '../hooks/useGames';
|
||||||
import { useGameActions } from '../hooks/useGameActions';
|
import { useGameActions } from '../hooks/useGameActions';
|
||||||
import { useThumbnails } from '../hooks/useThumbnails';
|
import { useThumbnails } from '../hooks/useThumbnails';
|
||||||
import { useSettings } from '../hooks/useSettings';
|
import { useSettings } from '../hooks/useSettings';
|
||||||
|
import { useCallToPlay } from '../hooks/useCallToPlay';
|
||||||
|
|
||||||
import { Game } from '../lib/types';
|
import { Game } from '../lib/types';
|
||||||
import { applyFilterAndSort, countByFilter, needsUpdate } from '../lib/gameState';
|
import { applyFilterAndSort, countByFilter, needsUpdate } from '../lib/gameState';
|
||||||
@@ -72,10 +75,13 @@ export const MainWindow = () => {
|
|||||||
const games = useGames(rescan);
|
const games = useGames(rescan);
|
||||||
const actions = useGameActions(games, settings);
|
const actions = useGameActions(games, settings);
|
||||||
const thumbnails = useThumbnails();
|
const thumbnails = useThumbnails();
|
||||||
|
const callToPlay = useCallToPlay(settings.username);
|
||||||
|
|
||||||
const [openGameId, setOpenGameId] = useState<string | null>(null);
|
const [openGameId, setOpenGameId] = useState<string | null>(null);
|
||||||
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
|
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [callToPlayOpen, setCallToPlayOpen] = useState(false);
|
||||||
|
const [focusedCallId, setFocusedCallId] = useState<string | null>(null);
|
||||||
const visibleGames = useMemo(
|
const visibleGames = useMemo(
|
||||||
() => hasGameDirectory ? games.games : [],
|
() => hasGameDirectory ? games.games : [],
|
||||||
[games.games, hasGameDirectory],
|
[games.games, hasGameDirectory],
|
||||||
@@ -154,8 +160,22 @@ export const MainWindow = () => {
|
|||||||
sort={settings.sort}
|
sort={settings.sort}
|
||||||
setSort={(v) => setSetting('sort', v)}
|
setSort={(v) => setSetting('sort', v)}
|
||||||
kebabItems={kebabItems}
|
kebabItems={kebabItems}
|
||||||
|
nominations={callToPlay.nominations}
|
||||||
|
onOpenCallToPlay={() => {
|
||||||
|
setFocusedCallId(null);
|
||||||
|
setCallToPlayOpen(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<main className="grid-wrap">
|
<main className="grid-wrap">
|
||||||
|
<CallToPlayTicker
|
||||||
|
nominations={callToPlay.nominations}
|
||||||
|
games={games.games}
|
||||||
|
accent={settings.accent}
|
||||||
|
onOpen={(callId) => {
|
||||||
|
setFocusedCallId(callId);
|
||||||
|
setCallToPlayOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{hasGameDirectory ? (
|
{hasGameDirectory ? (
|
||||||
<>
|
<>
|
||||||
<ResultsBar shown={filteredGames.length} total={counts.all} />
|
<ResultsBar shown={filteredGames.length} total={counts.all} />
|
||||||
@@ -220,6 +240,25 @@ export const MainWindow = () => {
|
|||||||
onClose={() => setSettingsOpen(false)}
|
onClose={() => setSettingsOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{callToPlayOpen && (
|
||||||
|
<CallToPlayOverlay
|
||||||
|
nominations={callToPlay.nominations}
|
||||||
|
games={games.games}
|
||||||
|
actorId={callToPlay.actorId}
|
||||||
|
actions={callToPlay.actions}
|
||||||
|
focusId={focusedCallId}
|
||||||
|
transportReady={callToPlay.transportReady}
|
||||||
|
error={callToPlay.error}
|
||||||
|
getThumbnail={thumbnails.get}
|
||||||
|
totalPeerCount={games.totalPeerCount}
|
||||||
|
onLaunch={handlePrimary}
|
||||||
|
onClose={() => {
|
||||||
|
setCallToPlayOpen(false);
|
||||||
|
setFocusedCallId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import {
|
||||||
|
CALL_TO_PLAY_CONNECTING_MESSAGE,
|
||||||
|
CHECKIN_LEAD_MS,
|
||||||
|
EXPIRED_RETENTION_MS,
|
||||||
|
TERMINAL_RETENTION_MS,
|
||||||
|
activeCallCount,
|
||||||
|
callToPlayPublishErrorMessage,
|
||||||
|
extendDeadline,
|
||||||
|
phaseOf,
|
||||||
|
bumpTime,
|
||||||
|
normalizeTimeInput,
|
||||||
|
pruneCallToPlayEvents,
|
||||||
|
readyCountOf,
|
||||||
|
reduceCallToPlayEvents,
|
||||||
|
sortNominations,
|
||||||
|
statusOf,
|
||||||
|
} from '../src/lib/callToPlay.ts';
|
||||||
|
import { type CallToPlayAction, type CallToPlayEvent } from '../src/lib/types.ts';
|
||||||
|
|
||||||
|
const NOW = 1_000_000;
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||||
|
if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const event = (
|
||||||
|
id: string,
|
||||||
|
actorId: string,
|
||||||
|
action: CallToPlayAction,
|
||||||
|
at = NOW,
|
||||||
|
actorName = actorId,
|
||||||
|
): CallToPlayEvent => ({
|
||||||
|
id,
|
||||||
|
call_id: 'call-1',
|
||||||
|
actor_id: actorId,
|
||||||
|
actor_name: actorName,
|
||||||
|
at,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
|
||||||
|
const create = (
|
||||||
|
scheduledFor: number | null = null,
|
||||||
|
deadline = NOW + 30 * 60_000,
|
||||||
|
): CallToPlayEvent => event('create', 'Alice', {
|
||||||
|
Create: {
|
||||||
|
game_id: 'game-1',
|
||||||
|
max_players: 3,
|
||||||
|
scheduled_for: scheduledFor,
|
||||||
|
deadline,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('play-now call starts with its creator ready', () => {
|
||||||
|
const [nomination] = reduceCallToPlayEvents([create()], NOW);
|
||||||
|
assert(nomination, 'call should exist');
|
||||||
|
assertEquals(nomination.creator, 'Alice', 'creator');
|
||||||
|
assertEquals(nomination.participants.Alice.status, 'ready', 'creator status');
|
||||||
|
assertEquals(phaseOf(nomination, NOW), 'now', 'phase');
|
||||||
|
assertEquals(statusOf(nomination, NOW), 'call', 'status outside starting-soon window');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('scheduled RSVP becomes check-in and pending response becomes ready over time', () => {
|
||||||
|
const scheduledFor = NOW + 60 * 60_000;
|
||||||
|
const events = [
|
||||||
|
create(scheduledFor, scheduledFor),
|
||||||
|
event('rsvp', 'Bob', 'Rsvp', NOW + 1),
|
||||||
|
event('respond', 'Bob', { Respond: { ready_at: scheduledFor - 5 * 60_000 } }, NOW + 2),
|
||||||
|
];
|
||||||
|
const [far] = reduceCallToPlayEvents(events, NOW);
|
||||||
|
assertEquals(phaseOf(far, NOW), 'scheduled', 'far-out phase');
|
||||||
|
assertEquals(far.participants.Bob.status, 'pending', 'buffered response state');
|
||||||
|
|
||||||
|
const checkinNow = scheduledFor - CHECKIN_LEAD_MS;
|
||||||
|
const [checkin] = reduceCallToPlayEvents(events, checkinNow);
|
||||||
|
assertEquals(phaseOf(checkin, checkinNow), 'checkin', 'check-in phase');
|
||||||
|
assertEquals(readyCountOf(checkin, checkinNow), 0, 'nobody ready at check-in opening');
|
||||||
|
|
||||||
|
const [ready] = reduceCallToPlayEvents(events, scheduledFor - 4 * 60_000);
|
||||||
|
assertEquals(readyCountOf(ready, scheduledFor - 4 * 60_000), 1, 'elapsed buffer is ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('call resolves when the roster fills or its deadline elapses', () => {
|
||||||
|
const full = [
|
||||||
|
create(),
|
||||||
|
event('bob', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
|
||||||
|
event('carol', 'Carol', { Respond: { ready_at: null } }, NOW + 2),
|
||||||
|
];
|
||||||
|
assertEquals(reduceCallToPlayEvents(full, NOW + 2)[0].state, 'done', 'full roster');
|
||||||
|
assertEquals(
|
||||||
|
reduceCallToPlayEvents([create()], NOW + 31 * 60_000)[0].state,
|
||||||
|
'done',
|
||||||
|
'elapsed deadline',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('elapsed calls show as expired briefly and then disappear', () => {
|
||||||
|
const deadline = NOW + 30 * 60_000;
|
||||||
|
const events = [create(null, deadline)];
|
||||||
|
const [expired] = reduceCallToPlayEvents(events, deadline + 1);
|
||||||
|
assert(expired, 'freshly expired call remains visible');
|
||||||
|
assertEquals(statusOf(expired, deadline + 1), 'expired', 'elapsed status');
|
||||||
|
assertEquals(
|
||||||
|
reduceCallToPlayEvents(events, deadline + EXPIRED_RETENTION_MS + 1).length,
|
||||||
|
0,
|
||||||
|
'expired call retention',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('creator-only controls cannot be forged by another participant', () => {
|
||||||
|
const forged = [
|
||||||
|
create(),
|
||||||
|
event('cancel', 'Mallory', 'Cancel', NOW + 1),
|
||||||
|
event('start', 'Mallory', 'Start', NOW + 2),
|
||||||
|
event('extend', 'Mallory', { AddTime: { deadline: NOW + 90 * 60_000 } }, NOW + 3),
|
||||||
|
];
|
||||||
|
const [nomination] = reduceCallToPlayEvents(forged, NOW + 4);
|
||||||
|
assert(nomination, 'forged cancel should not remove call');
|
||||||
|
assertEquals(nomination.state, 'open', 'forged start ignored');
|
||||||
|
assertEquals(nomination.deadline, NOW + 30 * 60_000, 'forged extension ignored');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('stable peer ids keep duplicate display names distinct', () => {
|
||||||
|
const createByCommander = event('create', 'peer-a', {
|
||||||
|
Create: {
|
||||||
|
game_id: 'game-1',
|
||||||
|
max_players: 3,
|
||||||
|
scheduled_for: null,
|
||||||
|
deadline: NOW + 30 * 60_000,
|
||||||
|
},
|
||||||
|
}, NOW, 'Commander');
|
||||||
|
const events = [
|
||||||
|
createByCommander,
|
||||||
|
event('join', 'peer-b', { Respond: { ready_at: null } }, NOW + 1, 'Commander'),
|
||||||
|
event('forged-cancel', 'peer-b', 'Cancel', NOW + 2, 'Commander'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const [nomination] = reduceCallToPlayEvents(events, NOW + 3);
|
||||||
|
assert(nomination, 'same-name participant must not cancel the call');
|
||||||
|
assertEquals(nomination.creatorId, 'peer-a', 'creator identity');
|
||||||
|
assertEquals(Object.keys(nomination.participants).length, 2, 'distinct peer participants');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('creator can extend, start, and cancel a call', () => {
|
||||||
|
const extended = reduceCallToPlayEvents([
|
||||||
|
create(),
|
||||||
|
event('extend', 'Alice', { AddTime: { deadline: NOW + 90 * 60_000 } }, NOW + 1),
|
||||||
|
], NOW + 40 * 60_000)[0];
|
||||||
|
assertEquals(extended.state, 'open', 'extension reopens an elapsed call');
|
||||||
|
|
||||||
|
const started = reduceCallToPlayEvents([
|
||||||
|
create(),
|
||||||
|
event('start', 'Alice', 'Start', NOW + 1),
|
||||||
|
], NOW + 2)[0];
|
||||||
|
assertEquals(started.state, 'running', 'creator start');
|
||||||
|
assertEquals(started.terminalAt, NOW + 1, 'running timestamp');
|
||||||
|
|
||||||
|
const [cancelled] = reduceCallToPlayEvents([
|
||||||
|
create(),
|
||||||
|
event('cancel', 'Alice', 'Cancel', NOW + 1),
|
||||||
|
], NOW + 2);
|
||||||
|
assertEquals(cancelled.state, 'cancelled', 'creator cancel');
|
||||||
|
assertEquals(cancelled.terminalAt, NOW + 1, 'cancel timestamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('adding time extends from the current deadline or the current time', () => {
|
||||||
|
const futureDeadline = NOW + 30 * 60_000;
|
||||||
|
assertEquals(
|
||||||
|
extendDeadline(NOW, futureDeadline),
|
||||||
|
futureDeadline + 5 * 60_000,
|
||||||
|
'ready-early call keeps its remaining time',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
extendDeadline(NOW, NOW - 60_000),
|
||||||
|
NOW + 5 * 60_000,
|
||||||
|
'overdue call gets five minutes from now',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('publish failures distinguish startup and store outcomes', () => {
|
||||||
|
assertEquals(
|
||||||
|
CALL_TO_PLAY_CONNECTING_MESSAGE,
|
||||||
|
'Call to Play is still connecting to the LAN. Try again in a moment.',
|
||||||
|
'peer startup message',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
callToPlayPublishErrorMessage('Call to Play event is obsolete'),
|
||||||
|
'This Call to Play has expired or already finished.',
|
||||||
|
'obsolete call message',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
callToPlayPublishErrorMessage('Call to Play history is missing'),
|
||||||
|
'This Call to Play has expired or already finished.',
|
||||||
|
'missing expired history message',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
callToPlayPublishErrorMessage(new Error('Call to Play event history is full')),
|
||||||
|
'Call to Play has reached its active update limit. Start or cancel an active call, then try again.',
|
||||||
|
'active history limit message',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
callToPlayPublishErrorMessage('channel closed'),
|
||||||
|
'Could not send this Call to Play update.',
|
||||||
|
'unexpected failure message',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('reduction is order-independent and deduplicates events and messages', () => {
|
||||||
|
const message = event('message-event', 'Bob', {
|
||||||
|
SendMessage: { message_id: 'message-1', text: 'Ready?' },
|
||||||
|
}, NOW + 2);
|
||||||
|
const duplicateMessage = event('other-event', 'Bob', {
|
||||||
|
SendMessage: { message_id: 'message-1', text: 'duplicate' },
|
||||||
|
}, NOW + 3);
|
||||||
|
const events = [message, create(), message, duplicateMessage];
|
||||||
|
const [nomination] = reduceCallToPlayEvents(events, NOW + 4);
|
||||||
|
assertEquals(nomination.messages.length, 1, 'unique message id');
|
||||||
|
assertEquals(nomination.messages[0].text, 'Ready?', 'first message wins');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('actions timestamped before creation cannot mutate a call', () => {
|
||||||
|
const events = [
|
||||||
|
event('early-cancel', 'Alice', 'Cancel', NOW - 1),
|
||||||
|
event('early-response', 'Bob', { Respond: { ready_at: null } }, NOW - 1),
|
||||||
|
create(),
|
||||||
|
];
|
||||||
|
const [nomination] = reduceCallToPlayEvents(events, NOW + 1);
|
||||||
|
assert(nomination, 'call should survive a pre-creation cancel');
|
||||||
|
assertEquals(Object.keys(nomination.participants).length, 1, 'pre-creation response ignored');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('terminal calls retain complete read-only history for fifteen minutes', () => {
|
||||||
|
const events = [
|
||||||
|
create(),
|
||||||
|
event('join', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
|
||||||
|
event('message', 'Bob', {
|
||||||
|
SendMessage: { message_id: 'message-1', text: 'Launching' },
|
||||||
|
}, NOW + 2),
|
||||||
|
event('start', 'Alice', 'Start', NOW + 3),
|
||||||
|
event('late-message', 'Bob', {
|
||||||
|
SendMessage: { message_id: 'message-2', text: 'Too late' },
|
||||||
|
}, NOW + 4),
|
||||||
|
];
|
||||||
|
const [running] = reduceCallToPlayEvents(events, NOW + TERMINAL_RETENTION_MS);
|
||||||
|
assertEquals(running.state, 'running', 'running receipt remains');
|
||||||
|
assertEquals(Object.keys(running.participants).length, 2, 'terminal roster retained');
|
||||||
|
assertEquals(running.messages.length, 1, 'pre-terminal chat retained');
|
||||||
|
assertEquals(
|
||||||
|
reduceCallToPlayEvents(events, NOW + 3 + TERMINAL_RETENTION_MS + 1).length,
|
||||||
|
0,
|
||||||
|
'terminal receipt retires after display window',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('terminal calls sort last and do not contribute to the badge', () => {
|
||||||
|
const active = reduceCallToPlayEvents([create()], NOW)[0];
|
||||||
|
const running = reduceCallToPlayEvents([
|
||||||
|
create(),
|
||||||
|
event('start', 'Alice', 'Start', NOW + 1),
|
||||||
|
], NOW + 2)[0];
|
||||||
|
const cancelled = reduceCallToPlayEvents([
|
||||||
|
create(),
|
||||||
|
event('cancel', 'Alice', 'Cancel', NOW + 2),
|
||||||
|
], NOW + 3)[0];
|
||||||
|
|
||||||
|
const sorted = sortNominations([running, cancelled, active]);
|
||||||
|
assertEquals(sorted[0].state, 'open', 'actionable call sorts first');
|
||||||
|
assertEquals(activeCallCount(sorted), 1, 'terminal calls excluded from badge');
|
||||||
|
assertEquals(statusOf(running, NOW + 2), 'running', 'running ticker status');
|
||||||
|
assertEquals(statusOf(cancelled, NOW + 3), 'cancelled', 'cancelled ticker status');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('retired calls are pruned from the frontend raw event map', () => {
|
||||||
|
const terminalEvents = [
|
||||||
|
create(),
|
||||||
|
event('start', 'Alice', 'Start', NOW + 1),
|
||||||
|
];
|
||||||
|
const map = new Map(terminalEvents.map(item => [item.id, item]));
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS).size,
|
||||||
|
2,
|
||||||
|
'visible terminal history stays cached',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS + 1).size,
|
||||||
|
0,
|
||||||
|
'retired terminal history is pruned',
|
||||||
|
);
|
||||||
|
|
||||||
|
const tombstone = event('terminal-only', 'Alice', 'Cancel', NOW + 1);
|
||||||
|
const tombstoneMap = new Map([[tombstone.id, tombstone]]);
|
||||||
|
assertEquals(
|
||||||
|
pruneCallToPlayEvents(
|
||||||
|
tombstoneMap,
|
||||||
|
NOW + 1 + TERMINAL_RETENTION_MS + 1,
|
||||||
|
).size,
|
||||||
|
0,
|
||||||
|
'backend tombstone is pruned too',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('scheduled time input accepts design formats and wraps steppers', () => {
|
||||||
|
assertEquals(normalizeTimeInput('20:00'), '20:00', 'colon format');
|
||||||
|
assertEquals(normalizeTimeInput('2000'), '20:00', 'compact format');
|
||||||
|
assertEquals(normalizeTimeInput('9:30'), '09:30', 'single-digit hour');
|
||||||
|
assertEquals(normalizeTimeInput('24:00'), null, 'invalid hour');
|
||||||
|
assertEquals(bumpTime('23:45', 'minutes', 15), '00:00', 'minute wrap');
|
||||||
|
assertEquals(bumpTime('00:00', 'hours', -1), '23:00', 'hour wrap');
|
||||||
|
});
|
||||||
+15
-14
@@ -1,9 +1,10 @@
|
|||||||
# SoftLAN Launcher — Design Handoff
|
# SoftLAN Launcher — Design Handoff
|
||||||
|
|
||||||
**This folder is the complete, current state of design for the SoftLAN Launcher.**
|
**This folder is the complete, current state of design for the SoftLAN
|
||||||
Everything an implementor needs to build the product is in here — and nothing
|
Launcher.** Everything an implementor needs to build the product is in here —
|
||||||
that isn't. (The exploration mockups, logo concept boards, and variant studies
|
and nothing that isn't. (The exploration mockups, logo concept boards, and
|
||||||
live back in the project workspace; they're history, not handoff.)
|
variant studies live back in the project workspace; they're history, not
|
||||||
|
handoff.)
|
||||||
|
|
||||||
Target codebase: **Tauri + React** desktop app. The references here are
|
Target codebase: **Tauri + React** desktop app. The references here are
|
||||||
HTML/React prototypes that communicate the intended look, layout, and behavior —
|
HTML/React prototypes that communicate the intended look, layout, and behavior —
|
||||||
@@ -14,7 +15,7 @@ be shipped as-is.
|
|||||||
|
|
||||||
## What's inside
|
## What's inside
|
||||||
|
|
||||||
```
|
```text
|
||||||
design_handoff_softlan_launcher/
|
design_handoff_softlan_launcher/
|
||||||
├── README.md ← you are here — start here
|
├── README.md ← you are here — start here
|
||||||
│
|
│
|
||||||
@@ -46,15 +47,15 @@ design_handoff_softlan_launcher/
|
|||||||
|
|
||||||
## Two pieces, one product
|
## Two pieces, one product
|
||||||
|
|
||||||
| | **launcher/** | **logo/** |
|
| | **launcher/** | **logo/** |
|
||||||
|---|---|---|
|
| ---------- | ------------------------------------------------------ | -------------------------------------------- |
|
||||||
| What | The full launcher UI redesign | The brand mark + wordmark lockup |
|
| What | The full launcher UI redesign | The brand mark + wordmark lockup |
|
||||||
| Read first | `launcher/SPEC.md` | `logo/INTEGRATION.md` |
|
| Read first | `launcher/SPEC.md` | `logo/INTEGRATION.md` |
|
||||||
| Preview | open `launcher/design_reference/SoftLAN Launcher.html` | open `logo/demo.html` |
|
| 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 |
|
| Fidelity | High — final colors/type/spacing/interactions | Final — recolors live via the `accent` token |
|
||||||
|
|
||||||
The two share one design language. The **accent** color is a single token
|
The two share one design language. The **accent** color is a single token
|
||||||
(`--accent`, default `#3b82f6`) that drives the launcher's primary actions *and*
|
(`--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
|
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
|
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
|
`logo/pixel-live.jsx` at `size={28}`; the static 28px "S" in the mock is a
|
||||||
@@ -80,8 +81,8 @@ placeholder for it.
|
|||||||
chrome. Includes **Call to Play** (rally the LAN around a game + time — live
|
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
|
or scheduled, with RSVP, check-in window, and per-call chat). Open questions
|
||||||
(empty/error states, logs viewer, keyboard grid nav, German strings, "server
|
(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
|
running" state, real-time transport for Call to Play) are listed at the end of
|
||||||
of `SPEC.md`.
|
`SPEC.md`.
|
||||||
- **Logo:** final. Live component + static assets + horizontal lockup (dark and
|
- **Logo:** final. Live component + static assets + horizontal lockup (dark and
|
||||||
light) all included.
|
light) all included.
|
||||||
|
|
||||||
|
|||||||
+762
-284
@@ -1,14 +1,22 @@
|
|||||||
# Handoff: SoftLAN Launcher redesign
|
# 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.
|
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
|
## 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 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:
|
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
|
- Exact pixel/spacing/color/typography values
|
||||||
- Component composition and interactions
|
- Component composition and interactions
|
||||||
@@ -18,17 +26,25 @@ The target codebase is a **Tauri + React** desktop app. The task is to **recreat
|
|||||||
But:
|
But:
|
||||||
|
|
||||||
- Don't ship the Babel-in-browser setup or import the .jsx files as-is
|
- 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 keep the `<deck>` / design-canvas wrapping — that's only for presenting
|
||||||
- Don't ship the Tweaks panel — it's superseded by the in-app **Settings dialog** (see "Screens" below)
|
variants
|
||||||
- Re-implement using whatever the codebase uses (Vite + plain JSX, CSS modules / styled-components / tailwind, etc.)
|
- 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
|
## 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).
|
**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
|
## 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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -41,31 +57,55 @@ The HTML mock includes two chrome variants — **A (single-row)** and **B (two-r
|
|||||||
call carries a small group **chat**. Surfaced in three places: a **Call to
|
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
|
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
|
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.
|
and a create form. Full spec in the new **"Call to Play"** section below. New
|
||||||
New source files: `calltoplay.jsx`, `ctp-chat.jsx`. New data: a mock LAN
|
source files: `calltoplay.jsx`, `ctp-chat.jsx`. New data: a mock LAN **peer
|
||||||
**peer roster** (`PEERS`) in `data.jsx`. New icons: `flag`, `chat`, `send`,
|
roster** (`PEERS`) in `data.jsx`. New icons: `flag`, `chat`, `send`, `clock`,
|
||||||
`clock`, `caretUp`, `caretDown`.
|
`caretUp`, `caretDown`.
|
||||||
|
|
||||||
## Changes since v3
|
## 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 button removed from the top bar.** Setting the games directory
|
||||||
- **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.
|
is a one-time action — it doesn't deserve permanent real estate in the chrome.
|
||||||
- **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).
|
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
|
## 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):
|
- **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.
|
- **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.
|
- **Center (semantically the "search cluster"):** segmented filter pills ·
|
||||||
- **Right:** kebab menu (game-folder configuration has moved into Settings — see v3 changes).
|
search field · sort menu. The **search field is positioned at the geometric
|
||||||
- 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.
|
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.
|
- See "Top bar (variant A)" below for the full spec and rationale.
|
||||||
|
|
||||||
## Changes since v1
|
## 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.
|
- **Settings → Profile section** added at the top of the dialog with two new
|
||||||
- **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.
|
persisted preferences: **Username** (text input) and **Language** (segmented
|
||||||
- Grid cards are **unchanged** — Start Server only ever appears in the detail overlay.
|
`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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -73,42 +113,102 @@ The HTML mock includes two chrome variants — **A (single-row)** and **B (two-r
|
|||||||
|
|
||||||
### 1. Main library (variant A — primary)
|
### 1. Main library (variant A — primary)
|
||||||
|
|
||||||
The default screen. A grid of game cards over a dark, gradient-tinted background.
|
The default screen. A grid of game cards over a dark, gradient-tinted
|
||||||
|
background.
|
||||||
|
|
||||||
**Layout (top-to-bottom):**
|
**Layout (top-to-bottom):**
|
||||||
|
|
||||||
1. **Top bar** — single row, sticky, full width, 64px tall, semi-transparent dark with backdrop-blur. Background `rgba(10,14,19,0.65)` + `backdrop-filter: blur(20px) saturate(140%)`. Border-bottom `1px solid rgba(255,255,255,0.06)`. Padding `14px 24px`. **Layout:** a 3-column CSS grid — `grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr)` with `column-gap: 16px` — putting the search field in the middle (auto-sized) column so it sits at the **geometric center of the window** regardless of how wide the side groups are. The side columns are each `display: flex; justify-content: space-between` so their contents pin to the outer edge on one end and hug the search on the other.
|
1. **Top bar** — single row, sticky, full width, 64px tall, semi-transparent
|
||||||
|
dark with backdrop-blur. Background `rgba(10,14,19,0.65)` +
|
||||||
|
`backdrop-filter: blur(20px) saturate(140%)`. Border-bottom
|
||||||
|
`1px solid rgba(255,255,255,0.06)`. 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):**
|
- **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`.
|
- **Brand** (pinned far-left) — 28×28 px rounded square in `--accent`
|
||||||
- **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:
|
(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
|
- `All Games` · count chip
|
||||||
- `Local` · count chip
|
- `Local` · count chip
|
||||||
- `Installed` · count chip
|
- `Installed` · count chip
|
||||||
|
|
||||||
Active button has an animated pill thumb (background `var(--accent)`, transitions `left` and `width` with `cubic-bezier(.4,1.2,.5,1)` over 220 ms), text becomes white, count-chip background goes `rgba(0,0,0,0.25)`. Inactive: text `var(--t-2) #9aa6b4`, count-chip background `rgba(255,255,255,0.08)`.
|
Active button has an animated pill thumb (background `var(--accent)`,
|
||||||
|
transitions `left` and `width` with `cubic-bezier(.4,1.2,.5,1)` over 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.
|
`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.
|
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):**
|
- **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.
|
- **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):**
|
- **Right zone (col 3, flex space-between with two sub-groups):**
|
||||||
- **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: <bold value>` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (A–Z)`, `Size (largest)`, `Recently Played`, `Status`. This is the only thing on the *left* side of the right zone — it's part of the search cluster, so it hugs the search.
|
- **Sort menu** (pinned left, hugging search) — 36 px button, same surface
|
||||||
- **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.
|
style as search. Label `Sort: <bold value>` plus 13 px sort-bars icon and
|
||||||
- **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.
|
11 px chevron. Click reveals dropdown menu below. Options: `Name (A–Z)`,
|
||||||
|
`Size (largest)`, `Recently Played`, `Status`. This is the only thing on
|
||||||
|
the _left_ side of the right zone — it's part of the search cluster, so
|
||||||
|
it hugs the search.
|
||||||
|
- **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface
|
||||||
|
as search. Menu items: `Settings` (opens Settings dialog),
|
||||||
|
`Refresh library`, separator, `Unpack logs`, `About SoftLAN`. This is the
|
||||||
|
only "app-level" control left in the top bar; the game-folder picker has
|
||||||
|
moved into Settings.
|
||||||
|
- **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-terminal
|
||||||
|
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.
|
**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:
|
2. **Results bar** — 18px top padding inside the scroll wrapper, 24px
|
||||||
- Left: `Showing <strong>N</strong> of M games` in 12.5px `var(--t-2)` (strong is `var(--t-1)`).
|
horizontal. Flex row with space-between:
|
||||||
- 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%)`.
|
- 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.
|
3. **Grid** — CSS grid with `repeat(auto-fill, minmax(188px, 1fr))` at default
|
||||||
|
density, 16px gap, 24px horizontal padding, 32px bottom padding. Scrolls
|
||||||
- Density: `compact` → min 148, gap 12. `normal` → min 188, gap 16. `large` → min 244, gap 20.
|
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).
|
**Game card** (see "Game card" below for full anatomy).
|
||||||
|
|
||||||
@@ -116,54 +216,106 @@ The default screen. A grid of game cards over a dark, gradient-tinted background
|
|||||||
|
|
||||||
### 2. Game detail overlay
|
### 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).
|
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.
|
**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 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):**
|
**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.
|
1. **Hero banner** — `aspect-ratio: 16/7`. Full-bleed cover art rendered as a
|
||||||
- **State chip** in the top-left of the hero (same chip style as on cards — see Game Card).
|
banner (same gradient + accent treatment as the small cards, scaled up).
|
||||||
- **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.
|
Bottom-fade gradient
|
||||||
- **Title overlay** in bottom-left at `left: 28px, right: 28px, bottom: 22px`:
|
`linear-gradient(180deg, transparent 40%, var(--bg-2) 100%)` so text reads.
|
||||||
- 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`)
|
- **State chip** in the top-left of the hero (same chip style as on cards —
|
||||||
- **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.
|
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:
|
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).
|
- **Meta grid** — 4-column CSS grid, 12px gap. Each cell:
|
||||||
- **Description** — 14px / 1.55 line-height, `var(--t-2)`, `text-wrap: pretty`, `max-width: 64ch`.
|
`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:
|
- **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).
|
1. **Primary action button** (44px tall, see "Action button" below — Play /
|
||||||
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).
|
Install / Download depending on state).
|
||||||
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`.
|
2. **Start Server** — _only_ when `game.canHostServer === true` **and**
|
||||||
4. If `state === 'local'`: ghost-button **Delete from disk** (same danger styling).
|
`state === 'installed'`. Same 44px height as Play, but visually a peer
|
||||||
5. If `state === 'downloading'`: ghost-button **Cancel** (same danger styling).
|
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`).
|
6. Spacer (`flex: 1`).
|
||||||
7. Ghost-button **View files** (neutral) — opens system file manager at the game folder.
|
7. Ghost-button **View files** (neutral) — opens system file manager at the
|
||||||
|
game folder.
|
||||||
|
|
||||||
#### Start Server button
|
#### 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.
|
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`.
|
- Same shape and height as Play: 44px tall, `border-radius: 8px`,
|
||||||
- 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`.
|
`font 14px / 600`, 8px gap between icon and label, padding `0 22px`.
|
||||||
- **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`.
|
- Surface:
|
||||||
- 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`).
|
`background: color-mix(in srgb, var(--accent) 14%, rgba(255,255,255,0.04))`,
|
||||||
- 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.
|
`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.
|
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
|
### 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.
|
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:**
|
**Structure:**
|
||||||
|
|
||||||
```
|
```text
|
||||||
┌─────────────────────────────────────────┐
|
┌─────────────────────────────────────────┐
|
||||||
│ Settings [×] │ ← head: 22 28 18, 1px bottom border
|
│ Settings [×] │ ← head: 22 28 18, 1px bottom border
|
||||||
├─────────────────────────────────────────┤
|
├─────────────────────────────────────────┤
|
||||||
@@ -208,69 +360,151 @@ Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim tr
|
|||||||
└─────────────────────────────────────────┘
|
└─────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Sections** are separated by 26px gap (column flex). Rows within a section: 14px gap. Each **row** is flex row with space-between (24px gap):
|
**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`)
|
- Left (`settings-row-info`): label (14px / 600 / `--t-1`) + hint (3px-top, 12px
|
||||||
|
/ `--t-3`)
|
||||||
- Right (`settings-row-control`): the control
|
- 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.
|
**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)`.
|
- **Username** — `<input type="text">` wrapped in a styled container: 220px
|
||||||
- **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.
|
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**.
|
**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.
|
- **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)`.
|
**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`.
|
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)`.
|
**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.
|
**Done button:** filled button in `--accent`, 36px tall, 13.5px / 600. Closes
|
||||||
|
the dialog.
|
||||||
|
|
||||||
Persisted settings (write through to local storage / Tauri config):
|
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`.
|
- `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`.
|
- `accent`: one of the six hex values above. Default `#3b82f6`.
|
||||||
- `bg`: `flat` | `gradient` | `animated`. Default `gradient`.
|
- `bg`: `flat` | `gradient` | `animated`. Default `gradient`.
|
||||||
- `density`: `compact` | `normal` | `large`. Default `normal`.
|
- `density`: `compact` | `normal` | `large`. Default `normal`.
|
||||||
- `aspect`: `box` | `square` | `banner`. Default `box`.
|
- `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.
|
- `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
|
## 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).
|
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.
|
**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:
|
Two visual states, driven by whether `settings.gameFolder` resolves to an
|
||||||
|
accessible directory:
|
||||||
|
|
||||||
| State | Trigger | Path display | Border | Button label |
|
| 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) |
|
| **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) |
|
| **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.
|
"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:
|
**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).
|
1. **Folder icon** — `Icon.folder` from `components.jsx`, 14×14, `var(--t-3)`
|
||||||
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.
|
(set state) or `#f87171` (unset state).
|
||||||
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.
|
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).
|
**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.
|
**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.
|
**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.
|
**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.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -278,39 +512,72 @@ Two visual states, driven by whether `settings.gameFolder` resolves to an access
|
|||||||
|
|
||||||
The unit element of the library grid.
|
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.
|
**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:**
|
**Hover/focus state:**
|
||||||
|
|
||||||
- `transform: translateY(-2px)` (180ms `cubic-bezier(.4,1.2,.5,1)`)
|
- `transform: translateY(-2px)` (180ms `cubic-bezier(.4,1.2,.5,1)`)
|
||||||
- `border-color: color-mix(in srgb, var(--accent) 45%, var(--bd-2))`
|
- `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)`
|
- 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)
|
- Cover inner image scales to 1.03 (350ms cubic-bezier)
|
||||||
- Focus-visible: same lift + 2px solid accent outline
|
- Focus-visible: same lift + 2px solid accent outline
|
||||||
|
|
||||||
### Anatomy (top to bottom)
|
### 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)`.
|
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`):
|
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.
|
- **Base gradient** — diagonal (`linear-gradient(<110-170deg>, c1, c2)` —
|
||||||
- **Radial accent blob** — `radial-gradient(ellipse at <x>% <y>%, <accent>38, transparent 55%)`. x/y also hashed from id.
|
angle hashed from game id for variety). Per-game color pair from the game's
|
||||||
- **Grain / scanline** — two `repeating-linear-gradient` overlays at 1px intervals, `mix-blend-mode: overlay`, opacity 0.7.
|
`cover` metadata.
|
||||||
- **Decorative SVG mark** — preserveAspectRatio bottom-right, draws a triangle and dot in the accent color at 12% opacity. Variation via id hash.
|
- **Radial accent blob** —
|
||||||
- **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).
|
`radial-gradient(ellipse at <x>% <y>%, <accent>38, transparent 55%)`. x/y
|
||||||
- **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).
|
also hashed from id.
|
||||||
- **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>`.
|
- **Grain / scanline** — two `repeating-linear-gradient` overlays at 1px
|
||||||
- **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. `2–32`). Always visible — every LAN game is multiplayer.
|
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.
|
||||||
|
`2–32`). Always visible — every LAN game is multiplayer.
|
||||||
|
|
||||||
3. **Card body** — `padding: 11px 12px 12px`, flex column, 8px gap:
|
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.
|
- **Title** — game's full (mixed-case) title in 13.5px / 600 / `--t-1`,
|
||||||
- **Meta line** — 11.5px tabular-nums, `--t-3`: size · genre. Dot separator at 50% opacity.
|
single line, ellipsis on overflow.
|
||||||
- **Action button** (full width) — primary action depending on state, see below.
|
- **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
|
### 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.
|
A single button per card with the _primary action for the current state_.
|
||||||
|
Color-coded as the main affordance for state at a glance.
|
||||||
|
|
||||||
```
|
```text
|
||||||
state label button style
|
state label button style
|
||||||
───────────── ────────── ────────────────────────────────────────────
|
───────────── ────────── ────────────────────────────────────────────
|
||||||
not downloaded Download neutral: bg rgba(255,255,255,0.08), 1px var(--bd-2), text var(--t-1)
|
not downloaded Download neutral: bg rgba(255,255,255,0.08), 1px var(--bd-2), text var(--t-1)
|
||||||
@@ -319,85 +586,156 @@ installed Play bg linear-gradient(180deg, #2bd07f 0%, #1aa460 100%),
|
|||||||
downloading — progress see "Download progress" below — the button slot is replaced with a live progress component
|
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).
|
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)`.
|
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).
|
**Uninstall / Delete-from-disk** are NOT on the card — only in the detail
|
||||||
|
overlay (as ghost-danger buttons).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Download progress (state === 'downloading')
|
## 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:
|
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
|
### 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).
|
- Container: `border-radius: 7px` (card) / `9px` (modal),
|
||||||
- **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) 38–26%, transparent)`. Right edge gets a 1px accent rule + accent glow.
|
`1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2))`, faint accent
|
||||||
- **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.
|
halo via `box-shadow`. `container-type: inline-size` (we use container queries
|
||||||
- **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.
|
for graceful fallback, see below).
|
||||||
- **Tabular numerics** on all values (`font-variant-numeric: tabular-nums`) so the percentage and speed don't jitter as digits roll over.
|
- **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) 38–26%, 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)
|
### Card layout (`.dl-md`, replaces the 32px action button)
|
||||||
|
|
||||||
A single row. Two values, separated by `justify-content: space-between`:
|
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%`.
|
- **Left:** `<pulse> <pct>%` — 12px / 600, `var(--t-1)`. `%` glyph at 0.55
|
||||||
- **Right:** `<speed>` — 11px / 500, `var(--t-2)`. Short format: `49 MB/s` (no decimals at card scale).
|
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`).
|
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:
|
**Container-query graceful degradation** — this is the important part, it has to
|
||||||
|
fit every aspect/density combo:
|
||||||
|
|
||||||
```css
|
```css
|
||||||
@container (max-width: 132px) { .dl-md .dl-speed { display: none; } .dl-md-row { justify-content: center; gap: 6px; } }
|
@container (max-width: 132px) {
|
||||||
@container (max-width: 96px) { .dl-md .dl-pulse { display: none; } }
|
.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.
|
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.
|
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)
|
### Detail-overlay layout (`.dl-lg`, replaces the 44px modal action button)
|
||||||
|
|
||||||
Fixed 56px height. CSS-grid with three columns and two rows:
|
Fixed 56px height. CSS-grid with three columns and two rows:
|
||||||
|
|
||||||
```
|
```text
|
||||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"primary pct cancel"
|
"primary pct cancel"
|
||||||
"secondary 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.
|
- **Primary row** (`.dl-lg-primary`, top-left) — pulse dot + the uppercase live
|
||||||
- **Secondary row** (`.dl-lg-secondary`, bottom-left) — the live stats. 12px, four groups separated by `·` (0.45 opacity):
|
label `DOWNLOADING` in `color-mix(in srgb, var(--accent) 80%, white)`, 13px /
|
||||||
1. `<strong>11.4 GB</strong> / 35 GB` (`var(--t-1)` strong + `var(--t-2)` rest)
|
600, `letter-spacing: 0.02em`. This is the only place the word "Downloading"
|
||||||
2. `47.6 MB/s` (`var(--t-1)`)
|
appears in the component.
|
||||||
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.
|
- **Secondary row** (`.dl-lg-secondary`, bottom-left) — the live stats. 12px,
|
||||||
4. `8 min left` (`var(--t-2)`)
|
four groups separated by `·` (0.45 opacity):
|
||||||
- **pct column** — large percentage, 20px / 700, `letter-spacing: -0.01em`, `var(--t-1)`. `%` glyph at 12px / 600 / 0.55 opacity.
|
1. `<strong>11.4 GB</strong> / 35 GB` (`var(--t-1)` strong + `var(--t-2)`
|
||||||
- **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.
|
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:**
|
**Graceful degradation in narrow modals:**
|
||||||
|
|
||||||
```css
|
```css
|
||||||
@container (max-width: 320px) { .dl-lg-secondary .dl-eta, .dl-lg-secondary .dl-sep-eta { display: none; } }
|
@container (max-width: 320px) {
|
||||||
@container (max-width: 240px) { .dl-lg-secondary .dl-peers, .dl-lg-secondary .dl-sep-peers { display: none; } }
|
.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.
|
ETA drops first, then peers; bytes + speed always stay (they're the actionable
|
||||||
|
numbers). The pct/cancel column never collapses.
|
||||||
|
|
||||||
### Number formatting
|
### Number formatting
|
||||||
|
|
||||||
All helpers live in `data.jsx`:
|
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`.
|
- `fmtSpeed(mbps)` — `49.4 MB/s` below 100, `MM MB/s` (rounded) at/above 100.
|
||||||
- `fmtSpeedShort(mbps)` — always rounded: `49 MB/s`. Used in `.dl-md` so the card stays compact.
|
Used in `.dl-lg`.
|
||||||
- `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`).
|
- `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"`.
|
- `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.
|
Keep these formats; they're tuned so the secondary row never wraps at normal
|
||||||
|
modal width.
|
||||||
|
|
||||||
### Data shape
|
### Data shape
|
||||||
|
|
||||||
@@ -406,17 +744,27 @@ The `Game` type gains a `downloading` state plus two transient fields:
|
|||||||
```ts
|
```ts
|
||||||
type Game = {
|
type Game = {
|
||||||
// … existing fields …
|
// … existing fields …
|
||||||
state: 'installed' | 'local' | 'downloading' | 'none';
|
state: "installed" | "local" | "downloading" | "none";
|
||||||
progress?: number; // 0–1, only when state === 'downloading'
|
progress?: number; // 0–1, only when state === 'downloading'
|
||||||
speed?: number; // current throughput in MB/s
|
speed?: number; // current throughput in MB/s
|
||||||
peers?: number; // number of LAN peers currently seeding
|
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.
|
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:
|
Filter changes:
|
||||||
- `Local` filter includes `installed` + `local` + `downloading` (in-flight downloads belong on the Local tab — you're managing them).
|
|
||||||
|
- `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`.
|
- Sort by `state` orders `installed < local < downloading < none`.
|
||||||
|
|
||||||
### State chip
|
### State chip
|
||||||
@@ -444,12 +792,12 @@ Source: `calltoplay.jsx` (the feature) + `ctp-chat.jsx` (per-call chat + shared
|
|||||||
|
|
||||||
### Two flavors of call
|
### Two flavors of call
|
||||||
|
|
||||||
| | **Play now** | **Scheduled** |
|
| | **Play now** | **Scheduled** |
|
||||||
|---|---|---|
|
| -------------- | ----------------------------------------------------- | ------------------------------------------------------ |
|
||||||
| Set up with | game + max players + **duration** (5/10/15/30/60 min) | game + max players + **clock time** (24h, + which day) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
**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
|
`scheduled` phase collecting RSVPs until 15 minutes before its start time, then
|
||||||
@@ -457,12 +805,16 @@ 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
|
"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."
|
a play-now call. A play-now call is effectively "always in its check-in window."
|
||||||
|
|
||||||
|
When the deadline passes, the call is labeled **Time's up** rather than Ready.
|
||||||
|
It remains visible for five minutes so the caller can start or extend it, then
|
||||||
|
the call and its history expire as a unit.
|
||||||
|
|
||||||
**Every call carries a small group chat** (see "Per-call chat" below).
|
**Every call carries a small group chat** (see "Per-call chat" below).
|
||||||
|
|
||||||
### Three surfaces
|
### Three surfaces
|
||||||
|
|
||||||
1. **Top-bar button** (`CallToPlayButton` / `.ctp-btn`) — flag icon + label +
|
1. **Top-bar button** (`CallToPlayButton` / `.ctp-btn`) — flag icon + label + an
|
||||||
an accent badge counting active (non-`started`) calls. Opens the overlay.
|
accent badge counting active, non-terminal calls. Opens the overlay.
|
||||||
2. **Quick bars** (`CallToPlayTicker` / `.ctp-ticker-stack`) — a persistent
|
2. **Quick bars** (`CallToPlayTicker` / `.ctp-ticker-stack`) — a persistent
|
||||||
stack rendered at the top of the grid area, **one row per active call**.
|
stack rendered at the top of the grid area, **one row per active call**.
|
||||||
Sorted **ready → starting-soon → the rest**, ties broken by whichever
|
Sorted **ready → starting-soon → the rest**, ties broken by whichever
|
||||||
@@ -470,7 +822,8 @@ a play-now call. A play-now call is effectively "always in its check-in window."
|
|||||||
then by `deadline`). Clicking a row opens the overlay focused on that call.
|
then by `deadline`). Clicking a row opens the overlay focused on that call.
|
||||||
3. **Overlay** (`CallToPlayOverlay`) — a modal (same scrim/panel treatment as
|
3. **Overlay** (`CallToPlayOverlay`) — a modal (same scrim/panel treatment as
|
||||||
the other dialogs) with a header, a **Call a new match** button, the create
|
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).
|
form, and a list of **nomination cards** (Running and Cancelled calls sink to
|
||||||
|
the bottom).
|
||||||
|
|
||||||
### Status model
|
### Status model
|
||||||
|
|
||||||
@@ -478,42 +831,64 @@ Two derived values drive everything (`calltoplay.jsx`):
|
|||||||
|
|
||||||
- `phaseOf(call)` → `'now'` (no `scheduledFor`) · `'scheduled'` (>15 min out) ·
|
- `phaseOf(call)` → `'now'` (no `scheduledFor`) · `'scheduled'` (>15 min out) ·
|
||||||
`'checkin'` (within the 15-min lead).
|
`'checkin'` (within the 15-min lead).
|
||||||
- `statusOf(call)` (quick-bar/label status) → `'started'` · `'ready'`
|
- `statusOf(call)` (quick-bar/label status) → `'running'` · `'cancelled'` ·
|
||||||
(`readyCount >= maxPlayers`, or state `done`) · `'soon'` (`deadline - now ≤
|
`'expired'` (deadline elapsed) · `'ready'` (`readyCount >= maxPlayers`, or
|
||||||
15 min`) · `'scheduled'` (has a clock time) · `'call'` (a plain play-now call).
|
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
|
Quick-bar labels + LED colors: **SCHEDULED**, **CALL TO PLAY**, **STARTING
|
||||||
SOON**, **READY** (`TICKER_LABEL`), each with its own dot color via
|
SOON**, **READY**, **TIME'S UP**, **RUNNING**, **CANCELLED** (`TICKER_LABEL`),
|
||||||
`.ctp-ticker-dot[data-status]`.
|
each with its own dot color via `.ctp-ticker-dot[data-status]`. Running and
|
||||||
|
Cancelled receipts remain for 15 minutes, sort after actionable calls, and do
|
||||||
|
not contribute to the top-bar badge.
|
||||||
|
|
||||||
**Per-participant ready state:** `ready` (explicitly readied, or their `readyAt`
|
**Per-participant ready state:** `ready` (explicitly readied, or their `readyAt`
|
||||||
countdown has elapsed) · `in` (RSVP'd to a scheduled call but not checked in
|
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
|
yet) · `pending` (checked in with a `+N minutes` buffer, counting down). Shown
|
||||||
as compact **ready-bubbles** in the quick bars (`MiniBubbles`: initials +
|
as compact **ready-bubbles** in the quick bars (`MiniBubbles`: initials + green
|
||||||
green ring/check when ready, `+Nm` tag when pending, dimmed when just "in") and
|
ring/check when ready, `+Nm` tag when pending, dimmed when just "in") and as
|
||||||
as larger `AvatarChip`s in the nomination card roster.
|
larger `AvatarChip`s in the nomination card roster.
|
||||||
|
|
||||||
### Nomination card (`NominationCard`)
|
### Nomination card (`NominationCard`)
|
||||||
|
|
||||||
Top to bottom:
|
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.
|
1. **Header** — square game cover + title + a sub-line:
|
||||||
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).
|
`Called by <creator> · N/M peers have it installed` (or
|
||||||
3. **Progress bar** — time remaining as a fill (accent, → green when done); hidden while a call is still in the far-out `scheduled` phase.
|
`Scheduled by <creator> · starts at HH:MM · …`), and a **timer** on the
|
||||||
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`.
|
right: a live `M:SS` countdown for play-now/check-in, the **clock time + "in
|
||||||
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.
|
N min"** for a scheduled call, `Ready`, `Time's up`, `Running`, or
|
||||||
|
`Cancelled`. If catalog data is temporarily unavailable, the card still
|
||||||
|
renders the caller, game ID, roster, chat, and coordination actions with a
|
||||||
|
clear `Game unavailable here` label. 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.
|
6. **Chat** — the collapsible per-call chat panel.
|
||||||
7. **Cancel** — creators get a `Cancel this call` link with an inline confirm.
|
7. **Cancel** — creators get a `Cancel this call` link with an inline confirm.
|
||||||
|
|
||||||
|
Running and Cancelled cards are read-only: the complete roster and chat remain
|
||||||
|
visible, but participant controls, creator controls, chat composition, and
|
||||||
|
cancel actions are disabled.
|
||||||
|
|
||||||
### Create form (`CreateNominationForm`)
|
### Create form (`CreateNominationForm`)
|
||||||
|
|
||||||
Game search (typeahead over the catalog) → on pick, max-players defaults to the
|
Game search (typeahead over the catalog) → on pick, max-players defaults to the
|
||||||
game's parsed player cap (`parseMaxPlayers`). **When** toggles `Now` vs
|
game's parsed player cap (`parseMaxPlayers`). **When** toggles `Now` vs
|
||||||
`Schedule`. `Now` reveals the **Give people** duration chips. `Schedule` reveals
|
`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
|
a **24-hour time picker** (hour/minute steppers **and** a "Type a time"
|
||||||
field accepting `20:00` / `2000` / `9:30`) plus **day chips** (Today + next two
|
free-text field accepting `20:00` / `2000` / `9:30`) plus **day chips** (Today +
|
||||||
days). The confirm button reads `Call it — <game>` or `Schedule it — <game> ·
|
next two days). The confirm button reads `Call it — <game>` or
|
||||||
<day> <time>`.
|
`Schedule it — <game> · <day> <time>`.
|
||||||
|
|
||||||
### Per-call chat (`CtpChat`, `ctp-chat.jsx`)
|
### Per-call chat (`CtpChat`, `ctp-chat.jsx`)
|
||||||
|
|
||||||
@@ -528,19 +903,31 @@ card. Usernames are colored deterministically by a hash of the name.
|
|||||||
type Nomination = {
|
type Nomination = {
|
||||||
id: string;
|
id: string;
|
||||||
gameId: string;
|
gameId: string;
|
||||||
creator: string; // username of the caller
|
creatorId: string; // stable peer ID of the caller
|
||||||
|
creator: string; // display name of the caller
|
||||||
maxPlayers: number;
|
maxPlayers: number;
|
||||||
createdAt: number; // ms epoch
|
createdAt: number; // ms epoch
|
||||||
scheduledFor: number | null; // ms epoch clock time; null = play-now
|
scheduledFor: number | null; // ms epoch clock time; null = play-now
|
||||||
deadline: number; // play-now: createdAt + durationMin; scheduled: === scheduledFor
|
deadline: number; // play-now: createdAt + durationMin; scheduled: === scheduledFor
|
||||||
participants: Record<string, { // keyed by username
|
participants: Record<
|
||||||
status: 'ready' | 'in' | 'pending';
|
string,
|
||||||
joinedAt: number;
|
{
|
||||||
readyAt?: number; // ms epoch a 'pending' buffer elapses
|
// keyed by stable peer ID
|
||||||
}>;
|
name: string; // current display name
|
||||||
messages: { id: string; from: string; text: string; at: number }[];
|
status: "ready" | "in" | "pending";
|
||||||
state: 'open' | 'done' | 'started';
|
joinedAt: number;
|
||||||
startedAt?: number;
|
readyAt?: number; // ms epoch a 'pending' buffer elapses
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
messages: {
|
||||||
|
id: string;
|
||||||
|
fromId: string;
|
||||||
|
from: string;
|
||||||
|
text: string;
|
||||||
|
at: number;
|
||||||
|
}[];
|
||||||
|
state: "open" | "done" | "running" | "cancelled";
|
||||||
|
terminalAt: number | null;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -548,34 +935,45 @@ The `useNominations({ username, seed })` hook owns the list and exposes
|
|||||||
`createNomination`, `respond`, `rsvp`, `sendMessage`, `leave`, `cancel`,
|
`createNomination`, `respond`, `rsvp`, `sendMessage`, `leave`, `cancel`,
|
||||||
`startNow`, `addTime`.
|
`startNow`, `addTime`.
|
||||||
|
|
||||||
### Mock vs production
|
### Design reference vs production
|
||||||
|
|
||||||
The mock **simulates other people** with a 1-second `setInterval`
|
The mock **simulates other people** with a 1-second `setInterval`
|
||||||
(`tickNomination`): bots RSVP to scheduled calls, ready-up during check-in, walk
|
(`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
|
in late, and occasionally post a chat line — purely so the demo resolves visibly
|
||||||
visibly (bots use second-scale buffers; real people use the minute-scale ones).
|
(bots use second-scale buffers; real people use the minute-scale ones). The mock
|
||||||
The mock also seeds a few representative calls on mount (a live call with chat,
|
also seeds a few representative calls on mount (a live call with chat, a fresh
|
||||||
a fresh call you started, a scheduled call whose check-in window just opened,
|
call you started, a scheduled call whose check-in window just opened, and one
|
||||||
and one scheduled for later collecting RSVPs).
|
scheduled for later collecting RSVPs).
|
||||||
|
|
||||||
**In production, replace the simulation with a real-time LAN transport:** the
|
The production launcher uses the peer's existing QUIC control channel. Each
|
||||||
launcher instances broadcast nominations, responses, RSVPs, and chat messages to
|
create, response, RSVP, chat, leave, cancel, start, or deadline-extension action
|
||||||
each other (whatever the app uses — a small pub/sub over the LAN, a lightweight
|
is an immutable, uniquely identified event. Connected peers receive new events
|
||||||
signaling server, or Tauri-side networking). Swap the guts of `useNominations`
|
immediately, while `Hello` / `HelloAck` exchange the bounded, deduplicated event
|
||||||
(the `setInterval` + local `setNoms`) for your networking layer that pushes the
|
history so a late joiner reconstructs every event and chat message for active
|
||||||
same state shape; the rendering components need nothing else. The `username` that
|
calls. Running and Cancelled calls retain their complete history for 15 minutes
|
||||||
identifies "you" comes from `settings.username` (the Profile setting), not a
|
so late joiners can see the outcome, roster, and chat, then compact to a Start
|
||||||
prop default.
|
or Cancel tombstone for the rest of the peer session. Unresolved calls are
|
||||||
|
removed after the five-minute post-deadline recovery period. The frontend
|
||||||
|
reducer turns that event history into the `Nomination` state above, derives
|
||||||
|
time-based phase changes locally, and prunes retired raw events. Stable peer IDs
|
||||||
|
identify actors and enforce creator controls; `settings.username` is only the
|
||||||
|
display name. These deadlines use event wall-clock timestamps, so LAN clocks are
|
||||||
|
assumed to be reasonably close; no clock-synchronization protocol is attempted.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Filter controls — variant B (not used, kept for reference)
|
## 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:
|
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.
|
- Buttons: no background, `padding: 10px 14px 12px`, font `13.5px / 600`. Color
|
||||||
- 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`.
|
`--t-2` inactive, `--t-1` active.
|
||||||
- Active tab has a 2px underline at the bottom (`left: 12px, right: 12px`) in `--accent`, animated in via opacity + scaleX (220ms cubic-bezier).
|
- 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.
|
Implement only if you decide variant A doesn't work after building.
|
||||||
|
|
||||||
@@ -584,58 +982,84 @@ Implement only if you decide variant A doesn't work after building.
|
|||||||
## Interactions & behavior
|
## Interactions & behavior
|
||||||
|
|
||||||
- **Click game card** (anywhere except the action button) → open detail overlay.
|
- **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.
|
- **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.
|
- **Press / (slash)** → focus the search input.
|
||||||
- **Type in search** → live-filter the visible grid by title or tag (case-insensitive substring).
|
- **Type in search** → live-filter the visible grid by title or tag
|
||||||
|
(case-insensitive substring).
|
||||||
- **Click filter tab / segmented pill** → change filter.
|
- **Click filter tab / segmented pill** → change filter.
|
||||||
- **Click sort button** → opens dropdown; click an option → re-sort grid; clicking outside the menu closes it.
|
- **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.
|
- **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 "Settings"** in kebab → open Settings dialog. Changes apply live and
|
||||||
- **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.
|
persist immediately (no Apply button — Done just closes).
|
||||||
- **Click "Unpack logs"** in kebab → opens a logs viewer (separate window or modal — out of scope for this design).
|
- **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.
|
- **Click "Refresh library"** in kebab → re-runs the library scan.
|
||||||
- **Esc** → closes any open modal (detail overlay, Settings).
|
- **Esc** → closes any open modal (detail overlay, Settings).
|
||||||
|
|
||||||
### Transitions / animations
|
### 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.
|
- Card hover: `180ms cubic-bezier(.4,1.2,.5,1)` on transform/border,
|
||||||
- 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)`.
|
`350ms cubic-bezier(.4,1.2,.5,1)` on cover scale.
|
||||||
- Segmented filter thumb: `220ms cubic-bezier(.4,1.2,.5,1)` on `left` and `width`.
|
- Modal fade-in: scrim `opacity 0 → 1` over 180ms ease; modal
|
||||||
- Underline tab indicator (variant B): `200ms` on opacity, `250ms cubic-bezier(.4,1.2,.5,1)` on `transform: scaleX`.
|
`transform: scale(.96) translateY(8px) → scale(1) translateY(0)` and opacity
|
||||||
- Animated background option: subtle 18s ease-in-out infinite alternate background-position shift on two accent-tinted radial gradients.
|
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
|
## State management
|
||||||
|
|
||||||
Recommend Zustand or a single React context for global launcher state; Tauri commands for filesystem and process operations.
|
Recommend Zustand or a single React context for global launcher state; Tauri
|
||||||
|
commands for filesystem and process operations.
|
||||||
|
|
||||||
**Library state** (rebuilt on `refresh`):
|
**Library state** (rebuilt on `refresh`):
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type Game = {
|
type Game = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
size: number; // GB
|
size: number; // GB
|
||||||
version: string; // "YYYY.MM.DD"
|
version: string; // "YYYY.MM.DD"
|
||||||
desc: string;
|
desc: string;
|
||||||
state: 'installed' | 'local' | 'downloading' | 'none';
|
state: "installed" | "local" | "downloading" | "none";
|
||||||
progress?: number; // 0–1 — present only when state === 'downloading'
|
progress?: number; // 0–1 — present only when state === 'downloading'
|
||||||
speed?: number; // MB/s — present only when state === 'downloading'
|
speed?: number; // MB/s — present only when state === 'downloading'
|
||||||
peers?: number; // LAN peers currently seeding
|
peers?: number; // LAN peers currently seeding
|
||||||
players: string; // e.g. "2–32"
|
players: string; // e.g. "2–32"
|
||||||
tags: string[];
|
tags: string[];
|
||||||
cover: { c1: string; c2: string; accent: string; mood?: string };
|
cover: { c1: string; c2: string; accent: string; mood?: string };
|
||||||
canHostServer?: boolean; // true if the game ships with a dedicated-server binary
|
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.
|
**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:**
|
**UI state:**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type LauncherUI = {
|
type LauncherUI = {
|
||||||
filter: 'all' | 'local' | 'installed';
|
filter: "all" | "local" | "installed";
|
||||||
sort: 'az' | 'size' | 'recent' | 'state';
|
sort: "az" | "size" | "recent" | "state";
|
||||||
query: string;
|
query: string;
|
||||||
openGameId: string | null;
|
openGameId: string | null;
|
||||||
settingsOpen: boolean;
|
settingsOpen: boolean;
|
||||||
@@ -643,21 +1067,24 @@ type LauncherUI = {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Persisted settings** (mirror of Settings dialog state):
|
**Persisted settings** (mirror of Settings dialog state):
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type LauncherSettings = {
|
type LauncherSettings = {
|
||||||
username: string;
|
username: string;
|
||||||
language: 'en' | 'de';
|
language: "en" | "de";
|
||||||
accent: string; // hex from the curated 6-color palette
|
accent: string; // hex from the curated 6-color palette
|
||||||
bg: 'flat' | 'gradient' | 'animated';
|
bg: "flat" | "gradient" | "animated";
|
||||||
density: 'compact' | 'normal' | 'large';
|
density: "compact" | "normal" | "large";
|
||||||
aspect: 'box' | 'square' | 'banner';
|
aspect: "box" | "square" | "banner";
|
||||||
gameFolder: string | null; // v3: moved out of top bar, persists actual path
|
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).
|
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.
|
**Storage figures:** computed by summing game sizes per state, plus free-space
|
||||||
|
query via Tauri.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -665,32 +1092,36 @@ Persist via Tauri's plugin-store or a local JSON file in app data dir. Changes f
|
|||||||
|
|
||||||
### Color
|
### Color
|
||||||
|
|
||||||
| token | value | usage |
|
| token | value | usage |
|
||||||
|---|---|---|
|
| ---------- | -------------------------------- | ---------------------------------------- |
|
||||||
| `--bg-0` | `#0a0e13` | launcher background |
|
| `--bg-0` | `#0a0e13` | launcher background |
|
||||||
| `--bg-1` | `#0f151c` | card bottom gradient stop |
|
| `--bg-1` | `#0f151c` | card bottom gradient stop |
|
||||||
| `--bg-2` | `#131b25` | top bar / card top / search bg |
|
| `--bg-2` | `#131b25` | top bar / card top / search bg |
|
||||||
| `--bg-3` | `#1a2330` | settings segmented bg / cover fallback |
|
| `--bg-3` | `#1a2330` | settings segmented bg / cover fallback |
|
||||||
| `--bg-4` | `#232f3e` | (reserved) |
|
| `--bg-4` | `#232f3e` | (reserved) |
|
||||||
| `--bd-1` | `rgba(255,255,255,0.06)` | subtle border |
|
| `--bd-1` | `rgba(255,255,255,0.06)` | subtle border |
|
||||||
| `--bd-2` | `rgba(255,255,255,0.10)` | stronger border |
|
| `--bd-2` | `rgba(255,255,255,0.10)` | stronger border |
|
||||||
| `--bd-3` | `rgba(255,255,255,0.16)` | scrollbar thumb |
|
| `--bd-3` | `rgba(255,255,255,0.16)` | scrollbar thumb |
|
||||||
| `--t-1` | `#e6edf3` | primary text |
|
| `--t-1` | `#e6edf3` | primary text |
|
||||||
| `--t-2` | `#9aa6b4` | secondary text |
|
| `--t-2` | `#9aa6b4` | secondary text |
|
||||||
| `--t-3` | `#6b7785` | muted text / metadata |
|
| `--t-3` | `#6b7785` | muted text / metadata |
|
||||||
| `--t-4` | `#4a5663` | (reserved) |
|
| `--t-4` | `#4a5663` | (reserved) |
|
||||||
| `--ok` | `#22c55e` | "installed" dot |
|
| `--ok` | `#22c55e` | "installed" dot |
|
||||||
| `--warn` | `#f59e0b` | "local" dot |
|
| `--warn` | `#f59e0b` | "local" dot |
|
||||||
| `--danger` | `#ef4444` | destructive actions |
|
| `--danger` | `#ef4444` | destructive actions |
|
||||||
| `--accent` | user-selected, default `#3b82f6` | primary actions, focus rings, brand mark |
|
| `--accent` | user-selected, default `#3b82f6` | primary actions, focus rings, brand mark |
|
||||||
|
|
||||||
### Typography
|
### Typography
|
||||||
|
|
||||||
- **UI font** — system sans stack: `-apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", system-ui, sans-serif`
|
- **UI font** — system sans stack:
|
||||||
- **Cover-art display font** — `"Bebas Neue"` (Google Fonts, weight 400) with fallback `"Oswald", Impact, "Arial Narrow Bold", sans-serif`
|
`-apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", system-ui, sans-serif`
|
||||||
- **Monospace** — `ui-monospace, "SF Mono", Menlo, Consolas, monospace` (used for: directory path, version field in detail overlay)
|
- **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:
|
Sizing reference:
|
||||||
|
|
||||||
- Brand wordmark: 15 / 700
|
- Brand wordmark: 15 / 700
|
||||||
- Modal title: 32 / 700 / -0.015em
|
- Modal title: 32 / 700 / -0.015em
|
||||||
- Card title: 13.5 / 600
|
- Card title: 13.5 / 600
|
||||||
@@ -704,39 +1135,59 @@ Sizing reference:
|
|||||||
|
|
||||||
- Card radius: 10px
|
- Card radius: 10px
|
||||||
- Modal radius: 14px
|
- Modal radius: 14px
|
||||||
- Pill/control radius: 8px (search, sort, dir button), 999px (filter segmented), 7px (action button)
|
- 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
|
- Common gaps: 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 28
|
||||||
- Card body padding: 11 12 12
|
- Card body padding: 11 12 12
|
||||||
|
|
||||||
### Shadows
|
### Shadows
|
||||||
|
|
||||||
- Card hover: `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)`
|
- 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)`
|
- 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)`
|
- Brand mark:
|
||||||
- Action button (filled): `0 6px 16px -8px <color>, inset 0 1px 0 rgba(255,255,255,0.22)`
|
`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
|
## 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.
|
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`.
|
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.
|
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:
|
Fonts to load:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap">
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap"
|
||||||
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## File reference
|
## File reference
|
||||||
|
|
||||||
```
|
```text
|
||||||
design_reference/
|
design_reference/
|
||||||
├── SoftLAN Launcher.html ← entry; wires React + Babel, mounts <App>
|
├── SoftLAN Launcher.html ← entry; wires React + Babel, mounts <App>
|
||||||
├── styles.css ← all visual styles (CSS custom props + components)
|
├── styles.css ← all visual styles (CSS custom props + components)
|
||||||
@@ -753,25 +1204,52 @@ design_reference/
|
|||||||
```
|
```
|
||||||
|
|
||||||
To preview the design in a browser:
|
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.
|
1. Open `SoftLAN Launcher.html` in a static-server (e.g. `python -m http.server`
|
||||||
- **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.
|
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)
|
- **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**
|
- **C** — detail overlay for an installed, server-capable game
|
||||||
- **D** — detail overlay for a downloaded-but-not-installed game (CoD 4) → shows **Install + Delete from disk**
|
(Counter-Strike 1.6) → shows **Play + Start Server + Uninstall**
|
||||||
- **E** — detail overlay for a downloading game (AvP) → shows the live progress component + **Cancel**
|
- **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
|
- **F** — Settings dialog open, with the new **Profile** section at the top
|
||||||
3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you live-change every persisted setting (username / language / accent / background / density / aspect / game folder). In the production app these all live in the Settings dialog.
|
3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you
|
||||||
|
live-change every persisted setting (username / language / accent /
|
||||||
|
background / density / aspect / game folder). In the production app these all
|
||||||
|
live in the Settings dialog.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Out of scope / open questions for the developer
|
## 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.
|
- **Unpack logs viewer** — referenced from kebab menu but not designed. Surface
|
||||||
- **Empty state** — when filter returns 0 games (e.g. nothing installed yet). Show a centered message with a CTA to install the first game.
|
it as a separate window or a slide-in panel, dev's choice.
|
||||||
- **Error state on action** — if a Download / Install fails, show inline error on the affected card (red border + retry button), and a toast.
|
- **Empty state** — when filter returns 0 games (e.g. nothing installed yet).
|
||||||
- **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.
|
Show a centered message with a CTA to install the first game.
|
||||||
- **Keyboard arrow nav** — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal.
|
- **Error state on action** — if a Download / Install fails, show inline error
|
||||||
- **"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.
|
on the affected card (red border + retry button), and a toast.
|
||||||
- **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.
|
- **Progress state** — designed. See "Download progress" section above. The
|
||||||
- **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.
|
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 notifications** — real-time LAN transport is implemented. OS /
|
||||||
|
tray notifications when a call you're in enters its check-in window remain 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.
|
||||||
|
|||||||
+71
-42
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
The SoftLAN mark is a pixelated **“S”** (5×5 grid) that, at rest, is a static
|
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
|
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
|
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.
|
re-lays itself back into the S. Two shorter “glitch” flickers add variety.
|
||||||
|
|
||||||
This folder is everything an engineer/agent needs to ship it.
|
This folder is everything an engineer/agent needs to ship it.
|
||||||
|
|
||||||
```
|
```text
|
||||||
logo_handoff/
|
logo_handoff/
|
||||||
├── pixel-live.jsx ← the live React component (the deliverable)
|
├── pixel-live.jsx ← the live React component (the deliverable)
|
||||||
├── demo.html ← open in a browser to see it in motion + in context
|
├── demo.html ← open in a browser to see it in motion + in context
|
||||||
@@ -31,34 +31,39 @@ normal React/TS toolchain and it compiles as-is.
|
|||||||
|
|
||||||
### Props
|
### Props
|
||||||
|
|
||||||
| prop | type | default | notes |
|
| prop | type | default | notes |
|
||||||
|-------------|----------|-------------|-------|
|
| ---------- | ------- | --------- | ----------------------------------------------------------------------- |
|
||||||
| `accent` | string | `#3b82f6` | the S color (any CSS color). Wire this to your theme accent. |
|
| `accent` | string | `#3b82f6` | the S color (any CSS color). Wire this to your theme accent. |
|
||||||
| `size` | number | `140` | rendered width/height in px (it’s a square SVG). |
|
| `size` | number | `140` | rendered width/height in px (it’s a square SVG). |
|
||||||
| `idleAuto` | boolean | `true` | when true, plays a random trick by itself every `idleMin`–`idleMax` ms. |
|
| `idleAuto` | boolean | `true` | when true, plays a random trick by itself every `idleMin`–`idleMax` ms. |
|
||||||
| `idleMin` | number | `5000` | min ms between idle auto-plays. |
|
| `idleMin` | number | `5000` | min ms between idle auto-plays. |
|
||||||
| `idleMax` | number | `11000` | max ms between idle auto-plays. |
|
| `idleMax` | number | `11000` | max ms between idle auto-plays. |
|
||||||
|
|
||||||
### Behavior (built in)
|
### Behavior (built in)
|
||||||
|
|
||||||
- **Rest:** renders the static pixel S in `accent`.
|
- **Rest:** renders the static pixel S in `accent`.
|
||||||
- **Hover:** plays a random trick (snake-weighted).
|
- **Hover:** plays a random trick (snake-weighted).
|
||||||
- **Click:** plays the snake trick.
|
- **Click:** plays the snake trick.
|
||||||
- **Idle:** if `idleAuto`, fires a random trick on a 5–11s jitter — but only
|
- **Idle:** if `idleAuto`, fires a random trick on a 5–11s jitter — but only
|
||||||
while the tab is visible (`document.hidden` guard), so background tabs stay quiet.
|
while the tab is visible (`document.hidden` guard), so background tabs stay
|
||||||
|
quiet.
|
||||||
- It never overlaps plays (a `playing` guard ignores triggers mid-animation).
|
- It never overlaps plays (a `playing` guard ignores triggers mid-animation).
|
||||||
|
|
||||||
### Imperative API (ref)
|
### Imperative API (ref)
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
const logo = useRef(null);
|
const logo = useRef(null);
|
||||||
// ...
|
// ...
|
||||||
<LiveLogo ref={logo} accent="#3b82f6" size={32} />
|
<LiveLogo ref={logo} accent="#3b82f6" size={32} />;
|
||||||
// trigger a specific trick on demand:
|
// trigger a specific trick on demand:
|
||||||
logo.current.play('snake'); // 'snake' | 'rgb' | 'glitch'
|
logo.current.play("snake"); // 'snake' | 'rgb' | 'glitch'
|
||||||
logo.current.isPlaying(); // boolean
|
logo.current.isPlaying(); // boolean
|
||||||
```
|
```
|
||||||
|
|
||||||
### Tricks
|
### Tricks
|
||||||
- `snake` (~2.6s) — the headline animation. S → slither across the 5×5 board → S.
|
|
||||||
|
- `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.
|
- `rgb` (~1.35s) — chromatic-aberration split that settles back to clean.
|
||||||
- `glitch` (~1.1s) — rows tear/kick sideways, then snap back.
|
- `glitch` (~1.1s) — rows tear/kick sideways, then snap back.
|
||||||
|
|
||||||
@@ -73,7 +78,9 @@ The current brand mark in `launcher.jsx` is a placeholder div:
|
|||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
// BEFORE — both the 'single' and 'two' topbar variants:
|
// BEFORE — both the 'single' and 'two' topbar variants:
|
||||||
<div className="brand-mark" style={{ background: accent }}>S</div>
|
<div className="brand-mark" style={{ background: accent }}>
|
||||||
|
S
|
||||||
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace each with the live component:
|
Replace each with the live component:
|
||||||
@@ -91,19 +98,21 @@ Replace each with the live component:
|
|||||||
event, share one `ref` and call `.play()`.
|
event, share one `ref` and call `.play()`.
|
||||||
|
|
||||||
Import at the top of the file (or via your bundler):
|
Import at the top of the file (or via your bundler):
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
import { LiveLogo } from './pixel-live'; // if you convert exports to ES modules
|
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
|
The file currently attaches `LiveLogo` to `window` for the no-build demo — swap
|
||||||
module build.
|
the final `Object.assign(window, …)` line for `export { LiveLogo }` in a module
|
||||||
|
build.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Static assets (`assets/`)
|
## 3. Static assets (`assets/`)
|
||||||
|
|
||||||
For places that must be static — favicons, OS app icons, store listings,
|
For places that must be static — favicons, OS app icons, store listings, loading
|
||||||
loading splash, OG images, anywhere JS isn’t running:
|
splash, OG images, anywhere JS isn’t running:
|
||||||
|
|
||||||
- **`softlan-tile.svg`** — the rounded app-icon tile (gradient + white S). Use
|
- **`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
|
this for the favicon, dock/taskbar icon, and installer art. `favicon.svg` is
|
||||||
@@ -120,6 +129,7 @@ Geometry note: all static marks use the exact same grid as the live component
|
|||||||
and animated S are pixel-identical — no jump when the live one mounts.
|
and animated S are pixel-identical — no jump when the live one mounts.
|
||||||
|
|
||||||
### Generating raster PNGs (if your pipeline needs them)
|
### Generating raster PNGs (if your pipeline needs them)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# requires librsvg (rsvg-convert) or Inkscape
|
# requires librsvg (rsvg-convert) or Inkscape
|
||||||
rsvg-convert -w 512 -h 512 assets/softlan-tile.svg > icon-512.png
|
rsvg-convert -w 512 -h 512 assets/softlan-tile.svg > icon-512.png
|
||||||
@@ -136,22 +146,24 @@ tracked-out “LAUNCHER” beneath. This is the canonical lockup — use it for
|
|||||||
top bar, About screen, installer, store header, splash, etc.
|
top bar, About screen, installer, store header, splash, etc.
|
||||||
|
|
||||||
### Exact spec
|
### 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
|
| part | value |
|
||||||
> chrome). If you need a fixed, OS-independent render — store art, OG images,
|
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
> anywhere the system font isn’t guaranteed — use the SVG assets below, which
|
| 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)` |
|
||||||
> carry the same metrics. For pixel-perfect raster, set the wordmark in your
|
| gap tile → text | `size·0.32` |
|
||||||
> design tool and export, since `system-ui` varies by platform.
|
| 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 isn’t 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`)
|
### Drop-in React (from `pixel-live.jsx`)
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
import { Lockup, Wordmark } from './pixel-live';
|
import { Lockup, Wordmark } from './pixel-live';
|
||||||
|
|
||||||
@@ -164,21 +176,34 @@ import { Lockup, Wordmark } from './pixel-live';
|
|||||||
// just the lettering (e.g. next to the bare LiveLogo in a slim top bar):
|
// just the lettering (e.g. next to the bare LiveLogo in a slim top bar):
|
||||||
<Wordmark accent="#3b82f6" size={19} />
|
<Wordmark accent="#3b82f6" size={19} />
|
||||||
```
|
```
|
||||||
|
|
||||||
`Lockup` props: `accent`, `tile` (px), `light` (true=dark UI), `sub` (show
|
`Lockup` props: `accent`, `tile` (px), `light` (true=dark UI), `sub` (show
|
||||||
“LAUNCHER”), `live` (animate the mark). `Wordmark` props: `accent`, `size`,
|
“LAUNCHER”), `live` (animate the mark). `Wordmark` props: `accent`, `size`,
|
||||||
`light`, `sub`.
|
`light`, `sub`.
|
||||||
|
|
||||||
### Plain CSS/HTML (no React)
|
### Plain CSS/HTML (no React)
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<span class="sl-wm">Soft<b>LAN</b></span>
|
<span class="sl-wm">Soft<b>LAN</b></span>
|
||||||
<style>
|
<style>
|
||||||
.sl-wm { font: 700 25px/1 -apple-system, "Segoe UI", system-ui, sans-serif;
|
.sl-wm {
|
||||||
letter-spacing: -0.01em; color: #e6edf3; } /* #0a0e13 on light */
|
font:
|
||||||
.sl-wm b { color: #3b82f6; font-weight: 700; } /* the accent */
|
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>
|
</style>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Static SVG
|
### Static SVG
|
||||||
|
|
||||||
- **`assets/softlan-lockup.svg`** — full lockup for **dark** backgrounds.
|
- **`assets/softlan-lockup.svg`** — full lockup for **dark** backgrounds.
|
||||||
- **`assets/softlan-lockup-ink.svg`** — same lockup for **light** backgrounds
|
- **`assets/softlan-lockup-ink.svg`** — same lockup for **light** backgrounds
|
||||||
(“Soft” goes ink-dark; “LAN” stays accent).
|
(“Soft” goes ink-dark; “LAN” stays accent).
|
||||||
@@ -195,10 +220,14 @@ three `linearGradient` stops and the “LAN” `fill`.
|
|||||||
it’s decorative motion over a brand mark.
|
it’s decorative motion over a brand mark.
|
||||||
- The idle auto-play already pauses in hidden tabs. If you want to fully respect
|
- The idle auto-play already pauses in hidden tabs. If you want to fully respect
|
||||||
`prefers-reduced-motion`, gate the triggers:
|
`prefers-reduced-motion`, gate the triggers:
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
<LiveLogo idleAuto={!reduce} /* and skip the hover/click play when reduce */ />
|
<LiveLogo
|
||||||
|
idleAuto={!reduce} /* and skip the hover/click play when reduce */
|
||||||
|
/>;
|
||||||
```
|
```
|
||||||
|
|
||||||
At rest it’s a clean static S, so reduced-motion users simply get the icon.
|
At rest it’s a clean static S, so reduced-motion users simply get the icon.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -207,6 +236,6 @@ three `linearGradient` stops and the “LAN” `fill`.
|
|||||||
|
|
||||||
Open `demo.html` in any browser: hover the big mark (or wait), use the trick
|
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
|
buttons, and try the accent swatches. The small mark in the mock top bar is the
|
||||||
same component at `size={30}` — that’s exactly how it looks in the launcher.
|
same component at `size={30}` — that’s exactly how it looks in the launcher. The
|
||||||
The **logo lockup** card shows the full icon-plus-wordmark on dark and light,
|
**logo lockup** card shows the full icon-plus-wordmark on dark and light, at
|
||||||
at several sizes, and recolors live with the accent swatches.
|
several sizes, and recolors live with the accent swatches.
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ bundle:
|
|||||||
fmt:
|
fmt:
|
||||||
cargo +nightly fmt
|
cargo +nightly fmt
|
||||||
tombi format
|
tombi format
|
||||||
|
fd -tf -e md -x prettier --write --prose-wrap always --print-width 80
|
||||||
|
rumdl check --flavor commonmark --fix
|
||||||
just --fmt
|
just --fmt
|
||||||
|
|
||||||
_fix:
|
_fix:
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
# Implementation Decisions
|
# Implementation Decisions
|
||||||
|
|
||||||
- Added a `just test` recipe so unit tests can be run through the repository's
|
- Added a `just test` recipe so unit tests can be run through the repository's
|
||||||
required `just ...` command surface instead of invoking `cargo test`
|
required `just ...` command surface instead of invoking `cargo test` directly.
|
||||||
directly.
|
- Renamed the frontend success event to `game-install-finished`; the old unpack
|
||||||
- Renamed the frontend success event to `game-install-finished`; the old
|
name no longer matched the transactional install/update lifecycle.
|
||||||
unpack name no longer matched the transactional install/update lifecycle.
|
|
||||||
- Implemented watcher rescans by reusing the app-state
|
- Implemented watcher rescans by reusing the app-state
|
||||||
`local_library/index.json` cache and updating a single game entry in that
|
`local_library/index.json` cache and updating a single game entry in that
|
||||||
index. This satisfies the per-ID optimized rescan requirement without adding a
|
index. This satisfies the per-ID optimized rescan requirement without adding a
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
# Pragmatic LAN safety, peer identity, and content integrity
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Revised implementation plan; not yet implemented.
|
||||||
|
|
||||||
|
This plan deliberately treats Lanspread as what it is: a desktop utility for
|
||||||
|
friends and other attendees at a LAN party to discover each other, share a known
|
||||||
|
game catalog at LAN speed, and coordinate a match. It is not an account system,
|
||||||
|
a global untrusted file-sharing network, or a device-administration product.
|
||||||
|
|
||||||
|
The normal user journey must remain:
|
||||||
|
|
||||||
|
1. Open Lanspread.
|
||||||
|
2. See nearby people and their available games automatically.
|
||||||
|
3. Click Download or Stream Install without approving every source.
|
||||||
|
4. Let Lanspread swarm from matching peers and verify the result itself.
|
||||||
|
5. Use Call to Play while those people are present.
|
||||||
|
|
||||||
|
Security mechanisms in this plan are automatic. There are no key backup dialogs,
|
||||||
|
trust ceremonies, fingerprint prompts, or per-device download permissions in the
|
||||||
|
normal UI.
|
||||||
|
|
||||||
|
The project still has one current wire version and no compatibility shims. The
|
||||||
|
wire changes below are developed together and activated with one protocol bump,
|
||||||
|
not three partially compatible protocol generations.
|
||||||
|
|
||||||
|
## 1. Product and architecture decisions
|
||||||
|
|
||||||
|
| Area | Decision | User-visible result |
|
||||||
|
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
|
| Filesystem safety | Validate the complete destination manifest before any mutation and confine it to one catalog game root. | A hostile peer cannot overwrite another game, `local/`, saves, or transaction state. |
|
||||||
|
| Content authority | Ship BLAKE3 file and chunk hashes from the same bundled catalog authority as `game.db`. | Every eligible nearby peer is usable automatically; wrong bytes are rejected and retried elsewhere. |
|
||||||
|
| Peer identity | Use one installation-local TLS key and derive `PeerId` from that TLS public key. | Identity works silently and survives ordinary restarts when possible; users do not manage it. |
|
||||||
|
| Transport | Pin every outbound QUIC connection to the expected `PeerId`. | An address spoof or MITM cannot impersonate the peer selected as a source. |
|
||||||
|
| Control messages | Use ordinary bounded protocol messages inside TLS. Treat unauthenticated inbound change notifications only as hints that trigger a pinned pull, and carry current revisions on the liveness ping that already runs so a lost hint self-heals. | No signed-envelope layer, nonce ledger, or message-signing overhead. |
|
||||||
|
| Call to Play | Exchange only each peer's own session state by direct pinned pulls; do not relay third-party histories. | Calls are live LAN-party state and disappear naturally as their authors leave. |
|
||||||
|
| Privacy | Provide one global Local network sharing switch. | Participation is easy to understand; no per-peer policy matrix. |
|
||||||
|
| Protocol rollout | Make one cutover to the new current protocol. | Mixed versions are explained clearly, without maintaining legacy paths. |
|
||||||
|
|
||||||
|
The resulting data flow is intentionally small:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mDNS candidate -> responder-pinned TLS -> peer-owned snapshot or file bytes
|
||||||
|
|
||||||
|
bundled content manifest -> validated local download plan
|
||||||
|
-> BLAKE3 check for every received chunk
|
||||||
|
-> version.ini commit only after complete success
|
||||||
|
|
||||||
|
local Call to Play change -> cheap invalidation hint to known peers
|
||||||
|
-> each peer pulls the author's current state over pinned TLS
|
||||||
|
|
||||||
|
pinned liveness ping -> responder's own current revisions
|
||||||
|
-> pull that one responder only on mismatch
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Threat model and guarantees
|
||||||
|
|
||||||
|
Assume a hostile device can join the same LAN, advertise arbitrary mDNS data,
|
||||||
|
send arbitrary protocol messages, occupy reused IP addresses, and run a modified
|
||||||
|
Lanspread build. The attacker does not control the victim's OS, the installed
|
||||||
|
Lanspread application, or its bundled catalog files.
|
||||||
|
|
||||||
|
After this plan:
|
||||||
|
|
||||||
|
- a remote description cannot make Lanspread create, truncate, or delete a path
|
||||||
|
outside the requested catalog game's download-owned area;
|
||||||
|
- a selected responder must prove possession of the TLS private key whose public
|
||||||
|
key derives the expected `PeerId`;
|
||||||
|
- mDNS, IP addresses, display names, and inbound notification bodies never
|
||||||
|
become identity authority by themselves;
|
||||||
|
- a source cannot make a download commit bytes that differ from the hashes in
|
||||||
|
the victim's bundled catalog, even if that source is the only peer present;
|
||||||
|
- corrupt sources are removed from the current transfer automatically rather
|
||||||
|
than presented to the user as a trust decision; and
|
||||||
|
- one peer cannot publish Call-to-Play actions as another peer or mutate another
|
||||||
|
peer's author-owned state.
|
||||||
|
|
||||||
|
The following are explicit non-goals:
|
||||||
|
|
||||||
|
- A `PeerId` does not prove a human name. Display names remain friendly labels.
|
||||||
|
- The installation key is not a user account and has no promised continuity
|
||||||
|
across OS reinstall, application-data deletion, or copying the application to
|
||||||
|
another computer.
|
||||||
|
- Content hashes prove that bytes match the bundled catalog. They do not prove
|
||||||
|
that the catalog publisher's game is benign, licensed, or malware-free.
|
||||||
|
- A hash advertised by the same peer that sends the bytes is not trusted. The
|
||||||
|
expected hash must come from the local bundled catalog.
|
||||||
|
- When Local network sharing is enabled, nearby devices may browse and request
|
||||||
|
shared catalog content. Per-device admission and requester blocking are not
|
||||||
|
part of this product model.
|
||||||
|
- Basic frame, connection, and work limits are required, but internet-scale
|
||||||
|
Sybil resistance and Byzantine convergence are not goals for a LAN-party
|
||||||
|
utility.
|
||||||
|
- Peers on another protocol version do not interoperate. The UI explains the
|
||||||
|
mismatch instead of adding a legacy protocol path.
|
||||||
|
|
||||||
|
## 3. Normative design
|
||||||
|
|
||||||
|
### 3.1 Confine download preparation first
|
||||||
|
|
||||||
|
This remains the first implementation task because it fixes a live local
|
||||||
|
data-loss path without depending on authentication or a wire change.
|
||||||
|
|
||||||
|
The peer core constructs a `ValidatedDownloadManifest` before
|
||||||
|
`begin_version_ini_transaction`, `prepare_game_storage`, directory creation,
|
||||||
|
file creation/truncation, preallocation, or cleanup. Storage functions accept
|
||||||
|
that validated type, never raw `GameFileDescription` values from Tauri or a
|
||||||
|
peer.
|
||||||
|
|
||||||
|
Validation is for the complete list and fails without any mutation. It must:
|
||||||
|
|
||||||
|
- require a known catalog `game_id` and resolve every destination relative to
|
||||||
|
exactly `<games_folder>/<game_id>`;
|
||||||
|
- use one canonical forward-slash relative-path form and reject empty, absolute,
|
||||||
|
drive-qualified, UNC, NUL, `.`, `..`, mixed-separator, and non-normalized
|
||||||
|
paths;
|
||||||
|
- reject duplicate paths, file/directory conflicts, and platform aliases such as
|
||||||
|
Windows case, trailing-dot/space, device-name, and alternate-data-stream
|
||||||
|
collisions;
|
||||||
|
- reject `local/`, `.local.*`, download/install intent state, legacy state,
|
||||||
|
scratch sentinels, and every other path owned by installation or recovery,
|
||||||
|
while allowing the intended root `version.ini`;
|
||||||
|
- require the game root to be one direct non-symlink child of the configured
|
||||||
|
games directory and avoid following symlink or reparse components while
|
||||||
|
opening destinations;
|
||||||
|
- enforce descriptor-count, individual-size, and aggregate-size limits; and
|
||||||
|
- require the exact root/file shape needed for a complete downloadable game,
|
||||||
|
including one regular root `version.ini`.
|
||||||
|
|
||||||
|
The Tauri command supplies only the selected `game_id`. The peer core chooses
|
||||||
|
the complete authoritative plan. A UI-echoed file list is never authority.
|
||||||
|
|
||||||
|
After a successful complete transfer, remove download-owned files absent from
|
||||||
|
the authoritative manifest before committing `version.ini`. Preserve `local/`,
|
||||||
|
install staging/backup state, and user-owned files in all success, failure,
|
||||||
|
cancellation, and recovery paths.
|
||||||
|
|
||||||
|
For the current protocol, this validator safely contains the existing remote
|
||||||
|
descriptions. A narrow protocol-7 adapter requires and removes exactly one
|
||||||
|
matching leading `game_id/` component (and discards only the current exact
|
||||||
|
redundant game-root directory entry) before constructing root-relative paths; it
|
||||||
|
rejects a missing/different/doubled prefix. After the protocol cutover, the same
|
||||||
|
validated type is constructed directly from the bundled content manifest and
|
||||||
|
remote descriptions cease to define local paths at all.
|
||||||
|
|
||||||
|
Required proof includes hostile descriptors placed after valid descriptors,
|
||||||
|
cross-game paths, both requested and other-game `local/` sentinels, reserved
|
||||||
|
paths, duplicates and aliases, symlink/reparse destinations, oversized lists,
|
||||||
|
and stale download-owned files. Every rejection must prove zero filesystem
|
||||||
|
mutation.
|
||||||
|
|
||||||
|
### 3.2 Make the bundled catalog the content authority
|
||||||
|
|
||||||
|
`game.db` is already the application's authority for game identity and version.
|
||||||
|
Add reproducibly generated per-game companion manifest artifacts, located at
|
||||||
|
`manifests/<game_id>.json` (loaded on-demand when downloading or serving a
|
||||||
|
game), and package them with both the desktop application and peer-CLI fixtures.
|
||||||
|
|
||||||
|
For each supported `(game_id, game_version)`, the manifest artifact contains:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CatalogContentManifest {
|
||||||
|
schema_version
|
||||||
|
game_id
|
||||||
|
game_version
|
||||||
|
chunk_size
|
||||||
|
files: [
|
||||||
|
{ canonical_path, kind, size, file_blake3, chunk_blake3[] }
|
||||||
|
]
|
||||||
|
streamed_install_files: [
|
||||||
|
{ canonical_path, kind, size, file_blake3 }
|
||||||
|
]
|
||||||
|
content_id
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Entries are sorted by canonical path. `content_id` is BLAKE3 over a versioned,
|
||||||
|
length-delimited encoding of all preceding manifest fields and hashes, excluding
|
||||||
|
the `content_id` field itself; it is not the current noncryptographic
|
||||||
|
`u64 manifest_hash`. Golden tests freeze that encoding. The ordinary chunk size
|
||||||
|
matches Lanspread's 128 MiB transfer chunk.
|
||||||
|
|
||||||
|
The catalog publishing workflow must generate these per-game manifests from the
|
||||||
|
canonical game packages, verify them by rereading the packages, and fail the
|
||||||
|
application build/release if a downloadable catalog entry lacks one. Runtime
|
||||||
|
peer consensus and “the only peer said this hash” are not substitutes for this
|
||||||
|
artifact. If real package inputs are unavailable during development, fixture
|
||||||
|
manifests may prove the code path, but the phase is not complete for production
|
||||||
|
games.
|
||||||
|
|
||||||
|
Peers advertise only that they can serve a catalog `content_id`. A peer counts
|
||||||
|
as a source for the local catalog game only when its advertised ID exactly
|
||||||
|
matches the receiver's expected ID. The receiver builds paths, sizes, chunks,
|
||||||
|
and expected hashes entirely from its local catalog manifest. This replaces
|
||||||
|
remote manifest selection and majority-by-file-size consensus.
|
||||||
|
|
||||||
|
For ordinary downloads:
|
||||||
|
|
||||||
|
1. Select every currently reachable peer advertising the expected `content_id`;
|
||||||
|
there is no approval prompt.
|
||||||
|
2. Carry `PeerEndpoint { peer_id, addr }` and `content_id` through planning,
|
||||||
|
swarming, progress, and retry.
|
||||||
|
3. The sender serves only an exact catalog file/range for the requested
|
||||||
|
`(game_id, content_id)` and applies the same canonical/reserved-path policy
|
||||||
|
before opening a local file. A caller-supplied path can never expose `local/`
|
||||||
|
or another local file.
|
||||||
|
4. Hash each chunk with BLAKE3 while receiving it and compare it before marking
|
||||||
|
that chunk complete. Exact length, offset coverage, and the catalog file
|
||||||
|
shape are also mandatory.
|
||||||
|
5. On mismatch, invalidate that write, quarantine that `(PeerId, content_id)`
|
||||||
|
for the current runtime/transfer, and retry the chunk from another matching
|
||||||
|
peer. Do not create durable “trust” state.
|
||||||
|
6. Commit `version.ini` only after every catalog entry and chunk has completed
|
||||||
|
successfully. Failure leaves the game non-downloadable/non-installable and
|
||||||
|
preserves `local/`.
|
||||||
|
|
||||||
|
No background disk-scanning or pre-hashing of existing files is required. Chunks
|
||||||
|
are verified strictly as they stream in during an active transfer.
|
||||||
|
|
||||||
|
Streamed install needs a catalog-owned extracted-file manifest because the
|
||||||
|
sender controls both today's RAR CRC32 metadata and extracted bytes. The
|
||||||
|
receiver accepts exactly the expected path set, sizes, and BLAKE3 values in
|
||||||
|
isolated staging, then applies the documented local account/language rewrite and
|
||||||
|
promotes the transaction. CRC32 may remain as an early corruption check, but it
|
||||||
|
is not the security boundary. A game without a verified extracted manifest does
|
||||||
|
not offer Stream Install; there is no unverified fallback or warning-through
|
||||||
|
button.
|
||||||
|
|
||||||
|
Hashing is performed in the existing streaming I/O path. The acceptance gate
|
||||||
|
measures end-to-end throughput on the standard LAN workload and avoids a second
|
||||||
|
full read when complete chunk coverage already proves the file bytes.
|
||||||
|
|
||||||
|
### 3.3 Use a simple installation-local TLS identity
|
||||||
|
|
||||||
|
The identity exists to bind a live peer and its changing address to TLS. It is
|
||||||
|
not exposed as a user credential.
|
||||||
|
|
||||||
|
- Generate one self-issued TLS certificate/key pair in Tauri's `app_data_dir()`
|
||||||
|
and store it in one versioned application file with restrictive permissions
|
||||||
|
where the platform supports them.
|
||||||
|
- Prefer Ed25519 if the selected s2n-quic rustls provider supports the complete
|
||||||
|
responder-verification path. Otherwise use one supported P-256 TLS key. Do not
|
||||||
|
add a second signing identity or a custom certificate-extension binding.
|
||||||
|
- Define `PeerId` as lowercase unpadded base32 of
|
||||||
|
`BLAKE3(canonical DER SubjectPublicKeyInfo)` from the actual TLS key. The same
|
||||||
|
key is therefore both the identity and the TLS proof-of-possession key.
|
||||||
|
- Validate on load that the private key, certificate SPKI, and derived ID agree.
|
||||||
|
Never log private material.
|
||||||
|
- A valid file is reused. A missing or corrupt file is regenerated automatically
|
||||||
|
(quarantining corrupt bytes best-effort) and produces at most a diagnostic log
|
||||||
|
entry. If persistence is unavailable, use a fresh in-memory identity for that
|
||||||
|
run and show a non-blocking diagnostic; LAN functionality should not become a
|
||||||
|
repair wizard.
|
||||||
|
- The peer CLI may accept an explicit deterministic identity file/seed for
|
||||||
|
repeatable tests. It does not probe keyrings or share a default container
|
||||||
|
identity accidentally.
|
||||||
|
|
||||||
|
There is no OS-keyring backend, sidecar/backend reconciliation, migration
|
||||||
|
intent, identity lease, encrypted backup, import, reset, clone warning, or
|
||||||
|
continuity repair UI. If application data is lost, the installation simply
|
||||||
|
appears as a new nearby peer. Because authorization is not attached to the old
|
||||||
|
ID, nothing security-sensitive needs migration.
|
||||||
|
|
||||||
|
### 3.4 Pin responders and make remote state pull-only
|
||||||
|
|
||||||
|
Every outbound operation accepts a first-class endpoint:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct PeerEndpoint {
|
||||||
|
peer_id: PeerId,
|
||||||
|
addr: SocketAddr,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This endpoint is carried through discovery handshake, library refresh,
|
||||||
|
Call-to-Play refresh, metadata/content requests, chunk plans, retries, streamed
|
||||||
|
install, healing, liveness, and direct peer-CLI operations. Delete
|
||||||
|
address-derived IDs, unique-IP identity fallbacks, and address-only connects.
|
||||||
|
|
||||||
|
mDNS supplies bounded candidates containing
|
||||||
|
`(peer_id, addr, protocol, revision hints)`. It may cause a dial, but it never
|
||||||
|
directly creates or updates authenticated peer/library/Call-to-Play state. A
|
||||||
|
candidate becomes a peer only after a successful outgoing TLS connection to its
|
||||||
|
advertised address proves the expected `PeerId`.
|
||||||
|
|
||||||
|
Use TLS 1.3 with a self-issued certificate. The custom client verifier must:
|
||||||
|
|
||||||
|
1. parse only the selected certificate/SPKI shape;
|
||||||
|
2. derive the `PeerId` from that SPKI and compare the full value with the
|
||||||
|
endpoint's expected ID; and
|
||||||
|
3. perform real TLS 1.3 CertificateVerify validation under the presented key.
|
||||||
|
|
||||||
|
The load-bearing negative test presents peer A's certificate/SPKI with peer B's
|
||||||
|
private key and requires the handshake to fail. Also reject a different valid
|
||||||
|
peer at a reused address. Use one version-bound ALPN, disable 0-RTT, and start
|
||||||
|
without TLS session resumption so every short-lived connection performs the
|
||||||
|
simple full proof.
|
||||||
|
|
||||||
|
The protocol is deliberately responder-authenticated rather than wrapping every
|
||||||
|
message in a signature:
|
||||||
|
|
||||||
|
- Requests that read public library/content state may be made by any LAN client
|
||||||
|
while Local network sharing is enabled.
|
||||||
|
- A response is authoritative only to the initiator that connected using the
|
||||||
|
expected `PeerEndpoint`; the TLS channel supplies integrity and request/
|
||||||
|
response correlation.
|
||||||
|
- A state-bearing response contains only the responder's own state. It cannot
|
||||||
|
vouch for third parties.
|
||||||
|
- Inbound `LibraryChanged` or `CallToPlayChanged` messages are untrusted hints.
|
||||||
|
For a known claimed ID they schedule one coalesced, rate-limited pull from
|
||||||
|
that ID's already known endpoint. Their payload never merges directly. Hints
|
||||||
|
for unknown IDs are ignored and mDNS remains the discovery path.
|
||||||
|
- `Hello` becomes a pull-oriented exchange: the initiator sends no authoritative
|
||||||
|
identity or replicated state, and the pinned responder returns its own current
|
||||||
|
snapshot.
|
||||||
|
|
||||||
|
This extra pull is one small LAN round trip and removes general signed
|
||||||
|
envelopes, canonical opaque payloads, nonce caches, replay semantics, inbound
|
||||||
|
client-certificate plumbing, and connect-back authority state.
|
||||||
|
|
||||||
|
Hints are a latency optimization, never a correctness requirement. The liveness
|
||||||
|
ping that already runs is the reconciliation channel: `Pong` carries the
|
||||||
|
responder's own `(runtime_session_id, library_revision, call_to_play_revision)`.
|
||||||
|
The initiator compares them against what it has cached for that endpoint
|
||||||
|
generation and, on any mismatch or a new session ID, schedules exactly the
|
||||||
|
coalesced pinned pull a hint would have scheduled. A hint that was dropped,
|
||||||
|
never sent, or discarded by rate limiting therefore converges within
|
||||||
|
`PEER_PING_IDLE_SECS + PEER_PING_INTERVAL_SECS`, because the idle threshold is
|
||||||
|
only evaluated on interval ticks; that is 50s at the current 30s/20s settings,
|
||||||
|
not one interval. Assert against the constants rather than a literal bound.
|
||||||
|
Reconciliation still adds no new timer, no mDNS payload growth, and no periodic
|
||||||
|
full-state polling, and 50s is acceptable for the rare lost-hint case.
|
||||||
|
|
||||||
|
Freshness is tracked per peer as `last_revision_check`, stamped only by an
|
||||||
|
exchange that actually returned that peer's current revisions: a `Pong` or a
|
||||||
|
completed pull. Content transfers must not stamp it. A large download is a long
|
||||||
|
run of outbound pinned exchanges that carry no revisions, and it is exactly when
|
||||||
|
reconciliation must not be postponed. Inbound activity must not stamp it either:
|
||||||
|
`ping_idle_peers` currently gates on `last_seen`, which
|
||||||
|
`update_last_seen_by_addr` refreshes from traffic arriving from that peer, so a
|
||||||
|
peer that keeps talking to us would suppress the very check that detects our
|
||||||
|
staleness about it. `last_seen` keeps its existing stale-peer pruning role and
|
||||||
|
is not reused here. Inbound traffic is not evidence of freshness, for the same
|
||||||
|
reason it is not evidence of identity.
|
||||||
|
|
||||||
|
Revisions on `Pong` are a staleness signal, not content authority; the pull
|
||||||
|
remains the authoritative step. A responder that inflates its revision only
|
||||||
|
causes pulls of its own state, bounded by the same coalescing and rate limits. A
|
||||||
|
responder that understates it leaves the initiator stale about that responder
|
||||||
|
alone, which it could already achieve by changing nothing.
|
||||||
|
|
||||||
|
An unproven address collision never evicts an authenticated peer. If a pinned
|
||||||
|
dial later proves that a different ID now owns the same address, atomically
|
||||||
|
replace address ownership and retire the old record only if it still names that
|
||||||
|
address/generation. A same-ID address move is likewise committed only after
|
||||||
|
pinning the new endpoint. Pings are also pinned, and a late ping result may
|
||||||
|
update/remove only the same endpoint generation it probed so it cannot delete a
|
||||||
|
peer that has already moved or reconnected.
|
||||||
|
|
||||||
|
Remove `Goodbye`. It is unnecessary for correctness and an unauthenticated
|
||||||
|
removal hint is unsafe. mDNS expiry plus responder-pinned liveness handles
|
||||||
|
departure.
|
||||||
|
|
||||||
|
### 3.5 Keep Call to Play direct and ephemeral
|
||||||
|
|
||||||
|
Call to Play is coordination among people currently at the party. It does not
|
||||||
|
need a Byzantine replicated ledger.
|
||||||
|
|
||||||
|
Each runtime owns only its locally authored slice:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CallId { creator: PeerId, random_nonce }
|
||||||
|
|
||||||
|
CallToPlayAuthorSnapshot {
|
||||||
|
runtime_session_id
|
||||||
|
revision
|
||||||
|
display_name
|
||||||
|
events[] // actor ID is not a wire field
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The local core creates call/event IDs, increments the revision after each
|
||||||
|
accepted local action, and sends a cheap change hint to known peers. A receiver
|
||||||
|
coalesces the hint, connects to the author's known `PeerEndpoint`, and pulls
|
||||||
|
that author's complete current slice. Because the responder is pinned, the
|
||||||
|
receiver assigns the author ID itself. A peer cannot put another actor ID into
|
||||||
|
the wire object.
|
||||||
|
|
||||||
|
Snapshots use replacement, not union/CRDT semantics. For each peer, permit one
|
||||||
|
in-flight refresh; a newer revision for the same runtime session replaces that
|
||||||
|
author's previous slice atomically. A new runtime session replaces the old
|
||||||
|
session after a fresh pinned handshake. Stale concurrent results cannot
|
||||||
|
overwrite the current session.
|
||||||
|
|
||||||
|
Authority rules remain simple:
|
||||||
|
|
||||||
|
- `Create`, `Start`, `Cancel`, and `AddTime` are effective only when the pinned
|
||||||
|
author equals `CallId.creator`.
|
||||||
|
- RSVP, ready/leave, and chat actions are attributed to the pinned author. They
|
||||||
|
become effective only while the referenced creator root is directly present;
|
||||||
|
an author slice pulled before its creator is retained within its ordinary
|
||||||
|
bound but remains hidden until that creator's direct pull arrives.
|
||||||
|
- A snapshot contains only events authored by its responder. Third-party events
|
||||||
|
are rejected rather than relayed.
|
||||||
|
- Display names never grant authority.
|
||||||
|
|
||||||
|
A newly arriving peer discovers and pulls directly from every live peer, so it
|
||||||
|
reconstructs calls from the people still present. If an author's peer goes away,
|
||||||
|
remove that author's slice. If the creator goes away, the call disappears from
|
||||||
|
the derived view. A participant who leaves naturally drops out. This is the
|
||||||
|
intended session model, not data loss.
|
||||||
|
|
||||||
|
Keep the useful human-scale timers: active calls expire, unresolved expired
|
||||||
|
calls may remain visible for five minutes, and Start/Cancel results may remain
|
||||||
|
visible for fifteen minutes. After that, the author drops them from its current
|
||||||
|
snapshot. There are no session-long tombstones, rootless terminal records,
|
||||||
|
three-day history horizons, verification caches, or permanent anti-resurrection
|
||||||
|
state because no third party can replay an old author's history as authority.
|
||||||
|
|
||||||
|
Retain straightforward schema and resource limits: bounded strings/chat, bounded
|
||||||
|
events and encoded bytes per author, bounded total live peers, and a named
|
||||||
|
control-frame maximum. Validate one author's snapshot off to the side and accept
|
||||||
|
or reject it as a unit; a bad/oversized peer cannot consume another author's
|
||||||
|
slice or the local author's capacity. Exact limits are set from the existing
|
||||||
|
three-peer and stress fixtures, not from an internet-scale adversary model.
|
||||||
|
|
||||||
|
A malicious creator can show inconsistent versions of its own noncritical call
|
||||||
|
to different peers. This plan accepts that limit rather than adding signatures,
|
||||||
|
gossip, consensus, or permanent storage to a party invitation feature.
|
||||||
|
|
||||||
|
### 3.6 Keep the UI about games and people
|
||||||
|
|
||||||
|
Add one visible `Local network sharing` setting, on by default for this
|
||||||
|
LAN-sharing application. When off, stop mDNS advertisement/discovery, the QUIC
|
||||||
|
listener, outbound refresh, and serving. The setting is durable and its state is
|
||||||
|
obvious in the main UI/settings.
|
||||||
|
|
||||||
|
Do not add per-peer source prompts. Every peer with the locally expected
|
||||||
|
`content_id` is an eligible swarm source; verification is automatic.
|
||||||
|
|
||||||
|
Normal UI uses display names and peer count. A short PeerId suffix may
|
||||||
|
disambiguate duplicate names or appear in diagnostics, but there are no New,
|
||||||
|
Trusted, key-changed, backup, repair, or fingerprint-confirmation workflows.
|
||||||
|
|
||||||
|
User-facing exceptional states are concrete:
|
||||||
|
|
||||||
|
- `Verifying downloaded chunks` while newly received content is checked;
|
||||||
|
- `A source sent invalid data; retrying another nearby peer` when recovery is in
|
||||||
|
progress;
|
||||||
|
- `No nearby peer could provide the verified catalog version` after all matching
|
||||||
|
sources fail;
|
||||||
|
- `Nearby devices are running a different Lanspread version` when mDNS sees an
|
||||||
|
incompatible protocol; and
|
||||||
|
- a non-blocking networking diagnostic if the installation identity cannot be
|
||||||
|
persisted and will change next launch.
|
||||||
|
|
||||||
|
Do not ask the user to solve a cryptographic implementation problem.
|
||||||
|
|
||||||
|
## 4. One protocol cutover
|
||||||
|
|
||||||
|
Develop the pieces behind internal APIs, then replace protocol 7 with one new
|
||||||
|
current protocol (protocol 8 if the version has not moved). Do not ship
|
||||||
|
intermediate protocol 8/9/10 designs and do not add compatibility decoding.
|
||||||
|
|
||||||
|
The cutover includes:
|
||||||
|
|
||||||
|
- `PeerId` derived from the TLS SPKI and `PeerEndpoint` required by every
|
||||||
|
outbound connection;
|
||||||
|
- version-bound ALPN and per-installation server certificates instead of the
|
||||||
|
repository-wide `cert.pem`/`key.pem`;
|
||||||
|
- mDNS candidate-only semantics and useful incompatible-version telemetry;
|
||||||
|
- responder-owned pull snapshots, revision-bearing `Pong`, and bounded change
|
||||||
|
hints instead of inbound state-bearing `Hello`, pushed `LibraryDelta`, and
|
||||||
|
pushed/relayed `CallToPlayEvents`;
|
||||||
|
- cryptographic `content_id` in game availability and catalog-driven chunk
|
||||||
|
requests;
|
||||||
|
- canonical forward-slash catalog paths;
|
||||||
|
- author-owned Call-to-Play snapshots; and
|
||||||
|
- removal of `Goodbye` and payload fields that pretend to identify an
|
||||||
|
authoritative sender.
|
||||||
|
|
||||||
|
Peers on another protocol remain excluded, as required by project policy. To
|
||||||
|
reduce real LAN-party friction, make this one coordinated bump and surface the
|
||||||
|
version mismatch rather than failing silently.
|
||||||
|
|
||||||
|
## 5. Code ownership
|
||||||
|
|
||||||
|
Keep the change inside existing crates unless implementation pressure proves a
|
||||||
|
real reusable boundary; a new identity crate is not required by the design.
|
||||||
|
|
||||||
|
| Area | Responsibility |
|
||||||
|
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `lanspread-db` / `lanspread-compat` | Catalog content-manifest types and loading beside `game.db`. |
|
||||||
|
| `lanspread-proto` | `PeerId`, `PeerEndpoint`, `content_id`, pull snapshots, change hints, author-owned Call-to-Play wire types, and the one protocol version. No crypto or storage logic. |
|
||||||
|
| `lanspread-peer::identity` | Simple key/certificate load-or-generate, SPKI-derived ID, and test identity injection. |
|
||||||
|
| `lanspread-peer::network` | Per-endpoint rustls client config, full responder verification, ALPN, and no address-only connect. |
|
||||||
|
| discovery/handshake/liveness | Candidate-only mDNS, pinned pulls, hint coalescing, revision reconciliation on ping, endpoint generations, and version-mismatch reporting. |
|
||||||
|
| `peer_db` | Authenticated endpoint/state records and exact `content_id` source lookup. |
|
||||||
|
| download/storage/stream install | Validated catalog plan, hash-as-received, source quarantine/retry, sentinel commit, and protected staging. |
|
||||||
|
| `call_to_play` | Local author slice, per-peer replacement snapshots, simple authority checks, timers, and bounds. |
|
||||||
|
| Tauri/frontend | Global sharing switch, verification/progress failures, incompatible-version notice, and replacement of the full derived Call-to-Play view. |
|
||||||
|
| peer CLI | Distinct deterministic identities, hostile TLS/content modes, and zero-prompt multi-peer scenarios. |
|
||||||
|
|
||||||
|
## 6. Implementation phases and gates
|
||||||
|
|
||||||
|
Every code phase runs `just fmt`, `just clippy`, and `just test`. Frontend or
|
||||||
|
Tauri phases also run `just frontend-test` and `just build`. Network/transfer
|
||||||
|
phases run focused peer-CLI scenarios during development and the unfiltered
|
||||||
|
`just peer-cli-tests` before completion. Manual alpha/bravo/charlie evidence
|
||||||
|
must use a freshly built image.
|
||||||
|
|
||||||
|
### Phase 1 — land filesystem confinement immediately
|
||||||
|
|
||||||
|
Implement `ValidatedDownloadManifest`, make the UI submit only `game_id`, and
|
||||||
|
move all validation before transaction/storage mutation. Centralize reserved
|
||||||
|
paths and add the zero-mutation hostile tests from §3.1. Preserve current wire
|
||||||
|
bytes in this phase; it is an independent safety fix.
|
||||||
|
|
||||||
|
Gate: standard Rust/Tauri checks, hostile descriptor tests, full peer-CLI suite,
|
||||||
|
and supported Windows path/reparse evidence. Linux-only results must not be
|
||||||
|
reported as Windows proof.
|
||||||
|
|
||||||
|
### Phase 2 — establish real catalog content authority
|
||||||
|
|
||||||
|
Add the reproducible content-manifest generator and fixture manifests. Freeze
|
||||||
|
the versioned manifest/content-ID encoding with golden tests. Extend local
|
||||||
|
catalog state, build download plans only from that state, implement streaming
|
||||||
|
BLAKE3 checks and source quarantine, and implement verified extracted manifests
|
||||||
|
for Stream Install.
|
||||||
|
|
||||||
|
Do not claim completion from test fixtures alone: production catalog packages
|
||||||
|
must have independently generated manifests, and the release/build path must
|
||||||
|
reject a missing manifest. Benchmark hashing at normal LAN throughput.
|
||||||
|
|
||||||
|
### Phase 3 — prove and implement simple responder identity
|
||||||
|
|
||||||
|
Start with a bounded rustls/s2n-quic spike that proves self-issued certificate
|
||||||
|
support, SPKI extraction, expected-ID pinning, and real TLS 1.3
|
||||||
|
CertificateVerify. The certificate-A/private-key-B negative is the go/no-go
|
||||||
|
gate. Choose Ed25519 or P-256 based on that proof, using one TLS identity key.
|
||||||
|
|
||||||
|
Then add simple load-or-generate persistence, deterministic CLI identities,
|
||||||
|
`PeerEndpoint`, and endpoint plumbing through every outbound consumer. Separate
|
||||||
|
mDNS candidates from authenticated peer records and make liveness removal
|
||||||
|
generation-conditional. No trust database or identity UI is introduced.
|
||||||
|
|
||||||
|
### Phase 4 — make the single wire cutover
|
||||||
|
|
||||||
|
Bump the current protocol once and activate all coupled wire behavior from §4:
|
||||||
|
pinned transport, catalog `content_id`, catalog-driven downloads, pull-only
|
||||||
|
library synchronization, bounded invalidation hints, author-owned Call-to-Play
|
||||||
|
snapshots, and no `Goodbye`.
|
||||||
|
|
||||||
|
This phase is not complete until:
|
||||||
|
|
||||||
|
- three fresh peers discover each other with no prompts and see post-start
|
||||||
|
library changes;
|
||||||
|
- a new peer reconstructs active Call-to-Play state by pulling every live
|
||||||
|
author, and creator departure removes the call;
|
||||||
|
- every metadata, chunk, retry, stream-install, healing, liveness, and direct
|
||||||
|
CLI dial rejects the wrong key at the expected address;
|
||||||
|
- a forged mDNS record or inbound hint cannot create/rebind/remove peer state,
|
||||||
|
inject a library/Call-to-Play update, or bypass a pinned pull;
|
||||||
|
- a change hint that is dropped, never sent, or rate-limited away still
|
||||||
|
converges within `PEER_PING_IDLE_SECS + PEER_PING_INTERVAL_SECS`, and neither
|
||||||
|
inbound traffic nor an in-flight content transfer defers that peer's revision
|
||||||
|
check;
|
||||||
|
- an honest multi-source download swarms automatically and commits only the
|
||||||
|
catalog bytes;
|
||||||
|
- one bad source is quarantined and another source completes the chunk;
|
||||||
|
- all-bad/only-bad sources fail without committing `version.ini` or touching
|
||||||
|
`local/`;
|
||||||
|
- a streamed path/hash/set mismatch cannot promote staging;
|
||||||
|
- an oversized Call-to-Play snapshot affects only that remote author and local
|
||||||
|
publication still works; and
|
||||||
|
- protocol-7 peers are rejected while the UI receives enough information to
|
||||||
|
explain the version mismatch.
|
||||||
|
|
||||||
|
Update `ARCHITECTURE.md`, protocol docs, and CLI documentation in the same
|
||||||
|
phase; do not leave the shared-certificate or relayed-event description behind.
|
||||||
|
|
||||||
|
### Phase 5 — finish the small user-facing surface and audit
|
||||||
|
|
||||||
|
Add the global sharing switch and the concrete progress/error states from §3.6.
|
||||||
|
Run a first-run test with an empty app-data directory, a normal restart, a
|
||||||
|
corrupt identity file, and unwritable identity persistence; none may produce a
|
||||||
|
key-management workflow or prevent the ephemeral fallback from participating for
|
||||||
|
that run.
|
||||||
|
|
||||||
|
Run all standard checks, the complete peer-CLI suite, fresh three-peer manual
|
||||||
|
scenarios, production builds/bundles on supported platforms, and a final audit
|
||||||
|
for:
|
||||||
|
|
||||||
|
- raw remote manifests reaching storage;
|
||||||
|
- unhashed transfer completion or CRC32 presented as malicious-source proof;
|
||||||
|
- the shared repository TLS private key;
|
||||||
|
- address-only outbound connections or fabricated peer IDs;
|
||||||
|
- direct mutation from mDNS, inbound Hello, deltas, or change hints;
|
||||||
|
- relayed third-party Call-to-Play history or permanent tombstones;
|
||||||
|
- signed control envelopes, nonce/replay tables, keyring/backup/repair code, or
|
||||||
|
per-peer source authorization reappearing without a new product requirement;
|
||||||
|
- silent protocol-version failure; and
|
||||||
|
- user wording that calls a peer, display name, or executable “trusted” merely
|
||||||
|
because TLS or a hash check succeeded.
|
||||||
|
|
||||||
|
## 7. Success criteria
|
||||||
|
|
||||||
|
The plan is complete when the following statement is true from a user's point of
|
||||||
|
view:
|
||||||
|
|
||||||
|
> I opened Lanspread at a LAN party, immediately saw the people and games
|
||||||
|
> nearby, downloaded from all matching peers without approving devices, and
|
||||||
|
> Lanspread itself rejected any wrong data. I never had to know that it owns a
|
||||||
|
> TLS key.
|
||||||
|
|
||||||
|
From the implementation point of view, that experience rests on only three
|
||||||
|
security boundaries: confined local paths, catalog-owned content hashes, and
|
||||||
|
responder-pinned TLS. Call to Play deliberately reuses the pinned-pull model and
|
||||||
|
remains ephemeral instead of becoming a second distributed security protocol.
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
# Peer CLI P2P Scenarios
|
||||||
|
|
||||||
|
This matrix tracks the headless peer-to-peer contract exercised through
|
||||||
|
`lanspread-peer-cli`. It intentionally avoids the GUI and uses direct connect
|
||||||
|
for deterministic local runs; mDNS/macvlan remains an environment smoke path.
|
||||||
|
|
||||||
|
## Scenario Matrix
|
||||||
|
|
||||||
|
| ID | Scenario | Setup | Expected result |
|
||||||
|
| --- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| S1 | Startup scan | Start one peer with `fixture-alpha`. | Peer emits `local-peer-ready` and `local-library-changed`; catalog fixture games are `downloaded=true`, `installed=false`, `availability=Ready`. |
|
||||||
|
| S2 | Direct connect handshake | Start alpha and bravo, send alpha `connect` to bravo's ready address. | Both peers record one remote peer, no self-peer entry appears, and each peer receives the other's library. |
|
||||||
|
| S3 | Remote aggregation | Empty client connects to alpha and bravo. | `list-games` shows remote-only games once; shared `ggoo` has `peer_count=2`, unique games have `peer_count=1`. |
|
||||||
|
| S4 | Single-source download, no install | Empty client connected to bravo downloads `bfbc2` with `install=false`. | Client emits `got-game-files`, `download-begin`, `download-finished`, then local `bfbc2` is `downloaded=true`, `installed=false`; root files exist and `local/` does not. |
|
||||||
|
| S5 | Auto-install download | Empty client connected to bravo downloads `cnctw` with default install. | Download finishes, install begins and finishes, and local `cnctw` is `downloaded=true`, `installed=true` with `local/fixture-payload.txt`. |
|
||||||
|
| S6 | Manual install and uninstall | After S4, client sends `install bfbc2`, then `uninstall bfbc2`. | Install marks `bfbc2` installed and creates `local/`; uninstall removes `local/` while preserving downloaded root files. |
|
||||||
|
| S7 | Duplicate-source majority download | Empty client connects to alpha and bravo, then downloads shared `ggoo`. | Metadata from both peers validates by majority/plurality, download completes once, and installed state matches the install flag. |
|
||||||
|
| 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. |
|
||||||
|
| 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. |
|
||||||
|
| 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. |
|
||||||
|
| 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. |
|
||||||
|
| 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. |
|
||||||
|
| 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. |
|
||||||
|
| 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. |
|
||||||
|
| S48 | Call to Play replication and late join | Alice and Bob connect; Alice publishes a call, then Bob publishes an RSVP and chat message. Charlie joins afterward and handshakes with Alice. | Alice and Bob receive the live events, while Charlie reconstructs the same three-event history during handshake with no duplicate event IDs. |
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
- The receiver's catalog is authoritative. A remote root whose `version.ini`
|
||||||
|
does not match the catalog's expected version for that game ID is not
|
||||||
|
downloadable.
|
||||||
|
- `list-games` aggregates by game ID. The game appears once; `peer_count` counts
|
||||||
|
only ready peers with that ID and the catalog version.
|
||||||
|
- The aggregated `eti_game_version` must be the catalog version.
|
||||||
|
- The descriptor set emitted to the download path, file-size validation, and
|
||||||
|
transfer planning are catalog-version-only. Stale peers must not supply
|
||||||
|
download descriptors, majority votes, or chunks.
|
||||||
|
- If exactly one peer has the catalog version, that peer is the only transfer
|
||||||
|
source. If several peers match the catalog version, validation and chunk
|
||||||
|
fanout happen among that catalog-version set only.
|
||||||
|
- Capture proof with the `list-games` row, `got-game-files` descriptors,
|
||||||
|
`download-chunk-finished` source addresses, and source/receiver SHA-256
|
||||||
|
manifests.
|
||||||
|
|
||||||
|
## Extended Failure And Mutation Contracts
|
||||||
|
|
||||||
|
Use S18-S36 to pin down operational behavior that is awkward to prove with the
|
||||||
|
GUI:
|
||||||
|
|
||||||
|
- A failed download must not commit the root `version.ini` sentinel. Partial
|
||||||
|
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.
|
||||||
|
- 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
|
||||||
|
game is already active is rejected instead of starting another writer.
|
||||||
|
- Unknown remote game IDs are filtered by the receiver's current catalog and are
|
||||||
|
not downloadable.
|
||||||
|
|
||||||
|
For a manual run, prefer a catalog game ID already served by the fixture lab,
|
||||||
|
such as `cnc4`, then create temporary `just peer-cli-run` game roots where some
|
||||||
|
peers match the catalog version and others deliberately use stale `version.ini`
|
||||||
|
contents. The existing alpha/bravo/charlie fixtures cover duplicate-source and
|
||||||
|
shared-game cases; S15-S17 add the focused skew cases.
|
||||||
|
|
||||||
|
## First-Play Launch-Setting Contract
|
||||||
|
|
||||||
|
Use S38 to pin down how launcher settings are stamped into an installed game:
|
||||||
|
|
||||||
|
- Stamping happens on the first `play`, not during install/update. The install
|
||||||
|
transaction only clears the `games/<id>/launch_settings_applied` marker so the
|
||||||
|
next play reapplies settings to a freshly (re)created `local/`.
|
||||||
|
- The first play stamps the username into the first `account_name.txt` and the
|
||||||
|
first `SmartSteamEmu.ini` `PersonaName` line, and the language into the first
|
||||||
|
`language.txt`, searching the whole `local/` tree. The matched `PersonaName`
|
||||||
|
line keeps its existing line ending (`\n` or `\r\n`).
|
||||||
|
- The marker records only that we _tried_: it is written unconditionally after
|
||||||
|
the first play, so a game with none of these files is still marked done.
|
||||||
|
- S38 needs a real archive expanded with `--unrar`; the Docker matrix image now
|
||||||
|
carries the Linux sidecar for streamed-install coverage, while the peer
|
||||||
|
crate's `launch_settings` unit tests cover the rewrite, line-ending, and
|
||||||
|
marker logic deterministically.
|
||||||
|
|
||||||
|
## Streamed Install Archive Contract
|
||||||
|
|
||||||
|
Use S39-S41 to pin down low-disk streamed installs:
|
||||||
|
|
||||||
|
- The stream provider performs one archive metadata pass and one payload pass
|
||||||
|
per `.eti`, then frames entry boundaries for the receiver.
|
||||||
|
- Non-solid and solid archives both install into `local/` without committing a
|
||||||
|
root archive or root `version.ini`, so the receiver is installed but not a
|
||||||
|
downloadable source.
|
||||||
|
- Streamed install integrity is currently sender archive integrity: size and RAR
|
||||||
|
CRC32 must match the sender's archive metadata. The SHA-256 checks in the
|
||||||
|
scenarios prove the Docker/provider path matches the source fixture; they are
|
||||||
|
not catalog-owned trust anchors.
|
||||||
|
- S41 verifies the fixture is actually solid inside the source container, so
|
||||||
|
solid handling stays covered by the same Docker harness as the existing
|
||||||
|
streamed-install scenarios.
|
||||||
|
- S42 verifies retry/resume semantics: failed streamed attempts roll back their
|
||||||
|
staging directory and retry the whole stream from another validated peer.
|
||||||
|
There is no byte-offset resume contract.
|
||||||
|
- S43-S47 cover the remaining streamed-install failure and archive-shape edges:
|
||||||
|
already-installed rejection, corrupt archive rollback, sender disconnect,
|
||||||
|
receiver cancel, and multi-archive root sorting.
|
||||||
|
|
||||||
|
## Run Log
|
||||||
|
|
||||||
|
### 2026-07-21 - Call to Play Transport (S48)
|
||||||
|
|
||||||
|
- Added JSONL commands to publish and inspect Call to Play events.
|
||||||
|
- S2 passed against the rebuilt image, preserving bidirectional library exchange
|
||||||
|
after the protocol version bump.
|
||||||
|
- S48 passed against the rebuilt image: create, RSVP, and chat propagated live,
|
||||||
|
then a late third peer received the same deduplicated history in handshake.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
the install transaction and into a single first-play step (shared with the new
|
||||||
|
`SmartSteamEmu.ini` `PersonaName` rewrite) gated by the
|
||||||
|
`games/<id>/launch_settings_applied` marker.
|
||||||
|
- `just test` passed the whole workspace, including the new
|
||||||
|
`lanspread_peer::launch_settings` unit tests and
|
||||||
|
`install::transaction::install_resets_launch_settings_marker`.
|
||||||
|
- S38 host run: built `crates/lanspread-peer-cli/fixtures/fixture-persona/css`
|
||||||
|
with a stored RAR `.eti` (verified by `unrar t`) burying a CRLF
|
||||||
|
`SmartSteamEmu.ini` plus stub `account_name.txt`/`language.txt`. A host peer
|
||||||
|
installed `css` with `--unrar /usr/bin/unrar`, then `play css` stamped the
|
||||||
|
username into the deep `PersonaName` line (CRLF preserved, sibling lines
|
||||||
|
intact) and `account_name.txt`, the language into `language.txt`, and created
|
||||||
|
the marker. A second `play css` returned `already_applied=true` and rewrote
|
||||||
|
nothing even after the value was reset externally.
|
||||||
|
|
||||||
|
### 2026-05-19 - Snapshot Status Fix Docker Matrix Pass
|
||||||
|
|
||||||
|
- Code under test included `5c4976d`
|
||||||
|
(`fix(peer): settle local state before clearing operations`) and `6651f02`
|
||||||
|
(`fix(ui): derive operation status from snapshots`).
|
||||||
|
- Gates before the matrix: `just fmt`, `just test`, `just frontend-test`, and
|
||||||
|
`just build` passed. The peer harness image was rebuilt with
|
||||||
|
`just peer-cli-image`.
|
||||||
|
- Runner: `python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
||||||
|
passed S1-S36 against the rebuilt `lanspread-peer-cli:dev` image.
|
||||||
|
- Auto-install coverage remained good: S5 downloaded and installed `cnctw`, saw
|
||||||
|
the fixture payload under `local/`, and the downloaded root diffed cleanly
|
||||||
|
against `fixture-bravo/cnctw` excluding local metadata.
|
||||||
|
- Large/exact transfer coverage remained good: S13 small and large downloads
|
||||||
|
diffed cleanly; S14 split `alienswarm` between two sources with chunk totals
|
||||||
|
`67,108,864` and `58,721,049` bytes and the final root diffed cleanly.
|
||||||
|
- Failure and mutation coverage remained good: S17 latest-version conflict, S19
|
||||||
|
sole-source drop, S20 write failure, S26 duplicate operation, and S35 unknown
|
||||||
|
catalog filtering all failed safely without advertising bad local state;
|
||||||
|
S21-S23 propagation, S24-S25 concurrency, S29-S31 bootstrapping, S32
|
||||||
|
reinstall, S33 mutation install, S34 many-small-files, and S36 latest
|
||||||
|
singleton all passed.
|
||||||
|
|
||||||
|
### 2026-05-18 - Full Automated Docker Matrix Pass
|
||||||
|
|
||||||
|
- Runner: `python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
||||||
|
passed S1-S36 against the current `lanspread-peer-cli:dev` image.
|
||||||
|
- S1-S17 rerun highlights: startup, direct connect, aggregation, download,
|
||||||
|
install/uninstall, duplicate-source, ambiguous metadata, missing game,
|
||||||
|
shutdown cleanup, identity reconnect, serve gates, exact equality, large
|
||||||
|
multi-peer chunking, and latest-version selection/conflict all passed. Exact
|
||||||
|
transfer scenarios used `diff -r`/SHA-256 manifest checks; S14 chunk totals
|
||||||
|
were `58,721,049` and `67,108,864` bytes, balanced within one `32 MiB` chunk.
|
||||||
|
- S18-S36 rerun highlights: source-drop, disk-full, live mutation, concurrency,
|
||||||
|
duplicate-operation rejection, self-connect rejection, empty-peer sourcing,
|
||||||
|
5-peer aggregation, bootstrapped sourcing, reinstall, external mutation,
|
||||||
|
many-small-files, unknown catalog filtering, and stale-majority/latest
|
||||||
|
singleton cases all passed. File-copy scenarios used diff/manifests or `cmp`
|
||||||
|
for the mutated install payload.
|
||||||
|
|
||||||
|
### 2026-05-18 - Extended Scenario Docker Pass
|
||||||
|
|
||||||
|
- Runner: `python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
||||||
|
passed for S18-S36 after rebuilding `lanspread-peer-cli:dev` with
|
||||||
|
`just peer-cli-image`.
|
||||||
|
- S18 redundant source drop: one `alienswarm` source was killed after
|
||||||
|
`download-begin`; the client emitted `download-finished`, no
|
||||||
|
`download-failed`, and `diff -r`/SHA-256 manifest comparison matched the
|
||||||
|
surviving source. Recorded large-file chunk bytes from the surviving source:
|
||||||
|
`58,721,049`.
|
||||||
|
- S19 sole-source drop: killing the only source after `download-begin` emitted
|
||||||
|
`download-failed`; the receiver had no committed `alienswarm/version.ini`, no
|
||||||
|
ready local row, and no active operation left.
|
||||||
|
- S20 receiver write failure: a client with `/games` constrained to a `32m`
|
||||||
|
tmpfs emitted `download-failed`; `/games/alienswarm/version.ini` was absent
|
||||||
|
inside the container and active operations were empty.
|
||||||
|
- S21-S23 live mutation propagation: a connected peer observed `cod5` added,
|
||||||
|
`cod5` removed, and `cnc4` bumped from `20250101` to `20260501` without
|
||||||
|
reconnecting or dropping the peer.
|
||||||
|
- S24-S25 concurrency: two clients downloaded `alienswarm` from one source at
|
||||||
|
the same time and both diffed cleanly; one client downloaded `bfbc2` and
|
||||||
|
`cnctw` concurrently and both roots diffed cleanly.
|
||||||
|
- S26 duplicate same-game download: the second `alienswarm` download command
|
||||||
|
returned `operation already in progress for game alienswarm`; the first
|
||||||
|
download still finished and diffed cleanly.
|
||||||
|
- S27 self-connect rejection: connecting a peer to its own listener returned
|
||||||
|
`cannot connect peer to itself ...`; `list-peers` stayed empty and the peer
|
||||||
|
stayed responsive.
|
||||||
|
- S28 address-change invariant: `just test` passed and included
|
||||||
|
`peer_db::tests::address_update_preserves_peer_identity_and_library`.
|
||||||
|
- S29 empty-library peer: an observer first saw the empty peer with zero games;
|
||||||
|
after that peer downloaded `alienswarm`, the downloaded root diffed cleanly
|
||||||
|
and the observer's peer snapshot for that same peer contained `alienswarm`.
|
||||||
|
- S30 5-peer aggregation: a sixth client connected to five peers and aggregated
|
||||||
|
six game IDs with expected `peer_count` and latest versions, with no duplicate
|
||||||
|
game rows and no self-peer entry.
|
||||||
|
- S31 bootstrapped source: after the original source was killed, a third peer
|
||||||
|
downloaded `alienswarm` from the bootstrapped client and diffed cleanly
|
||||||
|
against the original fixture.
|
||||||
|
- S32 reinstall: reinstall after uninstall recreated `local/`, reported
|
||||||
|
`installed=true`, and produced no transfer chunk events during reinstall.
|
||||||
|
- S33 external root mutation: after mutating the downloaded `bfbc2.eti` inside
|
||||||
|
the client container, `install` wrote `local/fixture-payload.txt` that matched
|
||||||
|
the mutated archive exactly by `cmp`.
|
||||||
|
- S34 many-small-files transfer: a `bf1942` fixture with 20 small regular files
|
||||||
|
and no `.eti` downloaded with `install=false`; 21 file chunks were observed
|
||||||
|
including `version.ini`, and the receiver diffed cleanly against the source.
|
||||||
|
- S35 unknown game ID: a source advertised `mystery-game` via `--fixture`; the
|
||||||
|
receiver filtered it out of `list-games`, `download mystery-game` returned
|
||||||
|
`game mystery-game is not in the local catalog`, and no local files were
|
||||||
|
created.
|
||||||
|
- S36 latest singleton: with one peer on `20260501` and four peers on
|
||||||
|
`20250101`, the client reported `peer_count=5` and latest `20260501`; only the
|
||||||
|
singleton latest peer sent chunks and the final root diffed cleanly.
|
||||||
|
|
||||||
|
### 2026-05-18 - Full Matrix Manual Docker Pass
|
||||||
|
|
||||||
|
- Build/setup: `just peer-cli-image` passed. Local `just peer-cli-build` needed
|
||||||
|
`RUSTC_WRAPPER=` because the host `kache` wrapper failed with a read-only
|
||||||
|
filesystem error; `RUSTC_WRAPPER= just peer-cli-build` passed.
|
||||||
|
- Temporary skew/conflict fixtures were created under the ignored
|
||||||
|
`.lanspread-peer-cli/full-fixtures/` tree using `rar a -idq -m0` against
|
||||||
|
`/dev/urandom` payloads and then renaming the archives to `.eti`.
|
||||||
|
`find .lanspread-peer-cli/full-fixtures -name '*.eti' -exec unrar t -idq {} \;`
|
||||||
|
passed.
|
||||||
|
- S1 startup scan: `just peer-cli-alpha` emitted `cli-started`,
|
||||||
|
`local-library-changed`, and `local-peer-ready`; `alienswarm`, `bf1942`, and
|
||||||
|
`ggoo` were `downloaded=true`, `installed=false`, `availability=Ready`.
|
||||||
|
- S2 clean direct connect: with only alpha and bravo running, alpha connected to
|
||||||
|
bravo at `10.66.0.3:42776`; `wait-peers` returned `peer_count=1`, and
|
||||||
|
`list-peers` showed exactly one bravo peer with four games.
|
||||||
|
- S3 clean remote aggregation: an empty `clean-s3-client` saw exactly alpha and
|
||||||
|
bravo. `list-games` showed `ggoo peer_count=2`; `alienswarm`, `bf1942`,
|
||||||
|
`bfbc2`, `cnc4`, and `cnctw` each had `peer_count=1`.
|
||||||
|
- S4 single-source no-install: `full-empty-client` downloaded `bfbc2` from bravo
|
||||||
|
with `install=false`. Events included `got-game-files`, `download-begin`,
|
||||||
|
`download-finished`, and local `installed=false`. Host verification:
|
||||||
|
`diff -r crates/lanspread-peer-cli/fixtures/fixture-bravo/bfbc2 .lanspread-peer-cli/full-empty-client/games/bfbc2`
|
||||||
|
passed and `local/` was absent.
|
||||||
|
- S5 auto-install: `full-empty-client` downloaded `cnctw` with default install.
|
||||||
|
Events included download finish, `install-begin`, and `install-finished`;
|
||||||
|
`local/fixture-payload.txt` existed. Host verification diffed the downloaded
|
||||||
|
files against `fixture-bravo/cnctw` excluding `local/` and `.lanspread.json`.
|
||||||
|
- S6 manual install/uninstall: after S4, `install bfbc2` created `local/` and
|
||||||
|
marked `installed=true`; `uninstall bfbc2` removed `local/` and preserved the
|
||||||
|
downloaded root files. Host verification diffed the preserved files against
|
||||||
|
`fixture-bravo/bfbc2` excluding `.lanspread.json`.
|
||||||
|
- S7 duplicate-source download: `full-empty-client` downloaded shared `ggoo`
|
||||||
|
from alpha/bravo with `install=false`. Chunk events used alpha for
|
||||||
|
`version.ini` and bravo for `ggoo.eti`; host `diff -r` matched both
|
||||||
|
`fixture-alpha/ggoo` and `fixture-bravo/ggoo`.
|
||||||
|
- S8 ambiguous metadata rejection: `full-s8-a` and `full-s8-b` both advertised
|
||||||
|
`ggoo` version `20260101` but with different `.eti` sizes (`1,048,746` and
|
||||||
|
`2,097,323` bytes). The client saw `peer_count=2`, then `download ggoo`
|
||||||
|
emitted `download-failed`; no target `ggoo/version.ini` was committed.
|
||||||
|
- S9 missing game: `download does-not-exist` emitted `no-peers-have-game` and
|
||||||
|
returned a command error; `.lanspread-peer-cli/full-empty-client/games` had no
|
||||||
|
`does-not-exist` directory.
|
||||||
|
- S10 shutdown cleanup: alpha saw bravo before shutdown with one remote peer and
|
||||||
|
bravo-only remote games. After bravo `shutdown`, alpha emitted `peer-lost`;
|
||||||
|
`list-peers` returned `[]` and `list-games` returned an empty remote list.
|
||||||
|
- S11 same identity reconnect: restarting bravo reused peer ID
|
||||||
|
`019e347d901e70c19adf5b9fd313fce4` at new address `10.66.0.3:41764`. Alpha
|
||||||
|
`list-peers` showed exactly one bravo entry at the new address.
|
||||||
|
- S12 transfer serving gates: this remains covered by unit tests because the CLI
|
||||||
|
cannot stably race raw transfer requests against non-catalog, missing
|
||||||
|
sentinel, active-operation, and `local/` path states.
|
||||||
|
`RUSTC_WRAPPER= just test` passed, including
|
||||||
|
`local_download_available_gates_on_catalog_operation_and_sentinel`,
|
||||||
|
`get_game_response_respects_serve_gates`,
|
||||||
|
`file_transfer_dispatch_respects_serve_gates`, and
|
||||||
|
`local_relative_paths_are_never_transferable`.
|
||||||
|
- S13 exact transferred-file equality: the S4 small transfer and S14 large
|
||||||
|
transfer both passed host `diff -r` against the original source game
|
||||||
|
directories, proving exact file equality beyond event flow.
|
||||||
|
- S14 large multi-peer chunked download: `full-empty-client` first downloaded
|
||||||
|
`alienswarm` from alpha and diffed cleanly against `fixture-alpha/alienswarm`.
|
||||||
|
A fresh `full-s14-client` then saw `alienswarm peer_count=2` and downloaded
|
||||||
|
from both alpha and `full-empty-client`. Large `.eti` chunk totals were
|
||||||
|
`67,108,864` bytes from alpha and `58,721,049` bytes from the staged peer,
|
||||||
|
balanced within one `32 MiB` chunk. Final host `diff -r` against
|
||||||
|
`fixture-alpha/alienswarm` passed.
|
||||||
|
- S15 three-way version skew: peers A/B/C advertised `cnc4` versions `20250101`,
|
||||||
|
`20250201`, and `20250301`. The client saw one row with `peer_count=3` and
|
||||||
|
`eti_game_version=20250301`; all chunks came only from C at `10.66.0.4:60290`.
|
||||||
|
Host `diff -r` against C passed.
|
||||||
|
- S16 latest-version fanout with stale peer present: A advertised stale
|
||||||
|
`20250101`; B/C both advertised latest `20250301` with a `134,217,906` byte
|
||||||
|
`.eti`. The client saw `peer_count=3`; chunks came only from B/C (`67,108,873`
|
||||||
|
and `67,109,042` bytes respectively), with stale A contributing zero. Host
|
||||||
|
`diff -r` matched both B and C.
|
||||||
|
- S17 latest-version conflict rejection: A advertised stale `20250101`; B/C both
|
||||||
|
advertised latest `20250301` but with conflicting `.eti` sizes (`1,048,748`
|
||||||
|
and `2,097,325` bytes). The client saw `peer_count=3` and latest `20250301`,
|
||||||
|
then `download cnc4` emitted `download-failed`; no target `cnc4/version.ini`
|
||||||
|
was committed.
|
||||||
|
- Gates after manual runs: `just fmt`, `RUSTC_WRAPPER= just test`, and
|
||||||
|
`RUSTC_WRAPPER= just clippy` passed.
|
||||||
|
|
||||||
|
### 2026-05-17 - Exact Transfer And Large Multi-Peer Chunking
|
||||||
|
|
||||||
|
- Fixture update: `fixture-alpha/alienswarm/alienswarm.eti` was rebuilt with
|
||||||
|
`rar a -idq -m0` from three random 40 MiB payload files, then renamed to
|
||||||
|
`.eti`. Final archive size: `125,829,913` bytes. `unrar t -idq` passed.
|
||||||
|
- Gates before manual runs: `just fmt`, `just test`, `just peer-cli-build`,
|
||||||
|
`just clippy`, and `just peer-cli-image` passed.
|
||||||
|
- S13 small exact transfer: `deep-small-client` downloaded `bfbc2` from
|
||||||
|
`fixture-bravo` with `install=false`. SHA-256 manifests matched exactly:
|
||||||
|
`bfbc2/bfbc2.eti`
|
||||||
|
`f7accef0833f29481acdeaac58261bc4fc23ebb58b7197049024d354f60daabc`;
|
||||||
|
`bfbc2/version.ini`
|
||||||
|
`f3d94f70edcebbbc7d8ce38fdf076412fb95114ce1ecf071b26c9c2f93586372`.
|
||||||
|
- S13 large exact transfer: `deep-stage-b` downloaded `alienswarm` from
|
||||||
|
`fixture-alpha` with `install=false`. SHA-256 manifests matched exactly:
|
||||||
|
`alienswarm/alienswarm.eti`
|
||||||
|
`8a4fb1fd458e731affb175134b7b99efc8d8a5eda80e978ba81f721d01aecc43`;
|
||||||
|
`alienswarm/notes.txt`
|
||||||
|
`3832bcb7057a4453981e975d2d2d528bfd9a26671423352f4a8527362d5b9810`;
|
||||||
|
`alienswarm/version.ini`
|
||||||
|
`8dfdc51d4dbfb06015b41a85a5f5d47f44144139e4a12db2b17eb040773082a3`.
|
||||||
|
- S14 multi-peer setup: `deep-stage-c` connected to alpha (`10.66.0.3:53514`)
|
||||||
|
and `deep-stage-b` (`10.66.0.2:58491`). `list-games` showed `alienswarm` with
|
||||||
|
`peer_count=2` before the download.
|
||||||
|
- S14 chunk-source evidence for `alienswarm/alienswarm.eti`: `deep-stage-c`
|
||||||
|
received chunks from `deep-stage-b` at offsets `0` and `67,108,864`
|
||||||
|
(`67,108,864` bytes total) and from alpha at offsets `33,554,432` and
|
||||||
|
`100,663,296` (`58,721,049` bytes total). The source-byte difference was
|
||||||
|
`8,387,815` bytes, below one `32 MiB` chunk.
|
||||||
|
- S14 final exactness: `deep-stage-c`'s `alienswarm` SHA-256 manifest matched
|
||||||
|
`fixture-alpha` exactly for `alienswarm.eti`, `notes.txt`, and `version.ini`.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Backlog
|
||||||
|
|
||||||
|
Smells and small inconsistencies found during post-PLAN.md review. None of these
|
||||||
|
block merging — they are tracked here so they aren't forgotten and so they don't
|
||||||
|
reopen as "new findings" the next time someone reads the code.
|
||||||
|
|
||||||
|
**Rule of engagement:** items in this file get touched only when (a) someone
|
||||||
|
hits the symptom in practice, or (b) work in a nearby area makes fixing the
|
||||||
|
smell incidental. No batch refactor passes. No "while we're here" cleanups that
|
||||||
|
grow beyond the in-scope change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
No open backlog items.
|
||||||
|
|
||||||
|
## How items leave this file
|
||||||
|
|
||||||
|
- Closed by fix → delete the entry, mention it in the commit.
|
||||||
|
- Closed by decision ("we're not doing this") → delete the entry, no commit
|
||||||
|
message ceremony needed.
|
||||||
|
- Promoted to active work → move back to `FINDINGS.md` only when there's a
|
||||||
|
concrete plan to fix it now.
|
||||||
|
|
||||||
|
This file does not grow unboundedly. If it does, that's a signal to either close
|
||||||
|
items or stop adding to it.
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# Finish Call to Play’s replication seam
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Keep the existing architecture: immutable events, deterministic reduction, live
|
||||||
|
delivery, and handshake history are appropriate for a LAN party. Do not replace
|
||||||
|
it with owner-authoritative state, consensus, persistent storage, or
|
||||||
|
cryptographic peer identities.
|
||||||
|
|
||||||
|
The focused redesign is the delivery/merge/store seam:
|
||||||
|
|
||||||
|
- Merge histories atomically.
|
||||||
|
- Acknowledge live deliveries.
|
||||||
|
- Preserve complete histories while users can still see a call.
|
||||||
|
- Make terminal outcomes understandable.
|
||||||
|
- Never report an action as accepted when it was immediately discarded.
|
||||||
|
|
||||||
|
## User-visible lifecycle
|
||||||
|
|
||||||
|
| State | Meaning | Visibility | Available actions |
|
||||||
|
| ------------ | --------------------------------------------------- | ----------------------: | ---------------------------------------------------------------- |
|
||||||
|
| Open / Ready | Call is still coordinating players | Until deadline | Existing participation, chat, creator controls |
|
||||||
|
| Time’s up | Deadline elapsed without a Start or Cancel | 5 minutes | Creator may Start, Add time, or Cancel; history remains complete |
|
||||||
|
| Running | Creator emitted Start; this is a successful outcome | 15 minutes after Start | Read-only card, roster, and complete chat |
|
||||||
|
| Cancelled | Creator emitted Cancel | 15 minutes after Cancel | Read-only card, roster, and complete chat |
|
||||||
|
| Retired | Display period ended | Hidden | None |
|
||||||
|
|
||||||
|
“Time’s up” and “Running” are meaningfully different: a timed-out call is
|
||||||
|
unresolved and recoverable, while Running is a final success receipt. Deadline
|
||||||
|
passage alone never means the game started.
|
||||||
|
|
||||||
|
Running and Cancelled rows remain in the ticker and overlay, sorted after
|
||||||
|
actionable calls. They do not increase the top-bar badge. Their chat history
|
||||||
|
remains readable, but all composers and controls are disabled.
|
||||||
|
|
||||||
|
## Interface and invariant changes
|
||||||
|
|
||||||
|
- Raise `PROTOCOL_VERSION` from 6 to 7; no compatibility path.
|
||||||
|
- Add `Response::CallToPlayAck` with outcomes equivalent to:
|
||||||
|
- `Applied`
|
||||||
|
- `Duplicate`
|
||||||
|
- `NeedHandshake`
|
||||||
|
- `NeedHistory`
|
||||||
|
- `Obsolete`
|
||||||
|
- `Rejected(reason)`
|
||||||
|
- Replace per-event insertion with one atomic `merge_batch(events, now)`
|
||||||
|
operation returning retained UI events, duplicate/obsolete counts, and
|
||||||
|
missing-history information.
|
||||||
|
- Change frontend nomination state to represent `running` and `cancelled`, with
|
||||||
|
a `terminalAt` timestamp.
|
||||||
|
- Change `addTime` to receive the current effective deadline and calculate
|
||||||
|
`max(now, deadline) + duration`.
|
||||||
|
- Preserve these invariants:
|
||||||
|
- Every visible call has its entire event and chat history.
|
||||||
|
- Handshake batches are evaluated as a whole, regardless of event order.
|
||||||
|
- Missing-root actions are not retained alone; they request history.
|
||||||
|
- Event IDs correspond only to retained events.
|
||||||
|
- Terminal tombstones prevent stale histories from resurrecting finished
|
||||||
|
calls.
|
||||||
|
- Local acceptance is immediate user success; remote delivery is acknowledged
|
||||||
|
and healed asynchronously.
|
||||||
|
|
||||||
|
## Commit sequence
|
||||||
|
|
||||||
|
1. `refactor(call-to-play): merge histories atomically`
|
||||||
|
- Validate and deduplicate the complete incoming batch before changing the
|
||||||
|
store.
|
||||||
|
- Treat an existing ID with different contents as a conflict and reject the
|
||||||
|
batch.
|
||||||
|
- Evaluate compaction once after all batch events are present, fixing the
|
||||||
|
quadratic handshake path.
|
||||||
|
- Commit the candidate store only when capacity and validation succeed.
|
||||||
|
- Rebuild event IDs from retained events instead of preserving every
|
||||||
|
historical ID.
|
||||||
|
- Return `NeedHistory` without storing an action when neither the store nor
|
||||||
|
batch contains its Create event.
|
||||||
|
- Permit a full `Create + AddTime` history to revive a call atomically.
|
||||||
|
- Mark an event as applied only when it survives compaction; obsolete events
|
||||||
|
are neither broadcast nor emitted to the UI.
|
||||||
|
- Keep the 4,096-event safety cap for unresolved histories, but always permit
|
||||||
|
Start and Cancel. Recently terminal histories and compact tombstones must
|
||||||
|
not prevent new active calls.
|
||||||
|
- Preserve full open/ready/Time’s-up histories; never evict individual chat
|
||||||
|
or participant events.
|
||||||
|
|
||||||
|
2. `fix(call-to-play): acknowledge live replication`
|
||||||
|
- Turn the live Call to Play request into a request/response exchange
|
||||||
|
returning `CallToPlayAck`.
|
||||||
|
- Remove source-IP-versus-advertised-IP equality checks.
|
||||||
|
- Under the selected trusted-LAN model, require:
|
||||||
|
- the envelope peer ID to exist in the known peer roster;
|
||||||
|
- every live event’s actor ID to match that envelope peer ID;
|
||||||
|
- local peer-core publication to continue stamping its own actor ID.
|
||||||
|
- Return `NeedHandshake` for unknown peers and `NeedHistory` for missing call
|
||||||
|
roots.
|
||||||
|
- On transport failure, malformed response, `NeedHandshake`, or
|
||||||
|
`NeedHistory`, perform one full Hello/HelloAck resync.
|
||||||
|
- Treat Applied and Duplicate as delivered, Obsolete as finished, and
|
||||||
|
Rejected as a logged non-retriable error.
|
||||||
|
- Keep publication locally successful without waiting for every peer, so an
|
||||||
|
offline machine cannot block a LAN-party action.
|
||||||
|
- Document that shared TLS plus stable peer IDs prevent accidental identity
|
||||||
|
mixing but are not hostile-peer authentication.
|
||||||
|
|
||||||
|
3. `feat(call-to-play): retain terminal outcomes`
|
||||||
|
- Preserve complete Running and Cancelled histories in backend snapshots for
|
||||||
|
15 minutes so late joiners receive the card, roster, and chat.
|
||||||
|
- After 15 minutes, compact each terminal call to its Start or Cancel
|
||||||
|
tombstone for the remainder of the peer session.
|
||||||
|
- Continue deleting unresolved Time’s-up histories after their separate
|
||||||
|
five-minute recovery window, including their event IDs.
|
||||||
|
- Derive and render Running and Cancelled frontend states instead of
|
||||||
|
immediately removing them.
|
||||||
|
- Show their ticker/card status, retain unknown-game degraded rendering, sort
|
||||||
|
them last, and exclude them from the badge.
|
||||||
|
- Prune the frontend’s raw event map after the corresponding display window
|
||||||
|
so a long GUI session does not accumulate invisible history.
|
||||||
|
- Update the feature specification and architecture documentation with these
|
||||||
|
lifecycle and clock-skew assumptions.
|
||||||
|
|
||||||
|
4. `fix(call-to-play): extend from the current deadline`
|
||||||
|
- Calculate extensions as
|
||||||
|
`max(Date.now(), nomination.deadline) + five minutes`.
|
||||||
|
- Preserve the existing behavior that an overdue call gets five minutes from
|
||||||
|
now.
|
||||||
|
- Ensure extending a call that became Ready early adds time instead of
|
||||||
|
shortening its remaining deadline.
|
||||||
|
- Keep terminal Running and Cancelled calls non-extendable.
|
||||||
|
|
||||||
|
5. `fix(call-to-play): explain peer startup state`
|
||||||
|
- Replace the game-folder advice shown while `actorId` is unavailable with:
|
||||||
|
“Call to Play is still connecting to the LAN. Try again in a moment.”
|
||||||
|
- Do not mark transport unavailable for store-level errors.
|
||||||
|
- Surface distinct messages for:
|
||||||
|
- expired/obsolete call;
|
||||||
|
- full active history;
|
||||||
|
- peer startup;
|
||||||
|
- unexpected update failure.
|
||||||
|
|
||||||
|
## Test and acceptance plan
|
||||||
|
|
||||||
|
- Store unit tests:
|
||||||
|
- `Create + AddTime` succeeds in every input order.
|
||||||
|
- An expired receiver returns NeedHistory for orphan AddTime, then revives
|
||||||
|
after receiving full history.
|
||||||
|
- IDs removed with expired histories do not block revival.
|
||||||
|
- Stale events against terminal tombstones remain obsolete.
|
||||||
|
- Accepted results contain only retained events.
|
||||||
|
- Invalid/conflicting batches leave the store unchanged.
|
||||||
|
- Batch merge compacts once rather than once per event.
|
||||||
|
- Start and Cancel remain possible at the active-history cap.
|
||||||
|
- Visible calls always retain every chat event.
|
||||||
|
|
||||||
|
- Transport tests:
|
||||||
|
- A known peer is accepted when transport and advertised IPs differ.
|
||||||
|
- Unknown peer IDs and mismatched actor IDs receive explicit acknowledgements.
|
||||||
|
- NeedHistory, NeedHandshake, and lost acknowledgements trigger idempotent
|
||||||
|
handshake healing.
|
||||||
|
- Duplicate delivery is harmless.
|
||||||
|
|
||||||
|
- Frontend tests:
|
||||||
|
- Ready, Time’s up, Running, Cancelled, and retired transitions.
|
||||||
|
- Full terminal history remains for 15 minutes and then disappears.
|
||||||
|
- Terminal calls appear in ticker/overlay but not the badge.
|
||||||
|
- Terminal controls and chat composer are disabled.
|
||||||
|
- Both Add-time deadline cases.
|
||||||
|
- Correct startup and store-error messages.
|
||||||
|
|
||||||
|
- Peer CLI:
|
||||||
|
- Keep S48 as the active-call/full-history late-join acceptance test.
|
||||||
|
- Add S49 covering a terminal call whose roster and chat are reconstructed by
|
||||||
|
a late joiner.
|
||||||
|
- Fix any snapshot waiting through the direct reply path rather than observing
|
||||||
|
unrelated generations.
|
||||||
|
|
||||||
|
- Final verification:
|
||||||
|
- `just fmt`
|
||||||
|
- `just clippy`
|
||||||
|
- `just test`
|
||||||
|
- `just frontend-test`
|
||||||
|
- `just build`
|
||||||
|
- `just peer-cli-tests S48 S49`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The LAN is cooperative; deliberate peer-ID impersonation is outside scope.
|
||||||
|
- Wall clocks are assumed reasonably close. Atomic history revival tolerates
|
||||||
|
boundary skew but does not attempt clock synchronization.
|
||||||
|
- Call to Play state remains transient and disappears when the peer
|
||||||
|
process/session ends.
|
||||||
|
- The existing `FABLE_5_FINDINGS.md` commit remains untouched; all
|
||||||
|
implementation work is added as focused forward commits.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Call to Play review — Fable 5 (xhigh)
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
The plan is faithfully implemented — all five commits match the planned
|
||||||
|
sequence, scope, and invariants, and the full acceptance suite passes on my
|
||||||
|
machine: workspace tests (189), frontend tests (26), clippy, fmt,
|
||||||
|
`git diff --check`, frontend `tsc`, and the docker scenarios S48 and S49. The
|
||||||
|
few deviations from the plan's letter are genuine improvements. I found no
|
||||||
|
correctness bugs. There is one architectural edge case worth knowing about
|
||||||
|
(self-healing, arguably by design) and one real UX friction point.
|
||||||
|
|
||||||
|
## a) Plan fidelity
|
||||||
|
|
||||||
|
Each plan bullet traced to code:
|
||||||
|
|
||||||
|
- **Atomic merge** — `merge_batch_at` (`call_to_play.rs:83`) validates the whole
|
||||||
|
batch, dedups, detects ID conflicts, evaluates tombstones/rootedness against
|
||||||
|
retained-plus-batch, compacts exactly once, and only then commits.
|
||||||
|
Store-unchanged-on-error is tested for both invalid and conflicting batches.
|
||||||
|
`Create + AddTime` revival is tested in both input orders.
|
||||||
|
- **Acknowledged delivery** — `PROTOCOL_VERSION` 7, `CallToPlayAck` with all six
|
||||||
|
planned outcomes, request/response in `send_call_to_play_events`, and the
|
||||||
|
resync matrix in `deliver_to_peer`/`delivery_resync_reason` exactly matches
|
||||||
|
the plan: transport failure/malformed/NeedHandshake/NeedHistory → one Hello
|
||||||
|
resync; Rejected → logged, non-retriable; Applied/Duplicate/Obsolete → done.
|
||||||
|
The IP check is gone; identity is roster membership + envelope==actor, and
|
||||||
|
ARCHITECTURE.md now states plainly that this is not hostile-peer
|
||||||
|
authentication.
|
||||||
|
- **Terminal retention** — 15-minute full-history window, then
|
||||||
|
tombstone-for-session, separate 5-minute recovery window for unresolved calls,
|
||||||
|
frontend `running`/`cancelled` states with `terminalAt`, badge exclusion,
|
||||||
|
sort-last, disabled composer/controls, raw-event pruning, and doc updates. S49
|
||||||
|
proves late-joiner reconstruction of a terminal call.
|
||||||
|
- **Deadline extension** — `extendDeadline = max(now, deadline) + 5min`, card
|
||||||
|
passes `nomination.deadline`, tested for both the early-ready and overdue
|
||||||
|
cases.
|
||||||
|
- **Startup message** — the Tauri command returns `Ok(false)` only for
|
||||||
|
uninitialized peer core and `Err(store reason)` otherwise, and the hook maps
|
||||||
|
these to the four distinct messages without marking transport unavailable for
|
||||||
|
store errors. The connecting message self-clears once the 2-second snapshot
|
||||||
|
poll succeeds.
|
||||||
|
|
||||||
|
**Deviations, all justified:**
|
||||||
|
|
||||||
|
1. The plan said "rebuild event IDs from retained events." The implementation
|
||||||
|
went further and **deleted the separate ID set entirely** — dedup scans
|
||||||
|
retained history directly. This makes the "IDs correspond only to retained
|
||||||
|
events" invariant structurally impossible to violate rather than merely
|
||||||
|
maintained. Better than the plan.
|
||||||
|
2. "Always permit Start and Cancel at the cap" is implemented as a
|
||||||
|
generalization: the 4,096 cap counts only _unresolved_ events
|
||||||
|
(`unresolved_event_count`), so a terminal action inherently passes because it
|
||||||
|
resolves the call, and settled histories/tombstones never consume active
|
||||||
|
capacity. Cleaner than special-casing two action types, and both behaviors
|
||||||
|
are tested.
|
||||||
|
3. A nice detail beyond the plan: a pre-terminal chat message arriving _after_
|
||||||
|
the call went terminal still merges into the read-only display during the
|
||||||
|
15-minute window (the obsolete check compares against the terminal event's
|
||||||
|
order key, not mere terminal existence). That's consistent with "every
|
||||||
|
visible call has its entire history."
|
||||||
|
|
||||||
|
## b) Architecture
|
||||||
|
|
||||||
|
The design holds up well. `merge_batch` is now the single choke point for every
|
||||||
|
mutation path — local publish, live delivery, and handshake all flow through one
|
||||||
|
atomic validate → dedup → apply → compact operation. That is exactly the seam
|
||||||
|
the findings pointed at, and collapsing findings 2, 3, and 5 into it was the
|
||||||
|
right call. Convergence comes from a grow-only deduplicated event set plus
|
||||||
|
deterministic compaction, with no consensus machinery — appropriate for a
|
||||||
|
trusted LAN.
|
||||||
|
|
||||||
|
Two observations, neither blocking:
|
||||||
|
|
||||||
|
- **Rootless tombstones don't propagate.** The "missing-root actions are not
|
||||||
|
retained alone" invariant applies to Start/Cancel too, so a peer that joins
|
||||||
|
_after_ a call's 15-minute window never stores the creator's tombstone (its
|
||||||
|
handshake merge returns `NeedHistory`, which in the handshake path only logs).
|
||||||
|
If a third peer that slept through the finish later hands that fresh peer the
|
||||||
|
stale active history, the finished call briefly resurrects on the fresh peer
|
||||||
|
until its next handshake with any tombstone-holder roots the call and applies
|
||||||
|
the tombstone. It self-heals and requires an unusual sequence (long-deadline
|
||||||
|
scheduled call + offline peer + fresh joiner), so I think the trade-off is
|
||||||
|
fine — but be aware of it, and note the secondary symptom: the fresh peer logs
|
||||||
|
a "handshake omitted roots" warning on every handshake with a tombstone-holder
|
||||||
|
for the rest of the session. If that log noise bothers you, downgrading that
|
||||||
|
specific case to debug would be cheap.
|
||||||
|
- **The backend `HistoryIndex` and the frontend reducer are parallel
|
||||||
|
implementations** of the same semantics (creator authority, `(at, id)`
|
||||||
|
ordering, earliest-terminal-wins, latest-extension-wins). I checked them
|
||||||
|
against each other and they agree today, including the subtle cases (forged
|
||||||
|
terminal by non-creator, extension ordering, pre-create actions). This
|
||||||
|
duplication is inherent to having a Rust store and a TS presentation reducer,
|
||||||
|
but it's the seam most likely to drift — any future rule change must land in
|
||||||
|
both `call_to_play.rs` and `callToPlay.ts`.
|
||||||
|
|
||||||
|
Minor: `merge_batch` clones the full store per call, so a live event costs O(n)
|
||||||
|
— irrelevant under the 4,096 cap, just don't raise the cap by 100× without
|
||||||
|
revisiting.
|
||||||
|
|
||||||
|
## c) User perspective
|
||||||
|
|
||||||
|
The lifecycle is now genuinely intuitive. "Time's up" being
|
||||||
|
unresolved-but-recoverable, and "Running" being an explicit success receipt that
|
||||||
|
only the creator's Start can produce, is a real conceptual improvement —
|
||||||
|
deadline passage never silently claims a game happened. The ticker ordering
|
||||||
|
supports this: Time's up ranks _first_ (it needs the creator's attention),
|
||||||
|
terminal receipts sink to the bottom in muted colors and don't inflate the
|
||||||
|
badge. "Add 5 more minutes" finally does what it says. The startup message no
|
||||||
|
longer sends users hunting for a game-folder problem that doesn't exist.
|
||||||
|
|
||||||
|
One real friction point: **when a call starts, participants get no launch
|
||||||
|
affordance.** The creator's "Start now" auto-launches locally, but everyone
|
||||||
|
else's card flips to a read-only "X is running." note — at precisely the moment
|
||||||
|
they all need to launch the game, they must close the overlay and find it in the
|
||||||
|
library. The plan specified terminal cards as read-only, so this is faithful —
|
||||||
|
but a "Launch" button on the Running card (for participants who have the game
|
||||||
|
installed) would remove the most awkward step in the happy path. Worth a
|
||||||
|
follow-up commit if you agree.
|
||||||
|
|
||||||
|
Two nits: `design/launcher/SPEC.md` still describes the ticker sort as "ready →
|
||||||
|
starting-soon → the rest (TICKER_RANK = ready 0, soon 1…)" while the code ranks
|
||||||
|
`expired` first — that mismatch predates this branch, but since the spec section
|
||||||
|
was touched anyway it could have been corrected. And the ticker's "waiting to
|
||||||
|
start" line for Ready calls doesn't say _who_ everyone is waiting for, while the
|
||||||
|
card note does name the creator — a tiny inconsistency, fine as is.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Call to Play Code & Architecture Review Report
|
||||||
|
|
||||||
|
I have conducted a thorough review of the commits (`e141229` through `2c204ac`)
|
||||||
|
on branch `calltoplay`, referencing
|
||||||
|
[`FABLE_5_FINDINGS.md`](file:///pantheon/pfs/git/rust/pfs/lanspread/FABLE_5_FINDINGS.md)
|
||||||
|
and
|
||||||
|
[`CALL_TO_PLAY_FIXES_PLAN.md`](file:///pantheon/pfs/git/rust/pfs/lanspread/CALL_TO_PLAY_FIXES_PLAN.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Plan Implementation & Deviation Assessment
|
||||||
|
|
||||||
|
The plan outlined in
|
||||||
|
[`CALL_TO_PLAY_FIXES_PLAN.md`](file:///pantheon/pfs/git/rust/pfs/lanspread/CALL_TO_PLAY_FIXES_PLAN.md)
|
||||||
|
is **faithfully and elegantly implemented across all 5 code commits**, with zero
|
||||||
|
regression to core invariants.
|
||||||
|
|
||||||
|
| Commit | Scope | Plan Requirements | Code Verification | Status |
|
||||||
|
| ------------------------------------------------------------------------------------------------------------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
|
||||||
|
| [`be7ad2e`](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-peer/src/call_to_play.rs) | Atomic Batch Merge & Store Semantics | - Transactional history merge<br>- Single-pass $O(N)$ compaction<br>- Conflicting ID batch rejection<br>- `NeedHistory` for missing roots<br>- Rebuild event IDs from retained events<br>- Capacity cap applies to unresolved history only | - `merge_batch_at` in [call_to_play.rs](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-peer/src/call_to_play.rs#L83)<br>- `deduplicate_batch`<br>- `unresolved_event_count`<br>- Tests cover revival & conflict behavior | **Faithful** |
|
||||||
|
| [`e5d70ae`](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-peer/src/services/stream.rs) | Live Replication & Acknowledgement | - Protocol v7 bump<br>- `CallToPlayAck` return outcomes<br>- Remove unreliable source-IP equality check<br>- Enforce envelope vs actor ID matching<br>- Async handshake resync on delivery failure | - Protocol updated in [`lanspread-proto`](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-proto/src/lib.rs)<br>- `handle_call_to_play_events` in [stream.rs](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-peer/src/services/stream.rs#L124)<br>- `delivery_resync_reason` in [call_to_play.rs](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-peer/src/call_to_play.rs#L472) | **Faithful** |
|
||||||
|
| [`9c34efa`](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts) | Terminal Outcome Retention | - 15-minute terminal outcome display (`TERMINAL_RETENTION_MS`) for `Running` and `Cancelled`<br>- Post-15-min compaction to tombstones<br>- Frontend status reduction, sorting, and badge exclusion<br>- Peer CLI scenario S49 | - `compact_history` in [call_to_play.rs](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-peer/src/call_to_play.rs#L340)<br>- Reducer logic in [callToPlay.ts](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts)<br>- Roster/chat retained | **Faithful** |
|
||||||
|
| [`8d3affe`](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts) | Extend from Current Deadline | - `extendDeadline`: $\max(\text{now}, \text{currentDeadline}) + \text{duration}$ | - Implemented in `extendDeadline` in [callToPlay.ts](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts#L12) | **Faithful** |
|
||||||
|
| [`2c204ac`](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-tauri-deno-ts/src/hooks/useCallToPlay.ts) | Startup & Error Guidance | - Replace missing folder prompt with LAN connecting state<br>- Map specific store errors (`Obsolete`, `NeedHistory`, `HistoryFull`) to human guidance | - Implemented in `useCallToPlay.ts` and `callToPlayPublishErrorMessage` | **Faithful** |
|
||||||
|
|
||||||
|
### Implementation Refinements Over the Initial Plan
|
||||||
|
|
||||||
|
1. **Tombstone Representation**: Rather than instantiating a separate tombstone
|
||||||
|
data structure, `compact_history` retains **only** the `Start` or `Cancel`
|
||||||
|
event (`event.id == terminal.event_id`) after the 15-minute terminal
|
||||||
|
retention window expires. `terminal_tombstone_call_ids` uses unrooted
|
||||||
|
terminal events to reject any incoming obsolete history. This is cleaner and
|
||||||
|
more memory-efficient than allocating explicit tombstone markers.
|
||||||
|
2. **Unresolved History Cap (`unresolved_event_count`)**: The active event cap
|
||||||
|
($4,096$) is enforced strictly against _unresolved_ calls (`Create` without
|
||||||
|
`Start`/`Cancel`). As soon as a creator emits `Start` or `Cancel`, that
|
||||||
|
call's events no longer count against the active cap. This guarantees a user
|
||||||
|
can always settle (Start or Cancel) an open call even when the store is full.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Holistic Architecture Review
|
||||||
|
|
||||||
|
```text
|
||||||
|
+------------------------+
|
||||||
|
| Frontend (TS/Tauri) |
|
||||||
|
| Event Reducer & Hooks |
|
||||||
|
+-----------+------------+
|
||||||
|
| publish_call_to_play / snapshot
|
||||||
|
v
|
||||||
|
+------------------------+
|
||||||
|
| CallToPlayStore |
|
||||||
|
| (Atomic Batch Merge) |
|
||||||
|
+-----------+------------+
|
||||||
|
|
|
||||||
|
+-------------------+-------------------+
|
||||||
|
| (Local immediate) | (Async broadcast)
|
||||||
|
v v
|
||||||
|
+-------------------+ +-------------------+
|
||||||
|
| Local UI Emitter | | Peer QUIC Stream |
|
||||||
|
+-------------------+ | (Protocol Ver 7) |
|
||||||
|
+---------+---------+
|
||||||
|
| CallToPlayEvents
|
||||||
|
v
|
||||||
|
+-------------------+
|
||||||
|
| CallToPlayAck |
|
||||||
|
| (Applied/NeedHist)|
|
||||||
|
+-------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architectural Soundness
|
||||||
|
|
||||||
|
1. **Event-Sourced LAN Replication vs Server-Authoritative State**: Maintaining
|
||||||
|
an event-sourced replication model with deterministic reduction is optimal
|
||||||
|
for LAN Spread. Decentralized peer-to-peer LAN parties lack guaranteed
|
||||||
|
central servers. Using atomic batch merges ($O(N)$ compaction) completely
|
||||||
|
eliminates the $O(N^2)$ quadratic slowdown of the previous per-event
|
||||||
|
insertion model.
|
||||||
|
|
||||||
|
2. **Network Identity Model**: Removing source-IP equality comparisons fixes a
|
||||||
|
major real-world bug on multi-homed hardware (Ethernet + Wi-Fi / VPN /
|
||||||
|
virtual bridges). Validating that `envelope peer_id` is present in the mDNS
|
||||||
|
peer roster and verifying `event.actor_id == envelope peer_id` accurately
|
||||||
|
matches the trusted-LAN threat model without making false cryptographic
|
||||||
|
guarantees.
|
||||||
|
|
||||||
|
3. **Asynchronous Healing**: Local updates succeed instantly for the local user
|
||||||
|
without blocking on network delivery (`task_tracker.spawn(...)`). If a remote
|
||||||
|
peer rejects an event with `NeedHistory` or `NeedHandshake`, an asynchronous
|
||||||
|
full `Hello`/`HelloAck` resync is scheduled. This isolates local UI
|
||||||
|
responsiveness from network transport delays.
|
||||||
|
|
||||||
|
4. **Lifecycle & Memory Management**: The 3-tier lifecycle (`Open` $\rightarrow$
|
||||||
|
`Time's up` [5-min recovery] $\rightarrow$ `Running`/`Cancelled` [15-min
|
||||||
|
display] $\rightarrow$ `Tombstone`) strikes the right balance between
|
||||||
|
retaining full chat/roster history for late joiners and preventing unbounded
|
||||||
|
memory growth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. User Experience (UX) Analysis
|
||||||
|
|
||||||
|
```text
|
||||||
|
UX Flow Comparison (Add Time Action)
|
||||||
|
|
||||||
|
BEFORE: [10-min Call] -- (Filled at min 2) --> Click "+5 mins" --> Deadline set to (2+5) = 7 mins! (SHORTENED!)
|
||||||
|
AFTER: [10-min Call] -- (Filled at min 2) --> Click "+5 mins" --> Deadline set to max(2, 10)+5 = 15 mins! (EXTENDED!)
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Intuitive "+5 minutes" Extension**:
|
||||||
|
- _Previous behavior_: Setting deadline to `now + 5` inadvertently shortened
|
||||||
|
calls that reached capacity early.
|
||||||
|
- _Current behavior_: `Math.max(now, currentDeadline) + 5` preserves existing
|
||||||
|
remaining time when extending early, and correctly grants 5 new minutes to
|
||||||
|
an overdue call.
|
||||||
|
|
||||||
|
2. **Startup & Connection Guidance**:
|
||||||
|
- _Previous behavior_: Attempting an action during startup raised misleading
|
||||||
|
errors about missing game folders.
|
||||||
|
- _Current behavior_: Shows
|
||||||
|
`"Call to Play is still connecting to the LAN. Try again in a moment."`
|
||||||
|
while `actorId` is initializing, clearing automatically upon connection.
|
||||||
|
|
||||||
|
3. **Clear Terminal Receipts (`Running` and `Cancelled`)**:
|
||||||
|
- _Previous behavior_: Starting or canceling a call caused it to disappear or
|
||||||
|
act erratically, hiding game chat.
|
||||||
|
- _Current behavior_: `Running` displays as a clear green success receipt
|
||||||
|
card, and `Cancelled` displays as a read-only historical card. Roster and
|
||||||
|
chat remain accessible for 15 minutes, sorted below active calls and
|
||||||
|
excluded from badge counts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion & Recommendation
|
||||||
|
|
||||||
|
The commits are **clean, robust, and fully faithful to the findings and plan**.
|
||||||
|
The architectural choices are sound for a LAN environment, and the UX is
|
||||||
|
intuitive and frictionless.
|
||||||
|
|
||||||
|
No further code changes are needed; the implementation is ready for merge.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Review: Call to Play fix series (`872692e..2c204ac`)
|
||||||
|
|
||||||
|
## a) Faithfulness to the plan — high, with only minor test-plan gaps
|
||||||
|
|
||||||
|
**Commit mapping is 1:1 with the planned sequence**, same titles, and every
|
||||||
|
bullet lands:
|
||||||
|
|
||||||
|
| Plan | Implementation | Verdict |
|
||||||
|
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||||
|
| 1. Atomic merge | `merge_batch` validates + dedups the whole batch, conflicts reject without mutation (`self.events` only assigned at the end), one post-merge compaction, IDs derived from retained events, orphan actions → `missing_call_ids`, Create+AddTime revives in any order (tested both orders), `applied` = only what survives compaction, cap counts only unresolved events so Start/Cancel always settle | ✓ Faithful |
|
||||||
|
| 2. Acknowledged replication | `PROTOCOL_VERSION` 6→7, `CallToPlayAck` with exactly the six planned outcomes, IP check removed, roster + actor-matches-envelope checks, NeedHandshake/NeedHistory/transport/malformed → one Hello/HelloAck resync, Applied/Duplicate delivered, Obsolete finished, Rejected logged non-retriable, publication spawns deliveries into `task_tracker` and returns after local merge (verified: `join_all` is inside the spawned task, so an offline peer can't block the UI), ARCHITECTURE.md documents the trust model honestly | ✓ Faithful |
|
||||||
|
| 3. Terminal retention | 15-min full terminal histories in snapshots, then tombstone-only for the session; unresolved Time's-up evicted as a unit after 5 min; `running`/`cancelled` states with `terminalAt`; ticker/overlay rendering, sorted last, excluded from badge; unknown-game degraded rendering kept; frontend raw-map pruning; SPEC.md + ARCHITECTURE.md updated incl. clock-skew assumption; S49 added | ✓ Faithful |
|
||||||
|
| 4. Extend from current deadline | `max(now, deadline) + 5min`, overdue case preserved, terminal calls non-extendable (controls unrendered) | ✓ Faithful |
|
||||||
|
| 5. Startup message | Exact planned wording, store errors no longer mark transport unavailable, four distinct messages (startup / obsolete / full / unexpected), connecting message auto-clears once the peer is ready | ✓ Faithful |
|
||||||
|
|
||||||
|
All six Fable-5 findings are addressed, and every invariant in the plan's list
|
||||||
|
verifiably holds in the final code. The "merge histories atomically" fix
|
||||||
|
correctly treats findings 2+5 as one problem, as the findings demanded.
|
||||||
|
|
||||||
|
**Test-plan gaps (minor):**
|
||||||
|
|
||||||
|
1. **No end-to-end test of the triggered heal.** The plan's "NeedHistory,
|
||||||
|
NeedHandshake, and lost acknowledgements trigger idempotent handshake
|
||||||
|
healing" is only covered at the `delivery_resync_reason` decision level.
|
||||||
|
S48/S49 prove handshake-based reconstruction, but nothing drives an
|
||||||
|
orphan-AddTime → NeedHistory → resync → revival sequence. Understandable
|
||||||
|
(needs 5-min waits or clock mocking), but it's a real gap against the plan's
|
||||||
|
own list.
|
||||||
|
2. **"Terminal controls and chat composer are disabled"** has no automated test
|
||||||
|
(frontend tests are lib-level only; there is no component-test
|
||||||
|
infrastructure). Verified by inspection instead.
|
||||||
|
3. **"Fix snapshot waiting via the direct reply path"** turned out to be a no-op
|
||||||
|
— the CLI's `call_to_play_events` already polls through the reply channel.
|
||||||
|
Justified deviation.
|
||||||
|
|
||||||
|
**Nits:** `known_peer_id_accepts_live_events_without_transport_ip_matching` is
|
||||||
|
somewhat vacuous (the function no longer takes `remote_addr`, so IP mismatch
|
||||||
|
can't even be expressed) — harmless as intent documentation.
|
||||||
|
`callToPlayPublishErrorMessage` substring-matches backend error strings — a
|
||||||
|
brittle coupling, though the tests pin the current wording.
|
||||||
|
|
||||||
|
## b) Architecture — sound choices throughout
|
||||||
|
|
||||||
|
- **Derived event IDs + tombstones** is the elegant part: the ID set is bounded
|
||||||
|
by construction, revival works, resurrection is blocked, and finding 2's
|
||||||
|
"retained IDs correspond to retained events and deliberate terminal
|
||||||
|
tombstones" is literally realized.
|
||||||
|
- **Fail-closed batch rejection** (invalid/conflict → store untouched) is the
|
||||||
|
right default under the trusted-LAN model. The trade-off — one bad event voids
|
||||||
|
an entire handshake heal — is practically unreachable: IDs are UUIDs and
|
||||||
|
validation is deterministic and identical sender-side.
|
||||||
|
- **Retention symmetry is the quiet win:** backend compaction and frontend
|
||||||
|
derivation use the same constants (5/15 min) keyed off _event timestamps_, not
|
||||||
|
receipt times. All peers converge on identical visibility with zero extra
|
||||||
|
protocol, and the S49 late-joiner case falls out naturally.
|
||||||
|
- **Ack semantics map 1:1 onto merge outcomes**, and the heal is idempotent
|
||||||
|
(lost ack → resync → duplicate). Not rebroadcasting live events keeps it
|
||||||
|
loop-free. The identity story is now honest: roster + actor match, documented
|
||||||
|
as not-authentication.
|
||||||
|
- **Capacity on unresolved history only** fixes the "Start at cap" deadlock and
|
||||||
|
keeps settled calls from pressuring new ones. Tombstones accumulate one small
|
||||||
|
event per finished call per session — negligible and deliberate.
|
||||||
|
- The handshake receiver _logging_ missing roots rather than re-requesting is
|
||||||
|
correct — the handshake is itself the heal, and re-requesting would loop.
|
||||||
|
|
||||||
|
## c) User experience — a genuine improvement; lifecycle finally coherent
|
||||||
|
|
||||||
|
The old flow had two genuinely weird behaviors: a started call vanished after
|
||||||
|
**3 seconds**, and a cancelled call vanished **instantly** — mid-conversation,
|
||||||
|
for everyone. The new flow (Open/Ready → Time's up, recoverable →
|
||||||
|
Running/Cancelled receipts for 15 min → retired) matches how a LAN party
|
||||||
|
actually works: "who's playing what right now?" is answerable at a glance,
|
||||||
|
receipts sort last, stay out of the badge, and chat remains readable. "Time's
|
||||||
|
up" vs "Running" being distinct states (unresolved vs final receipt) is the
|
||||||
|
right call — deadline passage never implies the game started. Add-time now does
|
||||||
|
what its label says, the startup message no longer blames the wrong cause, and
|
||||||
|
error messages are specific and actionable ("Start or cancel an active call,
|
||||||
|
then try again").
|
||||||
|
|
||||||
|
**Residual friction, in decreasing order of importance:**
|
||||||
|
|
||||||
|
1. **First-run users see "still connecting… try again in a moment" forever.**
|
||||||
|
The peer only starts via `update_game_directory`, so without a game folder
|
||||||
|
there is no "moment" after which it connects. Finding 6 explicitly wanted
|
||||||
|
folder guidance _reserved for a known missing-folder condition_ — the plan
|
||||||
|
narrowed that to just the connecting message and the implementation follows
|
||||||
|
the plan, so this is faithful, but the finding's full intent isn't realized.
|
||||||
|
The frontend already has `hasGameDirectory` in `useGameDirectory`; wiring it
|
||||||
|
into the Call to Play error path would close this cheaply. This is the one
|
||||||
|
place where deviating from the plan would have been justified.
|
||||||
|
2. **Terminal ticker rows render `MiniBubbles` with live "ready in Xm" countdown
|
||||||
|
tags** for participants whose `readyAt` hadn't elapsed — a Running receipt
|
||||||
|
with ticking countdowns looks slightly alive when it's meant to be a receipt.
|
||||||
|
3. **Terminal cards render empty roster slots up to maxPlayers** — on a
|
||||||
|
Cancelled receipt, empty slots can read as "seats still open".
|
||||||
|
4. Pre-existing, out of scope: the badge counts Time's-up calls for everyone,
|
||||||
|
though only the creator can act on them.
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
Approve. The plan is implemented faithfully and, where it matters (derived IDs,
|
||||||
|
atomic merge, ack-driven healing, retention symmetry), the execution is as good
|
||||||
|
as or better than the plan described. Architecture and UX are coherent. Before
|
||||||
|
merging I'd only consider: (1) the missing-folder special case, since the
|
||||||
|
finding called for it and the data is already available in the frontend, (2) the
|
||||||
|
two cosmetic terminal-receipt nits, and (3) noting the untested heal loop as a
|
||||||
|
known coverage gap — none of which are blockers.
|
||||||
@@ -68,9 +68,8 @@ up here. Structure:
|
|||||||
special cases, dedup keys that re-derive existing facts) that signal the
|
special cases, dedup keys that re-derive existing facts) that signal the
|
||||||
smell.
|
smell.
|
||||||
5. **Clean shape** — what the code would look like without the constraint.
|
5. **Clean shape** — what the code would look like without the constraint.
|
||||||
6. **Warning signs** — what observations in future work mean "do the
|
6. **Warning signs** — what observations in future work mean "do the refactor
|
||||||
refactor now."
|
now."
|
||||||
|
|
||||||
Keep entries narrative, not bulleted to death. The point is to preserve the
|
Keep entries narrative, not bulleted to death. The point is to preserve the
|
||||||
_reasoning_ so future contributors can decide whether the trade-off still
|
_reasoning_ so future contributors can decide whether the trade-off still holds.
|
||||||
holds.
|
|
||||||
@@ -31,8 +31,8 @@ and every manual invalidation call.
|
|||||||
|
|
||||||
## Implementation Steps
|
## Implementation Steps
|
||||||
|
|
||||||
1. Remove commit `a9f9845` from the local branch history before implementing
|
1. Remove commit `a9f9845` from the local branch history before implementing the
|
||||||
the replacement, so the final code is not built on the band-aid.
|
replacement, so the final code is not built on the band-aid.
|
||||||
2. Replace `PeerEvent::LocalGamesUpdated { games, active_operations }` with:
|
2. Replace `PeerEvent::LocalGamesUpdated { games, active_operations }` with:
|
||||||
- `PeerEvent::LocalLibraryChanged { games }`;
|
- `PeerEvent::LocalLibraryChanged { games }`;
|
||||||
- `PeerEvent::ActiveOperationsChanged { active_operations }`.
|
- `PeerEvent::ActiveOperationsChanged { active_operations }`.
|
||||||
@@ -49,8 +49,8 @@ and every manual invalidation call.
|
|||||||
6. Update the Tauri event loop to reconcile `ActiveOperationsChanged`
|
6. Update the Tauri event loop to reconcile `ActiveOperationsChanged`
|
||||||
independently, and call `emit_games_list` after both library and operation
|
independently, and call `emit_games_list` after both library and operation
|
||||||
state changes.
|
state changes.
|
||||||
7. Update focused tests in peer handlers, local monitor, liveness, context guard,
|
7. Update focused tests in peer handlers, local monitor, liveness, context
|
||||||
and Tauri reconciliation to prove:
|
guard, and Tauri reconciliation to prove:
|
||||||
- unchanged settled scans do not emit local-library events;
|
- unchanged settled scans do not emit local-library events;
|
||||||
- operation starts/transitions/ends emit authoritative snapshots;
|
- operation starts/transitions/ends emit authoritative snapshots;
|
||||||
- exceptional guard cleanup clears the operation snapshot;
|
- exceptional guard cleanup clears the operation snapshot;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Fable 5 findings
|
||||||
|
|
||||||
|
Review of the issues Fable found after the Call to Play follow-up fixes. This
|
||||||
|
document records the assessment only; it is not an implementation plan.
|
||||||
|
|
||||||
|
## 1. IP-based actor verification can reject legitimate peers
|
||||||
|
|
||||||
|
**Assessment: real and must-fix.**
|
||||||
|
|
||||||
|
`handle_call_to_play_events` requires the QUIC connection's source IP to match
|
||||||
|
the peer's advertised listener IP. That assumption is unreliable for a
|
||||||
|
multi-homed LAN machine: routing may select Ethernet, Wi-Fi, a VPN, or a bridge
|
||||||
|
address different from the advertised address. The receiver silently drops the
|
||||||
|
event, while the one-way sender sees a successful write and does not invoke its
|
||||||
|
resync fallback.
|
||||||
|
|
||||||
|
The check is not strong authentication either. Two peers on one host share an
|
||||||
|
IP, and the project currently uses a shared application TLS certificate rather
|
||||||
|
than a cryptographic identity unique to each `peer_id`. A normal client still
|
||||||
|
stamps its own peer ID, so same-host impersonation requires a modified client,
|
||||||
|
but the IP comparison should not be presented as an authenticated identity
|
||||||
|
boundary.
|
||||||
|
|
||||||
|
A sound correction must either bind a peer ID to a connection through a
|
||||||
|
same-connection protocol exchange, return an application-level
|
||||||
|
acknowledgement/rejection that can trigger resync, or deliberately rely on the
|
||||||
|
documented trusted-LAN model without the unreliable IP equality test.
|
||||||
|
|
||||||
|
## 2. Evicted event IDs can prevent a revived call from healing
|
||||||
|
|
||||||
|
**Assessment: real and must-fix together with finding 5.**
|
||||||
|
|
||||||
|
Expiry removes call events from `events` but leaves their IDs in `event_ids`. If
|
||||||
|
one peer expires a call and later receives an orphan `AddTime`, a subsequent
|
||||||
|
handshake cannot restore the original `Create`: it is rejected forever as a
|
||||||
|
duplicate. The call can therefore be alive on its creator while remaining
|
||||||
|
invisible on the other peer. The ID set also grows without a bound for the
|
||||||
|
session.
|
||||||
|
|
||||||
|
Pruning expired IDs alone is not sufficient with the current `insert_all`
|
||||||
|
behavior. A handshake commonly supplies `Create` followed by later actions;
|
||||||
|
per-event compaction can expire and remove `Create` before the merge reaches the
|
||||||
|
extending `AddTime`. Healing requires atomic batch semantics: validate and
|
||||||
|
deduplicate the batch, merge it with retained events, then compact once using
|
||||||
|
the complete history.
|
||||||
|
|
||||||
|
Retained IDs should correspond to retained events and deliberate terminal
|
||||||
|
tombstones, not every event ever observed.
|
||||||
|
|
||||||
|
## 3. `insert` can accept an event that compaction immediately removes
|
||||||
|
|
||||||
|
**Assessment: correct, lower-severity correctness/semantics issue.**
|
||||||
|
|
||||||
|
An action targeting a call that expired more than five minutes ago can be
|
||||||
|
inserted, removed by compaction in the same operation, and still return
|
||||||
|
`Ok(true)`. It is then emitted to the local UI and broadcast to peers even
|
||||||
|
though it has no durable effect.
|
||||||
|
|
||||||
|
The presentation reducer normally hides the result, but the accepted signal is
|
||||||
|
misleading and the work is wasted. Store results should distinguish retained,
|
||||||
|
duplicate, rejected, and immediately obsolete events, and only retained events
|
||||||
|
should be emitted or broadcast.
|
||||||
|
|
||||||
|
## 4. "Add 5 more minutes" can shorten the deadline
|
||||||
|
|
||||||
|
**Assessment: real user-visible bug.**
|
||||||
|
|
||||||
|
The action currently sets the deadline to `now + 5 minutes`. If a roster fills
|
||||||
|
early while the call still has more than five minutes remaining, the button
|
||||||
|
shortens the call despite saying that it adds time.
|
||||||
|
|
||||||
|
The intended calculation is `max(now, current deadline) + 5 minutes`. That also
|
||||||
|
behaves naturally for an already expired call.
|
||||||
|
|
||||||
|
## 5. Handshake history merge is quadratic
|
||||||
|
|
||||||
|
**Assessment: correct, and part of the correctness fix for finding 2 rather than
|
||||||
|
merely a performance nit.**
|
||||||
|
|
||||||
|
`insert_all` calls `insert` for every incoming event, and each insertion
|
||||||
|
rebuilds several maps over the growing store while holding its write lock.
|
||||||
|
Merging a large handshake history is therefore O(n²).
|
||||||
|
|
||||||
|
Compacting once after an atomic batch merge removes that cost and is also what
|
||||||
|
allows `Create` plus a later `AddTime` to revive consistently. Findings 2 and 5
|
||||||
|
should be treated as one store-semantics problem.
|
||||||
|
|
||||||
|
## 6. The startup error points users at the wrong cause
|
||||||
|
|
||||||
|
**Assessment: correct UX issue.**
|
||||||
|
|
||||||
|
When `actorId` is still unavailable, the peer may simply be starting. Telling
|
||||||
|
the user to choose a game folder is misleading unless the application actually
|
||||||
|
knows that the folder is missing. The normal startup state should say that Call
|
||||||
|
to Play is connecting and ask the user to try again shortly; folder guidance
|
||||||
|
should be reserved for a known missing-folder condition.
|
||||||
|
|
||||||
|
## Priority
|
||||||
|
|
||||||
|
1. Replace the unreliable IP identity check or make delivery acknowledged.
|
||||||
|
2. Correct batch merge, compaction, and event-ID retention as one change.
|
||||||
|
3. Correct the deadline-extension calculation.
|
||||||
|
4. Stop reporting and broadcasting immediately obsolete events.
|
||||||
|
5. Correct the startup message.
|
||||||
|
|
||||||
|
The existing QUIC connection must not be described as carrying an authenticated
|
||||||
|
per-peer identity until the protocol actually establishes one.
|
||||||
@@ -4,22 +4,23 @@
|
|||||||
|
|
||||||
### Crash-during-download leaves orphan archive files
|
### Crash-during-download leaves orphan archive files
|
||||||
|
|
||||||
`crates/lanspread-peer/src/install/transaction.rs:329` — `recover_download_transients`
|
`crates/lanspread-peer/src/install/transaction.rs:329` —
|
||||||
sweeps only `.version.ini.tmp` and `.version.ini.discarded` on startup. The new
|
`recover_download_transients` sweeps only `.version.ini.tmp` and
|
||||||
cancel-cleanup (`download/storage.rs::discard_cancelled_download`) is only invoked
|
`.version.ini.discarded` on startup. The new cancel-cleanup
|
||||||
from the in-flight orchestrator, so a crash mid-download leaves partial `.eti`
|
(`download/storage.rs::discard_cancelled_download`) is only invoked from the
|
||||||
archives in the game root. After restart the user sees a game that looks
|
in-flight orchestrator, so a crash mid-download leaves partial `.eti` archives
|
||||||
half-downloaded with no way to clean it up except `RemoveDownloadedGame`. Closing
|
in the game root. After restart the user sees a game that looks half-downloaded
|
||||||
this would mean calling the same discard pass during recovery for any game root
|
with no way to clean it up except `RemoveDownloadedGame`. Closing this would
|
||||||
whose intent is `None` and whose `version.ini` is absent.
|
mean calling the same discard pass during recovery for any game root whose
|
||||||
|
intent is `None` and whose `version.ini` is absent.
|
||||||
|
|
||||||
Not blocking. The cancel-button fix is correct in its scope; this is the symmetric
|
Not blocking. The cancel-button fix is correct in its scope; this is the
|
||||||
crash-recovery case.
|
symmetric crash-recovery case.
|
||||||
|
|
||||||
### `handleErrorEvent` still writes status fields directly
|
### `handleErrorEvent` still writes status fields directly
|
||||||
|
|
||||||
`crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts:80-89` — the error
|
`crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts:80-89` — the error handler
|
||||||
handler writes `install_status`, `status_message`, `status_level`, and
|
writes `install_status`, `status_message`, `status_level`, and
|
||||||
`download_progress` from a lifecycle event, which is the same "two sources of
|
`download_progress` from a lifecycle event, which is the same "two sources of
|
||||||
truth" pattern that commit `5df82aa` ("fix(ui): derive operation status from
|
truth" pattern that commit `5df82aa` ("fix(ui): derive operation status from
|
||||||
snapshots") removed everywhere else. That commit explicitly carved out error
|
snapshots") removed everywhere else. That commit explicitly carved out error
|
||||||
@@ -46,8 +47,8 @@ The previous three findings have landed in code and tests:
|
|||||||
ordered state transitions. Covered by
|
ordered state transitions. Covered by
|
||||||
`download_handoff_waits_for_readers_and_auto_installs` and the liveness
|
`download_handoff_waits_for_readers_and_auto_installs` and the liveness
|
||||||
cancellation tests.
|
cancellation tests.
|
||||||
- Library index reads and writes are serialized by `LIBRARY_INDEX_LOCK`.
|
- Library index reads and writes are serialized by `LIBRARY_INDEX_LOCK`. Covered
|
||||||
Covered by `concurrent_rescans_preserve_both_index_updates`.
|
by `concurrent_rescans_preserve_both_index_updates`.
|
||||||
|
|
||||||
Manual install/update/uninstall smoke testing is still a useful release check,
|
Manual install/update/uninstall smoke testing is still a useful release check,
|
||||||
but there are no known blocking findings left in this file.
|
but there are no known blocking findings left in this file.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Call to Play review findings evaluation
|
||||||
|
|
||||||
|
The implementation is fundamentally sound, but I would make four focused
|
||||||
|
follow-ups before calling it finished. Two address real behavioral gaps; two
|
||||||
|
complete the new lifecycle cleanly.
|
||||||
|
|
||||||
|
## Worth fixing before finish
|
||||||
|
|
||||||
|
### 1. Propagate rootless terminal tombstones
|
||||||
|
|
||||||
|
This is the most important finding.
|
||||||
|
|
||||||
|
After 15 minutes, a peer retains only the `Start` or `Cancel` event. A fresh
|
||||||
|
peer receiving that tombstone during handshake currently rejects it as missing
|
||||||
|
its `Create` root, and the handshake path merely logs the missing root.
|
||||||
|
|
||||||
|
That means the fresh peer has no protection against a stale peer later
|
||||||
|
resurrecting the finished call. It only self-heals if a tombstone-holder is
|
||||||
|
still online afterward.
|
||||||
|
|
||||||
|
I would:
|
||||||
|
|
||||||
|
- Add an explicit handshake merge policy that permits rootless `Start` and
|
||||||
|
`Cancel` events as non-visible tombstones.
|
||||||
|
- Keep local and live orphan actions returning `NeedHistory`.
|
||||||
|
- Never expose a rootless tombstone as a nomination in the UI.
|
||||||
|
- Verify that a later stale complete history is classified obsolete.
|
||||||
|
- Add store and handshake tests for this sequence.
|
||||||
|
|
||||||
|
This restores the intended "tombstone prevents resurrection for the rest of the
|
||||||
|
session" invariant. I would fix the semantics, not merely downgrade the repeated
|
||||||
|
warning.
|
||||||
|
|
||||||
|
### 2. Distinguish missing game directory from peer startup
|
||||||
|
|
||||||
|
Kimi is correct here. The peer is only started after a valid directory reaches
|
||||||
|
`update_game_directory`. Without one, "still connecting -- try again" never
|
||||||
|
becomes true.
|
||||||
|
|
||||||
|
I would:
|
||||||
|
|
||||||
|
- Model directory readiness as `checking | missing | ready`, rather than passing
|
||||||
|
only a boolean that conflates hydration with a known missing directory.
|
||||||
|
- Pass that prerequisite state into `useCallToPlay`.
|
||||||
|
- Show folder guidance only for the confirmed `missing` state.
|
||||||
|
- Preserve the current connecting message for `checking` or
|
||||||
|
`ready-but-peer-starting`.
|
||||||
|
- Test both states and the transition after a valid directory is selected.
|
||||||
|
|
||||||
|
That finishes the original finding's full intent without returning to misleading
|
||||||
|
folder advice during normal startup.
|
||||||
|
|
||||||
|
### 3. Add a local Launch action to Running receipts
|
||||||
|
|
||||||
|
Fable's UX point is persuasive. Participants currently reach the key moment and
|
||||||
|
see only that the game is running.
|
||||||
|
|
||||||
|
I would add a local-only Launch button when:
|
||||||
|
|
||||||
|
- The call is `running`.
|
||||||
|
- The game is installed and launchable locally.
|
||||||
|
- No conflicting operation prevents launch.
|
||||||
|
|
||||||
|
This would not violate the read-only terminal invariant: launching the local
|
||||||
|
game does not mutate the replicated call. I would use a dedicated play callback
|
||||||
|
rather than the generic primary action, so a button labelled "Launch" cannot
|
||||||
|
unexpectedly initiate an install or update.
|
||||||
|
|
||||||
|
### 4. Make terminal receipts visually static and correct the spec
|
||||||
|
|
||||||
|
The terminal receipt details are small but real:
|
||||||
|
|
||||||
|
- `MiniBubbles` continue advancing pending-ready countdowns after a call is
|
||||||
|
Running or Cancelled.
|
||||||
|
- Terminal cards still draw empty seats.
|
||||||
|
- The specification still says "one row per active call" and documents the old
|
||||||
|
ticker rank, despite terminal rows and expired-first ordering.
|
||||||
|
|
||||||
|
I would:
|
||||||
|
|
||||||
|
- Freeze participant readiness at `terminalAt`, or render terminal participants
|
||||||
|
without countdown tags.
|
||||||
|
- Suppress empty roster slots on terminal cards.
|
||||||
|
- Change Ready ticker text to name the creator.
|
||||||
|
- Update the ticker specification to match the actual visible-call and ranking
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
## Coverage to add alongside those fixes
|
||||||
|
|
||||||
|
I would close the acknowledgement-heal test gap, but without introducing a full
|
||||||
|
React test stack or waiting five real minutes:
|
||||||
|
|
||||||
|
- Exercise `NeedHistory` from a live orphan action.
|
||||||
|
- Apply the full history through the handshake merge path.
|
||||||
|
- Assert the receiver is revived exactly once.
|
||||||
|
- Cover the new rootless-tombstone handshake followed by stale-history
|
||||||
|
rejection.
|
||||||
|
- Rename the IP test so its name honestly describes what it proves; the current
|
||||||
|
helper no longer accepts a source address.
|
||||||
|
|
||||||
|
## Findings not worth pursuing now
|
||||||
|
|
||||||
|
- **Backend/frontend semantic duplication:** a real maintenance risk, but
|
||||||
|
eliminating it would require an architectural rewrite. Shared conformance
|
||||||
|
fixtures could be a later improvement.
|
||||||
|
- **Full-store cloning per merge:** acceptable under the 4,096 unresolved-event
|
||||||
|
cap.
|
||||||
|
- **Substring-matched frontend errors:** brittle, but currently pinned by tests;
|
||||||
|
a proper fix requires a typed peer-to-Tauri error contract and is
|
||||||
|
disproportionate for finishing this branch.
|
||||||
|
- **Missing component-test infrastructure:** inspection plus pure reducer tests
|
||||||
|
is adequate here; I would not add a UI test framework solely for these
|
||||||
|
controls.
|
||||||
|
- **Time's-up calls in the badge:** intentional. These calls remain unresolved
|
||||||
|
and visible, even for non-creators.
|
||||||
|
- **The direct-reply observation:** there is nothing to fix; the CLI already
|
||||||
|
uses the reply channel.
|
||||||
|
|
||||||
|
## Recommended finish scope
|
||||||
|
|
||||||
|
The recommended finish scope is:
|
||||||
|
|
||||||
|
1. Tombstone propagation.
|
||||||
|
2. Accurate directory/startup diagnosis.
|
||||||
|
3. A Running-card Launch action.
|
||||||
|
4. Terminal-receipt polish and specification corrections.
|
||||||
|
5. Targeted replication tests.
|
||||||
Reference in New Issue
Block a user