Compare commits

..
5 Commits
Author SHA1 Message Date
ddidderr 8f151e38b4 docs(launcher): add Call to Play specification and design reference
Extend the launcher design specification and prototype reference with the
"Call to Play" multiplayer coordination feature and clean up top-bar chrome.

Key additions and changes:
- Spec & Roadmap: Document Call to Play mechanics in SPEC.md, including Play
  Now / Scheduled call flavors, 15-minute check-in windows, top-bar button
  with active count badge, quick-bar ticker stack above grid, overlay modal,
  and per-call group chat. Update design/README.md.
- Components & Logic: Add calltoplay.jsx and ctp-chat.jsx components for call
  creation, status progression, and chat. Extend data.jsx with a mock peer
  roster (PEERS) and helper functions for peer install count tracking.
- Top-bar Cleanup: Move game-folder configuration from top bar into Settings ->
  Library, freeing top-bar space for the Call to Play action button.
- Styling & Layout: Add CTP quick-bar, ticker, badge, card, and chat styles to
  styles.css, and integrate CTP components into launcher.jsx, components.jsx,
  and SoftLAN Launcher.html.

Test Plan:
- `git diff --cached --check` -- passed (no trailing whitespace or conflict markers)
- `cargo check --workspace` -- passed cleanly
- Manual review of staged diff across 9 design/launcher files -- confirmed clean staging
2026-07-21 20:00:07 +02:00
ddidderr 8d54e6e954 [deps] cargo update
Adding   syn                 v3.0.2
Updating anyhow              v1.0.103 -> v1.0.104
Updating bytemuck            v1.25.1  -> v1.25.2
Updating cc                  v1.2.67  -> v1.3.0
Updating fastrand            v2.4.1   -> v2.5.0
Updating futures-channel     v0.3.32  -> v0.3.33
Updating futures-core        v0.3.32  -> v0.3.33
Updating futures-executor    v0.3.32  -> v0.3.33
Updating futures-io          v0.3.32  -> v0.3.33
Updating futures-macro       v0.3.32  -> v0.3.33
Updating futures-sink        v0.3.32  -> v0.3.33
Updating futures-task        v0.3.32  -> v0.3.33
Updating futures-util        v0.3.32  -> v0.3.33
Updating futures             v0.3.32  -> v0.3.33
Updating glob                v0.3.3   -> v0.3.4
Updating hyper               v1.10.1  -> v1.11.0
Updating libc                v0.2.186 -> v0.2.188
Updating proc-macro2         v1.0.106 -> v1.0.107
Updating quote               v1.0.46  -> v1.0.47
Updating ref-cast-impl       v1.0.25  -> v1.0.26
Updating ref-cast            v1.0.25  -> v1.0.26
Updating serde_core          v1.0.228 -> v1.0.229
Updating serde_derive        v1.0.228 -> v1.0.229
Updating serde_json          v1.0.150 -> v1.0.151
Updating serde_repr          v0.1.20  -> v0.1.21
Updating serde               v1.0.228 -> v1.0.229
Updating tauri-plugin-dialog v2.7.1   -> v2.7.2
Updating tauri-plugin-store  v2.4.3   -> v2.4.4
Updating thiserror-impl      v2.0.18  -> v2.0.19
Updating thiserror           v2.0.18  -> v2.0.19
Updating time-macros         v0.2.31  -> v0.2.32
Updating time                v0.3.53  -> v0.3.54
Updating tokio-util          v0.7.18  -> v0.7.19
Updating tokio               v1.53.0  -> v1.53.1
Updating zerocopy-derive     v0.8.54  -> v0.8.55
Updating zerocopy            v0.8.54  -> v0.8.55
2026-07-21 19:54:03 +02:00
ddidderr fa8bd86281 [deps] cargo update
Updating aws-lc-rs      v1.17.1             -> v1.17.3
Updating aws-lc-sys     v0.42.0             -> v0.43.0
Updating bitflags       v2.13.0             -> v2.13.1
Updating http-body-util v0.1.3              -> v0.1.4
Updating http-body      v1.0.1              -> v1.1.0
Updating mdns-sd        v0.20.1             -> v0.20.2
Updating mio            v1.2.1              -> v1.2.2
Updating regex-automata v0.4.15             -> v0.4.16
Updating regex          v1.13.0             -> v1.13.1
Updating rustls         v0.23.41            -> v0.23.42
Updating simd-adler32   v0.3.9              -> v0.3.10
Updating socket2        v0.6.4              -> v0.6.5
Updating spin           v0.9.8              -> v0.9.9
Updating syn            v2.0.118            -> v2.0.119
Updating tokio-macros   v2.7.0              -> v2.7.1
Updating tokio          v1.52.3             -> v1.53.0
Updating toml_edit      v0.25.12+spec-1.1.0 -> v0.25.13+spec-1.1.0
Updating toml           v1.1.2+spec-1.1.0   -> v1.1.3+spec-1.1.0
Updating toml_writer    v1.1.1+spec-1.1.0   -> v1.1.2+spec-1.1.0
Updating uuid           v1.23.4             -> v1.24.0
Updating winnow         v1.0.3              -> v1.0.4
Updating zmij           v1.0.21             -> v1.0.23
2026-07-18 01:16:56 +02:00
ddidderr 6379e97db4 [deps] cargo update
Removing flume                      v0.11.1
Removing pathdiff                   v0.2.3
Removing utf-8                      v0.7.6
Updating anyhow                     v1.0.102 -> v1.0.103
Updating aws-lc-rs                  v1.17.0  -> v1.17.1
Updating aws-lc-sys                 v0.41.0  -> v0.42.0
Updating bytemuck                   v1.25.0  -> v1.25.1
Updating bytes                      v1.12.0  -> v1.12.1
Updating camino                     v1.2.3   -> v1.2.4
Updating cc                         v1.2.65  -> v1.2.67
Updating chacha20                   v0.10.0  -> v0.10.1
Updating crossbeam-channel          v0.5.15  -> v0.5.16
Updating crossbeam-queue            v0.3.12  -> v0.3.13
Updating crossbeam-utils            v0.8.21  -> v0.8.22
Updating dbus                       v0.9.11  -> v0.9.12
Updating embed-resource             v3.0.9   -> v3.0.11
Updating inotify-sys                v0.1.5   -> v0.1.8
Updating inotify                    v0.11.2  -> v0.11.4
Updating jobserver                  v0.1.34  -> v0.1.35
Updating js-sys                     v0.3.102 -> v0.3.103
Updating libredox                   v0.1.17  -> v0.1.18
Updating log                        v0.4.32  -> v0.4.33
Updating mdns-sd                    v0.20.0  -> v0.20.1
Updating memchr                     v2.8.2   -> v2.8.3
Updating open                       v5.3.5   -> v5.4.0
Updating plist                      v1.9.0   -> v1.10.0
Updating quick-xml                  v0.39.4  -> v0.41.0
Updating quote                      v1.0.45  -> v1.0.46
Updating rand                       v0.10.1  -> v0.10.2
Updating regex-automata             v0.4.14  -> v0.4.15
Updating regex                      v1.12.4  -> v1.13.0
Updating rustc-hash                 v2.1.2   -> v2.1.3
Updating rustls-pki-types           v1.14.1  -> v1.15.0
Updating rustls                     v0.23.40 -> v0.23.41
Updating rustversion                v1.0.22  -> v1.0.23
Updating s2n-codec                  v0.82.0  -> v0.83.0
Updating s2n-quic-core              v0.82.0  -> v0.83.0
Updating s2n-quic-crypto            v0.82.0  -> v0.83.0
Updating s2n-quic-platform          v0.82.0  -> v0.83.0
Updating s2n-quic-rustls            v0.82.0  -> v0.83.0
Updating s2n-quic-tls-default       v0.82.0  -> v0.83.0
Updating s2n-quic-tls               v0.82.0  -> v0.83.0
Updating s2n-quic-transport         v0.82.0  -> v0.83.0
Updating s2n-quic                   v1.82.0  -> v1.83.0
Updating s2n-tls-sys                v0.3.38  -> v0.3.40
Updating s2n-tls                    v0.3.38  -> v0.3.40
Updating socket-pktinfo             v0.3.2   -> v0.4.0
Updating tauri-runtime-wry          v2.11.3  -> v2.11.4
Updating tauri                      v2.11.3  -> v2.11.5
Updating tendril                    v0.5.0   -> v0.5.1
Updating thread_local               v1.1.9   -> v1.1.10
Updating time-macros                v0.2.29  -> v0.2.31
Updating time                       v0.3.49  -> v0.3.53
Updating tinyvec                    v1.11.0  -> v1.12.0
Updating uuid                       v1.23.3  -> v1.23.4
Updating wasm-bindgen-futures       v0.4.75  -> v0.4.76
Updating wasm-bindgen-macro-support v0.2.125 -> v0.2.126
Updating wasm-bindgen-macro         v0.2.125 -> v0.2.126
Updating wasm-bindgen-shared        v0.2.125 -> v0.2.126
Updating wasm-bindgen               v0.2.125 -> v0.2.126
Updating web-sys                    v0.3.102 -> v0.3.103
Updating zerocopy-derive            v0.8.52  -> v0.8.54
Updating zerocopy                   v0.8.52  -> v0.8.54
2026-07-12 17:12:11 +02:00
ddidderr d376983296 fix: terminate unrar sidecars when the launcher closes mid-install
Bug report: unrar.exe kept running after closing the launcher during a
game install. The orphaned process kept extracting in the background and
held file handles on the staging directory.

Root cause (regular install path): run_unrar_sidecar ran the unrar
sidecar via tauri-plugin-shell's Command::output(). That helper spawns
the process on a detached SharedChild OS thread and immediately drops the
CommandChild; there is no Drop impl that kills the process. On app exit
only shutdown_peer_runtime ran, and Windows does not cascade-kill child
processes, so closing the launcher left unrar running.

Fix:
- run_unrar_sidecar now uses .spawn() instead of .output(), keeping a
  killable CommandChild. It registers the child in a new
  LanSpreadState.active_unrar_children registry and an RAII
  UnrarChildGuard deregisters it on every return path. The CommandEvent
  stream is drained to reproduce the exact stdout/stderr (NEWLINE_BYTE
  is b'\n'), status code, success flag, and UnpackLogEntry the old
  .output() produced, so logging behavior is unchanged.
- kill_active_unrar_children() runs in the RunEvent::Exit handler before
  shutdown_peer_runtime, killing every in-progress unrar. Killing first
  also lets the install task unwind so the runtime stops promptly.

Two concurrency hazards were closed in the registry design:
- Children are keyed by a monotonic id, not pid. A pid key let a
  finishing install's guard deregister a different install's child after
  the OS recycled the pid, which could re-orphan a live child.
- A shutting_down latch lives in the registry under the same mutex as
  the kill sweep. The sweep is a one-shot drain, so a child registered
  after it (a task caught between spawn() and registration, or a later
  archive in a multi-archive install -- unpack_archives does not observe
  the shutdown token) would be missed. Registration now checks the latch
  under that mutex and kills the child immediately instead of inserting
  it. Since registration and the sweep serialize on one mutex, every
  interleaving kills the child.

Also hardened the streamed-install sender path (ExternalUnrarStream
Provider) with kill_on_drop(true) on its tokio unrar spawns, so a
dropped or aborted producer task cannot orphan unrar there either.

Known limitation: a hard force-kill or crash of the launcher (e.g. Task
Manager -> End Task) bypasses RunEvent::Exit and is not covered. Making
that bulletproof would require a Windows Job Object with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; the reported "close the launcher"
case is fully fixed.

Test Plan:
- just clippy (pedantic, -D warnings): clean.
- just fmt: no changes.
- just test: all suites pass (incl. the 20 Tauri-lib unit tests).
- Manual (Windows): start a game install, close the launcher mid-extract,
  confirm no unrar.exe remains in Task Manager. Repeat with two
  concurrent installs and with a multi-archive game.
2026-06-22 07:07:45 +02:00
12 changed files with 2301 additions and 344 deletions
Generated
+312 -324
View File
@@ -49,9 +49,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "atk"
@@ -99,9 +99,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
version = "1.17.0"
version = "1.17.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -110,14 +110,15 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.41.0"
version = "0.43.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
@@ -155,9 +156,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.0"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
dependencies = [
"serde_core",
]
@@ -218,9 +219,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.0"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
@@ -230,9 +231,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.12.0"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
dependencies = [
"serde",
]
@@ -243,7 +244,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"cairo-sys-rs",
"glib",
"libc",
@@ -264,9 +265,9 @@ dependencies = [
[[package]]
name = "camino"
version = "1.2.3"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f"
checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0"
dependencies = [
"serde_core",
]
@@ -291,7 +292,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"thiserror 2.0.18",
"thiserror 2.0.19",
]
[[package]]
@@ -306,9 +307,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.65"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -351,9 +352,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.10.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -432,7 +433,7 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"core-foundation",
"core-graphics-types",
"foreign-types",
@@ -445,7 +446,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"core-foundation",
"libc",
]
@@ -494,27 +495,27 @@ dependencies = [
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crypto-common"
@@ -546,7 +547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -596,7 +597,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -607,14 +608,14 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
name = "dbus"
version = "0.9.11"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73"
checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
dependencies = [
"libc",
"libdbus-sys",
@@ -648,7 +649,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -688,7 +689,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"libc",
"objc2",
@@ -702,7 +703,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -725,7 +726,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -811,14 +812,14 @@ dependencies = [
[[package]]
name = "embed-resource"
version = "3.0.9"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb"
checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd"
dependencies = [
"cc",
"memchr",
"rustc_version",
"toml 1.1.2+spec-1.1.0",
"toml 1.1.3+spec-1.1.0",
"vswhom",
"winreg",
]
@@ -888,9 +889,9 @@ dependencies = [
[[package]]
name = "fastrand"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
@@ -927,17 +928,6 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "flume"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
dependencies = [
"futures-core",
"futures-sink",
"spin",
]
[[package]]
name = "flume"
version = "0.12.0"
@@ -979,7 +969,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -1014,9 +1004,9 @@ dependencies = [
[[package]]
name = "futures"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
dependencies = [
"futures-channel",
"futures-core",
@@ -1029,9 +1019,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
dependencies = [
"futures-core",
"futures-sink",
@@ -1039,15 +1029,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
[[package]]
name = "futures-executor"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
dependencies = [
"futures-core",
"futures-task",
@@ -1067,38 +1057,38 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
[[package]]
name = "futures-macro"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
[[package]]
name = "futures-task"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]]
name = "futures-util"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
dependencies = [
"futures-channel",
"futures-core",
@@ -1314,7 +1304,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"futures-channel",
"futures-core",
"futures-executor",
@@ -1342,7 +1332,7 @@ dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -1357,9 +1347,9 @@ dependencies = [
[[package]]
name = "glob"
version = "0.3.3"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "gobject-sys"
@@ -1421,7 +1411,7 @@ dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -1513,9 +1503,9 @@ dependencies = [
[[package]]
name = "http-body"
version = "1.0.1"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
"bytes",
"http",
@@ -1523,9 +1513,9 @@ dependencies = [
[[package]]
name = "http-body-util"
version = "0.1.3"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
dependencies = [
"bytes",
"futures-core",
@@ -1542,9 +1532,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hyper"
version = "1.10.1"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
dependencies = [
"atomic-waker",
"bytes",
@@ -1776,20 +1766,20 @@ dependencies = [
[[package]]
name = "inotify"
version = "0.11.2"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1"
checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
dependencies = [
"libc",
]
@@ -1898,24 +1888,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
name = "jobserver"
version = "0.1.34"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom 0.3.4",
"getrandom 0.4.3",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.102"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
"cfg-if",
"futures-util",
@@ -1950,7 +1940,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"serde",
"unicode-segmentation",
]
@@ -1971,7 +1961,7 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"libc",
]
@@ -2119,9 +2109,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.186"
version = "0.2.188"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b"
[[package]]
name = "libdbus-sys"
@@ -2159,9 +2149,9 @@ dependencies = [
[[package]]
name = "libredox"
version = "0.1.17"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
dependencies = [
"libc",
]
@@ -2200,9 +2190,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.32"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "markup5ever"
@@ -2217,12 +2207,12 @@ dependencies = [
[[package]]
name = "mdns-sd"
version = "0.20.0"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "892f96f6d2ebe1ea641279f986ac52a2a6bac71e8f743bb258315cfe2bd7e88e"
checksum = "f18d8ec9d1869796fb2910d95f4d957072df0b6a22e247a1d760d8b4c805e17a"
dependencies = [
"fastrand",
"flume 0.11.1",
"flume",
"if-addrs",
"log",
"mio",
@@ -2232,9 +2222,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.8.2"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memoffset"
@@ -2272,9 +2262,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.1"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"log",
@@ -2299,7 +2289,7 @@ dependencies = [
"once_cell",
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"thiserror 2.0.19",
"windows-sys 0.61.2",
]
@@ -2309,7 +2299,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"jni-sys 0.3.1",
"log",
"ndk-sys",
@@ -2339,7 +2329,7 @@ version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"fsevent-sys",
"inotify",
"kqueue",
@@ -2357,7 +2347,7 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
]
[[package]]
@@ -2423,7 +2413,7 @@ dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -2451,7 +2441,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"objc2",
"objc2-core-foundation",
@@ -2464,7 +2454,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"objc2",
"objc2-foundation",
]
@@ -2485,7 +2475,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"dispatch2",
"objc2",
]
@@ -2496,7 +2486,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"dispatch2",
"objc2",
"objc2-core-foundation",
@@ -2529,7 +2519,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -2556,7 +2546,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"libc",
"objc2",
@@ -2569,7 +2559,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"objc2",
"objc2-core-foundation",
]
@@ -2580,7 +2570,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
@@ -2592,7 +2582,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"objc2",
"objc2-cloud-kit",
@@ -2623,7 +2613,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"objc2",
"objc2-app-kit",
@@ -2639,14 +2629,13 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "open"
version = "5.3.5"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5"
dependencies = [
"dunce",
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
@@ -2719,12 +2708,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2772,7 +2755,7 @@ dependencies = [
"phf_shared",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -2798,9 +2781,9 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plist"
version = "1.9.0"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
@@ -2828,7 +2811,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"crc32fast",
"fdeflate",
"flate2",
@@ -2891,7 +2874,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.12+spec-1.1.0",
"toml_edit 0.25.13+spec-1.1.0",
]
[[package]]
@@ -2920,27 +2903,27 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.106"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.39.4"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.45"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
@@ -2972,9 +2955,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.10.1"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
@@ -3027,7 +3010,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
]
[[package]]
@@ -3038,34 +3021,34 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 2.0.18",
"thiserror 2.0.19",
]
[[package]]
name = "ref-cast"
version = "1.0.25"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.25"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 3.0.2",
]
[[package]]
name = "regex"
version = "1.12.4"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
@@ -3075,9 +3058,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.14"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
dependencies = [
"aho-corasick",
"memchr",
@@ -3164,9 +3147,9 @@ dependencies = [
[[package]]
name = "rustc-hash"
version = "2.1.2"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc_version"
@@ -3183,7 +3166,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
@@ -3192,9 +3175,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.40"
version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [
"aws-lc-rs",
"log",
@@ -3207,9 +3190,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [
"zeroize",
]
@@ -3228,15 +3211,15 @@ dependencies = [
[[package]]
name = "rustversion"
version = "1.0.22"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "s2n-codec"
version = "0.82.0"
version = "0.83.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a650d3f187901f3519ec8a1fe7da3faccc0b2fb40f350eda2c7851fdf2bda0f6"
checksum = "5323db3697b61f4f5161c346573a9089cf6b4e13656991a5e84c18dc91bc17d7"
dependencies = [
"byteorder",
"bytes",
@@ -3245,16 +3228,16 @@ dependencies = [
[[package]]
name = "s2n-quic"
version = "1.82.0"
version = "1.83.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c27c34127facefcd3e5530c4de5739a62cd4a593710b1194dacbd8e884b6be92"
checksum = "54598beca69a3970deaa988edcb2a00f5812433db085f23b08f3ebe40a3bf86d"
dependencies = [
"bytes",
"cfg-if",
"cuckoofilter",
"futures",
"hash_hasher",
"rand 0.10.1",
"rand 0.10.2",
"s2n-codec",
"s2n-quic-core",
"s2n-quic-crypto",
@@ -3269,9 +3252,9 @@ dependencies = [
[[package]]
name = "s2n-quic-core"
version = "0.82.0"
version = "0.83.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79fbc3f06797d985363f74de105d18554b5a272b924b166d73a6564943da1230"
checksum = "1ebf9897515906f85528b3301f6f54d9d7b02827a9f5a2e13fcd5c965161571e"
dependencies = [
"atomic-waker",
"byteorder",
@@ -3291,9 +3274,9 @@ dependencies = [
[[package]]
name = "s2n-quic-crypto"
version = "0.82.0"
version = "0.83.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e58ea5aa39eecc29559d1e1bb4a5d55a747fa7b80cff5a3400c57489510644e3"
checksum = "fc67b81d13e30e2b5d69a154fa312bfe523ec3a95c81a694c15c902a210b80de"
dependencies = [
"aws-lc-rs",
"cfg-if",
@@ -3305,9 +3288,9 @@ dependencies = [
[[package]]
name = "s2n-quic-platform"
version = "0.82.0"
version = "0.83.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4eebb6007139cfffdf3d473d39f01a214032c339432a6293b16b0f7b25343f40"
checksum = "6a488f217c6dec4eac1a0c61d25af5235a2abcd03af31ae2f3bc047901caad80"
dependencies = [
"cfg-if",
"futures",
@@ -3320,9 +3303,9 @@ dependencies = [
[[package]]
name = "s2n-quic-rustls"
version = "0.82.0"
version = "0.83.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb0084afa65eefae2c37d9ab44118a14dfc5bb78dbf997c0f5176f7cf8d2e633"
checksum = "7349312d82172938036b92119f666ba93ce9bed7cbfeedc968115574932398fc"
dependencies = [
"bytes",
"rustls",
@@ -3334,9 +3317,9 @@ dependencies = [
[[package]]
name = "s2n-quic-tls"
version = "0.82.0"
version = "0.83.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91150b25ce824ffea581b449ad04acf9b4aef2fa68a46f667cdc9cc6f7b87823"
checksum = "093191572f12842106d8e37326d1a99dd5d6c96bfcdd14c1718e6b6ae51730cb"
dependencies = [
"bytes",
"errno",
@@ -3349,9 +3332,9 @@ dependencies = [
[[package]]
name = "s2n-quic-tls-default"
version = "0.82.0"
version = "0.83.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1f5ae64863972facee778dc80a24317e613f035296631f267b71f225e569c22"
checksum = "3488ac2d7fab0b4921e412dbc8599419f8d1bfd6f9c4422fdb4ef8b5e4a33c68"
dependencies = [
"s2n-quic-rustls",
"s2n-quic-tls",
@@ -3359,9 +3342,9 @@ dependencies = [
[[package]]
name = "s2n-quic-transport"
version = "0.82.0"
version = "0.83.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b82fca53ce1734cc1d1dca96cc9ceb65ed528f27cb43b7de865215b6cf17908"
checksum = "899b3ffff460a7d4e8b8ed0933aca1b4d309a1e52bfffdd9fb54bd78e3c922b0"
dependencies = [
"bytes",
"futures-channel",
@@ -3377,9 +3360,9 @@ dependencies = [
[[package]]
name = "s2n-tls"
version = "0.3.38"
version = "0.3.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6188b4ff1e1fe554f3ed53ef81c550760006ff8003f08c3539447afcd20b6e8b"
checksum = "db76c77f8c280a581031ad88f8bc09c18581b11d785f530025d135dd22c03b9e"
dependencies = [
"errno",
"hex",
@@ -3390,9 +3373,9 @@ dependencies = [
[[package]]
name = "s2n-tls-sys"
version = "0.3.38"
version = "0.3.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbe6d97b0ca3d01056eb1446912799181f1e1ec6922601f80446f4f0337edb67"
checksum = "ce900e83d6cb0dee6623e6e0c955ce1973ad2994ce76c5ed577e27fa564cb82b"
dependencies = [
"aws-lc-rs",
"cc",
@@ -3456,7 +3439,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -3471,7 +3454,7 @@ version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"cssparser",
"derive_more",
"log",
@@ -3496,9 +3479,9 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
@@ -3518,22 +3501,22 @@ dependencies = [
[[package]]
name = "serde_core"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 3.0.2",
]
[[package]]
@@ -3544,14 +3527,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
name = "serde_json"
version = "1.0.150"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
@@ -3562,13 +3545,13 @@ dependencies = [
[[package]]
name = "serde_repr"
version = "0.1.20"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 3.0.2",
]
[[package]]
@@ -3618,7 +3601,7 @@ dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -3640,7 +3623,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -3722,9 +3705,9 @@ dependencies = [
[[package]]
name = "simd-adler32"
version = "0.3.9"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "siphasher"
@@ -3746,20 +3729,20 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket-pktinfo"
version = "0.3.2"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f"
checksum = "3e8e43b4bdce7cff8a4d3f8025ee38fce5ca138fab868ebbf9529c81328fbf9d"
dependencies = [
"libc",
"socket2",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
name = "socket2"
version = "0.6.4"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
@@ -3815,9 +3798,9 @@ dependencies = [
[[package]]
name = "spin"
version = "0.9.8"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
dependencies = [
"lock_api",
]
@@ -3859,7 +3842,7 @@ dependencies = [
"serde",
"sha2",
"smallvec",
"thiserror 2.0.18",
"thiserror 2.0.19",
"tokio",
"tokio-stream",
"tracing",
@@ -3876,7 +3859,7 @@ dependencies = [
"quote",
"sqlx-core",
"sqlx-macros-core",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -3897,7 +3880,7 @@ dependencies = [
"sha2",
"sqlx-core",
"sqlx-sqlite",
"syn 2.0.118",
"syn 2.0.119",
"tokio",
"url",
]
@@ -3909,7 +3892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
dependencies = [
"atoi",
"flume 0.12.0",
"flume",
"form_urlencoded",
"futures-channel",
"futures-core",
@@ -3921,7 +3904,7 @@ dependencies = [
"percent-encoding",
"serde",
"sqlx-core",
"thiserror 2.0.18",
"thiserror 2.0.19",
"tracing",
"url",
]
@@ -3980,7 +3963,7 @@ dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4012,9 +3995,20 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.118"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
dependencies = [
"proc-macro2",
"quote",
@@ -4038,7 +4032,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4060,7 +4054,7 @@ version = "0.35.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"core-foundation",
"core-graphics",
@@ -4102,7 +4096,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4113,9 +4107,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.11.3"
version = "2.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2616f96cb644bf2c5c456d9de4d5d5100e592d7424c74d8b55c5cb96e359e93"
checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5"
dependencies = [
"anyhow",
"bytes",
@@ -4152,7 +4146,7 @@ dependencies = [
"tauri-runtime",
"tauri-runtime-wry",
"tauri-utils",
"thiserror 2.0.18",
"thiserror 2.0.19",
"tokio",
"tray-icon",
"url",
@@ -4201,9 +4195,9 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"syn 2.0.118",
"syn 2.0.119",
"tauri-utils",
"thiserror 2.0.18",
"thiserror 2.0.19",
"time",
"url",
"uuid",
@@ -4219,7 +4213,7 @@ dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
"tauri-codegen",
"tauri-utils",
]
@@ -4242,9 +4236,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
dependencies = [
"log",
"raw-window-handle",
@@ -4254,7 +4248,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"thiserror 2.0.19",
"url",
]
@@ -4277,8 +4271,8 @@ dependencies = [
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"thiserror 2.0.19",
"toml 1.1.3+spec-1.1.0",
"url",
]
@@ -4299,22 +4293,22 @@ dependencies = [
"shared_child",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"thiserror 2.0.19",
"tokio",
]
[[package]]
name = "tauri-plugin-store"
version = "2.4.3"
version = "2.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c72dda16786eb4a3f903e43a17b64d8d78dc0f00fe2aa4b757c28f617a8630b"
checksum = "6708afbe549f176b712066e71648ba8fafba20789453718260c7ca356733cb0c"
dependencies = [
"dunce",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"thiserror 2.0.19",
"tokio",
"tracing",
]
@@ -4337,7 +4331,7 @@ dependencies = [
"serde",
"serde_json",
"tauri-utils",
"thiserror 2.0.18",
"thiserror 2.0.19",
"url",
"webkit2gtk",
"webview2-com",
@@ -4346,9 +4340,9 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
version = "2.11.3"
version = "2.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c"
checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [
"gtk",
"http",
@@ -4400,8 +4394,8 @@ dependencies = [
"serde_json",
"serde_with",
"swift-rs",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"thiserror 2.0.19",
"toml 1.1.3+spec-1.1.0",
"url",
"urlpattern",
"uuid",
@@ -4416,17 +4410,16 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
dependencies = [
"dunce",
"embed-resource",
"toml 1.1.2+spec-1.1.0",
"toml 1.1.3+spec-1.1.0",
]
[[package]]
name = "tendril"
version = "0.5.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24"
checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
dependencies = [
"new_debug_unreachable",
"utf-8",
]
[[package]]
@@ -4440,11 +4433,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.18"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [
"thiserror-impl 2.0.18",
"thiserror-impl 2.0.19",
]
[[package]]
@@ -4455,34 +4448,34 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 3.0.2",
]
[[package]]
name = "thread_local"
version = "1.1.9"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.49"
version = "0.3.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469"
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
dependencies = [
"deranged",
"libc",
@@ -4502,9 +4495,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.29"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
@@ -4522,9 +4515,9 @@ dependencies = [
[[package]]
name = "tinyvec"
version = "1.11.0"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
dependencies = [
"tinyvec_macros",
]
@@ -4537,9 +4530,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.3"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"libc",
@@ -4554,13 +4547,13 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.7.0"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4576,14 +4569,15 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.18"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"futures-util",
"libc",
"pin-project-lite",
"tokio",
]
@@ -4617,9 +4611,9 @@ dependencies = [
[[package]]
name = "toml"
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"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
dependencies = [
"indexmap 2.14.0",
"serde_core",
@@ -4627,7 +4621,7 @@ dependencies = [
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.3",
"winnow 1.0.4",
]
[[package]]
@@ -4683,14 +4677,14 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
version = "0.25.13+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
dependencies = [
"indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.3",
"winnow 1.0.4",
]
[[package]]
@@ -4699,14 +4693,14 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow 1.0.3",
"winnow 1.0.4",
]
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tower"
@@ -4729,7 +4723,7 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"bytes",
"futures-util",
"http",
@@ -4773,7 +4767,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4829,7 +4823,7 @@ dependencies = [
"once_cell",
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"thiserror 2.0.19",
"windows-sys 0.61.2",
]
@@ -4941,12 +4935,6 @@ dependencies = [
"url",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4955,9 +4943,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.23.3"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -5051,9 +5039,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.125"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"cfg-if",
"once_cell",
@@ -5064,9 +5052,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.75"
version = "0.4.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280"
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -5074,9 +5062,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.125"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -5084,22 +5072,22 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.125"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.125"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
"unicode-ident",
]
@@ -5119,9 +5107,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.102"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -5205,7 +5193,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -5214,7 +5202,7 @@ version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.18",
"thiserror 2.0.19",
"windows 0.61.3",
"windows-core 0.61.2",
]
@@ -5364,7 +5352,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -5375,7 +5363,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -5721,9 +5709,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winnow"
version = "1.0.3"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
@@ -5783,7 +5771,7 @@ dependencies = [
"sha2",
"soup3",
"tao-macros",
"thiserror 2.0.18",
"thiserror 2.0.19",
"url",
"webkit2gtk",
"webkit2gtk-sys",
@@ -5834,28 +5822,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.52"
version = "0.8.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.52"
version = "0.8.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -5875,7 +5863,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
"synstructure",
]
@@ -5896,7 +5884,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -5929,11 +5917,11 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.21"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -225,6 +225,7 @@ async fn unrar_listing(program: &Path, archive: &Path) -> eyre::Result<RarListin
.arg("lt")
.arg("-cfg-")
.arg(archive)
.kill_on_drop(true)
.output()
.await?;
if !output.status.success() {
@@ -329,6 +330,9 @@ async fn stream_unrar_entries(
.arg(archive)
.stdout(Stdio::piped())
.stderr(Stdio::null())
// Safety net: if this task is dropped before its cancel/error path runs
// (e.g. on shutdown), tokio still kills unrar instead of orphaning it.
.kill_on_drop(true)
.spawn()?;
let result = async {
@@ -3,7 +3,12 @@ use std::{
fs::{self, OpenOptions},
io::{self, Read as _, Seek as _, SeekFrom, Write as _},
path::{Component, Path, PathBuf},
sync::{Arc, Mutex, OnceLock},
sync::{
Arc,
Mutex,
OnceLock,
atomic::{AtomicU64, Ordering},
},
time::{Duration, SystemTime, UNIX_EPOCH},
};
@@ -27,7 +32,10 @@ use lanspread_peer::{
start_peer_with_options,
};
use tauri::{AppHandle, Emitter as _, Manager};
use tauri_plugin_shell::{ShellExt, process::Command};
use tauri_plugin_shell::{
ShellExt,
process::{Command, CommandChild, CommandEvent},
};
use tokio::sync::{
RwLock,
mpsc::{UnboundedReceiver, UnboundedSender},
@@ -92,8 +100,32 @@ struct LanSpreadState {
main_log_sink: OnceLock<MainLogSink>,
active_outbound_transfers: OutboundTransfers,
outbound_transfer_emit: Arc<RwLock<OutboundTransferEmitState>>,
/// Live unrar sidecar processes, so they can be killed if the launcher exits
/// mid-unpack. The shell plugin does not kill spawned children on app exit,
/// and the OS does not cascade-kill child processes, so without this an
/// in-progress unrar keeps running after the launcher closes.
active_unrar_children: Arc<Mutex<UnrarChildRegistry>>,
}
/// Live unrar sidecar children plus a shutdown latch.
///
/// Children are keyed by a monotonic id (not pid) so a finishing install never
/// deregisters another install's child after a pid is recycled. The
/// `shutting_down` latch closes a time-of-check/time-of-use hole: the exit kill
/// sweep drains the map only once, so a child registered *after* the sweep (an
/// install task caught between `spawn()` and registration, or a later archive in
/// a multi-archive install — `unpack_archives` does not observe the shutdown
/// token) would be orphaned. Once the latch is set under this mutex, registration
/// kills the child immediately instead of inserting it where nothing will reap it.
#[derive(Default)]
struct UnrarChildRegistry {
shutting_down: bool,
children: HashMap<u64, CommandChild>,
}
/// Monotonic id source for [`UnrarChildRegistry`] entries.
static UNRAR_CHILD_SEQ: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Debug, PartialEq, Eq)]
struct InstallSettings {
account_name: String,
@@ -1303,16 +1335,19 @@ async fn run_unrar_sidecar(
paths: &UnrarPaths,
started_at_ms: u64,
) -> eyre::Result<()> {
let out = match sidecar
// Spawn (instead of `.output()`) so we keep a killable handle. The shell
// plugin's `output()` drops the `CommandChild` immediately and only drains
// the event channel, leaving the unrar process orphaned if the launcher
// exits before extraction finishes.
let (mut events, child) = match sidecar
.arg("x") // extract files
.arg(&paths.archive)
.arg("-y") // Assume Yes on all queries
.arg("-o") // Set overwrite mode
.arg(&paths.destination_arg)
.output()
.await
.spawn()
{
Ok(out) => out,
Ok(spawned) => spawned,
Err(err) => {
let stderr = format!("failed to run unrar sidecar: {err}");
record_unpack_failure(
@@ -1327,10 +1362,42 @@ async fn run_unrar_sidecar(
}
};
let stdout = clean_terminal_log(&String::from_utf8_lossy(&out.stdout));
let stderr = clean_terminal_log(&String::from_utf8_lossy(&out.stderr));
let status_code = out.status.code();
let success = out.status.success();
// Register the live child so a launcher exit can kill it, and deregister it
// automatically on every exit path via the RAII guard.
let registry = app_handle
.state::<LanSpreadState>()
.active_unrar_children
.clone();
let child_id = UNRAR_CHILD_SEQ.fetch_add(1, Ordering::Relaxed);
register_unrar_child(&registry, child_id, child);
let _child_guard = UnrarChildGuard { registry, child_id };
let mut stdout_bytes = Vec::new();
let mut stderr_bytes = Vec::new();
let mut status_code = None;
while let Some(event) = events.recv().await {
match event {
CommandEvent::Stdout(line) => {
stdout_bytes.extend(line);
stdout_bytes.push(b'\n');
}
CommandEvent::Stderr(line) => {
stderr_bytes.extend(line);
stderr_bytes.push(b'\n');
}
CommandEvent::Terminated(payload) => {
status_code = payload.code;
}
CommandEvent::Error(err) => {
log::warn!("unrar sidecar event error: {err}");
}
_ => {}
}
}
let stdout = clean_terminal_log(&String::from_utf8_lossy(&stdout_bytes));
let stderr = clean_terminal_log(&String::from_utf8_lossy(&stderr_bytes));
let success = status_code == Some(0);
record_unpack_log(
app_handle,
@@ -1360,6 +1427,79 @@ async fn run_unrar_sidecar(
Ok(())
}
/// Tracks a spawned unrar sidecar so the launcher can kill it on shutdown.
///
/// If shutdown has already begun (or the registry is poisoned), the child is
/// killed immediately instead of inserted, since the exit kill sweep has already
/// run and would never reap a late registration.
fn register_unrar_child(
registry: &Arc<Mutex<UnrarChildRegistry>>,
child_id: u64,
child: CommandChild,
) {
let Ok(mut guard) = registry.lock() else {
// A poisoned registry means we can no longer guarantee the child is
// killed on exit, so kill it now rather than risk orphaning it.
let pid = child.pid();
log::warn!("unrar child registry is poisoned; killing pid {pid} immediately");
if let Err(err) = child.kill() {
log::warn!("Failed to kill untracked unrar child (pid {pid}): {err}");
}
return;
};
if guard.shutting_down {
drop(guard);
let pid = child.pid();
log::info!("Killing unrar child (pid {pid}) that spawned during shutdown");
if let Err(err) = child.kill() {
log::warn!("Failed to kill unrar child (pid {pid}) spawned during shutdown: {err}");
}
return;
}
guard.children.insert(child_id, child);
}
/// Removes an unrar sidecar from the registry when its `run_unrar_sidecar` call
/// returns, regardless of success, error, or early bail.
struct UnrarChildGuard {
registry: Arc<Mutex<UnrarChildRegistry>>,
child_id: u64,
}
impl Drop for UnrarChildGuard {
fn drop(&mut self) {
if let Ok(mut guard) = self.registry.lock() {
guard.children.remove(&self.child_id);
}
}
}
/// Kills every in-progress unrar sidecar and latches the registry into shutdown
/// so any install task that spawns unrar after this point kills it on
/// registration. Called on app exit so a game install that is mid-extraction does
/// not leave `unrar` running after the launcher closes.
fn kill_active_unrar_children(app_handle: &AppHandle) {
let state = app_handle.state::<LanSpreadState>();
let children = {
let Ok(mut guard) = state.active_unrar_children.lock() else {
log::warn!("unrar child registry is poisoned; cannot kill children on shutdown");
return;
};
guard.shutting_down = true;
guard.children.drain().collect::<Vec<_>>()
};
for (_child_id, child) in children {
let pid = child.pid();
match child.kill() {
Ok(()) => log::info!("Killed in-progress unrar child (pid {pid}) on shutdown"),
Err(err) => log::warn!("Failed to kill unrar child (pid {pid}) on shutdown: {err}"),
}
}
}
async fn record_unpack_failure(
app_handle: &AppHandle,
archive: String,
@@ -2249,6 +2389,10 @@ pub fn run() {
.expect("error while building tauri application")
.run(|app_handle, event| {
if matches!(event, tauri::RunEvent::Exit) {
// Kill unrar first: an in-progress extraction would otherwise keep
// running after the launcher closes, and killing it lets the
// install task unwind so the peer runtime can stop promptly.
kill_active_unrar_children(app_handle);
shutdown_peer_runtime(app_handle);
}
});
+9 -4
View File
@@ -23,9 +23,11 @@ design_handoff_softlan_launcher/
│ └── design_reference/ the working prototype the spec describes
│ ├── SoftLAN Launcher.html open this to see all screens/variants
│ ├── styles.css
│ ├── data.jsx mock catalog + format helpers
│ ├── data.jsx mock catalog + LAN peer roster + format helpers
│ ├── components.jsx Icon set, cards, modals, Settings dialog
── launcher.jsx composes chrome + grid + modals
── ctp-chat.jsx Call-to-Play chat panel + shared helpers
│ ├── calltoplay.jsx Call-to-Play: quick bars, overlay, create form
│ └── launcher.jsx composes chrome + grid + modals + Call to Play
└── logo/ ← THE BRAND MARK
├── INTEGRATION.md how to ship the logo (component + assets + lockup)
@@ -75,8 +77,11 @@ placeholder for it.
## Status
- **Launcher:** high-fidelity, decisions locked. Variant **A** is the chosen
chrome. Open questions (empty/error states, logs viewer, keyboard grid nav,
German strings, "server running" state) are listed at the end of `SPEC.md`.
chrome. Includes **Call to Play** (rally the LAN around a game + time — live
or scheduled, with RSVP, check-in window, and per-call chat). Open questions
(empty/error states, logs viewer, keyboard grid nav, German strings, "server
running" state, real-time transport for Call to Play) are listed at the end
of `SPEC.md`.
- **Logo:** final. Live component + static assets + horizontal lockup (dark and
light) all included.
+163 -4
View File
@@ -32,6 +32,20 @@ The HTML mock includes two chrome variants — **A (single-row)** and **B (two-r
---
## Changes since v4
- **Call to Play added.** A whole new coordination layer on top of the library:
rally the LAN around a game and a time. Two flavors — **Play now** (give
people N minutes to ready up) and **Scheduled** ("Among Us at 20:00", collect
"I'm in" RSVPs, then a **check-in window** opens 15 min before start). Every
call carries a small group **chat**. Surfaced in three places: a **Call to
Play button** in the top bar (with an active-call badge), a persistent stack
of **quick bars** above the grid, and a full **overlay** with per-call cards
and a create form. Full spec in the new **"Call to Play"** section below.
New source files: `calltoplay.jsx`, `ctp-chat.jsx`. New data: a mock LAN
**peer roster** (`PEERS`) in `data.jsx`. New icons: `flag`, `chat`, `send`,
`clock`, `caretUp`, `caretDown`.
## Changes since v3
- **Game-folder button removed from the top bar.** Setting the games directory is a one-time action — it doesn't deserve permanent real estate in the chrome. The button is gone from both top-bar variants, freeing the right zone for the kebab menu alone (variant A) / the storage meter + kebab pair (variant B).
@@ -84,6 +98,7 @@ The default screen. A grid of game cards over a dark, gradient-tinted background
- **Right zone (col 3, flex space-between with two sub-groups):**
- **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: <bold value>` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (AZ)`, `Size (largest)`, `Recently Played`, `Status`. This is the only thing on the *left* side of the right zone — it's part of the search cluster, so it hugs the search.
- **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface as search. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`. This is the only "app-level" control left in the top bar; the game-folder picker has moved into Settings.
- **Call to Play button** (`.ctp-btn`, sits just left of the kebab in the far-right sub-group) — 36 px pill, flag icon + `Call to Play` label. Carries an accent-filled **badge** with the count of active (non-started) calls. Opens the Call to Play overlay. See the **"Call to Play"** section for the full feature. In variant B it lives in row 1's right group, between the storage meter and the kebab.
**Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet.
@@ -416,6 +431,144 @@ Dot uses the live accent so it visually ties to the progress fill.
---
## Call to Play
A coordination layer that sits on top of the library: **rally the people on the
LAN around a specific game at a specific time.** It answers the actual social
question at a LAN party ("what are we all playing, and when?") that a plain
library grid can't.
Source: `calltoplay.jsx` (the feature) + `ctp-chat.jsx` (per-call chat + shared
`ctpAvatarColor` / `ctpFmtClock` helpers). Styles are the `ctp-*` classes in
`styles.css`. Backed by the mock LAN **peer roster** `PEERS` in `data.jsx`.
### Two flavors of call
| | **Play now** | **Scheduled** |
|---|---|---|
| Set up with | game + max players + **duration** (5/10/15/30/60 min) | game + max players + **clock time** (24h, + which day) |
| Others respond | `Ready now` or `+N minutes` | `I'm in` (RSVP), then check in later |
| Resolves | when the roster fills **or** the timer runs out | at the scheduled time, after a check-in window |
| Who starts it | the **caller** decides the actual launch | the **caller**, once people have checked in |
**Check-in window.** `CHECKIN_LEAD_MS = 15 min`. A scheduled call sits in the
`scheduled` phase collecting RSVPs until 15 minutes before its start time, then
flips into the `checkin` phase: it lights up everywhere and everyone who said
"I'm in" is nudged to answer with the same `Ready now` / `+N minutes` states as
a play-now call. A play-now call is effectively "always in its check-in window."
**Every call carries a small group chat** (see "Per-call chat" below).
### Three surfaces
1. **Top-bar button** (`CallToPlayButton` / `.ctp-btn`) — flag icon + label +
an accent badge counting active (non-`started`) calls. Opens the overlay.
2. **Quick bars** (`CallToPlayTicker` / `.ctp-ticker-stack`) — a persistent
stack rendered at the top of the grid area, **one row per active call**.
Sorted **ready → starting-soon → the rest**, ties broken by whichever
resolves/starts soonest (`TICKER_RANK` = ready 0, soon 1, call/scheduled 2;
then by `deadline`). Clicking a row opens the overlay focused on that call.
3. **Overlay** (`CallToPlayOverlay`) — a modal (same scrim/panel treatment as
the other dialogs) with a header, a **Call a new match** button, the create
form, and a list of **nomination cards** (started calls sink to the bottom).
### Status model
Two derived values drive everything (`calltoplay.jsx`):
- `phaseOf(call)``'now'` (no `scheduledFor`) · `'scheduled'` (>15 min out) ·
`'checkin'` (within the 15-min lead).
- `statusOf(call)` (quick-bar/label status) → `'started'` · `'ready'`
(`readyCount >= maxPlayers`, or state `done`) · `'soon'` (`deadline - now ≤
15 min`) · `'scheduled'` (has a clock time) · `'call'` (a plain play-now call).
Quick-bar labels + LED colors: **SCHEDULED**, **CALL TO PLAY**, **STARTING
SOON**, **READY** (`TICKER_LABEL`), each with its own dot color via
`.ctp-ticker-dot[data-status]`.
**Per-participant ready state:** `ready` (explicitly readied, or their `readyAt`
countdown has elapsed) · `in` (RSVP'd to a scheduled call but not checked in
yet) · `pending` (checked in with a `+N minutes` buffer, counting down). Shown
as compact **ready-bubbles** in the quick bars (`MiniBubbles`: initials +
green ring/check when ready, `+Nm` tag when pending, dimmed when just "in") and
as larger `AvatarChip`s in the nomination card roster.
### Nomination card (`NominationCard`)
Top to bottom:
1. **Header** — square game cover + title + a sub-line: `Called by <creator> · N/M peers have it installed` (or `Scheduled by <creator> · starts at HH:MM · …`), and a **timer** on the right: a live `M:SS` countdown for play-now/check-in, the **clock time + "in N min"** for a scheduled call, `Ready`, or `Launching…`. Countdown urgency (`data-urgency` high/mid/low) tints it as time runs low.
2. **Check-in note** — only in the `checkin` phase: a clock icon + "Starting soon — check-in is open" (or a personalized nudge if you RSVP'd).
3. **Progress bar** — time remaining as a fill (accent, → green when done); hidden while a call is still in the far-out `scheduled` phase.
4. **Roster**`readyCount/maxPlayers ready` (scheduled shows `N in · up to M players`; check-in adds `· K not checked in yet`), then avatar chips for each participant plus empty slots up to `maxPlayers`.
5. **Actions** — context-dependent on your role (**creator** / **participant** / **outsider**) and phase: `Ready now` + `+5/10/15/30m` buffer buttons, `I'm in` (RSVP), `Start now` / `Add 5 more minutes` (creator once resolved), `Leave` / `Can't make it`, or a status note.
6. **Chat** — the collapsible per-call chat panel.
7. **Cancel** — creators get a `Cancel this call` link with an inline confirm.
### Create form (`CreateNominationForm`)
Game search (typeahead over the catalog) → on pick, max-players defaults to the
game's parsed player cap (`parseMaxPlayers`). **When** toggles `Now` vs
`Schedule`. `Now` reveals the **Give people** duration chips. `Schedule` reveals
a **24-hour time picker** (hour/minute steppers **and** a "Type a time" free-text
field accepting `20:00` / `2000` / `9:30`) plus **day chips** (Today + next two
days). The confirm button reads `Call it — <game>` or `Schedule it — <game> ·
<day> <time>`.
### Per-call chat (`CtpChat`, `ctp-chat.jsx`)
Collapsed to a one-line toggle showing an unread badge + a preview of the last
message; expands to a compact IRC-style list (colored usernames via
`ctpAvatarColor`, `HH:MM` timestamps) + an input. All chat state is local to the
card. Usernames are colored deterministically by a hash of the name.
### Data shape
```ts
type Nomination = {
id: string;
gameId: string;
creator: string; // username of the caller
maxPlayers: number;
createdAt: number; // ms epoch
scheduledFor: number | null; // ms epoch clock time; null = play-now
deadline: number; // play-now: createdAt + durationMin; scheduled: === scheduledFor
participants: Record<string, { // keyed by username
status: 'ready' | 'in' | 'pending';
joinedAt: number;
readyAt?: number; // ms epoch a 'pending' buffer elapses
}>;
messages: { id: string; from: string; text: string; at: number }[];
state: 'open' | 'done' | 'started';
startedAt?: number;
};
```
The `useNominations({ username, seed })` hook owns the list and exposes
`createNomination`, `respond`, `rsvp`, `sendMessage`, `leave`, `cancel`,
`startNow`, `addTime`.
### Mock vs production
The mock **simulates other people** with a 1-second `setInterval`
(`tickNomination`): bots RSVP to scheduled calls, ready-up during check-in, walk
in late, and occasionally post a chat line — purely so the demo resolves
visibly (bots use second-scale buffers; real people use the minute-scale ones).
The mock also seeds a few representative calls on mount (a live call with chat,
a fresh call you started, a scheduled call whose check-in window just opened,
and one scheduled for later collecting RSVPs).
**In production, replace the simulation with a real-time LAN transport:** the
launcher instances broadcast nominations, responses, RSVPs, and chat messages to
each other (whatever the app uses — a small pub/sub over the LAN, a lightweight
signaling server, or Tauri-side networking). Swap the guts of `useNominations`
(the `setInterval` + local `setNoms`) for your networking layer that pushes the
same state shape; the rendering components need nothing else. The `username` that
identifies "you" comes from `settings.username` (the Profile setting), not a
prop default.
---
## Filter controls — variant B (not used, kept for reference)
The two-row chrome has a different filter style — **underlined tabs with counts**, like browser tabs:
@@ -570,7 +723,7 @@ Cover art in the design files is **stylized placeholder art** — generated enti
In the production app, the launcher should ideally use real cover-art when available (fetch from IGDB / Steam / local game folder) and fall back to the placeholder generator for games without art. The placeholder generator is in `design_reference/components.jsx → GameCover`.
The icon set (search, play, **server**, install, download, folder, kebab, sort, users, close, check, chevron, trash) is in `design_reference/components.jsx → Icon`. They are 12-14px inline SVGs using `currentColor`. Reuse as-is or substitute with the codebase's existing icon library at the same visual weight. The `server` glyph is new in this round — two stacked rounded rectangles with LED dots, used only on the Start Server button.
The icon set (search, play, **server**, install, download, folder, kebab, sort, users, close, check, chevron, trash, **flag**, **clock**, **chat**, **send**, **caretUp**, **caretDown**) is in `design_reference/components.jsx → Icon`. They are 12-14px inline SVGs using `currentColor`. Reuse as-is or substitute with the codebase's existing icon library at the same visual weight. The `server` glyph drives the Start Server button; `flag` / `clock` / `chat` / `send` / `caretUp` / `caretDown` are used by Call to Play.
Fonts to load:
```html
@@ -587,17 +740,22 @@ Fonts to load:
design_reference/
├── SoftLAN Launcher.html ← entry; wires React + Babel, mounts <App>
├── styles.css ← all visual styles (CSS custom props + components)
├── data.jsx ← mock GAMES array + filter/sort helpers + STORAGE mock
├── data.jsx ← mock GAMES array + PEERS roster + filter/sort helpers + STORAGE mock
├── components.jsx ← Icon, GameCover, StateChip, ActionButton, GameCard,
│ SegmentedFilters, UnderlineFilters, SearchField,
│ SortMenu, StorageMeter, KebabMenu,
│ GameDetailModal, SettingsDialog (incl. GameFolderField)
── launcher.jsx ← <Launcher> component composing chrome + grid + modals
── ctp-chat.jsx ← CtpChat panel + ctpAvatarColor / ctpFmtClock helpers
├── calltoplay.jsx ← Call to Play: useNominations hook, quick bars,
│ overlay, nomination cards, create form
└── launcher.jsx ← <Launcher> component composing chrome + grid +
modals + Call to Play
```
To preview the design in a browser:
1. Open `SoftLAN Launcher.html` in a static-server (e.g. `python -m http.server` from the folder).
2. You'll see a design canvas with all variants (A, B, C, D, E, F) side-by-side. Click an artboard's expand button to view it full-screen.
2. You'll see a design canvas with all variants side-by-side. Click an artboard's expand button to view it full-screen.
- **Call to Play** (top section) — the quick-bar main view, and the same with the overlay open (live call + chat, your call, a check-in nudge, and a scheduled RSVP). Click anything to interact live — create a call, ready up, RSVP, chat.
- **A / B** — chrome variants (A is the chosen direction)
- **C** — detail overlay for an installed, server-capable game (Counter-Strike 1.6) → shows **Play + Start Server + Uninstall**
- **D** — detail overlay for a downloaded-but-not-installed game (CoD 4) → shows **Install + Delete from disk**
@@ -615,4 +773,5 @@ To preview the design in a browser:
- **Progress state** — designed. See "Download progress" section above. The action-button slot is swapped for a live `DownloadProgress` component (card + modal variants with container-query fallback for narrow tiles). Wire it to your real progress events; the rendering layer is dev-ready.
- **Keyboard arrow nav** — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal.
- **"Server running" state** — once Start Server actually spawns a process, the button should switch to a *running* state (live indicator dot + "Server running" label + click-to-stop). Not designed this round — flag for follow-up alongside whatever server-status panel the app grows.
- **Call to Play real-time transport** — the feature is fully designed and interactive, but the mock fakes other players with a local `setInterval` simulation. Production needs a real LAN transport that broadcasts nominations / responses / RSVPs / chat between launcher instances and pushes the same state shape into `useNominations`. Notifications when a call you're in enters its check-in window (OS notification / tray) are also a follow-up.
- **German translations** — the language toggle is wired in Settings, but the catalog of translated UI strings hasn't been compiled. Stand up `react-i18next` (or equivalent) and seed `en.json` from the existing copy; `de.json` is a translation task for whoever owns localization.
@@ -23,6 +23,8 @@
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="data.jsx"></script>
<script type="text/babel" src="components.jsx"></script>
<script type="text/babel" src="ctp-chat.jsx"></script>
<script type="text/babel" src="calltoplay.jsx"></script>
<script type="text/babel" src="launcher.jsx"></script>
</head>
<body>
@@ -32,7 +34,7 @@
const { useState } = React;
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accent": "#3b82f6",
"accent": "#a855f7",
"density": "normal",
"aspect": "square",
"bg": "gradient",
@@ -47,9 +49,77 @@ function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const heroGame = GAMES.find(g => g.id === 'cs'); // installed + canHostServer → shows Play + Start Server + Uninstall
// Seed data for the "Call to Play" showcase — a live call with chat, a
// fresh call started by you, a SCHEDULED call whose 15-minute check-in
// window just opened (highlighted, you still have to ready up), and one
// scheduled for later tonight that's still collecting "I'm in" RSVPs.
const ctpSeeds = React.useMemo(() => {
const now = Date.now();
const me = (t.username || 'you').trim() || 'you';
return [
{
gameId: 'cs', creator: 'shadowfox', maxPlayers: 10, ageSec: 95, durationMin: 25,
participants: {
pixelqueen: { status: 'ready', joinedAt: now - 60000 },
kbmaster: { status: 'ready', joinedAt: now - 40000 },
nullptr: { status: 'pending', joinedAt: now - 20000, readyAt: now + 50000 },
},
messages: [
{ from: 'shadowfox', text: 'dust2, default rules — who is in?', agoSec: 90 },
{ from: 'kbmaster', text: 'in. someone seed the map pack for nullptr', agoSec: 60 },
{ from: 'nullptr', text: 'downloading, 2 min', agoSec: 35 },
],
},
{
gameId: 'l4d2', creator: me, maxPlayers: 4, ageSec: 15, durationMin: 6,
participants: {
afk_andy: { status: 'pending', joinedAt: now - 5000, readyAt: now + 35000 },
},
},
{
gameId: 'tf2', creator: 'wavedash', maxPlayers: 12, ageSec: 3600, scheduledInMin: 12,
participants: {
[me]: { status: 'in', joinedAt: now - 3000000 },
gg_val: { status: 'ready', joinedAt: now - 2000000 },
crit_happens: { status: 'in', joinedAt: now - 1500000 },
lowping_larry: { status: 'pending', joinedAt: now - 1000000, readyAt: now + 240000 },
},
messages: [
{ from: 'wavedash', text: 'payload maps tonight — check in when you are at your seat', agoSec: 300 },
{ from: 'gg_val', text: 'ready, lobby is open', agoSec: 120 },
],
},
{
gameId: 'amongus', creator: 'pixelqueen', maxPlayers: 10, ageSec: 5400, scheduledInMin: 128,
participants: {
ctrlaltdave: { status: 'in', joinedAt: now - 4000000 },
afk_andy: { status: 'in', joinedAt: now - 3000000 },
},
messages: [
{ from: 'pixelqueen', text: 'right after the pizza break — bring headphones', agoSec: 4800 },
{ from: 'ctrlaltdave', text: 'in. can we start 10 min later?', agoSec: 3600 },
],
},
];
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<React.Fragment>
<DesignCanvas>
<DCSection id="call-to-play" title="Call to Play — now, scheduled &amp; chat"
subtitle="Anyone can call a match — right now (give people N minutes to ready up) or scheduled for a clock time ('Among Us at 20:00'). Scheduled calls collect 'I'm in' RSVPs; 15 minutes before start the call enters its check-in window and lights up everywhere, nudging everyone who's in to answer Ready now or +N minutes. Each call carries a small group chat. The quick bars lead with the status — SCHEDULED / CALL TO PLAY / STARTING SOON / READY, each with its own LED color — sorted ready → starting soon → the rest (soonest first), with per-player ready-bubbles and the latest chat line inline. Click anything to interact live.">
<DCArtboard id="ctp-quickbars" label="Main view — quick bars with ready-bubbles + check-in highlight" width={1340} height={840}>
<Launcher variant="single" tweaks={t} setTweak={setTweak}
initialFilter="all" initialSort="recent"
seedCallToPlay={ctpSeeds}/>
</DCArtboard>
<DCArtboard id="ctp-live" label="Overlay — live call, your call, check-in &amp; scheduled RSVP + chat" width={1340} height={840}>
<Launcher variant="single" tweaks={t} setTweak={setTweak}
initialFilter="all" initialSort="recent"
seedCallToPlay={ctpSeeds} initialCtpOpen={true}/>
</DCArtboard>
</DCSection>
<DCSection id="chrome" title="Chrome variations"
subtitle="Two ways to organize the top bar — pick whichever density of controls you prefer. Click any card to open the detail overlay, or the kebab menu to open Settings.">
<DCArtboard id="single-row" label="A · Single-row + segmented filters" width={1340} height={840}>
@@ -0,0 +1,814 @@
// calltoplay.jsx — "Call to Play": rally the LAN around a game + a time.
//
// Two flavors of call, one feature:
// · Play NOW — pick a game, max players, and how long to give people.
// Others respond "Ready now" or "+N minutes". Resolves early if the
// roster fills, or when the clock runs out — the CALLER starts it.
// · SCHEDULED — pick a game and a clock time ("Among Us at 20:00").
// People RSVP with "I'm in". 15 minutes before start the call enters
// its CHECK-IN window: it gets highlighted everywhere and everyone
// who's in is nudged to answer with the same ready / +N-minute states.
//
// Every call also carries a small group chat (see ctp-chat.jsx).
//
// Loaded after data.jsx + components.jsx + ctp-chat.jsx, before launcher.jsx.
const { useState, useEffect, useMemo, useRef, useLayoutEffect } = React;
const GAMES_ = window.GAMES;
const PEERS_ = window.PEERS;
const installedPeersFor_ = window.installedPeersFor;
const parseMaxPlayers_ = window.parseMaxPlayers;
const Icon_ = window.Icon;
const GameCover_ = window.GameCover;
const CtpChat_ = window.CtpChat;
const avatarColor = window.ctpAvatarColor;
const fmtClock = window.ctpFmtClock;
// Check-in opens this long before a scheduled call's start time — and any
// call (scheduled or not) counts as "starting soon" inside this window.
const CHECKIN_LEAD_MS = 15 * 60000;
function phaseOf(n, now) {
if (!n.scheduledFor) return 'now';
return now < n.scheduledFor - CHECKIN_LEAD_MS ? 'scheduled' : 'checkin';
}
// Quick-bar status: READY > STARTING SOON > CALL TO PLAY / SCHEDULED.
function statusOf(n, now) {
if (n.state === 'started') return 'started';
if (n.state === 'done' || readyCountOf(n, now) >= n.maxPlayers) return 'ready';
if (n.deadline - now <= CHECKIN_LEAD_MS) return 'soon';
return n.scheduledFor ? 'scheduled' : 'call';
}
function fmtCountdown(ms) {
if (ms <= 0) return '0:00';
const total = Math.ceil(ms / 1000);
const m = Math.floor(total / 60), s = total % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function fmtCountdownShort(ms) {
if (ms <= 0) return 'now';
const total = Math.ceil(ms / 1000);
if (total < 60) return `${total}s`;
return `${Math.ceil(total / 60)}m`;
}
function fmtUntil(ms) {
if (ms <= 60000) return 'in <1 min';
const min = Math.round(ms / 60000);
if (min < 60) return `in ${min} min`;
const h = Math.floor(min / 60), m = min % 60;
return m ? `in ${h}h ${m}m` : `in ${h}h`;
}
function isReady(p, now) {
if (!p) return false;
return p.status === 'ready' || (p.readyAt != null && now >= p.readyAt);
}
function readyCountOf(n, now) {
return Object.values(n.participants).filter(p => isReady(p, now)).length;
}
function inCountOf(n, now) {
return Object.values(n.participants).filter(p => !isReady(p, now) && p.status === 'in').length;
}
// ────────────────────────────────────────────────────────────────────
// Simulation — bots trickle into open calls (RSVP on scheduled ones,
// ready-up during check-in / now-calls) and occasionally post a chat
// line. Bots use second-scale buffers purely so the demo resolves
// visibly; real people would use minute-scale ones.
// ────────────────────────────────────────────────────────────────────
const CTP_BOT_LINES = [
'anyone need the game files? I can seed',
'gimme 5, grabbing food',
'who hosts?',
'voice is up — channel 2',
'ok, back at my desk',
'poke your neighbor, we need one more',
'default settings, no mods — right?',
'glhf',
];
function maybeBotChat(n, now) {
const msgs = n.messages || [];
const speakers = Object.keys(n.participants).filter(p => PEERS_.includes(p));
if (!speakers.length || msgs.length > 40 || Math.random() >= 0.03) return msgs;
const from = speakers[Math.floor(Math.random() * speakers.length)];
const text = CTP_BOT_LINES[Math.floor(Math.random() * CTP_BOT_LINES.length)];
return [...msgs, { id: `bm${now}${Math.random().toString(36).slice(2, 6)}`, from, text, at: now }];
}
function tickNomination(n, now) {
if (n.state !== 'open') return n;
const phase = phaseOf(n, now);
let participants = n.participants;
const joined = Object.keys(participants);
const available = PEERS_.filter(p => p !== n.creator && !joined.includes(p));
if (phase === 'scheduled') {
// RSVP trickle — far from start, people just say "I'm in".
if (available.length && joined.length < n.maxPlayers && Math.random() < 0.06) {
const bot = available[Math.floor(Math.random() * available.length)];
participants = { ...participants, [bot]: { status: 'in', joinedAt: now } };
}
return { ...n, participants, messages: maybeBotChat({ ...n, participants }, now) };
}
// Check-in window: RSVP'd bots convert to ready / "+N minutes".
const inBots = joined.filter(p => PEERS_.includes(p) && participants[p].status === 'in');
if (phase === 'checkin' && inBots.length && Math.random() < 0.22) {
const bot = inBots[Math.floor(Math.random() * inBots.length)];
participants = {
...participants,
[bot]: Math.random() < 0.6
? { ...participants[bot], status: 'ready' }
: { ...participants[bot], status: 'pending', readyAt: now + (10 + Math.random() * 70) * 1000 },
};
} else if (available.length && joined.length < n.maxPlayers && Math.random() < 0.10) {
// Walk-ins can still join late.
const bot = available[Math.floor(Math.random() * available.length)];
const readyNow = Math.random() < 0.55;
participants = {
...participants,
[bot]: readyNow
? { status: 'ready', joinedAt: now }
: { status: 'pending', joinedAt: now, readyAt: now + (10 + Math.random() * 70) * 1000 },
};
}
const messages = maybeBotChat({ ...n, participants }, now);
const readyCount = readyCountOf({ participants }, now);
const state = (readyCount >= n.maxPlayers || now >= n.deadline) ? 'done' : 'open';
return { ...n, participants, messages, state };
}
function makeSeedNomination({ gameId, creator, maxPlayers, ageSec = 0, durationMin = 10, scheduledInMin = null, participants = {}, messages = [] }) {
const now = Date.now() - ageSec * 1000;
const scheduledFor = scheduledInMin != null ? Date.now() + scheduledInMin * 60000 : null;
return {
id: `seed-${gameId}-${creator}`,
gameId, creator, maxPlayers,
createdAt: now,
scheduledFor,
deadline: scheduledFor != null ? scheduledFor : now + durationMin * 60000,
participants: { [creator]: { status: scheduledFor ? 'in' : 'ready', joinedAt: now }, ...participants },
messages: messages.map((m, i) => ({ id: `sm-${gameId}-${i}`, from: m.from, text: m.text, at: Date.now() - (m.agoSec || 0) * 1000 })),
state: 'open',
};
}
function useNominations({ username, seed = [] }) {
const [noms, setNoms] = useState(() => seed.map(makeSeedNomination));
useEffect(() => {
const id = setInterval(() => {
setNoms(prev => {
const now = Date.now();
return prev
.map(n => tickNomination(n, now))
.filter(n => !(n.state === 'started' && now - (n.startedAt || 0) > 2600));
});
}, 1000);
return () => clearInterval(id);
}, []);
const createNomination = (gameId, maxPlayers, durationMin, scheduledFor = null) => {
const now = Date.now();
const nom = {
id: `n${now}${Math.random().toString(36).slice(2, 7)}`,
gameId, creator: username, maxPlayers,
createdAt: now,
scheduledFor,
deadline: scheduledFor != null ? scheduledFor : now + durationMin * 60000,
participants: { [username]: { status: scheduledFor ? 'in' : 'ready', joinedAt: now } },
messages: [],
state: 'open',
};
setNoms(prev => [nom, ...prev]);
};
const respond = (id, choice) => {
setNoms(prev => prev.map(n => {
if (n.id !== id) return n;
const now = Date.now();
const participants = { ...n.participants };
participants[username] = choice === 'ready'
? { status: 'ready', joinedAt: (participants[username] || {}).joinedAt || now }
: { status: 'pending', joinedAt: (participants[username] || {}).joinedAt || now, readyAt: now + choice * 60000 };
return { ...n, participants };
}));
};
// RSVP for a scheduled call: "I'm in".
const rsvp = (id) => {
setNoms(prev => prev.map(n => n.id === id
? { ...n, participants: { ...n.participants, [username]: { status: 'in', joinedAt: Date.now() } } }
: n));
};
const sendMessage = (id, text) => {
const t = String(text || '').trim();
if (!t) return;
setNoms(prev => prev.map(n => n.id === id
? { ...n, messages: [...(n.messages || []), { id: `m${Date.now()}${Math.random().toString(36).slice(2, 6)}`, from: username, text: t, at: Date.now() }] }
: n));
};
const leave = (id) => {
setNoms(prev => prev.map(n => {
if (n.id !== id) return n;
const participants = { ...n.participants };
delete participants[username];
return { ...n, participants };
}));
};
const cancel = (id) => setNoms(prev => prev.filter(n => n.id !== id));
const startNow = (id) => setNoms(prev => prev.map(n => n.id === id ? { ...n, state: 'started', startedAt: Date.now() } : n));
const addTime = (id, minutes = 5) => setNoms(prev => prev.map(n => n.id === id ? { ...n, deadline: Date.now() + minutes * 60000, state: 'open' } : n));
return { nominations: noms, createNomination, respond, rsvp, sendMessage, leave, cancel, startNow, addTime };
}
// ────────────────────────────────────────────────────────────────────
// Top-bar entry point
// ────────────────────────────────────────────────────────────────────
function CallToPlayButton({ nominations, onClick }) {
const active = nominations.filter(n => n.state !== 'started');
return (
<button className="ctp-btn" onClick={onClick}>
<Icon_.flag/>
<span>Call to Play</span>
{active.length > 0 && <span className="ctp-badge">{active.length}</span>}
</button>
);
}
// ────────────────────────────────────────────────────────────────────
// Mini ready-bubbles — the per-player state, compressed for the
// quick bars: green ring + check = ready, "+Nm" tag = needs minutes,
// dimmed = RSVP'd but not checked in yet.
// ────────────────────────────────────────────────────────────────────
function MiniBubbles({ nomination: n, now, max = 6 }) {
const entries = Object.entries(n.participants);
const shown = entries.slice(0, max);
return (
<span className="ctp-ticker-bubbles">
{shown.map(([name, p]) => {
const ready = isReady(p, now);
const st = ready ? 'ready' : p.status === 'in' ? 'in' : 'pending';
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
const title = ready ? `${name} — ready`
: st === 'in' ? `${name} — in, not checked in yet`
: `${name} — ready in ${fmtCountdownShort(p.readyAt - now)}`;
return (
<span key={name} className="ctp-mini" data-state={st} title={title} style={{ background: avatarColor(name) }}>
{initials}
{ready && <span className="ctp-mini-check"><Icon_.check/></span>}
{st === 'pending' && <i className="ctp-mini-tag">{fmtCountdownShort(p.readyAt - now)}</i>}
</span>
);
})}
{entries.length > max && <span className="ctp-mini ctp-mini-more">+{entries.length - max}</span>}
</span>
);
}
// ────────────────────────────────────────────────────────────────────
// Persistent quick bars — one row per active call, soonest first.
// Scheduled calls show their clock time; once check-in opens the row
// lights up to nudge everyone who said "I'm in".
// ────────────────────────────────────────────────────────────────────
const TICKER_LABEL = { scheduled: 'Scheduled', call: 'Call to Play', soon: 'Starting soon', ready: 'Ready' };
// Sort rank: ready first, starting-soon next, everything else after —
// ties broken by whichever resolves/starts soonest.
const TICKER_RANK = { ready: 0, soon: 1, call: 2, scheduled: 2 };
function CallToPlayTickerRow({ nomination: n, accent, onOpen }) {
const [, setTick] = useState(0);
useEffect(() => { const id = setInterval(() => setTick(t => t + 1), 1000); return () => clearInterval(id); }, []);
const game = GAMES_.find(g => g.id === n.gameId);
if (!game) return null;
const now = Date.now();
const status = statusOf(n, now);
const ready = readyCountOf(n, now);
const total = Object.keys(n.participants).length;
const remaining = Math.max(0, n.deadline - now);
const msgs = n.messages || [];
const last = msgs[msgs.length - 1];
const count = status === 'scheduled' ? `${total} in` : `${ready}/${n.maxPlayers} ready`;
const time = status === 'ready' ? 'waiting to start'
: status === 'scheduled' ? `${fmtClock(n.scheduledFor)} · ${fmtUntil(n.scheduledFor - now)}`
: n.scheduledFor ? `starts ${fmtClock(n.scheduledFor)} · ${fmtCountdown(remaining)}`
: fmtCountdown(remaining);
return (
<button className="ctp-ticker" data-status={status} style={{ '--accent': accent }} onClick={() => onOpen(n.id)}>
<span className="ctp-ticker-dot" data-status={status}/>
<span className="ctp-ticker-label" data-status={status}>{TICKER_LABEL[status]}</span>
<span className="ctp-ticker-game">{game.title}</span>
<span className="ctp-ticker-by">by {n.creator}</span>
<span className="ctp-ticker-ready">{count}</span>
<span className="ctp-ticker-time">{time}</span>
{last ? (
<span className="ctp-ticker-chat" title={`${last.from}: ${last.text}`}>
<Icon_.chat/>
<b style={{ color: avatarColor(last.from) }}>{last.from}:</b>
<span className="ctp-ticker-chat-text">{last.text}</span>
</span>
) : <span className="ctp-ticker-chat"/>}
<MiniBubbles nomination={n} now={now}/>
<span className="ctp-ticker-cta">{status === 'soon' ? 'Check in' : 'View'}<Icon_.chevron/></span>
</button>
);
}
function CallToPlayTicker({ nominations, accent, onOpen }) {
const now = Date.now();
const active = nominations.filter(n => n.state !== 'started')
.sort((a, b) => TICKER_RANK[statusOf(a, now)] - TICKER_RANK[statusOf(b, now)] || a.deadline - b.deadline);
if (!active.length) return null;
return (
<div className="ctp-ticker-stack">
{active.map(n => <CallToPlayTickerRow key={n.id} nomination={n} accent={accent} onOpen={onOpen}/>)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Avatar chip — ready / counting-down / RSVP'd ("in")
// ────────────────────────────────────────────────────────────────────
function AvatarChip({ name, participant, now }) {
const ready = isReady(participant, now);
const isIn = !ready && participant.status === 'in';
const remaining = participant.readyAt ? Math.max(0, participant.readyAt - now) : 0;
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
const title = ready ? `${name} — ready`
: isIn ? `${name} — in, not checked in yet`
: `${name} — ready in ${fmtCountdown(remaining)}`;
return (
<div className={`ctp-avatar ${ready ? 'is-ready' : isIn ? 'is-in' : 'is-pending'}`} title={title}>
<span className="ctp-avatar-dot" style={{ background: avatarColor(name) }}>{initials}</span>
{ready
? <span className="ctp-avatar-check"><Icon_.check/></span>
: isIn
? <span className="ctp-avatar-in">in</span>
: <span className="ctp-avatar-pending">{fmtCountdownShort(remaining)}</span>}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Nomination card
// ────────────────────────────────────────────────────────────────────
function NominationCard({ nomination: n, username, accent, actions, focused }) {
const [, setTick] = useState(0);
useEffect(() => { const id = setInterval(() => setTick(t => t + 1), 1000); return () => clearInterval(id); }, []);
const [confirmCancel, setConfirmCancel] = useState(false);
const game = GAMES_.find(g => g.id === n.gameId);
if (!game) return null;
const now = Date.now();
const entries = Object.entries(n.participants);
const readyCount = readyCountOf(n, now);
const inCount = inCountOf(n, now);
const isMe = username in n.participants;
const myStatus = n.participants[username];
const isCreator = n.creator === username;
const isDone = n.state === 'done';
const isStarted = n.state === 'started';
const phase = phaseOf(n, now);
const isSched = phase === 'scheduled' && !isDone && !isStarted;
const isCheckin = phase === 'checkin' && !isDone && !isStarted;
const windowStart = n.scheduledFor ? n.scheduledFor - CHECKIN_LEAD_MS : n.createdAt;
const totalMs = Math.max(1, n.deadline - windowStart);
const remaining = Math.max(0, n.deadline - now);
const pct = Math.max(0, Math.min(100, (remaining / totalMs) * 100));
const urgency = pct < 20 ? 'high' : pct < 50 ? 'mid' : 'low';
const peerCount = installedPeersFor_(game).length;
let actionsBody;
if (isStarted) {
actionsBody = <div className="ctp-note ctp-note-launch">Launching {game.title} for {n.maxPlayers} players</div>;
} else if (isSched) {
if (isCreator) {
actionsBody = <div className="ctp-note">Scheduled for {fmtClock(n.scheduledFor)} check-in opens 15 min before start.</div>;
} else if (isMe) {
actionsBody = (
<React.Fragment>
<div className="ctp-me-status">Youre in well nudge you to check in 15 min before start</div>
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(n.id)}>Cant make it</button>
</React.Fragment>
);
} else {
actionsBody = (
<React.Fragment>
<button className="act-btn act-play" onClick={() => actions.rsvp(n.id)}><Icon_.check/><span>Im in</span></button>
<div className="ctp-note">Check-in opens 15 min before start</div>
</React.Fragment>
);
}
} else if (isCreator) {
if (isDone) {
actionsBody = (
<React.Fragment>
<button className="act-btn act-play" onClick={() => actions.startNow(n.id)}><Icon_.play/><span>Start now</span></button>
<button className="ghost-btn" onClick={() => actions.addTime(n.id, 5)}>Add 5 more minutes</button>
</React.Fragment>
);
} else if (myStatus && myStatus.status === 'in') {
actionsBody = (
<React.Fragment>
<button className="act-btn act-play" onClick={() => actions.respond(n.id, 'ready')}><Icon_.play/><span>Ready now</span></button>
<div className="ctp-buffer-group">
{[5, 10, 15].map(min => (
<button key={min} className="ctp-buffer-btn" onClick={() => actions.respond(n.id, min)}>+{min}m</button>
))}
</div>
</React.Fragment>
);
} else {
actionsBody = <div className="ctp-note">Waiting for players to ready up</div>;
}
} else if (isDone) {
actionsBody = <div className="ctp-note">{readyCount >= n.maxPlayers ? 'Everyones ready.' : `Its time — waiting for ${n.creator} to start.`}</div>;
} else if (isMe) {
if (myStatus.status === 'in') {
actionsBody = (
<React.Fragment>
<div className="ctp-me-status">You said youre in check in:</div>
<button className="act-btn act-play" onClick={() => actions.respond(n.id, 'ready')}><Icon_.play/><span>Ready now</span></button>
<div className="ctp-buffer-group">
{[5, 10, 15].map(min => (
<button key={min} className="ctp-buffer-btn" onClick={() => actions.respond(n.id, min)}>+{min}m</button>
))}
</div>
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(n.id)}>Leave</button>
</React.Fragment>
);
} else {
const ready = isReady(myStatus, now);
actionsBody = (
<React.Fragment>
<div className="ctp-me-status">{ready ? 'Youre in — ready' : `You: ready in ${fmtCountdown(myStatus.readyAt - now)}`}</div>
{!ready && <button className="ghost-btn" onClick={() => actions.respond(n.id, 'ready')}>Im ready now</button>}
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(n.id)}>Leave</button>
</React.Fragment>
);
}
} else {
actionsBody = (
<React.Fragment>
<button className="act-btn act-play" onClick={() => actions.respond(n.id, 'ready')}><Icon_.play/><span>Ready now</span></button>
<div className="ctp-buffer-group">
{[5, 10, 15, 30].map(min => (
<button key={min} className="ctp-buffer-btn" onClick={() => actions.respond(n.id, min)}>+{min}m</button>
))}
</div>
</React.Fragment>
);
}
const timerEl = isStarted ? <div className="ctp-card-timer" data-urgency="off">Launching</div>
: isDone ? <div className="ctp-card-timer" data-urgency="off">Ready</div>
: isSched ? (
<div className="ctp-card-timer is-sched" data-urgency="off">
<span className="ctp-card-clock">{fmtClock(n.scheduledFor)}</span>
<span className="ctp-card-until">{fmtUntil(n.scheduledFor - now)}</span>
</div>
)
: isCheckin ? (
<div className="ctp-card-timer is-sched" data-urgency={urgency}>
<span className="ctp-card-clock">{fmtCountdown(remaining)}</span>
<span className="ctp-card-until">starts {fmtClock(n.scheduledFor)}</span>
</div>
)
: <div className="ctp-card-timer" data-urgency={urgency}>{fmtCountdown(remaining)}</div>;
const subEl = n.scheduledFor
? <React.Fragment>Scheduled by <strong>{n.creator}</strong> · starts at {fmtClock(n.scheduledFor)} · {peerCount}/{PEERS_.length} peers have it installed</React.Fragment>
: <React.Fragment>Called by <strong>{n.creator}</strong> · {peerCount}/{PEERS_.length} peers have it installed</React.Fragment>;
const rosterLabel = isSched
? `${entries.length} in · up to ${n.maxPlayers} players`
: isCheckin && inCount > 0
? `${readyCount}/${n.maxPlayers} ready · ${inCount} not checked in yet`
: `${readyCount}/${n.maxPlayers} ready`;
return (
<div className={`ctp-card ${isDone ? 'is-done' : ''} ${isStarted ? 'is-started' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}>
<div className="ctp-card-top">
<div className="ctp-card-cover"><GameCover_ game={game} aspect="square"/></div>
<div className="ctp-card-info">
<div className="ctp-card-title">{game.title}</div>
<div className="ctp-card-sub">{subEl}</div>
</div>
{timerEl}
</div>
{isCheckin && (
<div className="ctp-checkin-note">
<Icon_.clock/>
<span>{isMe && myStatus && myStatus.status === 'in'
? 'Starting soon — you said youre in. Check in below.'
: 'Starting soon — check-in is open.'}</span>
</div>
)}
{!isSched && (
<div className="ctp-progress">
<div className="ctp-progress-fill"
style={{ width: `${isStarted || isDone ? 100 : pct}%`, background: isDone || isStarted ? 'var(--ok)' : accent }}/>
</div>
)}
<div className="ctp-roster">
<div className="ctp-roster-count">{rosterLabel}</div>
<div className="ctp-avatars">
{entries.map(([name, p]) => <AvatarChip key={name} name={name} participant={p} now={now}/>)}
{Array.from({ length: Math.max(0, n.maxPlayers - entries.length) }).map((_, i) => (
<div key={`empty${i}`} className="ctp-avatar ctp-avatar-empty"/>
))}
</div>
</div>
<div className="ctp-actions">{actionsBody}</div>
<CtpChat_ nomination={n} username={username} disabled={isStarted}
onSend={(text) => actions.sendMessage(n.id, text)}/>
{isCreator && !isStarted && (
confirmCancel ? (
<div className="ctp-cancel-confirm">
<span>Cancel this call for everyone?</span>
<div className="ctp-cancel-confirm-btns">
<button className="ghost-btn ghost-danger" onClick={() => actions.cancel(n.id)}>Yes, cancel it</button>
<button className="ghost-btn" onClick={() => setConfirmCancel(false)}>No, keep it</button>
</div>
</div>
) : (
<button className="ctp-cancel-link" onClick={() => setConfirmCancel(true)}>
<Icon_.trash/><span>Cancel this call</span>
</button>
)
)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Create-nomination form — "Now" or a scheduled clock time
// ────────────────────────────────────────────────────────────────────
function CreateNominationForm({ accent, onCancel, onCreate }) {
const [query, setQuery] = useState('');
const [gameId, setGameId] = useState(null);
const [maxPlayers, setMaxPlayers] = useState(8);
const [duration, setDuration] = useState(10);
const [when, setWhen] = useState('now');
const [dayOffset, setDayOffset] = useState(0);
const nextSlot = () => {
const d = new Date(Date.now() + 40 * 60000);
d.setMinutes(d.getMinutes() <= 30 ? 30 : 60, 0, 0);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
const [timeStr, setTimeStr] = useState(nextSlot); // committed "HH:MM" (24h)
const [editing, setEditing] = useState(false);
const [timeDraft, setTimeDraft] = useState(timeStr);
const timeInputRef = useRef(null);
// Select-all when the type-a-time field appears, so the user can overtype
// immediately. Done in a layout effect (not onFocus+autoFocus) so it fires
// after the browser places the autofocus caret, and re-asserts if the
// per-second nominations tick re-renders the overlay.
useLayoutEffect(() => {
if (editing && timeInputRef.current) {
timeInputRef.current.focus();
timeInputRef.current.select();
}
}, [editing]);
const [hStr, mStr] = timeStr.split(':');
// Nudge the time by whole hours or minute-steps, wrapping within 023:59.
const bump = (field, delta) => {
let [h, m] = timeStr.split(':').map(Number);
if (field === 'h') h = (h + delta + 24) % 24;
else {
let total = h * 60 + m + delta;
total = ((total % 1440) + 1440) % 1440;
h = Math.floor(total / 60); m = total % 60;
}
setTimeStr(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
};
// Constrain free-text entry: digits + at most one colon, ≤2 digits per side
// (≤5 chars with a colon), ≤4 digits without one. Anything else is rejected
// (returns the previous draft) — so a colon can't be typed after 3 digits.
const sanitizeTime = (raw, prev) => {
const v = String(raw).replace(/[^\d:]/g, '');
if ((v.match(/:/g) || []).length > 1) return prev;
const ci = v.indexOf(':');
if (ci !== -1) {
if (v.slice(0, ci).length > 2 || v.slice(ci + 1).length > 2) return prev;
return v;
}
return v.length > 4 ? prev : v;
};
// Accepts "20:00", "2000", "20", "9:30" — normalizes or reverts.
const commitDraft = () => {
const mm = String(timeDraft).trim().match(/^(\d{1,2})(?:[:.\s]?(\d{2}))?$/);
if (mm) {
const h = Number(mm[1]), min = Number(mm[2] || 0);
if (h <= 23 && min <= 59) setTimeStr(`${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`);
}
setEditing(false);
};
const DE_DAYS = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const dayChips = [0, 1, 2].map(off => {
const d = new Date();
d.setDate(d.getDate() + off);
const label = off === 0 ? 'Today' : DE_DAYS[d.getDay()];
return { off, label };
});
const matches = useMemo(() => {
if (!query.trim()) return [];
const q = query.toLowerCase();
return GAMES_.filter(g => g.title.toLowerCase().includes(q)).slice(0, 6);
}, [query]);
const selected = GAMES_.find(g => g.id === gameId);
const pickGame = (g) => {
setGameId(g.id);
setMaxPlayers(parseMaxPlayers_(g.players));
setQuery(g.title);
};
const scheduledTs = () => {
const [h, m] = timeStr.split(':').map(Number);
const d = new Date();
d.setDate(d.getDate() + dayOffset);
d.setHours(h || 0, m || 0, 0, 0);
if (d.getTime() < Date.now() + 60000) d.setDate(d.getDate() + 1);
return d.getTime();
};
return (
<div className="ctp-create">
<div className="ctp-create-row">
<label className="ctp-create-label">Game</label>
<div className="search ctp-create-search" style={{ '--accent': accent }}>
<Icon_.search/>
<input type="text" placeholder="Search the catalog…" value={query}
onChange={(e) => { setQuery(e.target.value); setGameId(null); }}/>
</div>
{!selected && query.trim() && (
<div className="ctp-create-matches">
{matches.map(g => (
<button key={g.id} onClick={() => pickGame(g)}>
<span>{g.title}</span>
<span className="ctp-create-match-meta">{g.players} players</span>
</button>
))}
{matches.length === 0 && <div className="ctp-create-nomatch">No games match "{query}"</div>}
</div>
)}
</div>
{selected && (
<React.Fragment>
<div className="ctp-create-row ctp-create-row-inline">
<label className="ctp-create-label">Max players</label>
<input type="number" className="ctp-create-num" min={2} max={64} value={maxPlayers}
onChange={(e) => setMaxPlayers(Math.max(2, Number(e.target.value) || 2))}/>
<span className="ctp-create-hint">Suggested for {selected.title}: {parseMaxPlayers_(selected.players)}</span>
</div>
<div className="ctp-create-row">
<label className="ctp-create-label">When</label>
<div className="ctp-duration-opts">
<button className={`ctp-duration-btn ${when === 'now' ? 'is-active' : ''}`}
style={when === 'now' ? { background: accent, borderColor: accent } : undefined}
onClick={() => setWhen('now')}>Now</button>
<button className={`ctp-duration-btn ${when === 'later' ? 'is-active' : ''}`}
style={when === 'later' ? { background: accent, borderColor: accent } : undefined}
onClick={() => setWhen('later')}>Schedule</button>
</div>
{when === 'later' && (
<React.Fragment>
<div className="ctp-timepick">
{editing ? (
<input ref={timeInputRef} type="text" inputMode="numeric" maxLength={5} placeholder="20:00"
className="ctp-time-editinput" value={timeDraft}
onChange={(e) => setTimeDraft(prev => sanitizeTime(e.target.value, prev))}
onBlur={commitDraft}
onKeyDown={(e) => { if (e.key === 'Enter') commitDraft(); if (e.key === 'Escape') setEditing(false); }}/>
) : (
<React.Fragment>
<div className="ctp-timepick-field">
<button type="button" className="ctp-time-step" aria-label="Hour up" onClick={() => bump('h', 1)}><Icon_.caretUp/></button>
<span className="ctp-time-cell">{hStr}</span>
<button type="button" className="ctp-time-step" aria-label="Hour down" onClick={() => bump('h', -1)}><Icon_.caretDown/></button>
</div>
<span className="ctp-time-colon">:</span>
<div className="ctp-timepick-field">
<button type="button" className="ctp-time-step" aria-label="Minute up" onClick={() => bump('m', 15)}><Icon_.caretUp/></button>
<span className="ctp-time-cell">{mStr}</span>
<button type="button" className="ctp-time-step" aria-label="Minute down" onClick={() => bump('m', -15)}><Icon_.caretDown/></button>
</div>
<button type="button" className="ctp-time-type"
onClick={() => { setTimeDraft(timeStr); setEditing(true); }}>Type a time</button>
</React.Fragment>
)}
</div>
<div className="ctp-time-row ctp-day-row">
<span className="ctp-day-label">Day</span>
{dayChips.map(c => (
<button key={c.off} className={`ctp-duration-btn ctp-day-btn ${dayOffset === c.off ? 'is-active' : ''}`}
style={dayOffset === c.off ? { background: accent, borderColor: accent } : undefined}
onClick={() => setDayOffset(c.off)}>{c.label}</button>
))}
</div>
<span className="ctp-create-hint">24-hour clock. People RSVP with Im in check-in to ready up opens 15 min before start.</span>
</React.Fragment>
)}
</div>
{when === 'now' && (
<div className="ctp-create-row">
<label className="ctp-create-label">Give people</label>
<div className="ctp-duration-opts">
{[5, 10, 15, 30, 60].map(min => (
<button key={min} className={`ctp-duration-btn ${duration === min ? 'is-active' : ''}`}
style={duration === min ? { background: accent, borderColor: accent } : undefined}
onClick={() => setDuration(min)}>{min}m</button>
))}
</div>
</div>
)}
<div className="ctp-create-foot">
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
<button className="act-btn act-play"
onClick={() => onCreate(selected.id, maxPlayers, duration, when === 'later' ? scheduledTs() : null)}>
{when === 'later'
? `Schedule it — ${selected.title} · ${dayOffset > 0 ? `${dayChips[dayOffset].label} ` : ''}${timeStr}`
: `Call it — ${selected.title}`}
</button>
</div>
</React.Fragment>
)}
{!selected && (
<div className="ctp-create-foot">
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
</div>
)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Full overlay
// ────────────────────────────────────────────────────────────────────
function CallToPlayOverlay({ nominations, username, accent, onClose, actions, focusId }) {
const [showCreate, setShowCreate] = useState(false);
const sorted = [...nominations].sort((a, b) => (a.state === 'started' ? 1 : 0) - (b.state === 'started' ? 1 : 0) || a.deadline - b.deadline);
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal ctp-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose} aria-label="Close"><Icon_.close/></button>
<div className="ctp-head">
<h2>Call to Play</h2>
<p className="ctp-head-sub">Rally the LAN around a game and a time right now, or scheduled for later with an Im in RSVP. The caller decides when it actually starts.</p>
{!showCreate && (
<button className="act-btn act-play ctp-head-new" onClick={() => setShowCreate(true)}>
<Icon_.flag/><span>Call a new match</span>
</button>
)}
</div>
<div className="ctp-body">
{showCreate && (
<CreateNominationForm accent={accent}
onCancel={() => setShowCreate(false)}
onCreate={(gid, max, dur, sched) => { actions.createNomination(gid, max, dur, sched); setShowCreate(false); }}/>
)}
{sorted.length === 0 && !showCreate && (
<div className="ctp-empty">No active calls right now be the one to start something.</div>
)}
{sorted.map(n => (
<NominationCard key={n.id} nomination={n} username={username} accent={accent} actions={actions}
focused={n.id === focusId}/>
))}
</div>
</div>
</div>
);
}
Object.assign(window, {
useNominations, makeSeedNomination,
CallToPlayButton, CallToPlayTicker, CallToPlayOverlay,
NominationCard, CreateNominationForm,
});
@@ -20,6 +20,12 @@ const Icon = {
check: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m3 8 3.5 3.5L13 5"/></svg>,
chevron:(p) => <svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m4 6 4 4 4-4"/></svg>,
trash: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 4.5h10"/><path d="M5.5 4.5V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1.5"/><path d="M4.5 4.5 5 13a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1l.5-8.5"/></svg>,
flag: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3.5 1.5v13"/><path d="M3.5 2.25c3-1.3 4.5 1.3 7.5 0v6.7c-3 1.3-4.5-1.3-7.5 0"/></svg>,
clock: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/></svg>,
chat: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M2 3.5A1.5 1.5 0 0 1 3.5 2h9A1.5 1.5 0 0 1 14 3.5v6a1.5 1.5 0 0 1-1.5 1.5H6.5L3 14v-3h.5A1.5 1.5 0 0 1 2 9.5z"/></svg>,
send: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" {...p}><path d="M1.5 2 15 8 1.5 14l2.3-6z"/></svg>,
caretUp: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m4 10 4-4 4 4"/></svg>,
caretDown: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m4 6 4 4 4-4"/></svg>,
};
// ────────────────────────────────────────────────────────────────────
@@ -217,6 +223,10 @@ function GameCard({ game, accent, aspect, onOpen }) {
<span>{fmtSize(game.size)}</span>
<span className="card-dot">·</span>
<span>{game.tags[0]}</span>
<span className="card-dot">·</span>
<span className="card-peers" title={`${installedPeersFor(game).length} of ${PEERS.length} peers on the LAN have this installed`}>
<Icon.users/>{installedPeersFor(game).length}/{PEERS.length}
</span>
</div>
<ActionButton state={game.state} accent={accent} full game={game}/>
</div>
@@ -293,6 +303,7 @@ const SORTS = [
{ key: 'size', label: 'Size (largest)' },
{ key: 'recent', label: 'Recently Played' },
{ key: 'state', label: 'Status' },
{ key: 'peers', label: 'Peers with it (most)' },
];
function SortMenu({ value, onChange, accent }) {
@@ -0,0 +1,86 @@
// ctp-chat.jsx — shared Call-to-Play helpers (avatar colors, clock formatting)
// plus the per-call chat panel. Loaded after components.jsx, before calltoplay.jsx.
const { useState: useChatState, useEffect: useChatEffect, useRef: useChatRef } = React;
const CTP_AVATAR_COLORS = ['#60a5fa', '#34d399', '#c084fc', '#fbbf24', '#f472b6', '#38bdf8', '#a3e635', '#fb7185'];
const ctpHashName = (s) => [...String(s)].reduce((a, c) => a + c.charCodeAt(0), 0);
const ctpAvatarColor = (name) => CTP_AVATAR_COLORS[ctpHashName(name) % CTP_AVATAR_COLORS.length];
// 24h clock, LAN-party style: "20:00"
const ctpFmtClock = (ts) => {
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
// ────────────────────────────────────────────────────────────────────
// Per-call chat — collapsed to a one-line toggle with an unread badge
// and a preview of the last message; expands to a compact IRC-style
// message list + input. All state is local to the card.
// ────────────────────────────────────────────────────────────────────
function CtpChat({ nomination: n, username, onSend, disabled }) {
const IconX = window.Icon;
const msgs = n.messages || [];
const [open, setOpen] = useChatState(false);
const [seen, setSeen] = useChatState(msgs.length);
const [draft, setDraft] = useChatState('');
const listRef = useChatRef(null);
useChatEffect(() => { if (open) setSeen(msgs.length); }, [open, msgs.length]);
useChatEffect(() => {
if (open && listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
}, [open, msgs.length]);
const unread = Math.max(0, msgs.length - seen);
const last = msgs[msgs.length - 1];
const submit = () => {
const t = draft.trim();
if (!t) return;
onSend(t);
setDraft('');
};
return (
<div className={`ctp-chat ${open ? 'is-open' : ''}`}>
<button className="ctp-chat-toggle" onClick={() => setOpen(o => !o)}>
<IconX.chat/>
<span>Chat</span>
{msgs.length > 0 && <span className="ctp-chat-count">{msgs.length}</span>}
{!open && unread > 0 && <span className="ctp-chat-unread">{unread}</span>}
{!open && last && (
<span className="ctp-chat-preview">
<b style={{ color: ctpAvatarColor(last.from) }}>{last.from}:</b> {last.text}
</span>
)}
<span className="ctp-chat-chevron"><IconX.chevron/></span>
</button>
{open && (
<React.Fragment>
<div className="ctp-chat-list" ref={listRef}>
{msgs.length === 0 && <div className="ctp-chat-empty">No messages yet say hi.</div>}
{msgs.map(m => (
<div key={m.id} className={`ctp-chat-msg ${m.from === username ? 'is-me' : ''}`}>
<b style={{ color: ctpAvatarColor(m.from) }}>{m.from}</b>
<span className="ctp-chat-time">{ctpFmtClock(m.at)}</span>
<span className="ctp-chat-text"> {m.text}</span>
</div>
))}
</div>
{!disabled && (
<div className="ctp-chat-form">
<input type="text" className="ctp-chat-input" placeholder="Message the group…"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}/>
<button className="ctp-chat-send" onClick={submit} aria-label="Send"><IconX.send/></button>
</div>
)}
</React.Fragment>
)}
</div>
);
}
Object.assign(window, { CtpChat, ctpAvatarColor, ctpFmtClock });
+30
View File
@@ -203,7 +203,37 @@ const STORAGE = {
total: 512,
};
// ────────────────────────────────────────────────────────────────────
// LAN roster (mock) — used by the peer-install indicator and by the
// Call to Play feature to simulate other people in the room.
// ────────────────────────────────────────────────────────────────────
const PEERS = [
'shadowfox', 'pixelqueen', 'kbmaster', 'nullptr', 'wavedash',
'crit_happens', 'afk_andy', 'lowping_larry', 'gg_val', 'ctrlaltdave',
];
const hashStr = (s) => [...String(s)].reduce((a, c) => a + c.charCodeAt(0), 0);
// Deterministic-but-varied subset of PEERS who "have this game installed" —
// stable per game id so the same game always shows the same roster/count.
function installedPeersFor(game) {
const h = hashStr(game.id);
const count = 1 + (h % PEERS.length); // 1..PEERS.length
const scored = PEERS.map((p, i) => ({ p, s: (h * 7 + i * 13 + hashStr(p)) % 97 }));
scored.sort((a, b) => a.s - b.s);
return scored.slice(0, count).map(x => x.p);
}
// "216" / "1-8" → 16 / 8. Falls back to 8 if unparsable.
function parseMaxPlayers(playersStr) {
const m = String(playersStr).match(/(\d+)\s*$/);
return m ? parseInt(m[1], 10) : 8;
}
window.GAMES = GAMES;
window.PEERS = PEERS;
window.installedPeersFor = installedPeersFor;
window.parseMaxPlayers = parseMaxPlayers;
window.STATE_META = STATE_META;
window.ACTION_FOR_STATE = ACTION_FOR_STATE;
window.countByFilter = countByFilter;
@@ -15,6 +15,8 @@ function applyFilterAndSort(games, filter, sort, query) {
} else if (sort === 'recent') {
const order = { installed: 0, local: 1, none: 2 };
g = [...g].sort((a, b) => order[a.state] - order[b.state] || b.version.localeCompare(a.version));
} else if (sort === 'peers') {
g = [...g].sort((a, b) => installedPeersFor(b).length - installedPeersFor(a).length || a.title.localeCompare(b.title));
}
return g;
}
@@ -25,6 +27,8 @@ function Launcher({
initialFilter = 'all', initialSort = 'recent', initialQuery = '',
initialOpenGame = null,
initialSettingsOpen = false,
seedCallToPlay = [],
initialCtpOpen = false,
}) {
const { density, aspect, accent, bg } = tweaks;
const [filter, setFilter] = useState(initialFilter);
@@ -32,6 +36,11 @@ function Launcher({
const [query, setQuery] = useState(initialQuery);
const [openGame, setOpenGame] = useState(initialOpenGame);
const [settingsOpen, setSettingsOpen] = useState(initialSettingsOpen);
const [ctpOpen, setCtpOpen] = useState(initialCtpOpen);
const [ctpFocusId, setCtpFocusId] = useState(null);
const nomUsername = tweaks.username && tweaks.username.trim() ? tweaks.username.trim() : 'you';
const ctp = useNominations({ username: nomUsername, seed: seedCallToPlay });
const counts = useMemo(() => countByFilter(GAMES), []);
const list = useMemo(() => applyFilterAndSort(GAMES, filter, sort, query), [filter, sort, query]);
@@ -66,6 +75,7 @@ function Launcher({
<SortMenu value={sort} onChange={setSort} accent={accent}/>
</div>
<div className="topbar-right-trail">
<CallToPlayButton nominations={ctp.nominations} onClick={() => setCtpOpen(true)}/>
<KebabMenu items={menuItems}/>
</div>
</div>
@@ -79,6 +89,7 @@ function Launcher({
</div>
<div className="topbar-row1-right">
<StorageMeter accent={accent}/>
<CallToPlayButton nominations={ctp.nominations} onClick={() => setCtpOpen(true)}/>
<KebabMenu items={menuItems}/>
</div>
</div>
@@ -93,6 +104,8 @@ function Launcher({
)}
<main className="grid-wrap">
<CallToPlayTicker nominations={ctp.nominations} accent={accent}
onOpen={(id) => { setCtpFocusId(id); setCtpOpen(true); }}/>
{variant === 'single' && (
<div className="results-bar">
<div className="results-count">
@@ -113,6 +126,12 @@ function Launcher({
{settingsOpen && setTweak && (
<SettingsDialog settings={tweaks} onChange={setTweak} onClose={() => setSettingsOpen(false)}/>
)}
{ctpOpen && (
<CallToPlayOverlay nominations={ctp.nominations} username={nomUsername} accent={accent}
focusId={ctpFocusId}
onClose={() => { setCtpOpen(false); setCtpFocusId(null); }}
actions={{ createNomination: ctp.createNomination, respond: ctp.respond, rsvp: ctp.rsvp, sendMessage: ctp.sendMessage, leave: ctp.leave, cancel: ctp.cancel, startNow: ctp.startNow, addTime: ctp.addTime }}/>
)}
</div>
);
}
+628 -1
View File
@@ -636,8 +636,14 @@
font-size: 11.5px;
color: var(--t-3);
font-variant-numeric: tabular-nums;
min-width: 0;
}
.card-meta .card-dot { opacity: 0.5; }
.card-meta .card-dot { opacity: 0.5; flex-shrink: 0; }
.card-peers {
display: inline-flex; align-items: center; gap: 3px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.card-peers svg { flex-shrink: 0; opacity: 0.75; }
.density-compact .card-body { padding: 9px 10px 10px; gap: 6px; }
.density-compact .card-title { font-size: 12.5px; }
@@ -1311,3 +1317,624 @@
.folder-field.is-unset .folder-field-btn:hover {
background: var(--accent, #3b82f6);
}
/* ═══════════════════════════════════════════════════════════════════
Call to Play — rally a game + a time
═══════════════════════════════════════════════════════════════════ */
/* ─── Top-bar entry button ─── */
.ctp-btn {
position: relative;
display: inline-flex; align-items: center; gap: 8px;
height: 36px; padding: 0 14px;
background: color-mix(in srgb, var(--accent) 14%, var(--bg-2));
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
border-radius: 8px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 700;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: background .15s, border-color .15s;
}
.ctp-btn svg { color: var(--accent); flex-shrink: 0; }
.ctp-btn:hover {
background: color-mix(in srgb, var(--accent) 22%, var(--bg-2));
border-color: color-mix(in srgb, var(--accent) 65%, var(--bd-2));
}
.ctp-badge {
display: inline-grid; place-items: center;
min-width: 18px; height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--accent);
color: white;
font-size: 10.5px; font-weight: 800;
font-variant-numeric: tabular-nums;
}
/* ─── Ticker strip ─── */
.ctp-ticker-stack {
display: flex; flex-direction: column; gap: 8px;
margin-bottom: 14px;
}
.ctp-ticker-stack .ctp-ticker { margin-bottom: 0; }
.ctp-ticker {
display: grid;
grid-template-columns:
10px /* LED */
98px /* status */
220px /* game */
130px /* by */
88px /* count */
170px /* time */
minmax(0, 1fr) /* chat — sole flexible track, absorbs bubbles variance */
max-content /* bubbles */
82px; /* cta */
align-items: center;
column-gap: 12px;
width: 100%;
padding: 10px 16px;
background: color-mix(in srgb, var(--accent) 10%, var(--bg-2));
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--bd-2));
border-radius: 10px;
color: var(--t-1);
font: inherit; font-size: 12.5px;
cursor: pointer;
text-align: left;
transition: background .15s, border-color .15s;
}
.ctp-ticker:hover { background: color-mix(in srgb, var(--accent) 16%, var(--bg-2)); }
.ctp-ticker-dot {
width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0;
background: var(--accent);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent);
animation: ctp-tickerpulse 1.6s ease-out infinite;
}
@keyframes ctp-tickerpulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
}
.ctp-ticker-label {
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
font-size: 10.5px;
letter-spacing: 0.06em;
flex-shrink: 0;
}
.ctp-ticker-game { font-weight: 700; color: var(--t-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-by { color: var(--t-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-sep { display: none; }
.ctp-ticker-ready, .ctp-ticker-time { color: var(--t-2); font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-more {
color: var(--t-3);
font-size: 11.5px;
flex-shrink: 0;
}
.ctp-ticker-cta {
display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px;
font-weight: 700;
color: var(--accent);
}
.ctp-ticker-cta svg { transform: rotate(-90deg); }
/* ─── Overlay modal ─── */
.ctp-modal { width: min(720px, 100%); }
.ctp-head-row {
display: flex; align-items: center; justify-content: space-between;
gap: 12px;
margin-right: 36px;
}
.ctp-head-new { height: 40px; padding: 0 18px; font-size: 13px; margin-top: 14px; width: 100%; }
.ctp-head { padding: 26px 28px 14px; border-bottom: 1px solid var(--bd-1); }
.ctp-head h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -0.01em; color: var(--t-1); }
.ctp-head-sub { margin: 6px 0 0; font-size: 12.5px; color: var(--t-3); max-width: 52ch; }
.ctp-body {
padding: 18px 24px 24px;
display: flex; flex-direction: column; gap: 14px;
max-height: 66vh;
overflow: auto;
}
.ctp-empty {
padding: 28px 8px;
text-align: center;
color: var(--t-3);
font-size: 13px;
}
/* ─── Nomination card ─── */
.ctp-card {
display: flex; flex-direction: column; gap: 12px;
padding: 14px;
background: rgba(255,255,255,0.025);
border: 1px solid var(--bd-1);
border-radius: 12px;
}
.ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); }
.ctp-card.is-started { opacity: 0.6; }
.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; }
@keyframes ctp-cardflash {
0% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
}
.ctp-card-top { display: flex; align-items: flex-start; gap: 12px; position: relative; }
.ctp-card-cover {
position: relative;
width: 52px; height: 52px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
}
.ctp-card-info { flex: 1; min-width: 0; }
.ctp-card-title { font-size: 14.5px; font-weight: 700; color: var(--t-1); }
.ctp-card-sub { margin-top: 3px; font-size: 11.5px; color: var(--t-3); }
.ctp-card-sub strong { color: var(--t-2); font-weight: 700; }
.ctp-card-timer {
font-variant-numeric: tabular-nums;
font-size: 18px;
font-weight: 700;
color: var(--t-1);
flex-shrink: 0;
padding-top: 2px;
}
.ctp-card-timer[data-urgency="mid"] { color: var(--warn); }
.ctp-card-timer[data-urgency="high"] { color: var(--danger); }
.ctp-card.is-done .ctp-card-timer { color: var(--ok); font-size: 14px; text-transform: uppercase; letter-spacing: 0.04em; }
.ctp-cancel-link {
display: inline-flex; align-items: center; gap: 6px;
align-self: flex-start;
margin-top: -2px;
padding: 6px 2px;
background: transparent;
border: 0;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 600;
cursor: pointer;
transition: color .15s;
}
.ctp-cancel-link:hover { color: #fca5a5; }
.ctp-cancel-confirm {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
gap: 10px;
padding: 10px 12px;
background: rgba(239,68,68,0.08);
border: 1px solid rgba(239,68,68,0.35);
border-radius: 8px;
font-size: 12.5px;
color: #fca5a5;
font-weight: 600;
}
.ctp-cancel-confirm-btns { display: inline-flex; gap: 8px; flex-shrink: 0; }
.ctp-cancel-confirm-btns .ghost-btn { height: 32px; padding: 0 12px; font-size: 12px; }
.ctp-progress {
height: 5px;
border-radius: 3px;
background: rgba(255,255,255,0.06);
overflow: hidden;
}
.ctp-progress-fill { height: 100%; transition: width 1s linear; }
.ctp-roster { display: flex; flex-direction: column; gap: 8px; }
.ctp-roster-count { font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--t-3); }
.ctp-avatars { display: flex; flex-wrap: wrap; gap: 6px; }
.ctp-avatar {
position: relative;
display: inline-flex;
}
.ctp-avatar-dot {
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 999px;
color: white;
font-size: 10.5px; font-weight: 800;
letter-spacing: 0.01em;
}
.ctp-avatar.is-pending .ctp-avatar-dot { opacity: 0.55; }
.ctp-avatar-check {
position: absolute; bottom: -2px; right: -2px;
width: 14px; height: 14px;
display: grid; place-items: center;
border-radius: 999px;
background: var(--ok);
color: #06240f;
border: 2px solid var(--bg-2);
}
.ctp-avatar-pending {
position: absolute; bottom: -6px; left: 50%;
transform: translateX(-50%);
font-size: 9px; font-weight: 700;
color: var(--t-2);
background: var(--bg-3);
border: 1px solid var(--bd-2);
padding: 0 4px;
border-radius: 999px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.ctp-avatar-empty {
width: 30px; height: 30px;
border-radius: 999px;
border: 1.5px dashed var(--bd-2);
}
.ctp-actions {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
padding-top: 2px;
}
.ctp-actions .act-btn { height: 36px; padding: 0 16px; font-size: 12.5px; }
.ctp-actions .ghost-btn { height: 36px; padding: 0 14px; font-size: 12.5px; }
.ctp-note { font-size: 12.5px; color: var(--t-3); }
.ctp-note-launch { color: var(--ok); font-weight: 600; }
.ctp-me-status { font-size: 12.5px; font-weight: 600; color: var(--t-2); }
.ctp-buffer-group { display: inline-flex; gap: 6px; }
.ctp-buffer-btn {
height: 36px; padding: 0 11px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--bd-2);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 12px; font-weight: 600;
cursor: pointer;
transition: background .15s, border-color .15s;
}
.ctp-buffer-btn:hover { background: rgba(255,255,255,0.08); border-color: var(--bd-3); }
/* ─── Create-nomination form ─── */
.ctp-create-cta {
display: flex; align-items: center; justify-content: center; gap: 8px;
height: 48px;
background: transparent;
border: 1.5px dashed var(--bd-3);
border-radius: 12px;
color: var(--t-2);
font: inherit; font-size: 13px; font-weight: 700;
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.ctp-create-cta:hover { border-color: var(--accent); color: var(--t-1); background: rgba(255,255,255,0.02); }
.ctp-create {
display: flex; flex-direction: column; gap: 14px;
padding: 16px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--bd-2);
border-radius: 12px;
}
.ctp-create-row { display: flex; flex-direction: column; gap: 6px; position: relative; }
.ctp-create-row-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; }
.ctp-create-label {
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--t-3);
}
.ctp-create-search { width: 100%; }
.ctp-create-matches {
position: absolute;
top: calc(100% + 4px);
left: 0; right: 0;
z-index: 20;
max-height: 220px;
overflow: auto;
padding: 4px;
background: var(--bg-3);
border: 1px solid var(--bd-2);
border-radius: 10px;
box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5);
}
.ctp-create-matches button {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
width: 100%;
padding: 9px 10px;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 600;
text-align: left;
cursor: pointer;
}
.ctp-create-matches button:hover { background: rgba(255,255,255,0.06); }
.ctp-create-match-meta { color: var(--t-3); font-size: 11px; font-weight: 500; }
.ctp-create-nomatch { padding: 10px; font-size: 12px; color: var(--t-3); }
.ctp-create-num {
width: 70px; height: 34px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 13px; font-weight: 600;
text-align: center;
}
.ctp-create-hint { font-size: 11.5px; color: var(--t-3); }
.ctp-duration-opts { display: inline-flex; gap: 6px; flex-wrap: wrap; }
.ctp-duration-btn {
height: 32px; padding: 0 13px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-2);
font: inherit; font-size: 12.5px; font-weight: 700;
cursor: pointer;
transition: background .15s, color .15s, border-color .15s;
}
.ctp-duration-btn:hover { color: var(--t-1); }
.ctp-duration-btn.is-active { color: white; }
.ctp-create-foot {
display: flex; justify-content: flex-end; gap: 10px;
padding-top: 4px;
}
.ctp-create-foot .act-btn,
.ctp-create-foot .ghost-btn { height: 40px; padding: 0 18px; }
.ctp-time-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
/* ─── Time stepper ─── */
.ctp-timepick {
display: flex; align-items: center; gap: 6px;
margin-top: 6px;
}
.ctp-timepick-field {
display: flex; flex-direction: column; align-items: center; gap: 3px;
}
.ctp-time-step {
width: 54px; height: 24px;
display: grid; place-items: center;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-2);
cursor: pointer;
transition: background .12s, color .12s, border-color .12s;
}
.ctp-time-step:hover { background: var(--bg-4); color: var(--t-1); border-color: var(--bd-3); }
.ctp-time-step:active { background: color-mix(in srgb, var(--accent) 30%, var(--bg-3)); }
.ctp-time-cell {
width: 54px; height: 44px;
display: grid; place-items: center;
background: var(--bg-3);
border: 1px solid var(--bd-2);
border-radius: 9px;
font-size: 26px; font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--t-1);
letter-spacing: 0.02em;
}
.ctp-time-editinput {
width: 132px; height: 44px;
padding: 0;
background: var(--bg-3);
border: 1px solid color-mix(in srgb, var(--accent) 70%, var(--bd-2));
border-radius: 9px;
font: inherit; font-size: 26px; font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--t-1);
text-align: center;
letter-spacing: 0.06em;
}
.ctp-time-editinput:focus { outline: none; }
.ctp-time-editinput::placeholder { color: var(--t-4); }
.ctp-time-colon { font-size: 26px; font-weight: 700; color: var(--t-2); padding-bottom: 2px; }
.ctp-time-type {
align-self: center;
margin-left: 8px;
height: 30px; padding: 0 12px;
background: transparent;
border: 1px dashed var(--bd-3);
border-radius: 7px;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 600;
cursor: pointer;
transition: color .12s, border-color .12s;
}
.ctp-time-type:hover { color: var(--t-1); border-color: var(--accent); }
.ctp-day-row { gap: 5px; }
.ctp-day-label {
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--t-3);
margin-right: 4px;
}
.ctp-day-btn { height: 26px; padding: 0 10px; font-size: 11px; border-radius: 6px; }
/* ─── Quick-bar status variants — LED + label + row tint per status:
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
STARTING SOON (amber, glowing) · READY (green, steady) ─── */
.ctp-ticker[data-status="scheduled"] {
background: var(--bg-2);
border-color: var(--bd-2);
}
.ctp-ticker[data-status="scheduled"]:hover { background: var(--bg-3); }
.ctp-ticker[data-status="soon"] {
background: color-mix(in srgb, var(--warn) 10%, var(--bg-2));
border-color: color-mix(in srgb, var(--warn) 50%, var(--bd-2));
animation: ctp-soon-glow 2.4s ease-in-out infinite;
}
.ctp-ticker[data-status="soon"]:hover { background: color-mix(in srgb, var(--warn) 16%, var(--bg-2)); }
@keyframes ctp-soon-glow {
0%, 100% { box-shadow: 0 0 0 0 transparent; }
50% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 28%, transparent); }
}
.ctp-ticker[data-status="ready"] {
background: color-mix(in srgb, var(--ok) 11%, var(--bg-2));
border-color: color-mix(in srgb, var(--ok) 55%, var(--bd-2));
box-shadow: 0 0 14px -2px color-mix(in srgb, var(--ok) 35%, transparent);
}
.ctp-ticker[data-status="ready"]:hover { background: color-mix(in srgb, var(--ok) 17%, var(--bg-2)); }
.ctp-ticker-dot[data-status="scheduled"] { background: var(--t-3); animation: none; box-shadow: none; }
.ctp-ticker-dot[data-status="soon"] { background: var(--warn); animation: ctp-tickerpulse-warn 1.6s ease-out infinite; }
.ctp-ticker-dot[data-status="ready"] { background: var(--ok); animation: none; box-shadow: 0 0 6px var(--ok); }
@keyframes ctp-tickerpulse-warn {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); }
70% { box-shadow: 0 0 0 6px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
.ctp-ticker-label[data-status="scheduled"] { color: var(--t-2); }
.ctp-ticker-label[data-status="soon"] { color: var(--warn); }
.ctp-ticker-label[data-status="ready"] { color: var(--ok); }
.ctp-ticker[data-status="soon"] .ctp-ticker-cta { color: var(--warn); }
.ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); }
/* ─── Quick-bar inline chat preview ─── */
.ctp-ticker-chat {
min-width: 0;
display: inline-flex; align-items: center; gap: 5px;
color: var(--t-3);
font-size: 12px;
}
.ctp-ticker-chat svg { flex-shrink: 0; opacity: 0.7; }
.ctp-ticker-chat b { flex-shrink: 0; font-weight: 700; }
.ctp-ticker-chat-text {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--t-2);
}
.ctp-card.is-checkin { border-color: color-mix(in srgb, var(--accent) 70%, var(--bd-2)); }
.ctp-checkin-note {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px;
background: color-mix(in srgb, var(--accent) 13%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
border-radius: 8px;
font-size: 12.5px; font-weight: 600;
color: var(--t-1);
}
.ctp-checkin-note svg { color: var(--accent); flex-shrink: 0; }
.ctp-card-timer.is-sched {
display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
}
.ctp-card-clock { font-size: 18px; line-height: 1; }
.ctp-card-until { font-size: 10.5px; font-weight: 600; color: var(--t-3); letter-spacing: 0.01em; }
.ctp-avatar.is-in .ctp-avatar-dot { opacity: 0.8; }
.ctp-avatar-in {
position: absolute; bottom: -6px; left: 50%;
transform: translateX(-50%);
font-size: 8.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--t-2);
background: var(--bg-3);
border: 1px solid var(--bd-2);
padding: 0 4px;
border-radius: 999px;
white-space: nowrap;
}
/* ─── Ticker mini ready-bubbles ─── */
.ctp-ticker-bubbles {
display: inline-flex; align-items: center;
justify-self: end;
}
.ctp-mini {
position: relative;
width: 22px; height: 22px;
display: grid; place-items: center;
border-radius: 999px;
margin-left: -6px;
border: 2px solid var(--bg-2);
color: white;
font-size: 8px; font-weight: 800; letter-spacing: 0.02em;
}
.ctp-mini:first-child { margin-left: 0; }
.ctp-mini[data-state="ready"] { box-shadow: 0 0 0 1.5px var(--ok); }
.ctp-mini[data-state="pending"] { opacity: 0.75; }
.ctp-mini[data-state="in"] { opacity: 0.85; }
.ctp-mini-check {
position: absolute; bottom: -3px; right: -4px;
width: 11px; height: 11px;
display: grid; place-items: center;
border-radius: 999px;
background: var(--ok);
color: #06240f;
border: 1.5px solid var(--bg-2);
}
.ctp-mini-check svg { width: 7px; height: 7px; }
.ctp-mini-tag {
position: absolute; bottom: -6px; right: -7px;
font-style: normal;
font-size: 8px; font-weight: 700;
line-height: 11px;
padding: 0 3px;
border-radius: 999px;
background: var(--bg-3);
border: 1px solid var(--bd-2);
color: var(--t-2);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ctp-mini-more { background: var(--bg-4); color: var(--t-2); font-size: 8.5px; }
/* ─── Per-call chat ─── */
.ctp-chat {
display: flex; flex-direction: column;
border-top: 1px solid var(--bd-1);
margin-top: 2px;
padding-top: 8px;
}
.ctp-chat-toggle {
display: flex; align-items: center; gap: 8px;
width: 100%;
padding: 4px 2px;
background: transparent;
border: 0;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 700;
cursor: pointer;
transition: color .15s;
}
.ctp-chat-toggle:hover { color: var(--t-1); }
.ctp-chat-toggle svg { flex-shrink: 0; }
.ctp-chat-count { color: var(--t-4); font-weight: 600; }
.ctp-chat-unread {
min-width: 16px; height: 16px;
display: grid; place-items: center;
padding: 0 4px;
border-radius: 999px;
background: var(--accent);
color: white;
font-size: 9.5px; font-weight: 800;
flex-shrink: 0;
}
.ctp-chat-preview {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-weight: 500;
color: var(--t-3);
text-align: left;
}
.ctp-chat-preview b { font-weight: 700; }
.ctp-chat-chevron { margin-left: auto; flex-shrink: 0; transition: transform .15s; }
.ctp-chat.is-open .ctp-chat-chevron { transform: rotate(180deg); }
.ctp-chat-list {
display: flex; flex-direction: column; gap: 6px;
max-height: 168px;
overflow-y: auto;
margin: 8px 0;
padding: 10px 12px;
background: rgba(0,0,0,0.22);
border: 1px solid var(--bd-1);
border-radius: 8px;
}
.ctp-chat-msg { font-size: 12px; line-height: 1.45; color: var(--t-2); overflow-wrap: anywhere; }
.ctp-chat-msg b { font-weight: 700; }
.ctp-chat-text { color: var(--t-1); }
.ctp-chat-time { margin-left: 6px; font-size: 10px; color: var(--t-4); font-variant-numeric: tabular-nums; }
.ctp-chat-empty { font-size: 11.5px; color: var(--t-4); text-align: center; padding: 6px 0; }
.ctp-chat-form { display: flex; gap: 6px; }
.ctp-chat-input {
flex: 1; height: 32px;
padding: 0 10px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 12px;
}
.ctp-chat-input::placeholder { color: var(--t-4); }
.ctp-chat-input:focus { outline: none; border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2)); }
.ctp-chat-send {
width: 32px; height: 32px;
display: grid; place-items: center;
background: color-mix(in srgb, var(--accent) 24%, var(--bg-3));
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
border-radius: 7px;
color: var(--t-1);
cursor: pointer;
flex-shrink: 0;
transition: background .15s;
}
.ctp-chat-send:hover { background: color-mix(in srgb, var(--accent) 40%, var(--bg-3)); }