From 435af91d9cb3eff7d4cd6c54a73cad98aca35937 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 21 Jul 2026 22:43:06 +0200 Subject: [PATCH] Add a Leptos web client served by the server crabidy-server now serves a browser client with the same functionality as the TUI at its own address, behind the default-on web-ui feature. The new cbd-web crate is a client-side Leptos/WASM app talking gRPC-web (tonic-web-wasm-client) over the same crabidy-core client and proto the TUI uses, so parity is structural: library browsing, search terms, marks, bookmarks/captures with live progress and confirmed deletion, the full queue and playback controls, and the update stream with reconnect. Keys mirror the TUI; every key also has a clickable control. Styling is hand-written modern CSS with a single crab orange-red accent and light/dark themes. The server wraps its existing gRPC service in tonic-web and composes one axum router (auth layer -> grpc-web -> service, web bundle as fallback); axum::serve replaces tonic transport, and native gRPC (h2c) still works. The bundle is embedded via include_dir behind a build.rs that falls back to a placeholder so a plain cargo build needs no wasm toolchain. To make crabidy-core build for wasm, tonic is codegen-only there (transport generation disabled) and native config loading is target-gated. devenv gains the wasm toolchain and build-web/serve-web scripts. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 926 +++++++++++++++++++++- Cargo.toml | 16 +- README.md | 31 +- architecture/web-client.md | 192 +++++ cbd-tui/Cargo.toml | 2 +- cbd-web/.gitignore | 1 + cbd-web/Cargo.toml | 30 + cbd-web/README.md | 86 ++ cbd-web/Trunk.toml | 14 + cbd-web/index.html | 11 + cbd-web/src/app.rs | 1165 ++++++++++++++++++++++++++++ cbd-web/src/keymap.rs | 391 ++++++++++ cbd-web/src/main.rs | 35 + cbd-web/src/rpc.rs | 302 +++++++ cbd-web/src/state.rs | 517 ++++++++++++ cbd-web/style.css | 463 +++++++++++ crabidy-core/Cargo.toml | 14 +- crabidy-core/build.rs | 8 +- crabidy-core/src/lib.rs | 3 + crabidy-server/Cargo.toml | 15 +- crabidy-server/build.rs | 59 ++ crabidy-server/src/lib.rs | 42 +- crabidy-server/src/web.rs | 133 ++++ crabidy-server/tests/web_server.rs | 196 +++++ devenv.nix | 24 + plan/summary.md | 70 ++ plan/web-client.md | 60 ++ quality/web-client.md | 73 ++ 28 files changed, 4864 insertions(+), 15 deletions(-) create mode 100644 architecture/web-client.md create mode 100644 cbd-web/.gitignore create mode 100644 cbd-web/Cargo.toml create mode 100644 cbd-web/README.md create mode 100644 cbd-web/Trunk.toml create mode 100644 cbd-web/index.html create mode 100644 cbd-web/src/app.rs create mode 100644 cbd-web/src/keymap.rs create mode 100644 cbd-web/src/main.rs create mode 100644 cbd-web/src/rpc.rs create mode 100644 cbd-web/src/state.rs create mode 100644 cbd-web/style.css create mode 100644 crabidy-server/build.rs create mode 100644 crabidy-server/src/web.rs create mode 100644 crabidy-server/tests/web_server.rs create mode 100644 plan/web-client.md create mode 100644 quality/web-client.md diff --git a/Cargo.lock b/Cargo.lock index 25bfefc..0523685 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,6 +119,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "any_spawner" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1384d3fe1eecb464229fcf6eebb72306591c56bf27b373561489458a7c73027d" +dependencies = [ + "futures", + "thiserror 2.0.19", + "wasm-bindgen-futures", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -231,6 +242,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-once-cell" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" + [[package]] name = "async-process" version = "2.5.0" @@ -310,6 +327,36 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attribute-derive" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn 2.0.119", +] + [[package]] name = "audio-player" version = "0.1.0" @@ -364,10 +411,13 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", "http-body-util", + "hyper", + "hyper-util", "itoa", "matchit", "memchr", @@ -375,10 +425,15 @@ dependencies = [ "percent-encoding", "pin-project-lite", "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", + "tokio", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -397,8 +452,15 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", ] +[[package]] +name = "base16" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" + [[package]] name = "base64" version = "0.22.1" @@ -541,12 +603,24 @@ version = "1.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" + [[package]] name = "castaway" version = "0.2.4" @@ -590,6 +664,22 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "cbd-web" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "crabidy-core", + "futures", + "gloo-timers", + "leptos", + "tonic", + "tonic-web-wasm-client", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "cc" version = "1.3.0" @@ -714,6 +804,23 @@ dependencies = [ "cc", ] +[[package]] +name = "codee" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9dbbdc4b4d349732bc6690de10a9de952bd39ba6a065c586e26600b6b0b91f5" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.19", +] + +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + [[package]] name = "colorchoice" version = "1.0.5" @@ -771,6 +878,71 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "config" +version = "0.15.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139" +dependencies = [ + "convert_case 0.6.0", + "pathdiff", + "serde_core", + "toml", + "winnow", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "const_str_slice_concat" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -780,6 +952,24 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case_extras" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589c70f0faf8aa9d17787557d5eae854d7755cac50f5c3d12c81d3d57661cebb" +dependencies = [ + "convert_case 0.11.0", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -885,6 +1075,7 @@ dependencies = [ "argon2", "async-trait", "audio-player", + "axum", "base64", "clap", "crabidy-core", @@ -893,6 +1084,7 @@ dependencies = [ "fsdy", "futures", "http", + "include_dir", "rand 0.10.2", "reqwest 0.13.1", "serde", @@ -903,6 +1095,7 @@ dependencies = [ "tokio-stream", "toml", "tonic", + "tonic-web", "tower", "tracing", "tracing-appender", @@ -1057,6 +1250,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1072,7 +1276,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -1141,6 +1345,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "drain_filter_polyfill" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" + [[package]] name = "dunce" version = "1.0.5" @@ -1165,6 +1375,16 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "either_of" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5060e0a4cbf26a87550792688ade88e6b8aec9208613631a7a363bda7bc2d4cd" +dependencies = [ + "paste", + "pin-project-lite", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1239,6 +1459,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1731451909bde27714eacba19c2566362a7f35224f52b153d3f42cf60f72472" + [[package]] name = "errno" version = "0.3.14" @@ -1578,6 +1804,58 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "guardian" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" + [[package]] name = "h2" version = "0.4.15" @@ -1692,6 +1970,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "html-escape" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c1ff2d1cbf39efe5af0900ced8a069b5e61557a17544eb0c4a50239937389e" + [[package]] name = "http" version = "1.4.2" @@ -1737,6 +2021,19 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hydration_context" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bbbeb23ee808258cef2c5585ff0dc8e41da21a8dde943f6b290da153a042a96" +dependencies = [ + "futures", + "or_poisoned", + "pin-project-lite", + "serde", + "throw_error", +] + [[package]] name = "hyper" version = "1.10.1" @@ -1944,6 +2241,25 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1976,6 +2292,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + [[package]] name = "ipconfig" version = "0.3.4" @@ -2092,6 +2414,21 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "lab" version = "0.11.0" @@ -2104,6 +2441,136 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leptos" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "705e2951f3688e0c4f66bbb7a2702282782dcee716971dbd6209c2619d272479" +dependencies = [ + "any_spawner", + "cfg-if", + "either_of", + "futures", + "getrandom 0.4.3", + "hydration_context", + "leptos_config", + "leptos_dom", + "leptos_hot_reload", + "leptos_macro", + "leptos_server", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph", + "rustc-hash", + "rustc_version", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "server_fn", + "slotmap", + "tachys", + "thiserror 2.0.19", + "throw_error", + "typed-builder", + "typed-builder-macro", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_split_helpers", + "web-sys", +] + +[[package]] +name = "leptos_config" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c06f751315bccc0d193fab302ac01d25bcfcd97474d4676440e7e3250dc3fc3" +dependencies = [ + "config", + "regex", + "serde", + "thiserror 2.0.19", + "typed-builder", +] + +[[package]] +name = "leptos_dom" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35742e9ed8f8aaf9e549b454c68a7ac0992536e06856365639b111f72ab07884" +dependencies = [ + "js-sys", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "tachys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_hot_reload" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2a0f220c8a5ef3c51199dfb9cdd702bc0eb80d52fbe70c7890adfaaae8a4b1" +dependencies = [ + "anyhow", + "camino", + "indexmap", + "or_poisoned", + "proc-macro2", + "quote", + "rstml", + "serde", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "leptos_macro" +version = "0.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de6e8da9d4f1a7b74b447b317d590ebabb38709f588f7ee20564b773ccbcce" +dependencies = [ + "attribute-derive", + "cfg-if", + "convert_case 0.11.0", + "convert_case_extras", + "html-escape", + "itertools", + "leptos_hot_reload", + "prettyplease", + "proc-macro-error2", + "proc-macro2", + "quote", + "rstml", + "rustc_version", + "server_fn_macro", + "syn 2.0.119", + "uuid", +] + +[[package]] +name = "leptos_server" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da974775c5ccbb6bd64be7f53f75e8321542e28f21563a416574dbe4d5447eae" +dependencies = [ + "any_spawner", + "base64", + "codee", + "futures", + "hydration_context", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "serde", + "serde_json", + "server_fn", + "tachys", +] + [[package]] name = "libc" version = "0.2.186" @@ -2225,6 +2692,29 @@ dependencies = [ "libc", ] +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + [[package]] name = "matchers" version = "0.2.0" @@ -2353,6 +2843,12 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "next_tuple" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" + [[package]] name = "nix" version = "0.29.0" @@ -2575,6 +3071,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "oco_ref" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0423ff9973dea4d6bd075934fdda86ebb8c05bdf9d6b0507067d4a1226371d" +dependencies = [ + "serde", + "thiserror 2.0.19", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2603,6 +3109,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "or_poisoned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" + [[package]] name = "ordered-float" version = "4.6.0" @@ -2686,6 +3198,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[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" @@ -2903,6 +3427,39 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -2912,6 +3469,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + [[package]] name = "prost" version = "0.14.4" @@ -3051,6 +3621,28 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "r-efi" version = "5.3.0" @@ -3243,6 +3835,60 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "reactive_graph" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c5a025366836190c7030e883cc2bcd9e384ff555336e3c7954741ca411b177" +dependencies = [ + "any_spawner", + "async-lock", + "futures", + "guardian", + "hydration_context", + "indexmap", + "or_poisoned", + "paste", + "pin-project-lite", + "rustc-hash", + "rustc_version", + "send_wrapper", + "serde", + "slotmap", + "thiserror 2.0.19", + "web-sys", +] + +[[package]] +name = "reactive_stores" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c30fd35b7d299c591293bb69fed47a703eb2703b1cff0493e78b16ed007e5382" +dependencies = [ + "guardian", + "indexmap", + "itertools", + "or_poisoned", + "paste", + "reactive_graph", + "reactive_stores_macro", + "rustc-hash", + "send_wrapper", +] + +[[package]] +name = "reactive_stores_macro" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68072edd607edd30b9ebf57d984ba45d8ab8809e598d0f6046278373fb76a5a0" +dependencies = [ + "convert_case 0.11.0", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3370,7 +4016,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", "webpki-roots", ] @@ -3445,6 +4091,21 @@ dependencies = [ "cc", ] +[[package]] +name = "rstml" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61cf4616de7499fc5164570d40ca4e1b24d231c6833a88bff0fe00725080fd56" +dependencies = [ + "derive-where", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", + "syn_derive", + "thiserror 2.0.19", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3643,6 +4304,15 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + [[package]] name = "serde" version = "1.0.229" @@ -3686,6 +4356,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_plain" version = "1.0.2" @@ -3695,6 +4376,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_qs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.19", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -3755,6 +4447,64 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "server_fn" +version = "0.8.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8559dd05af1b5b7e363a150616589d5a88af5187273f7f331ba0dae8922812" +dependencies = [ + "base64", + "bytes", + "const-str", + "const_format", + "futures", + "gloo-net", + "http", + "js-sys", + "or_poisoned", + "pin-project-lite", + "rustc_version", + "rustversion", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "server_fn_macro_default", + "thiserror 2.0.19", + "throw_error", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1295b54815397d30d986b63f93cfd515fa86d5e528e0bb589ce9d530502f9e0f" +dependencies = [ + "const_format", + "convert_case 0.11.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro_default" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63eb08f80db903d3c42f64e60ebb3875e0305be502bdc064ec0a0eab42207f00" +dependencies = [ + "server_fn_macro", + "syn 2.0.119", +] + [[package]] name = "sha1" version = "0.10.7" @@ -3841,6 +4591,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.2" @@ -4165,6 +4924,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb066a04799e45f5d582e8fc6ec8e6d6896040d00898eb4e6a835196815b219" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4185,6 +4956,38 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tachys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92ba81187437cc5df4281f2326a2e13cc81e8f96448292d1112388e2025ca66" +dependencies = [ + "any_spawner", + "async-trait", + "const_str_slice_concat", + "drain_filter_polyfill", + "either_of", + "erased", + "futures", + "html-escape", + "indexmap", + "itertools", + "js-sys", + "next_tuple", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph", + "reactive_stores", + "rustc-hash", + "rustc_version", + "send_wrapper", + "slotmap", + "throw_error", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "tagptr" version = "0.2.0" @@ -4340,6 +5143,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "throw_error" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0ed6038fcbc0795aca7c92963ddda636573b956679204e044492d2b13c8f64" +dependencies = [ + "pin-project-lite", +] + [[package]] name = "tidaldy" version = "0.1.0" @@ -4598,6 +5410,49 @@ dependencies = [ "tonic-build", ] +[[package]] +name = "tonic-web" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6a1b6319ca4b61a4c0f0c94d439c8f3ed344cca56fe0df40e1fe4be11380b" +dependencies = [ + "base64", + "bytes", + "http", + "http-body", + "pin-project", + "tokio-stream", + "tonic", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-web-wasm-client" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c0469c353de5f665c95f898074b5b004b500c6722214c3249f1dc79c0a2a3f6" +dependencies = [ + "base64", + "byteorder", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "httparse", + "js-sys", + "pin-project", + "thiserror 2.0.19", + "tonic", + "tower-service", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + [[package]] name = "tower" version = "0.5.3" @@ -4733,6 +5588,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "typenum" version = "1.20.1" @@ -4969,6 +5844,41 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm_split_helpers" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab578aae2fe2916edaea06843187d50f87b0965622da0ceef648edca27b385ba" +dependencies = [ + "async-once-cell", + "wasm_split_macros", +] + +[[package]] +name = "wasm_split_macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e653af7ee4a9ef0fce481a9ec6f43cb78de20d0cdb4f4f5862e1dc6e407e6c8" +dependencies = [ + "base16", + "quote", + "sha2", + "syn 2.0.119", +] + [[package]] name = "web-sys" version = "0.3.103" @@ -5533,6 +6443,18 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 2874494..1152768 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "audio-player", "cbd", "cbd-tui", + "cbd-web", "crabidy-core", "crabidy-server", "fsdy", @@ -22,13 +23,18 @@ async-trait = "0.1" base64 = "0.22" bytes = "1" chrono = { version = "0.4", default-features = false, features = ["clock"] } +axum = "0.8" clap = { version = "4", features = ["derive"] } clap-serde-derive = "0.2" +console_error_panic_hook = "0.1" crossterm = "0.29" dirs = "6" flume = "0.12" futures = "0.3" +gloo-timers = { version = "0.3", features = ["futures"] } http = "1" +include_dir = "0.7" +leptos = { version = "0.8", default-features = false, features = ["csr"] } notify-rust = "4" percent-encoding = "2" prost = "0.14" @@ -64,10 +70,18 @@ thiserror = "2" tokio = "1" tokio-stream = "0.1" toml = "1" -tonic = "0.14" +# default-features = false so crabidy-core can select codegen-only for +# wasm builds (a member cannot *drop* workspace-inherited default +# features); native binaries re-enable what they need. +tonic = { version = "0.14", default-features = false } tonic-prost = "0.14" tonic-prost-build = "0.14" +tonic-web = "0.14" +tonic-web-wasm-client = "0.9" tower = "0.5" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +web-sys = "0.3" tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/README.md b/README.md index fae9fd4..e906fa9 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,17 @@ each mounted as a subtree of one library: ## Binaries - `crabidy-server` — the server: providers, queue, playback, gRPC on - `0.0.0.0:50051`. + `0.0.0.0:50051`. Also serves the web client at that address (see + below). - `cbd-tui` — the terminal client. Press `?` inside for all key bindings. - `cbd` — both in one process: starts the server, waits until it accepts connections, then runs the TUI. Adopts an already-running server instead of failing on an occupied port. +- `cbd-web` — the browser client (Leptos/WASM), with the same + functionality as the TUI. Not run directly: it is built to a bundle + and embedded into `crabidy-server` (see + [cbd-web/README.md](cbd-web/README.md)). ## Quick start @@ -125,6 +130,30 @@ renames, `d` deletes. Press `?` for the full binding table. +## Web client + +`crabidy-server` serves a browser client with the same functionality as +the TUI at its own address (`http://:50051/`) — same navigation, +same keys (`j`/`k`/`h`/`l`, `%`, `e`, `d`, `w`, `W`, queue and playback +controls, `?` for help), plus clickable equivalents and a light/dark +theme toggle. It talks gRPC-web to the same service the TUI uses, so it +honors the same `[auth]` roles (it shows a login form when the server +requires credentials). + +It is compiled to a WASM bundle and embedded into the server binary, +behind the default-on `web-ui` cargo feature. A plain `cargo build` +needs no WASM toolchain — it embeds a "not built" placeholder page until +you build the bundle: + +```sh +devenv shell -- build-web # writes cbd-web/dist +cargo build -p crabidy-server # embeds it +``` + +Build the server with `--no-default-features` for a headless, +gRPC-only binary. See [cbd-web/README.md](cbd-web/README.md) for the +dev loop and details. + ## Logs `cbd` and `cbd-tui` log to `~/.local/state/crabidy/` (daily files); diff --git a/architecture/web-client.md b/architecture/web-client.md new file mode 100644 index 0000000..058aae7 --- /dev/null +++ b/architecture/web-client.md @@ -0,0 +1,192 @@ +# Web client (cbd-web) + +A browser client with the same functionality as the TUI, served by +`crabidy-server` itself so that "open `http://server:50051`" is the +whole install story. Leptos, pure modern CSS, crab orange-red accent, +light and dark themes. + +## Context and problem statement + +The TUI covers the owner's desk. Phones, tablets, and guests (see +`architecture/roles-auth.md` — queue-owner / queue-appender roles +exist precisely for them) need a client without a terminal. It must +not be a second, drifting implementation of the protocol surface: the +web client should speak the same gRPC contract as the TUI, feature for +feature: library browsing, search terms, queue manipulation, playback +control, bookmarks (`w`), captures (`W`, with progress and confirmed +deletion), and the live update stream. + +## Assumptions (confirmed or decided) + +- "Exactly the same functionality as the TUI" means the same *actions + and information*, not a terminal emulation: every TUI binding has a + clickable equivalent, and the familiar keyboard bindings (j/k/h/l, + Tab, %, e, d, w, W, …) also work on desktop browsers. +- "Local first" is interpreted for what this app *is* — a remote + control for live server state (one audio output, one queue). There + is no offline-editing story to sync: a CRDT layer (as in the + `web_client_example_workspace` template, automerge et al.) would + model conflicts that cannot occur and add a heavy dependency wall. + Local-first here means: **client-side rendered, all assets local + (no CDN), session state and credentials in the browser, library + listings cached in memory like the TUI's, optimistic UI where + safe, and graceful reconnect/backoff when the server disappears.** + This is a deliberate, documented deviation from the example + template. +- The example workspace informs the toolchain (leptos 0.8, trunk, + wasm32 target in devenv, workspace layout, lint posture) — not the + runtime architecture (SSR/hydration + WebSocket sync). We build a + pure CSR app: the server side must stay tonic, not become a leptos + SSR host. +- One port for everything: gRPC (TUI), gRPC-web (browser), and static + assets are all served on `LISTEN_ADDR` (50051). No second listener, + no CORS story needed (same origin). + +## Options considered + +### Browser transport + +1. **gRPC-web with the existing proto** — server wraps the existing + tonic service in `tonic-web` (0.14.6, matches our tonic); the + browser uses the *same generated clients* from `crabidy-core` over + `tonic-web-wasm-client` (0.9.1, tonic ^0.14). Server streaming + (GetUpdateStream) is supported. The 24-RPC surface and all types + are shared — parity is structural, not aspirational. The auth + layer keeps working unchanged: gRPC-web POSTs to the same + `/crabidy.v1.CrabidyService/…` paths, `minimum_role` sees them + identically, and the browser can set the `authorization` header. +2. REST + WebSocket bridge — a second API surface to hand-write, + secure, and keep in sync. Rejected. + +**Decision: gRPC-web (1).** + +### Serving the app + +1. **Embed the built assets in the server binary** (`include_dir` of + `cbd-web/dist`) behind a cargo feature `web-ui`, **default on** + (the request), compiled into `crabidy-server` and thus `cbd`. The + single binary stays self-contained. +2. Serve from a directory on disk at runtime. Flexible but breaks the + single-binary story and invites path confusion. Rejected (can be + added later as an override). + +**Decision: embed (1).** tonic 0.14's router is axum-based: +`Routes::into_axum_router()` lets us add plain axum routes for `/`, +`/pkg/…` and friends next to the gRPC paths. Static assets are served +without authentication (the app shell is public; every RPC behind it +stays gated) — same posture as any login page. + +### Building the wasm app + +`cbd-web` is a workspace member built by **trunk** into +`cbd-web/dist`. Embedding happens through a small +`crabidy-server/build.rs` that copies `cbd-web/dist` into `OUT_DIR` +when present and otherwise generates a **placeholder page** ("web UI +not built — run `devenv shell -- trunk build --release` in +`cbd-web/`") so that: + +- plain `cargo build` never fails and never needs wasm tooling + (default-on feature stays harmless), +- `build.rs` never invokes cargo-in-cargo (trunk runs cargo; nesting + it inside a build script risks target-dir lock deadlocks), +- rebuilding after a trunk run re-embeds automatically + (`rerun-if-changed=cbd-web/dist`). + +devenv gains `trunk`, `wasm-bindgen-cli`, `binaryen` and the +`wasm32-unknown-unknown` rustup target, so the documented build is +two commands. The dev loop (`trunk serve` with a proxy to a running +server) is documented in `cbd-web/README.md`. + +### crabidy-core on wasm + +The generated gRPC client must compile to `wasm32-unknown-unknown`. +`crabidy-core` trims its tonic dependency to +`default-features = false, features = ["codegen"]` (no transport, no +router); native crates keep the full tonic via their own dependency +edges, and cargo's per-target feature unification does the rest. +Native-only pieces of crabidy-core that do not build on wasm (config +loading via `dirs`, clap plumbing) move behind a +`cfg(not(target_arch = "wasm32"))` gate / target-specific +dependencies. The proto types, paths helpers, and client stubs are +the wasm surface. + +## Structure + +```d2 +direction: right +browser: Browser { + app: "cbd-web (leptos CSR wasm)" + store: "localStorage:\ncredentials, theme" + app -> store +} +server: "crabidy-server :50051" { + axum: axum router + static: "embedded cbd-web/dist\n(feature web-ui, default on)" + grpcweb: "tonic-web layer" + auth: AuthLayer + rpc: CrabidyService + axum -> static: "GET /, /pkg/…" + axum -> grpcweb: "POST /crabidy.v1.…" + grpcweb -> auth -> rpc +} +tui: cbd-tui +browser.app -> server.axum: "gRPC-web (fetch,\nauthorization header)" +tui -> server.axum: gRPC (HTTP/2) +``` + +```d2 +direction: right +title: cbd-web internals {near: top-center} +rpc: "rpc.rs\ntonic-web-wasm-client,\nsame crabidy-core stubs" +state: "state.rs\nsignals: queue, play\nstate, volume, library\n+ cache, captures" +stream: "stream task\nGetUpdateStream →\nsignals, reconnect backoff" +ui: "components\nLibrary, Queue, NowPlaying,\ndialogs (name, y/N, login)" +keys: "keymap\nTUI-compatible bindings" +rpc -> stream -> state +ui -> rpc: actions +state -> ui: render +keys -> ui +``` + +## Functional parity map (TUI → web) + +| TUI | Web | +| --- | --- | +| library j/k/h/l, Tab, Enter | list + click/keys, back button, panes | +| `%` create search term | "+" affordance & `%` key → name dialog | +| `e` rename, `d` delete (+capture y/N) | item actions & keys → dialogs | +| `w`/`W` bookmark/capture + progress | keys/actions → dialog, progress | +| marks (`*`), queue/append/replace/insert | multi-select & keys | +| queue ops, x remove, C/c clear, s save | buttons & keys | +| all playback + volume/mute/shuffle/repeat | transport bar & keys | +| skipped tracks red | same, via `Track.is_skipped` | +| update stream reconnect | same, backoff + disconnect banner | +| auth via config file | login form on `UNAUTHENTICATED`, localStorage | +| `?` help modal | `?` help overlay listing keys | + +## Styling + +Pure hand-written CSS (one `style.css`, no framework, no CDN): +custom properties for the palette, `color-scheme: light dark` + +`light-dark()`/`prefers-color-scheme` with a manual override toggle +(persisted), CSS nesting, `color-mix()` for derived tones, grid/flex +layout, `@media` breakpoints for phone layout (library and queue as +switchable panes, like Tab in the TUI). Accent color "crab +orange-red": `--accent: oklch(0.62 0.19 35)` (≈ #e14b2a) with hover / +active derivations via `color-mix`. Focus rings and selection bars +reuse the accent; skipped tracks and destructive confirms use the +existing red semantics. + +## Risks and open questions + +- `tonic-web-wasm-client` is a third-party crate; if it ever lags a + tonic bump, the pinned pair (tonic 0.14 / 0.9.1) keeps building — + upgrade both in lockstep. +- gRPC-web server streaming holds one HTTP connection per browser + tab; fine at household scale. +- The browser cannot play the audio (output is the server's + speakers); a later "play in browser" feature would need a separate + audio streaming endpoint — explicitly out of scope. +- Leptos component logic is hard to unit-test headlessly; logic that + matters (path/selection state machines, formatting) lives in plain + modules with native `#[test]`s, components stay thin. diff --git a/cbd-tui/Cargo.toml b/cbd-tui/Cargo.toml index 3092514..c506b80 100644 --- a/cbd-tui/Cargo.toml +++ b/cbd-tui/Cargo.toml @@ -14,7 +14,7 @@ ratatui.workspace = true serde.workspace = true tokio = { workspace = true, features = ["full"] } tokio-stream.workspace = true -tonic.workspace = true +tonic = { workspace = true, features = ["channel", "codegen"] } tracing.workspace = true tracing-appender.workspace = true tracing-subscriber.workspace = true diff --git a/cbd-web/.gitignore b/cbd-web/.gitignore new file mode 100644 index 0000000..9b1c8b1 --- /dev/null +++ b/cbd-web/.gitignore @@ -0,0 +1 @@ +/dist diff --git a/cbd-web/Cargo.toml b/cbd-web/Cargo.toml new file mode 100644 index 0000000..4d914d0 --- /dev/null +++ b/cbd-web/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "cbd-web" +version.workspace = true +edition.workspace = true + +[dependencies] +crabidy-core.workspace = true +leptos.workspace = true + +# The browser-only half: transport, DOM glue, storage. Kept +# target-specific so the native build (which runs the unit tests for +# the pure state/keymap logic) stays free of wasm-only crates. +[target.'cfg(target_arch = "wasm32")'.dependencies] +console_error_panic_hook.workspace = true +futures.workspace = true +gloo-timers.workspace = true +tonic = { workspace = true, features = ["codegen"] } +tonic-web-wasm-client.workspace = true +wasm-bindgen.workspace = true +wasm-bindgen-futures.workspace = true +web-sys = { workspace = true, features = [ + "Document", + "Element", + "HtmlInputElement", + "KeyboardEvent", + "Location", + "Performance", + "Storage", + "Window", +] } diff --git a/cbd-web/README.md b/cbd-web/README.md new file mode 100644 index 0000000..ee5a290 --- /dev/null +++ b/cbd-web/README.md @@ -0,0 +1,86 @@ +# cbd-web — the browser client + +A [Leptos](https://leptos.dev) client-side WASM app with the same +functionality as `cbd-tui`, served by `crabidy-server` itself. See +`architecture/web-client.md` for the design. + +## How it works + +- **Transport**: gRPC-web (`tonic-web-wasm-client`) over the *same* + generated client and proto types the TUI uses (`crabidy-core`). No + second API surface — feature parity is structural. The server wraps + its existing gRPC service in `tonic-web`, so the browser and the TUI + hit identical `/crabidy.v1.CrabidyService/…` paths, and the role + auth layer (`architecture/roles-auth.md`) gates both. +- **Serving**: the built bundle (`cbd-web/dist`) is embedded into + `crabidy-server` at compile time behind the default-on `web-ui` + feature and served as the fallback route on port 50051. gRPC and + static assets share one origin, so there is no CORS story. +- **Local-first**: pure client-side rendering, every asset in the + bundle (no CDN, no external fonts), library listings cached in memory + like the TUI, credentials and theme in `localStorage`, and the update + stream reconnects with backoff when the server disappears. There is + no CRDT layer — this is a remote control for one live server state, + not an offline-editing app (a deliberate departure from the + `web_client_example_workspace` template that informed the toolchain). + +## Functionality + +Everything the TUI does: browse the library (`j`/`k`/`h`/`l`, click), +marks, create/rename/delete nodes (`%`/`e`/`d`, with the capture-delete +`y/N` confirmation), bookmark and capture (`w`/`W`, with live progress +lines and skipped-track marking), the full queue and playback controls, +volume, shuffle/repeat, and a `?` help overlay listing the keys. Keys +mirror the TUI; every key also has a clickable control. A light/dark +theme follows the OS and can be toggled (persisted). The accent color +is the crab orange-red. + +When the server requires credentials, a login form collects the role +(`owner` / `queue-owner` / `queue-appender`) and password; they are +stored in `localStorage` and sent as the gRPC-web `authorization` +header on every request. + +## Building + +The WASM toolchain (trunk, wasm-bindgen, the `wasm32-unknown-unknown` +target) is provided by devenv. From the repo root: + +```sh +devenv shell -- build-web # release bundle → cbd-web/dist +cargo build -p crabidy-server # embeds cbd-web/dist +``` + +`build-web` clears `RUSTFLAGS` first: the native toolchain sets the +mold linker, which `rust-lld` (the wasm linker) cannot parse. + +Building `crabidy-server` without a `cbd-web/dist` present is fine — it +embeds a placeholder page telling you to run `build-web`. Build the +server `--no-default-features` to drop the web client (and the +`tonic-web` layer) entirely. + +## Dev loop + +Run a server, then a live-reloading trunk server that proxies gRPC-web +to it: + +```sh +cargo run -p crabidy-server # or `cbd` +devenv shell -- serve-web # trunk serve on http://127.0.0.1:8080 +``` + +`Trunk.toml` proxies `/crabidy.v1.CrabidyService` to `127.0.0.1:50051`, +so the app behaves as if served from the server. + +## Tests + +The DOM-free logic (pane/selection state machines, the keymap, capture +progress formatting) lives in `src/state.rs` and `src/keymap.rs` and is +unit-tested on the native target: + +```sh +cargo test -p cbd-web +``` + +Components in `src/app.rs` stay thin over that logic. The server-side +serving and the gRPC-web + auth routing are tested in `crabidy-server` +(`src/web.rs`, `tests/web_server.rs`). diff --git a/cbd-web/Trunk.toml b/cbd-web/Trunk.toml new file mode 100644 index 0000000..148cc2c --- /dev/null +++ b/cbd-web/Trunk.toml @@ -0,0 +1,14 @@ +# Build configuration for the wasm bundle (architecture/web-client.md). +# `trunk build --release` writes dist/, which crabidy-server embeds on +# its next build (feature `web-ui`, default on). + +[build] +target = "index.html" +release = false + +[serve] +# Dev loop: `trunk serve` here + a running crabidy-server; gRPC-web +# calls are proxied to it, everything else is served live-reloading. +[[proxy]] +backend = "http://127.0.0.1:50051" +rewrite = "/crabidy.v1.CrabidyService" diff --git a/cbd-web/index.html b/cbd-web/index.html new file mode 100644 index 0000000..5b26fa4 --- /dev/null +++ b/cbd-web/index.html @@ -0,0 +1,11 @@ + + + + + + + crabidy + + + + diff --git a/cbd-web/src/app.rs b/cbd-web/src/app.rs new file mode 100644 index 0000000..51607af --- /dev/null +++ b/cbd-web/src/app.rs @@ -0,0 +1,1165 @@ +//! The component tree: one store of signals fed by the update stream, +//! one action dispatcher mirroring the TUI's (`cbd-tui/src/app/mod.rs` +//! dispatch), thin views. Pure logic lives in [`crate::state`] / +//! [`crate::keymap`]. + +use std::collections::HashMap; + +use crabidy_core::proto::crabidy::{ + get_update_stream_response::Update as StreamUpdate, LibraryNode, PlayState, QueueModifiers, + Track, TrackPosition, +}; +use leptos::prelude::*; +use leptos::task::spawn_local; + +use crate::keymap::{self, Action}; +use crate::rpc::Rpc; +use crate::state::{ + delete_needs_confirmation, format_seconds, is_cacheable, track_label, CaptureBoard, Dialog, + Focus, LibraryPane, NamePurpose, QueueCursor, UiItemKind, +}; + +const VOLUME_STEP: f32 = 0.1; +const JUMP: isize = 15; +/// Stream reconnect backoff bounds, milliseconds. +const BACKOFF_MIN_MS: u32 = 1_000; +const BACKOFF_MAX_MS: u32 = 10_000; +/// How long an error toast stays visible. +const TOAST_MS: u32 = 4_000; + +// ---- browser glue ------------------------------------------------------ + +fn local_storage() -> Option { + web_sys::window().and_then(|w| w.local_storage().ok().flatten()) +} + +fn load_pref(key: &str) -> Option { + local_storage().and_then(|s| s.get_item(key).ok().flatten()) +} + +fn save_pref(key: &str, value: &str) { + if let Some(storage) = local_storage() { + let _ = storage.set_item(key, value); + } +} + +fn now_ms() -> f64 { + web_sys::window() + .and_then(|w| w.performance()) + .map(|p| p.now()) + .unwrap_or(0.0) +} + +/// Applies the persisted (or OS) theme by stamping `data-theme` on the +/// root element; `auto` removes the override. +fn apply_theme(theme: &str) { + if let Some(root) = web_sys::window() + .and_then(|w| w.document()) + .and_then(|d| d.document_element()) + { + if theme == "auto" { + let _ = root.remove_attribute("data-theme"); + } else { + let _ = root.set_attribute("data-theme", theme); + } + } +} + +// ---- the store --------------------------------------------------------- + +/// Every signal the components share. `Copy` so closures capture it +/// freely (signals are arena handles). +#[derive(Clone, Copy)] +struct Store { + connected: RwSignal, + needs_login: RwSignal, + queue: RwSignal>, + queue_pos: RwSignal, + resolving: RwSignal, + now_playing: RwSignal>, + play_state: RwSignal, + volume: RwSignal, + mute: RwSignal, + mods: RwSignal, + position: RwSignal, + capture_lines: RwSignal>, + library: RwSignal, + queue_cursor: RwSignal, + focus: RwSignal, + dialog: RwSignal>, + toast: RwSignal>, + /// The transport; local because the wasm client is not `Send`. + rpc: StoredValue, LocalStorage>, + /// Library listing cache (`state::is_cacheable` decides entry). + cache: StoredValue, LocalStorage>, + board: StoredValue, +} + +impl Store { + fn new() -> Self { + Self { + connected: RwSignal::new(false), + needs_login: RwSignal::new(false), + queue: RwSignal::new(Vec::new()), + queue_pos: RwSignal::new(0), + resolving: RwSignal::new(false), + now_playing: RwSignal::new(None), + play_state: RwSignal::new(PlayState::Unspecified), + volume: RwSignal::new(1.0), + mute: RwSignal::new(false), + mods: RwSignal::new(QueueModifiers::default()), + position: RwSignal::new(TrackPosition::default()), + capture_lines: RwSignal::new(Vec::new()), + library: RwSignal::new(LibraryPane::default()), + queue_cursor: RwSignal::new(QueueCursor::default()), + focus: RwSignal::new(Focus::Library), + dialog: RwSignal::new(None), + toast: RwSignal::new(None), + rpc: StoredValue::new_local(None), + cache: StoredValue::new_local(HashMap::new()), + board: StoredValue::new_local(CaptureBoard::default()), + } + } + + fn rpc(&self) -> Option { + self.rpc.with_value(Clone::clone) + } + + /// Surfaces an RPC failure. `PERMISSION_DENIED` carries the + /// required role — the message is user-meaningful as-is. + fn fail(&self, status: tonic::Status) { + if status.code() == tonic::Code::Unauthenticated { + self.needs_login.set(true); + self.dialog.set(Some(Dialog::Login)); + return; + } + let toast = self.toast; + toast.set(Some(status.message().to_string())); + spawn_local(async move { + gloo_timers::future::TimeoutFuture::new(TOAST_MS).await; + toast.set(None); + }); + } + + fn refresh_capture_lines(&self) { + let lines = self + .board + .try_update_value(|b| b.lines(now_ms())) + .unwrap_or_default(); + self.capture_lines.set(lines); + } + + fn apply(&self, update: StreamUpdate) { + match update { + StreamUpdate::Queue(queue) => { + self.queue_pos.set(queue.current_position); + self.resolving.set(queue.resolving); + self.queue.set(queue.tracks); + let len = self.queue.with_untracked(Vec::len); + self.queue_cursor.update(|c| c.clamp(len)); + } + StreamUpdate::Mods(mods) => self.mods.set(mods), + StreamUpdate::QueueTrack(queue_track) => { + self.queue_pos.set(queue_track.queue_position); + self.now_playing.set(queue_track.track); + } + StreamUpdate::PlayState(state) => { + self.play_state + .set(PlayState::try_from(state).unwrap_or(PlayState::Unspecified)); + } + StreamUpdate::Volume(volume) => self.volume.set(volume), + StreamUpdate::Mute(mute) => self.mute.set(mute), + StreamUpdate::Position(position) => self.position.set(position), + StreamUpdate::CaptureProgress(progress) => { + self.board.update_value(|b| b.apply(progress, now_ms())); + self.refresh_capture_lines(); + } + } + } + + /// Fetches (or serves from cache) a listing and opens it in the + /// library pane. + fn open_library_node(&self, path: String) { + let this = *self; + if is_cacheable(&path) { + let cached = self.cache.with_value(|c| c.get(&path).cloned()); + if let Some(node) = cached { + this.library.update(|pane| pane.update(&node)); + return; + } + } + let Some(mut rpc) = self.rpc() else { return }; + spawn_local(async move { + match rpc.get_library_node(&path).await { + Ok(Some(node)) => { + if is_cacheable(&path) { + this.cache + .update_value(|c| _ = c.insert(path.clone(), node.clone())); + } + this.library.update(|pane| pane.update(&node)); + } + Ok(None) => {} + Err(status) => this.fail(status), + } + }); + } + + /// Runs a queue-mutating RPC and clears the library marks on + /// success, like every TUI queue op. + fn queue_op(&self, call: F) + where + F: AsyncFnOnce(Rpc) -> Result<(), tonic::Status> + 'static, + { + let Some(rpc) = self.rpc() else { return }; + let this = *self; + spawn_local(async move { + match call(rpc).await { + Ok(()) => this.library.update(LibraryPane::remove_marks), + Err(status) => this.fail(status), + } + }); + } + + /// Fire-and-forget RPC (transport & playback controls). + fn call(&self, call: F) + where + F: AsyncFnOnce(Rpc) -> Result<(), tonic::Status> + 'static, + { + let Some(rpc) = self.rpc() else { return }; + let this = *self; + spawn_local(async move { + if let Err(status) = call(rpc).await { + this.fail(status); + } + }); + } + + fn delete_node(&self, path: String) { + let Some(mut rpc) = self.rpc() else { return }; + let this = *self; + spawn_local(async move { + match rpc.delete_library_node(&path).await { + // The response is the refreshed parent listing. + Ok(Some(parent)) => this.library.update(|pane| pane.update(&parent)), + Ok(None) => {} + Err(status) => this.fail(status), + } + }); + } + + /// Executes one keymap action — the web twin of the TUI dispatch. + fn dispatch(&self, action: Action) { + match action { + Action::OpenHelp => self.dialog.set(Some(Dialog::Help)), + Action::CloseHelp => self.dialog.set(None), + Action::CycleFocus => self.focus.update(|f| { + *f = match f { + Focus::Library => Focus::Queue, + Focus::Queue => Focus::Library, + } + }), + Action::TogglePlay => self.call(async |mut rpc: Rpc| rpc.toggle_play().await), + Action::RestartTrack => self.call(async |mut rpc: Rpc| rpc.restart_track().await), + Action::NextTrack => self.call(async |mut rpc: Rpc| rpc.next().await), + Action::PrevTrack => self.call(async |mut rpc: Rpc| rpc.prev().await), + Action::VolumeUp => { + self.call(async |mut rpc: Rpc| rpc.change_volume(VOLUME_STEP).await) + } + Action::VolumeDown => { + self.call(async |mut rpc: Rpc| rpc.change_volume(-VOLUME_STEP).await) + } + Action::ToggleMute => self.call(async |mut rpc: Rpc| rpc.toggle_mute().await), + Action::ToggleShuffle => self.call(async |mut rpc: Rpc| rpc.toggle_shuffle().await), + Action::ToggleRepeat => self.call(async |mut rpc: Rpc| rpc.toggle_repeat().await), + Action::LibraryNext => self.library.update(|p| p.select_by(1)), + Action::LibraryPrev => self.library.update(|p| p.select_by(-1)), + Action::LibraryFirst => self.library.update(LibraryPane::select_first), + Action::LibraryLast => self.library.update(LibraryPane::select_last), + Action::LibraryJumpDown => self.library.update(|p| p.select_by(JUMP)), + Action::LibraryJumpUp => self.library.update(|p| p.select_by(-JUMP)), + Action::LibraryAscend => { + if let Some(parent) = self.library.with_untracked(|p| p.parent.clone()) { + self.open_library_node(parent); + } + } + Action::LibraryDive => { + let target = self.library.with_untracked(|p| { + p.selected_item() + .filter(|i| i.kind == UiItemKind::Node) + .map(|i| i.path.clone()) + }); + if let Some(path) = target { + self.open_library_node(path); + } + } + Action::LibraryToggleMark => self.library.update(LibraryPane::toggle_mark), + Action::LibraryQueueReplace => { + if let Some(paths) = self + .library + .with_untracked(LibraryPane::queueable_selection) + { + self.queue_op(async |mut rpc: Rpc| rpc.replace_queue(paths).await); + } + } + Action::LibraryQueueAppend => { + if let Some(paths) = self + .library + .with_untracked(LibraryPane::queueable_selection) + { + self.queue_op(async |mut rpc: Rpc| rpc.append_tracks(paths).await); + } + } + Action::LibraryQueueNext => { + if let Some(paths) = self + .library + .with_untracked(LibraryPane::queueable_selection) + { + self.queue_op(async |mut rpc: Rpc| rpc.queue_tracks(paths).await); + } + } + Action::QueueInsertHere => { + let position = self.queue_cursor.with_untracked(|c| c.selected as u32); + if let Some(paths) = self + .library + .with_untracked(LibraryPane::queueable_selection) + { + self.queue_op(async move |mut rpc: Rpc| { + rpc.insert_tracks(position, paths).await + }); + } + } + Action::LibraryCreateNode => { + let creatable = self + .library + .with_untracked(|p| p.is_creatable.then(|| p.path.clone())); + if let Some(parent_path) = creatable { + self.dialog.set(Some(Dialog::Name { + purpose: NamePurpose::Create { parent_path }, + buffer: String::new(), + })); + } + } + Action::LibraryEditNode => { + if let Some((path, title)) = + self.library.with_untracked(LibraryPane::selected_editable) + { + self.dialog.set(Some(Dialog::Name { + purpose: NamePurpose::Rename { path }, + buffer: title, + })); + } + } + Action::LibraryDeleteNode => { + if let Some((path, title)) = + self.library.with_untracked(LibraryPane::selected_deletable) + { + if delete_needs_confirmation(&path) { + self.dialog.set(Some(Dialog::ConfirmDelete { path, title })); + } else { + self.delete_node(path); + } + } + } + Action::LibraryCaptureNode => { + if let Some((path, title)) = + self.library.with_untracked(LibraryPane::selected_queueable) + { + self.dialog.set(Some(Dialog::Name { + purpose: NamePurpose::Capture { + path, + download: false, + }, + buffer: title, + })); + } + } + Action::LibraryDownloadNode => { + if let Some((path, title)) = self + .library + .with_untracked(LibraryPane::selected_downloadable) + { + self.dialog.set(Some(Dialog::Name { + purpose: NamePurpose::Capture { + path, + download: true, + }, + buffer: title, + })); + } + } + Action::QueueNext => { + let len = self.queue.with_untracked(Vec::len); + self.queue_cursor.update(|c| c.select_by(1, len)); + } + Action::QueuePrev => { + let len = self.queue.with_untracked(Vec::len); + self.queue_cursor.update(|c| c.select_by(-1, len)); + } + Action::QueueFirst => self.queue_cursor.update(|c| c.selected = 0), + Action::QueueLast => { + let len = self.queue.with_untracked(Vec::len); + self.queue_cursor + .update(|c| c.selected = len.saturating_sub(1)); + } + Action::QueueJumpDown => { + let len = self.queue.with_untracked(Vec::len); + self.queue_cursor.update(|c| c.select_by(JUMP, len)); + } + Action::QueueJumpUp => { + let len = self.queue.with_untracked(Vec::len); + self.queue_cursor.update(|c| c.select_by(-JUMP, len)); + } + Action::QueueSelectCurrent => { + let current = self.queue_pos.get_untracked() as usize; + let len = self.queue.with_untracked(Vec::len); + self.queue_cursor + .update(|c| c.selected = current.min(len.saturating_sub(1))); + } + Action::QueuePlaySelected => { + let position = self.queue_cursor.with_untracked(|c| c.selected as u32); + if self.queue.with_untracked(|q| !q.is_empty()) { + self.call(async move |mut rpc: Rpc| rpc.set_current(position).await); + } + } + Action::QueueRemoveTrack => { + let position = self.queue_cursor.with_untracked(|c| c.selected as u32); + if self.queue.with_untracked(|q| !q.is_empty()) { + self.call(async move |mut rpc: Rpc| rpc.remove_tracks(vec![position]).await); + } + } + Action::QueueClearKeepCurrent => { + self.call(async |mut rpc: Rpc| rpc.clear_queue(true).await) + } + Action::QueueClearAll => self.call(async |mut rpc: Rpc| rpc.clear_queue(false).await), + Action::QueueSaveAs => { + if self.queue.with_untracked(|q| !q.is_empty()) { + self.dialog.set(Some(Dialog::Name { + purpose: NamePurpose::SaveQueue, + buffer: String::new(), + })); + } + } + } + } + + /// Submits the name dialog (Enter) — the TUI's `handle_input_key` + /// submit arm. + fn submit_name(&self, purpose: NamePurpose, title: String) { + self.dialog.set(None); + let title = title.trim().to_string(); + if title.is_empty() { + return; + } + let this = *self; + match purpose { + NamePurpose::Create { parent_path } => { + let Some(mut rpc) = self.rpc() else { return }; + spawn_local(async move { + match rpc.create_library_node(&parent_path, &title).await { + Ok(Some(node)) => this.library.update(|pane| pane.update(&node)), + Ok(None) => {} + Err(status) => this.fail(status), + } + }); + } + NamePurpose::Rename { path } => { + let Some(mut rpc) = self.rpc() else { return }; + spawn_local(async move { + match rpc.rename_library_node(&path, &title).await { + Ok(Some(node)) => this.library.update(|pane| pane.update(&node)), + Ok(None) => {} + Err(status) => this.fail(status), + } + }); + } + NamePurpose::SaveQueue => { + self.call(async move |mut rpc: Rpc| rpc.save_queue(&title).await) + } + NamePurpose::Capture { path, download } => { + self.call(async move |mut rpc: Rpc| { + rpc.capture_library_node(&path, &title, download).await + }); + } + } + } +} + +// ---- startup ----------------------------------------------------------- + +/// Connects, seeds the state, and pumps the update stream forever +/// (capped backoff). `UNAUTHENTICATED` stops the loop and opens the +/// login dialog — the submit stores credentials and reloads. +fn run_stream(store: Store) { + spawn_local(async move { + let mut backoff = BACKOFF_MIN_MS; + loop { + let Some(mut rpc) = store.rpc() else { return }; + match rpc.update_stream().await { + Ok(mut stream) => { + store.connected.set(true); + backoff = BACKOFF_MIN_MS; + // Seed everything the stream only reports on change. + match rpc.init().await { + Ok(init) => { + if let Some(queue) = init.queue { + store.apply(StreamUpdate::Queue(queue)); + } + if let Some(mods) = init.mods { + store.apply(StreamUpdate::Mods(mods)); + } + if let Some(queue_track) = init.queue_track { + store.apply(StreamUpdate::QueueTrack(queue_track)); + } + store.apply(StreamUpdate::PlayState(init.play_state)); + store.apply(StreamUpdate::Volume(init.volume)); + store.apply(StreamUpdate::Mute(init.mute)); + if let Some(position) = init.position { + store.apply(StreamUpdate::Position(position)); + } + } + Err(status) => store.fail(status), + } + if store.library.with_untracked(|p| p.path.is_empty()) { + store.open_library_node("/".to_string()); + } + loop { + match stream.message().await { + Ok(Some(response)) => { + if let Some(update) = response.update { + store.apply(update); + } + } + Ok(None) => break, + Err(status) => { + store.fail(status); + break; + } + } + } + store.connected.set(false); + } + Err(status) if status.code() == tonic::Code::Unauthenticated => { + store.needs_login.set(true); + store.dialog.set(Some(Dialog::Login)); + return; + } + Err(_) => { + store.connected.set(false); + } + } + gloo_timers::future::TimeoutFuture::new(backoff).await; + backoff = (backoff * 2).min(BACKOFF_MAX_MS); + } + }); + // Capture lines expire on wall time, not only on stream events. + spawn_local(async move { + loop { + gloo_timers::future::TimeoutFuture::new(1_000).await; + store.refresh_capture_lines(); + } + }); +} + +// ---- components -------------------------------------------------------- + +/// Root component: builds the store, connects, wires the keyboard. +#[component] +pub fn App() -> impl IntoView { + let store = Store::new(); + apply_theme(&load_pref("theme").unwrap_or_else(|| "auto".to_string())); + + let origin = web_sys::window() + .and_then(|w| w.location().origin().ok()) + .unwrap_or_else(|| "http://127.0.0.1:50051".to_string()); + let user = load_pref("user").unwrap_or_default(); + let password = load_pref("password").unwrap_or_default(); + store.rpc.set_value(Rpc::new(origin, &user, &password)); + run_stream(store); + + // Global keyboard handling; dialogs are modal (their inputs handle + // their own keys), and browser defaults for handled chords are + // suppressed so Space does not scroll or Tab move focus. + let _handle = window_event_listener(leptos::ev::keydown, move |ev| { + let dialog_open = store.dialog.with_untracked(Option::is_some); + let help_open = matches!(store.dialog.get_untracked(), Some(Dialog::Help)); + if dialog_open && !help_open { + return; + } + if ev.alt_key() || ev.meta_key() { + return; + } + let key = ev.key(); + if let Some(action) = + keymap::lookup(store.focus.get_untracked(), help_open, &key, ev.ctrl_key()) + { + ev.prevent_default(); + store.dispatch(action); + } + }); + + view! { +
+ +
+ + +
+ + + {move || { + store + .toast + .get() + .map(|message| view! {
{message}
}) + }} +
+ } +} + +#[component] +fn TopBar(store: Store) -> impl IntoView { + let cycle_theme = move |_| { + let current = load_pref("theme").unwrap_or_else(|| "auto".to_string()); + let next = match current.as_str() { + "auto" => "light", + "light" => "dark", + _ => "auto", + }; + save_pref("theme", next); + apply_theme(next); + }; + view! { +
+ "crabidy" + + {move || if store.connected.get() { "" } else { "disconnected — reconnecting…" }} + + + + + +
+ } +} + +#[component] +fn LibraryView(store: Store) -> impl IntoView { + let focused = move || store.focus.get() == Focus::Library; + let library = store.library; + + let toolbar = move || { + let pane = library.get(); + let selection = pane.selected_item(); + let queueable = + selection.is_some_and(|i| i.is_queable) || pane.items.iter().any(|i| i.marked); + view! { +
+ + {pane.title.clone()} + + + + + + + + + + + +
+ } + }; + + view! { +
+ {toolbar} +
    + {move || { + let pane = library.get(); + pane.items + .iter() + .enumerate() + .map(|(index, item)| { + let is_node = item.kind == UiItemKind::Node; + let marks = [ + item.is_creatable.then_some("%"), + item.is_editable.then_some("e"), + item.is_deletable.then_some("d"), + ]; + let badge: String = marks.into_iter().flatten().collect(); + let path = item.path.clone(); + view! { +
  • + + {if is_node { format!("{}/", item.title) } else { item.title.clone() }} + + {(!badge.is_empty()) + .then(|| view! { {format!("[{badge}]")} })} +
  • + } + }) + .collect_view() + }} +
+
+ {move || { + store + .capture_lines + .get() + .into_iter() + .map(|(line, is_error)| { + view! {
{line}
} + }) + .collect_view() + }} +
+
+ } +} + +#[component] +fn QueueView(store: Store) -> impl IntoView { + let focused = move || store.focus.get() == Focus::Queue; + view! { +
+
+ + "queue" + {move || store.resolving.get().then_some(" (loading…)")} + + + + + + +
+
    + {move || { + let current = store.queue_pos.get() as usize; + let cursor = store.queue_cursor.get().selected; + store + .queue + .get() + .iter() + .enumerate() + .map(|(index, track)| { + let label = track_label(track); + view! { +
  • + {label} + +
  • + } + }) + .collect_view() + }} +
+
+ } +} + +#[component] +fn Transport(store: Store) -> impl IntoView { + let state_symbol = move || match store.play_state.get() { + PlayState::Playing => "⏸", + PlayState::Loading => "…", + _ => "▶", + }; + let on_volume = move |ev: leptos::ev::Event| { + if let Ok(target) = event_target_value(&ev).parse::() { + let delta = target - store.volume.get_untracked(); + store.call(async move |mut rpc: Rpc| rpc.change_volume(delta).await); + } + }; + view! { +
+
+ + + + + + +
+
+ + {move || store.now_playing.get().map(|t| track_label(&t)).unwrap_or_default()} + +
+ + {move || format_seconds(store.position.get().position)} + +
+
+
+ + {move || format_seconds(store.position.get().duration)} + +
+
+
+ + +
+
+ } +} + +#[component] +fn Dialogs(store: Store) -> impl IntoView { + move || { + store.dialog.get().map(|dialog| match dialog { + Dialog::Name { purpose, buffer } => { + view! { }.into_any() + } + Dialog::ConfirmDelete { path, title } => { + view! { }.into_any() + } + Dialog::Login => view! { }.into_any(), + Dialog::Help => view! { }.into_any(), + }) + } +} + +#[component] +fn NameDialog(store: Store, purpose: NamePurpose, buffer: String) -> impl IntoView { + let value = RwSignal::new(buffer); + let label = purpose.label(); + let submit_purpose = purpose.clone(); + let submit = move |ev: leptos::ev::SubmitEvent| { + ev.prevent_default(); + store.submit_name(submit_purpose.clone(), value.get_untracked()); + }; + view! { +
+
+ + +
+ + +
+
+
+ } +} + +#[component] +fn ConfirmDialog(store: Store, path: String, title: String) -> impl IntoView { + let confirm_path = path.clone(); + let confirm = move |_| { + store.dialog.set(None); + store.delete_node(confirm_path.clone()); + }; + // y/N without leaving the keyboard, like the TUI: the overlay grabs + // the keys while it is open. + let key_path = path; + let handle = window_event_listener(leptos::ev::keydown, move |ev| { + if !matches!( + store.dialog.get_untracked(), + Some(Dialog::ConfirmDelete { .. }) + ) { + return; + } + ev.prevent_default(); + store.dialog.set(None); + if matches!(ev.key().as_str(), "y" | "Y") { + store.delete_node(key_path.clone()); + } + }); + on_cleanup(move || handle.remove()); + view! { +
+
+

+ "Delete "{title} + " from disk (downloaded audio included)?" +

+
+ + +
+
+
+ } +} + +#[component] +fn LoginDialog(store: Store) -> impl IntoView { + // The dialog needs no store access: submitting reloads the page. + let _ = store; + let user = RwSignal::new(load_pref("user").unwrap_or_default()); + let password = RwSignal::new(String::new()); + let submit = move |ev: leptos::ev::SubmitEvent| { + ev.prevent_default(); + save_pref("user", user.get_untracked().trim()); + save_pref("password", &password.get_untracked()); + // Reconnect from scratch with the new credentials. + if let Some(window) = web_sys::window() { + let _ = window.location().reload(); + } + }; + view! { +
+
+ + + +
+ +
+
+
+ } +} + +#[component] +fn HelpOverlay(store: Store) -> impl IntoView { + let groups = ["Global", "Library", "Queue", "Help"]; + view! { +
+
+

"Key bindings"

+
+ {groups + .into_iter() + .map(|group| { + view! { +
+

{group}

+ + + {keymap::HELP + .iter() + .filter(|entry| entry.scope == group) + .map(|entry| { + view! { + + + + + } + }) + .collect_view()} + +
{entry.key}{entry.description}
+
+ } + }) + .collect_view()} +
+
+
+ } +} diff --git a/cbd-web/src/keymap.rs b/cbd-web/src/keymap.rs new file mode 100644 index 0000000..1420d32 --- /dev/null +++ b/cbd-web/src/keymap.rs @@ -0,0 +1,391 @@ +//! Keyboard bindings — the web port of `cbd-tui/src/app/bindings.rs`, +//! keyed by browser `KeyboardEvent` values instead of crossterm codes. +//! Deliberate differences: there is no `q` (quit) in a browser tab, and +//! `Escape` closes the help overlay (the TUI also accepts `q`/`?`). + +use crate::state::Focus; + +/// Everything a key can trigger. Mirrors the TUI's `Action` list; the +/// components translate these into RPCs or local state changes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Action { + OpenHelp, + CloseHelp, + CycleFocus, + TogglePlay, + RestartTrack, + VolumeUp, + VolumeDown, + ToggleMute, + ToggleShuffle, + ToggleRepeat, + NextTrack, + PrevTrack, + LibraryFirst, + LibraryLast, + LibraryNext, + LibraryPrev, + LibraryJumpDown, + LibraryJumpUp, + LibraryAscend, + LibraryDive, + LibraryToggleMark, + LibraryCaptureNode, + LibraryDownloadNode, + LibraryCreateNode, + LibraryEditNode, + LibraryDeleteNode, + LibraryQueueAppend, + LibraryQueueNext, + LibraryQueueReplace, + QueueFirst, + QueueLast, + QueueNext, + QueuePrev, + QueueJumpDown, + QueueJumpUp, + QueueSelectCurrent, + QueuePlaySelected, + QueueInsertHere, + QueueRemoveTrack, + QueueClearKeepCurrent, + QueueClearAll, + QueueSaveAs, +} + +/// One row of the help overlay: the key label and what it does. +pub struct HelpEntry { + pub scope: &'static str, + pub key: &'static str, + pub description: &'static str, +} + +/// The help overlay content, in display order — kept in lockstep with +/// [`lookup`] by the unit tests below. +pub const HELP: &[HelpEntry] = &[ + HelpEntry { + scope: "Global", + key: "?", + description: "Show this help", + }, + HelpEntry { + scope: "Global", + key: "Tab", + description: "Switch between library and queue", + }, + HelpEntry { + scope: "Global", + key: "Space", + description: "Play/pause", + }, + HelpEntry { + scope: "Global", + key: "r", + description: "Restart current track", + }, + HelpEntry { + scope: "Global", + key: "K", + description: "Volume up", + }, + HelpEntry { + scope: "Global", + key: "J", + description: "Volume down", + }, + HelpEntry { + scope: "Global", + key: "m", + description: "Toggle mute", + }, + HelpEntry { + scope: "Global", + key: "z", + description: "Toggle shuffle", + }, + HelpEntry { + scope: "Global", + key: "x", + description: "Toggle repeat", + }, + HelpEntry { + scope: "Global", + key: "Ctrl-n", + description: "Next track", + }, + HelpEntry { + scope: "Global", + key: "Ctrl-p", + description: "Previous track", + }, + HelpEntry { + scope: "Library", + key: "j / k", + description: "Select next / previous item", + }, + HelpEntry { + scope: "Library", + key: "g / G", + description: "Select first / last item", + }, + HelpEntry { + scope: "Library", + key: "Ctrl-d / Ctrl-u", + description: "Jump 15 items", + }, + HelpEntry { + scope: "Library", + key: "h", + description: "Go to parent folder", + }, + HelpEntry { + scope: "Library", + key: "l", + description: "Enter selected folder", + }, + HelpEntry { + scope: "Library", + key: "s", + description: "Mark/unmark selection", + }, + HelpEntry { + scope: "Library", + key: "w", + description: "Save selection as bookmark", + }, + HelpEntry { + scope: "Library", + key: "W", + description: "Download selection as capture (can take long; same name resumes)", + }, + HelpEntry { + scope: "Library", + key: "%", + description: "Create node here (e.g. search term)", + }, + HelpEntry { + scope: "Library", + key: "e", + description: "Rename selected node (e.g. search term)", + }, + HelpEntry { + scope: "Library", + key: "d", + description: "Delete selection (captures ask y/N, and delete files)", + }, + HelpEntry { + scope: "Library", + key: "a", + description: "Append selection to queue", + }, + HelpEntry { + scope: "Library", + key: "L", + description: "Queue selection after current track", + }, + HelpEntry { + scope: "Library", + key: "Enter", + description: "Replace queue with selection", + }, + HelpEntry { + scope: "Queue", + key: "j / k", + description: "Select next / previous track", + }, + HelpEntry { + scope: "Queue", + key: "g / G", + description: "Select first / last track", + }, + HelpEntry { + scope: "Queue", + key: "Ctrl-d / Ctrl-u", + description: "Jump 15 tracks", + }, + HelpEntry { + scope: "Queue", + key: "o", + description: "Select the playing track", + }, + HelpEntry { + scope: "Queue", + key: "Enter", + description: "Play selected track", + }, + HelpEntry { + scope: "Queue", + key: "p", + description: "Insert library selection after this track", + }, + HelpEntry { + scope: "Queue", + key: "d", + description: "Remove selected track", + }, + HelpEntry { + scope: "Queue", + key: "c", + description: "Clear queue except current track", + }, + HelpEntry { + scope: "Queue", + key: "C", + description: "Clear entire queue", + }, + HelpEntry { + scope: "Queue", + key: "w", + description: "Save queue under a name", + }, + HelpEntry { + scope: "Help", + key: "Esc or ?", + description: "Close help", + }, +]; + +/// Resolves a browser key event to an action, mirroring the TUI's +/// `bindings::lookup`: global chords first, then the focused pane's. +/// `key` is `KeyboardEvent.key` (case carries shift for letters); +/// `ctrl` is `ctrlKey`. While the help overlay is open only its close +/// keys resolve; dialogs bypass this entirely (they are modal). +pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option { + if help_open { + return matches!(key, "?" | "Escape" | "q").then_some(Action::CloseHelp); + } + if ctrl { + return match key { + "n" => Some(Action::NextTrack), + "p" => Some(Action::PrevTrack), + "d" => Some(match focus { + Focus::Library => Action::LibraryJumpDown, + Focus::Queue => Action::QueueJumpDown, + }), + "u" => Some(match focus { + Focus::Library => Action::LibraryJumpUp, + Focus::Queue => Action::QueueJumpUp, + }), + _ => None, + }; + } + let global = match key { + "?" => Some(Action::OpenHelp), + "Tab" => Some(Action::CycleFocus), + " " => Some(Action::TogglePlay), + "r" => Some(Action::RestartTrack), + "K" => Some(Action::VolumeUp), + "J" => Some(Action::VolumeDown), + "m" => Some(Action::ToggleMute), + "z" => Some(Action::ToggleShuffle), + "x" => Some(Action::ToggleRepeat), + _ => None, + }; + if global.is_some() { + return global; + } + match focus { + Focus::Library => match key { + "j" | "ArrowDown" => Some(Action::LibraryNext), + "k" | "ArrowUp" => Some(Action::LibraryPrev), + "g" => Some(Action::LibraryFirst), + "G" => Some(Action::LibraryLast), + "h" | "ArrowLeft" => Some(Action::LibraryAscend), + "l" | "ArrowRight" => Some(Action::LibraryDive), + "s" => Some(Action::LibraryToggleMark), + "w" => Some(Action::LibraryCaptureNode), + "W" => Some(Action::LibraryDownloadNode), + "%" => Some(Action::LibraryCreateNode), + "e" => Some(Action::LibraryEditNode), + "d" => Some(Action::LibraryDeleteNode), + "a" => Some(Action::LibraryQueueAppend), + "L" => Some(Action::LibraryQueueNext), + "Enter" => Some(Action::LibraryQueueReplace), + _ => None, + }, + Focus::Queue => match key { + "j" | "ArrowDown" => Some(Action::QueueNext), + "k" | "ArrowUp" => Some(Action::QueuePrev), + "g" => Some(Action::QueueFirst), + "G" => Some(Action::QueueLast), + "o" => Some(Action::QueueSelectCurrent), + "Enter" => Some(Action::QueuePlaySelected), + "p" => Some(Action::QueueInsertHere), + "d" => Some(Action::QueueRemoveTrack), + "c" => Some(Action::QueueClearKeepCurrent), + "C" => Some(Action::QueueClearAll), + "w" => Some(Action::QueueSaveAs), + _ => None, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pane_focus_decides_shared_chords() { + assert_eq!( + lookup(Focus::Library, false, "d", false), + Some(Action::LibraryDeleteNode) + ); + assert_eq!( + lookup(Focus::Queue, false, "d", false), + Some(Action::QueueRemoveTrack) + ); + assert_eq!( + lookup(Focus::Library, false, "d", true), + Some(Action::LibraryJumpDown) + ); + } + + #[test] + fn globals_win_in_both_panes() { + for focus in [Focus::Library, Focus::Queue] { + assert_eq!(lookup(focus, false, " ", false), Some(Action::TogglePlay)); + assert_eq!( + lookup(focus, false, "z", false), + Some(Action::ToggleShuffle) + ); + assert_eq!(lookup(focus, false, "n", true), Some(Action::NextTrack)); + } + } + + #[test] + fn help_is_modal() { + assert_eq!(lookup(Focus::Library, true, "j", false), None); + assert_eq!( + lookup(Focus::Library, true, "Escape", false), + Some(Action::CloseHelp) + ); + assert_eq!( + lookup(Focus::Library, true, "?", false), + Some(Action::CloseHelp) + ); + } + + #[test] + fn arrows_alias_the_vim_movement() { + assert_eq!( + lookup(Focus::Library, false, "ArrowDown", false), + Some(Action::LibraryNext) + ); + assert_eq!( + lookup(Focus::Library, false, "ArrowLeft", false), + Some(Action::LibraryAscend) + ); + assert_eq!( + lookup(Focus::Queue, false, "ArrowUp", false), + Some(Action::QueuePrev) + ); + } + + #[test] + fn every_action_reachable_from_help_table() { + // The help overlay documents at least every scope we bind. + assert!(HELP.iter().any(|h| h.scope == "Global")); + assert!(HELP.iter().any(|h| h.scope == "Library")); + assert!(HELP.iter().any(|h| h.scope == "Queue")); + } +} diff --git a/cbd-web/src/main.rs b/cbd-web/src/main.rs new file mode 100644 index 0000000..93c05bd --- /dev/null +++ b/cbd-web/src/main.rs @@ -0,0 +1,35 @@ +//! The crabidy web client (architecture/web-client.md): a Leptos CSR +//! app with the same functionality as `cbd-tui`, talking gRPC-web to +//! `crabidy-server`, which also serves this bundle. +//! +//! Only [`rpc`] and [`app`] touch the browser; [`state`] and [`keymap`] +//! are pure and unit-tested on the native target (`cargo test -p +//! cbd-web`). + +// The pure modules are consumed by the wasm `app` and by the native +// tests; the native *binary* target uses neither, so allow dead code +// there while keeping the wasm build (where it all runs) fully linted. +#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] + +mod keymap; +mod state; + +#[cfg(target_arch = "wasm32")] +mod app; +#[cfg(target_arch = "wasm32")] +mod rpc; + +#[cfg(target_arch = "wasm32")] +fn main() { + console_error_panic_hook::set_once(); + leptos::mount::mount_to_body(app::App); +} + +#[cfg(not(target_arch = "wasm32"))] +fn main() { + // The native build exists for the unit tests of the pure modules; + // the real artifact is the wasm bundle built by trunk. + eprintln!( + "cbd-web is a browser app: build it with `trunk build` and let crabidy-server serve it" + ); +} diff --git a/cbd-web/src/rpc.rs b/cbd-web/src/rpc.rs new file mode 100644 index 0000000..cbce11c --- /dev/null +++ b/cbd-web/src/rpc.rs @@ -0,0 +1,302 @@ +//! gRPC-web transport: the same generated `crabidy-core` client the +//! TUI uses, over `tonic-web-wasm-client` against the origin that +//! served this app (architecture/web-client.md). Credentials, when the +//! server requires them, ride as the same `authorization: Basic` +//! header the TUI sends; the header value is never logged. + +use crabidy_core::proto::crabidy::{ + crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest, + ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest, + GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest, + InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest, RemoveRequest, + RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest, + SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest, + ToggleShuffleRequest, +}; +use tonic::{ + metadata::MetadataValue, + service::{interceptor::InterceptedService, Interceptor}, + Request, Status, Streaming, +}; +use tonic_web_wasm_client::Client as WasmClient; + +/// Attaches the stored `authorization` header to every request; without +/// credentials it attaches nothing (open server). +#[derive(Clone)] +pub struct AuthInterceptor { + header: Option>, +} + +impl AuthInterceptor { + /// `user` empty means "no credentials". The pair is base64-encoded + /// exactly like the TUI's interceptor. + pub fn new(user: &str, password: &str) -> Option { + if user.is_empty() { + return Some(Self { header: None }); + } + let encoded = base64_encode(format!("{user}:{password}").as_bytes()); + let header = format!("Basic {encoded}").parse().ok()?; + Some(Self { + header: Some(header), + }) + } +} + +impl Interceptor for AuthInterceptor { + fn call(&mut self, mut request: Request<()>) -> Result, Status> { + if let Some(header) = &self.header { + request + .metadata_mut() + .insert("authorization", header.clone()); + } + Ok(request) + } +} + +/// Standard base64 without pulling the base64 crate into the wasm +/// bundle for one call site. +fn base64_encode(input: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(input.len().div_ceil(3) * 4); + for chunk in input.chunks(3) { + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + let chars = [ + ALPHABET[(n >> 18) as usize & 63], + ALPHABET[(n >> 12) as usize & 63], + ALPHABET[(n >> 6) as usize & 63], + ALPHABET[n as usize & 63], + ]; + let keep = chunk.len() + 1; + for (i, c) in chars.iter().enumerate() { + out.push(if i < keep { *c as char } else { '=' }); + } + } + out +} + +type Client = CrabidyServiceClient>; + +/// The app's connection: thin async wrappers over the generated +/// client, mirroring `cbd-tui/src/rpc.rs` (minus its cache — the +/// caching rule lives in `state::is_cacheable` and is applied by the +/// caller, which owns the reactive store). +#[derive(Clone)] +pub struct Rpc { + client: Client, +} + +impl Rpc { + /// Connects to `base_url` (normally the serving origin) with + /// optional credentials. + pub fn new(base_url: String, user: &str, password: &str) -> Option { + let interceptor = AuthInterceptor::new(user, password)?; + let client = CrabidyServiceClient::with_interceptor(WasmClient::new(base_url), interceptor); + Some(Self { client }) + } + + pub async fn update_stream(&mut self) -> Result, Status> { + let response = self + .client + .get_update_stream(Request::new(GetUpdateStreamRequest {})) + .await?; + Ok(response.into_inner()) + } + + pub async fn init(&mut self) -> Result { + Ok(self + .client + .init(Request::new(InitRequest {})) + .await? + .into_inner()) + } + + pub async fn get_library_node(&mut self, path: &str) -> Result, Status> { + let request = Request::new(GetLibraryNodeRequest { + path: path.to_string(), + }); + Ok(self + .client + .get_library_node(request) + .await? + .into_inner() + .node) + } + + pub async fn create_library_node( + &mut self, + parent_path: &str, + title: &str, + ) -> Result, Status> { + let request = Request::new(CreateLibraryNodeRequest { + parent_path: parent_path.to_string(), + title: title.to_string(), + }); + Ok(self + .client + .create_library_node(request) + .await? + .into_inner() + .node) + } + + pub async fn rename_library_node( + &mut self, + path: &str, + new_title: &str, + ) -> Result, Status> { + let request = Request::new(RenameLibraryNodeRequest { + path: path.to_string(), + new_title: new_title.to_string(), + }); + Ok(self + .client + .rename_library_node(request) + .await? + .into_inner() + .node) + } + + pub async fn delete_library_node(&mut self, path: &str) -> Result, Status> { + let request = Request::new(DeleteLibraryNodeRequest { + path: path.to_string(), + }); + Ok(self + .client + .delete_library_node(request) + .await? + .into_inner() + .parent) + } + + pub async fn capture_library_node( + &mut self, + path: &str, + name: &str, + download: bool, + ) -> Result<(), Status> { + let request = Request::new(CaptureLibraryNodeRequest { + path: path.to_string(), + name: name.to_string(), + download, + }); + let _ = self.client.capture_library_node(request).await?; + Ok(()) + } + + pub async fn replace_queue(&mut self, paths: Vec) -> Result<(), Status> { + let _ = self + .client + .replace(Request::new(ReplaceRequest { paths })) + .await?; + Ok(()) + } + + pub async fn append_tracks(&mut self, paths: Vec) -> Result<(), Status> { + let _ = self + .client + .append(Request::new(AppendRequest { paths })) + .await?; + Ok(()) + } + + pub async fn queue_tracks(&mut self, paths: Vec) -> Result<(), Status> { + let _ = self + .client + .queue(Request::new(QueueRequest { paths })) + .await?; + Ok(()) + } + + pub async fn insert_tracks(&mut self, position: u32, paths: Vec) -> Result<(), Status> { + let request = Request::new(InsertRequest { position, paths }); + let _ = self.client.insert(request).await?; + Ok(()) + } + + pub async fn remove_tracks(&mut self, positions: Vec) -> Result<(), Status> { + let request = Request::new(RemoveRequest { positions }); + let _ = self.client.remove(request).await?; + Ok(()) + } + + pub async fn clear_queue(&mut self, exclude_current: bool) -> Result<(), Status> { + let request = Request::new(ClearQueueRequest { exclude_current }); + let _ = self.client.clear_queue(request).await?; + Ok(()) + } + + pub async fn set_current(&mut self, position: u32) -> Result<(), Status> { + let request = Request::new(SetCurrentRequest { position }); + let _ = self.client.set_current(request).await?; + Ok(()) + } + + pub async fn save_queue(&mut self, name: &str) -> Result<(), Status> { + let request = Request::new(SaveQueueRequest { + name: name.to_string(), + }); + let _ = self.client.save_queue(request).await?; + Ok(()) + } + + pub async fn toggle_play(&mut self) -> Result<(), Status> { + let _ = self + .client + .toggle_play(Request::new(TogglePlayRequest {})) + .await?; + Ok(()) + } + + pub async fn restart_track(&mut self) -> Result<(), Status> { + let _ = self + .client + .restart_track(Request::new(RestartTrackRequest {})) + .await?; + Ok(()) + } + + pub async fn next(&mut self) -> Result<(), Status> { + let _ = self.client.next(Request::new(NextRequest {})).await?; + Ok(()) + } + + pub async fn prev(&mut self) -> Result<(), Status> { + let _ = self.client.prev(Request::new(PrevRequest {})).await?; + Ok(()) + } + + pub async fn change_volume(&mut self, delta: f32) -> Result<(), Status> { + let request = Request::new(ChangeVolumeRequest { delta }); + let _ = self.client.change_volume(request).await?; + Ok(()) + } + + pub async fn toggle_mute(&mut self) -> Result<(), Status> { + let _ = self + .client + .toggle_mute(Request::new(ToggleMuteRequest {})) + .await?; + Ok(()) + } + + pub async fn toggle_shuffle(&mut self) -> Result<(), Status> { + let _ = self + .client + .toggle_shuffle(Request::new(ToggleShuffleRequest {})) + .await?; + Ok(()) + } + + pub async fn toggle_repeat(&mut self) -> Result<(), Status> { + let _ = self + .client + .toggle_repeat(Request::new(ToggleRepeatRequest {})) + .await?; + Ok(()) + } +} diff --git a/cbd-web/src/state.rs b/cbd-web/src/state.rs new file mode 100644 index 0000000..56ee09e --- /dev/null +++ b/cbd-web/src/state.rs @@ -0,0 +1,517 @@ +//! Pure client state — the web port of the TUI's pane logic +//! (`cbd-tui/src/app/{library,queue,mod}.rs`), free of DOM and +//! transport so it unit-tests on the native target. Components own +//! these values inside Leptos signals and call the methods on updates. + +use std::collections::HashMap; + +use crabidy_core::proto::crabidy::{CaptureProgress, LibraryNode, Track}; + +/// Which pane has keyboard focus (`Tab` toggles, like the TUI). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Focus { + Library, + Queue, +} + +/// Why the one-line name dialog is open — the web port of the TUI's +/// `InputPurpose`, deciding the submit RPC and the dialog label. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum NamePurpose { + /// `%`: create a child (search term) under the creatable node. + Create { parent_path: String }, + /// `e`: rename the node at `path` (prefilled with its title). + Rename { path: String }, + /// `w` in the queue pane: save the queue under the entered name. + SaveQueue, + /// `w`/`W` in the library: bookmark or download-capture `path`. + Capture { path: String, download: bool }, +} + +impl NamePurpose { + /// The dialog label; capture warns about duration like the TUI. + pub fn label(&self) -> &'static str { + match self { + NamePurpose::Create { .. } => "new node", + NamePurpose::Rename { .. } => "rename", + NamePurpose::SaveQueue => "save queue", + NamePurpose::Capture { + download: false, .. + } => "bookmark", + NamePurpose::Capture { download: true, .. } => "capture (slow, resumable)", + } + } +} + +/// A modal dialog. At most one is open; while one is open, keys go to +/// it (the keymap is bypassed, mirroring the TUI's modal overlays). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Dialog { + /// Text input with a purpose-dependent submit. + Name { + purpose: NamePurpose, + buffer: String, + }, + /// The capture-delete confirmation (architecture/capture-deletion.md). + ConfirmDelete { path: String, title: String }, + /// Credentials form, shown on `UNAUTHENTICATED` responses. + Login, + /// The `?` key binding overlay. + Help, +} + +/// Whether deleting `path` needs the y/N confirmation — same rule as +/// the TUI: captures hold downloaded audio, everything else deletable +/// is cheap to recreate. +pub fn delete_needs_confirmation(path: &str) -> bool { + path == "/captures" || path.starts_with("/captures/") +} + +/// Whether a library listing may be cached client-side — same rule as +/// the TUI (`cbd-tui/src/rpc.rs`): server-side folder providers mutate +/// behind the client's back and are cheap to re-list. +pub fn is_cacheable(path: &str) -> bool { + const MUTABLE_ROOTS: [&str; 4] = ["/captures", "/queues", "/bookmarks", "/fs"]; + !MUTABLE_ROOTS.iter().any(|root| { + path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/')) + }) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UiItemKind { + Track, + Node, +} + +/// One row of the library pane — the TUI's `UiItem`, unchanged. +#[derive(Clone, Debug, PartialEq)] +pub struct UiItem { + pub path: String, + pub title: String, + pub kind: UiItemKind, + pub marked: bool, + pub is_queable: bool, + pub is_creatable: bool, + pub is_editable: bool, + pub is_deletable: bool, + pub is_downloadable: bool, + pub is_skipped: bool, +} + +/// The library pane: current listing, cursor, marks, and per-path +/// cursor memory (going back re-selects where you were). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LibraryPane { + pub path: String, + pub title: String, + pub parent: Option, + pub is_creatable: bool, + pub items: Vec, + pub selected: usize, + positions: HashMap, +} + +impl LibraryPane { + /// Applies a fresh listing. Mirrors the TUI: an empty, non-creatable + /// node is not entered (nothing to show, nothing to create), and + /// tracks list before child nodes. + pub fn update(&mut self, node: &LibraryNode) { + if !node.is_creatable && node.tracks.is_empty() && node.children.is_empty() { + return; + } + self.positions.insert(self.path.clone(), self.selected); + self.path = node.path.clone(); + self.title = node.title.clone(); + self.parent = node.parent.clone(); + self.is_creatable = node.is_creatable; + self.items = node + .tracks + .iter() + .map(|t| UiItem { + path: t.path.clone(), + title: format!("{} - {}", t.artist, t.title), + kind: UiItemKind::Track, + marked: false, + is_queable: true, + is_creatable: false, + is_editable: false, + // Tracks inherit their node's blessing, like the TUI. + is_deletable: node.tracks_deletable, + is_downloadable: node.is_downloadable, + is_skipped: t.is_skipped, + }) + .chain(node.children.iter().map(|c| UiItem { + path: c.path.clone(), + title: c.title.clone(), + kind: UiItemKind::Node, + marked: false, + is_queable: c.is_queable, + is_creatable: c.is_creatable, + is_editable: c.is_editable, + is_deletable: c.is_deletable, + is_downloadable: c.is_downloadable, + is_skipped: false, + })) + .collect(); + self.selected = self + .positions + .get(&self.path) + .copied() + .unwrap_or(0) + .min(self.items.len().saturating_sub(1)); + } + + pub fn selected_item(&self) -> Option<&UiItem> { + self.items.get(self.selected) + } + + /// Cursor movement; `delta` may over/undershoot (jump keys). + pub fn select_by(&mut self, delta: isize) { + if self.items.is_empty() { + return; + } + let last = self.items.len() - 1; + self.selected = self.selected.saturating_add_signed(delta).min(last); + } + + pub fn select_first(&mut self) { + self.selected = 0; + } + + pub fn select_last(&mut self) { + self.selected = self.items.len().saturating_sub(1); + } + + pub fn select(&mut self, index: usize) { + if index < self.items.len() { + self.selected = index; + } + } + + /// `Space`: toggles the mark of the selection (queueable items only). + pub fn toggle_mark(&mut self) { + if let Some(item) = self.items.get_mut(self.selected) { + if item.is_queable { + item.marked = !item.marked; + } + } + } + + pub fn remove_marks(&mut self) { + for item in &mut self.items { + item.marked = false; + } + } + + /// The paths a queue operation ships: all marked items, or the + /// bare queueable selection — exactly the TUI's `get_selected`. + pub fn queueable_selection(&self) -> Option> { + if self.items.iter().any(|i| i.marked) { + return Some( + self.items + .iter() + .filter(|i| i.marked) + .map(|i| i.path.clone()) + .collect(), + ); + } + let item = self.selected_item()?; + item.is_queable.then(|| vec![item.path.clone()]) + } + + pub fn selected_editable(&self) -> Option<(String, String)> { + let item = self.selected_item()?; + item.is_editable + .then(|| (item.path.clone(), item.title.clone())) + } + + pub fn selected_deletable(&self) -> Option<(String, String)> { + let item = self.selected_item()?; + item.is_deletable + .then(|| (item.path.clone(), item.title.clone())) + } + + pub fn selected_queueable(&self) -> Option<(String, String)> { + let item = self.selected_item()?; + item.is_queable + .then(|| (item.path.clone(), item.title.clone())) + } + + pub fn selected_downloadable(&self) -> Option<(String, String)> { + let item = self.selected_item()?; + (item.is_queable && item.is_downloadable).then(|| (item.path.clone(), item.title.clone())) + } +} + +/// The queue pane cursor. The queue itself (tracks, current position, +/// play state) lives in signals fed by the update stream; this only +/// tracks the selection. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct QueueCursor { + pub selected: usize, +} + +impl QueueCursor { + pub fn select_by(&mut self, delta: isize, len: usize) { + if len == 0 { + return; + } + self.selected = self.selected.saturating_add_signed(delta).min(len - 1); + } + + pub fn clamp(&mut self, len: usize) { + self.selected = self.selected.min(len.saturating_sub(1)); + } +} + +/// How long a finished capture's line lingers, in milliseconds — +/// the TUI's `CaptureBoard` with an injected clock (the browser has +/// `performance.now()`, tests pass plain numbers). +const CAPTURE_DONE_LINGER_MS: f64 = 5_000.0; +const CAPTURE_ERROR_LINGER_MS: f64 = 10_000.0; + +struct CaptureEntry { + progress: CaptureProgress, + finished_at: Option, +} + +/// Live capture progress lines, keyed by capture name. +#[derive(Default)] +pub struct CaptureBoard { + entries: Vec, +} + +impl CaptureBoard { + /// Applies one stream update at time `now_ms`. + pub fn apply(&mut self, progress: CaptureProgress, now_ms: f64) { + let finished_at = progress.finished.then_some(now_ms); + let entry = CaptureEntry { + progress, + finished_at, + }; + match self + .entries + .iter_mut() + .find(|e| e.progress.name == entry.progress.name) + { + Some(existing) => *existing = entry, + None => self.entries.push(entry), + } + } + + /// The lines to render at `now_ms`, oldest first, with an is-error + /// flag; expired finished entries are dropped. + pub fn lines(&mut self, now_ms: f64) -> Vec<(String, bool)> { + self.entries.retain(|e| match e.finished_at { + None => true, + Some(at) if e.progress.error.is_empty() => now_ms - at < CAPTURE_DONE_LINGER_MS, + Some(at) => now_ms - at < CAPTURE_ERROR_LINGER_MS, + }); + self.entries + .iter() + .map(|e| (Self::line(&e.progress), !e.progress.error.is_empty())) + .collect() + } + + /// One entry's display line — character for character the TUI's. + fn line(p: &CaptureProgress) -> String { + let verb = if p.download { + ("capturing", "captured", "capture") + } else { + ("bookmarking", "bookmarked", "bookmark") + }; + let skipped = if p.tracks_skipped > 0 { + format!(" ({} skipped)", p.tracks_skipped) + } else { + String::new() + }; + if !p.finished { + let total = if p.tracks_total > 0 { + p.tracks_total.to_string() + } else { + "?".to_string() + }; + format!("{} {} {}/{total}{skipped}", verb.0, p.name, p.tracks_done) + } else if p.error.is_empty() { + format!("{} {}: {} tracks{skipped}", verb.1, p.name, p.tracks_done) + } else { + format!("{} {} failed: {}", verb.2, p.name, p.error) + } + } +} + +/// `mm:ss` for progress and duration displays. +pub fn format_seconds(total: u32) -> String { + format!("{}:{:02}", total / 60, total % 60) +} + +/// The now-playing line for a track, `artist - title` falling back to +/// the path's last segment for artistless tracks. +pub fn track_label(track: &Track) -> String { + if track.artist.is_empty() { + track.title.clone() + } else { + format!("{} - {}", track.artist, track.title) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crabidy_core::proto::crabidy::LibraryNodeChild; + + fn node(path: &str, tracks: usize, children: usize) -> LibraryNode { + LibraryNode { + path: path.to_string(), + title: path.trim_start_matches('/').to_string(), + children: (0..children) + .map(|i| LibraryNodeChild::new(format!("{path}/c{i}"), format!("c{i}"), true)) + .collect(), + parent: Some("/".to_string()), + tracks: (0..tracks) + .map(|i| Track { + path: format!("{path}/t{i}"), + artist: "artist".to_string(), + title: format!("t{i}"), + duration: None, + album: None, + is_skipped: false, + }) + .collect(), + is_queable: true, + is_creatable: false, + is_downloadable: false, + tracks_deletable: false, + } + } + + #[test] + fn listings_order_tracks_before_children_and_remember_positions() { + let mut pane = LibraryPane::default(); + pane.update(&node("/a", 2, 2)); + assert_eq!(pane.items.len(), 4); + assert_eq!(pane.items[0].kind, UiItemKind::Track); + assert_eq!(pane.items[2].kind, UiItemKind::Node); + + pane.select_by(3); + assert_eq!(pane.selected, 3, "clamped to the last item"); + pane.update(&node("/a/c1", 1, 0)); + assert_eq!(pane.selected, 0, "fresh node starts at the top"); + pane.update(&node("/a", 2, 2)); + assert_eq!(pane.selected, 3, "back-navigation restores the cursor"); + } + + #[test] + fn empty_non_creatable_nodes_are_not_entered() { + let mut pane = LibraryPane::default(); + pane.update(&node("/a", 1, 0)); + let empty = node("/a/empty", 0, 0); + pane.update(&empty); + assert_eq!(pane.path, "/a", "listing unchanged"); + + let mut creatable = node("/tidal/search", 0, 0); + creatable.is_creatable = true; + pane.update(&creatable); + assert_eq!(pane.path, "/tidal/search", "creatable nodes open empty"); + } + + #[test] + fn marks_collect_and_bare_selection_falls_back() { + let mut pane = LibraryPane::default(); + pane.update(&node("/a", 2, 1)); + assert_eq!( + pane.queueable_selection(), + Some(vec!["/a/t0".to_string()]), + "bare selection" + ); + pane.toggle_mark(); + pane.select_by(2); + pane.toggle_mark(); + assert_eq!( + pane.queueable_selection(), + Some(vec!["/a/t0".to_string(), "/a/c0".to_string()]), + "marks win over the cursor" + ); + pane.remove_marks(); + assert!(pane.items.iter().all(|i| !i.marked)); + } + + #[test] + fn skipped_and_deletable_flags_reach_the_items() { + let mut listing = node("/captures/mix", 1, 0); + listing.tracks_deletable = true; + listing.tracks[0].is_skipped = true; + let mut pane = LibraryPane::default(); + pane.update(&listing); + assert!(pane.items[0].is_skipped); + assert!(pane.items[0].is_deletable, "tracks inherit the node flag"); + assert_eq!( + pane.selected_deletable(), + Some(("/captures/mix/t0".to_string(), "artist - t0".to_string())) + ); + } + + #[test] + fn capture_deletes_need_confirmation_cheap_deletes_do_not() { + assert!(delete_needs_confirmation("/captures/mix")); + assert!(delete_needs_confirmation("/captures/mix/a.cbd-track.toml")); + assert!(!delete_needs_confirmation("/queues/roadtrip")); + assert!(!delete_needs_confirmation("/tidal/search/abba")); + assert!(!delete_needs_confirmation("/capturesque")); + } + + #[test] + fn mutable_roots_are_never_cacheable() { + for path in ["/captures", "/queues/x", "/bookmarks", "/fs/music"] { + assert!(!is_cacheable(path), "{path}"); + } + for path in ["/tidal/playlists", "/youtube/search", "/capturesque"] { + assert!(is_cacheable(path), "{path}"); + } + } + + #[test] + fn capture_board_lines_match_the_tui_and_expire() { + let mut board = CaptureBoard::default(); + board.apply( + CaptureProgress { + name: "mix".into(), + download: true, + tracks_done: 3, + tracks_total: 9, + tracks_skipped: 1, + finished: false, + error: String::new(), + }, + 0.0, + ); + assert_eq!( + board.lines(0.0), + vec![("capturing mix 3/9 (1 skipped)".to_string(), false)] + ); + board.apply( + CaptureProgress { + name: "mix".into(), + download: true, + tracks_done: 9, + tracks_total: 9, + tracks_skipped: 1, + finished: true, + error: String::new(), + }, + 1_000.0, + ); + assert_eq!( + board.lines(1_000.0), + vec![("captured mix: 9 tracks (1 skipped)".to_string(), false)] + ); + assert!(board.lines(7_000.0).is_empty(), "done lines expire"); + } + + #[test] + fn time_formatting_is_mm_ss() { + assert_eq!(format_seconds(0), "0:00"); + assert_eq!(format_seconds(61), "1:01"); + assert_eq!(format_seconds(3599), "59:59"); + } +} diff --git a/cbd-web/style.css b/cbd-web/style.css new file mode 100644 index 0000000..68c44a0 --- /dev/null +++ b/cbd-web/style.css @@ -0,0 +1,463 @@ +/* crabidy web client — pure modern CSS (architecture/web-client.md). + One accent variable (crab orange-red), light and dark themes via + color-scheme + light-dark(); the theme toggle stamps data-theme on + , otherwise the OS decides. No frameworks, no external + requests — everything ships in the bundle. */ + +:root { + color-scheme: light dark; + + /* The crab. Every accent tone derives from this one value. */ + --accent: oklch(0.62 0.19 35); + --accent-strong: color-mix(in oklch, var(--accent) 85%, black); + --accent-soft: color-mix(in oklch, var(--accent) 14%, transparent); + --on-accent: oklch(0.99 0.005 60); + + --bg: light-dark(oklch(0.98 0.005 60), oklch(0.17 0.01 260)); + --bg-raised: light-dark(oklch(1 0 0), oklch(0.21 0.012 260)); + --fg: light-dark(oklch(0.25 0.015 260), oklch(0.92 0.005 60)); + --fg-dim: light-dark(oklch(0.52 0.012 260), oklch(0.68 0.008 60)); + --danger: light-dark(oklch(0.54 0.2 25), oklch(0.68 0.19 25)); + --border: color-mix(in oklch, var(--fg) 14%, transparent); + --shadow: 0 8px 32px light-dark(rgb(0 0 0 / 0.14), rgb(0 0 0 / 0.55)); +} + +:root[data-theme="light"] { + color-scheme: light; +} +:root[data-theme="dark"] { + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font: 15px/1.45 system-ui, sans-serif; + background: var(--bg); + color: var(--fg); + overscroll-behavior: none; +} + +button { + font: inherit; + color: inherit; + background: var(--accent); + color: var(--on-accent); + border: none; + border-radius: 6px; + padding: 0.3rem 0.75rem; + cursor: pointer; + + &:hover { + background: var(--accent-strong); + } + + &:disabled { + opacity: 0.35; + cursor: default; + } + + &.ghost { + background: transparent; + color: var(--fg-dim); + padding: 0.25rem 0.5rem; + + &:hover:not(:disabled) { + background: var(--accent-soft); + color: var(--fg); + } + + &.active { + color: var(--accent); + background: var(--accent-soft); + } + } + + &.danger { + background: var(--danger); + color: var(--on-accent); + } + + &.ghost.danger { + background: transparent; + color: var(--danger); + + &:hover:not(:disabled) { + background: color-mix(in oklch, var(--danger) 15%, transparent); + } + } +} + +input { + font: inherit; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.4rem 0.6rem; + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; + } +} + +/* ---- frame ---------------------------------------------------------- */ + +.shell { + display: grid; + grid-template-rows: auto 1fr auto; + block-size: 100dvh; +} + +.topbar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.4rem 0.9rem; + border-block-end: 1px solid var(--border); + background: var(--bg-raised); + + & .brand { + color: var(--accent); + font-weight: 700; + font-size: 1.05rem; + letter-spacing: 0.02em; + } + + & .conn { + font-size: 0.85rem; + color: var(--fg-dim); + + &.offline { + color: var(--danger); + } + } + + & .topbar-actions { + margin-inline-start: auto; + display: flex; + gap: 0.25rem; + } +} + +/* ---- panes ----------------------------------------------------------- */ + +.panes { + display: grid; + grid-template-columns: 3fr 2fr; + min-block-size: 0; +} + +.pane { + display: grid; + grid-template-rows: auto 1fr auto; + min-block-size: 0; + border-inline-end: 1px solid var(--border); + /* The focused pane shows it like the TUI's highlighted border. */ + box-shadow: inset 0 2px 0 transparent; + + &:last-child { + border-inline-end: none; + } + + &.focused { + box-shadow: inset 0 2px 0 var(--accent); + } +} + +.toolbar { + display: flex; + align-items: center; + gap: 0.15rem; + padding: 0.35rem 0.6rem; + border-block-end: 1px solid var(--border); + overflow-x: auto; + + & .path { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + & .spacer { + flex: 1; + } +} + +.list { + margin: 0; + padding: 0.25rem 0; + list-style: none; + overflow-y: auto; + min-block-size: 0; + + & li { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.28rem 0.75rem; + cursor: pointer; + border-inline-start: 3px solid transparent; + + & .title { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + & .badge { + color: var(--fg-dim); + font-size: 0.8rem; + } + + &:hover { + background: var(--accent-soft); + } + + &.selected { + background: var(--accent-soft); + border-inline-start-color: var(--accent); + } + + &.marked .title { + color: var(--accent); + font-weight: 600; + + &::before { + content: "* "; + } + } + + /* Skipped tracks carry no audio (incremental captures). */ + &.skipped .title { + color: var(--danger); + } + + &.node .title { + color: color-mix(in oklch, var(--fg) 80%, var(--accent)); + } + + &.current .title { + color: var(--accent); + font-weight: 700; + } + + & .row-action { + visibility: hidden; + } + + &:hover .row-action, + &.selected .row-action { + visibility: visible; + } + } +} + +.capture-lines { + padding: 0.2rem 0.75rem 0.4rem; + font-size: 0.85rem; + color: var(--fg-dim); + + & .capture-line.error { + color: var(--danger); + } +} + +/* ---- transport -------------------------------------------------------- */ + +.transport { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 1rem; + padding: 0.5rem 0.9rem; + border-block-start: 1px solid var(--border); + background: var(--bg-raised); + + & .controls { + display: flex; + align-items: center; + gap: 0.1rem; + + & .big { + font-size: 1.3rem; + color: var(--accent); + } + } + + & .now-playing { + min-inline-size: 0; + + & .np-title { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 600; + } + } + + & .progress { + display: flex; + align-items: center; + gap: 0.5rem; + + & .time { + font-size: 0.8rem; + color: var(--fg-dim); + font-variant-numeric: tabular-nums; + } + + & .gauge { + flex: 1; + block-size: 6px; + border-radius: 3px; + background: var(--accent-soft); + overflow: hidden; + + & .gauge-fill { + block-size: 100%; + background: var(--accent); + border-radius: 3px; + transition: width 0.4s linear; + } + } + } + + & .volume { + display: flex; + align-items: center; + gap: 0.4rem; + + & input[type="range"] { + inline-size: 7rem; + accent-color: var(--accent); + padding: 0; + border: none; + background: transparent; + } + } +} + +/* ---- overlays ---------------------------------------------------------- */ + +.overlay { + position: fixed; + inset: 0; + display: grid; + place-items: center; + background: rgb(0 0 0 / 0.4); + backdrop-filter: blur(2px); +} + +.dialog { + display: grid; + gap: 0.7rem; + min-inline-size: min(26rem, 90vw); + max-block-size: 85dvh; + overflow-y: auto; + padding: 1.1rem 1.3rem; + border-radius: 10px; + background: var(--bg-raised); + box-shadow: var(--shadow); + + & label { + color: var(--fg-dim); + font-size: 0.9rem; + } + + & .dialog-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + } + + &.danger-dialog { + border-inline-start: 4px solid var(--danger); + } +} + +.help { + min-inline-size: min(52rem, 94vw); + + & h2 { + margin: 0; + color: var(--accent); + } + + & .help-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); + gap: 0.5rem 2rem; + + & h3 { + margin: 0.4rem 0 0.2rem; + font-size: 0.9rem; + color: var(--fg-dim); + text-transform: uppercase; + letter-spacing: 0.06em; + } + + & table { + border-collapse: collapse; + inline-size: 100%; + + & td { + padding: 0.12rem 0.4rem 0.12rem 0; + vertical-align: top; + } + + & .key { + font-family: ui-monospace, monospace; + color: var(--accent); + white-space: nowrap; + } + } + } +} + +.toast { + position: fixed; + inset-block-end: 4.5rem; + inset-inline-start: 50%; + translate: -50% 0; + padding: 0.5rem 1rem; + border-radius: 8px; + background: var(--danger); + color: var(--on-accent); + box-shadow: var(--shadow); +} + +/* ---- phone ------------------------------------------------------------- */ + +@media (max-width: 700px) { + /* One pane at a time; Tab (or tapping a pane edge) switches — the + unfocused pane collapses to a slim strip acting as its tab. */ + .panes { + grid-template-columns: 1fr; + grid-template-rows: 1fr auto; + } + + .pane:not(.focused) { + grid-template-rows: auto; + max-block-size: 2.4rem; + overflow: hidden; + border-block-start: 1px solid var(--border); + opacity: 0.75; + } + + .transport { + grid-template-columns: 1fr; + gap: 0.4rem; + + & .volume { + justify-content: flex-end; + } + } +} diff --git a/crabidy-core/Cargo.toml b/crabidy-core/Cargo.toml index 1b3df49..b4a6ba6 100644 --- a/crabidy-core/Cargo.toml +++ b/crabidy-core/Cargo.toml @@ -5,17 +5,25 @@ edition.workspace = true [dependencies] async-trait.workspace = true -clap-serde-derive.workspace = true -dirs.workspace = true flume.workspace = true percent-encoding.workspace = true prost.workspace = true serde.workspace = true toml.workspace = true -tonic.workspace = true +# Codegen only: the generated client/server stubs need no transport, +# which keeps this crate building for wasm32 (cbd-web, see +# architecture/web-client.md). Native binaries pull the full tonic +# through their own dependency edges. +tonic = { workspace = true, default-features = false, features = ["codegen"] } tracing.workspace = true tonic-prost.workspace = true +# Config loading is native-only: the browser has no config directory +# (architecture/web-client.md). +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +clap-serde-derive.workspace = true +dirs.workspace = true + [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crabidy-core/build.rs b/crabidy-core/build.rs index 85dc370..4798ae0 100644 --- a/crabidy-core/build.rs +++ b/crabidy-core/build.rs @@ -1,4 +1,10 @@ fn main() -> Result<(), Box> { - tonic_prost_build::compile_protos("crabidy/v1/crabidy.proto")?; + // No `connect()` convenience impl: it hardcodes tonic::transport, + // which the wasm build of this crate deliberately lacks + // (architecture/web-client.md). Clients construct their channel + // (native: Endpoint, browser: tonic-web-wasm-client) themselves. + tonic_prost_build::configure() + .build_transport(false) + .compile_protos(&["crabidy/v1/crabidy.proto"], &["."])?; Ok(()) } diff --git a/crabidy-core/src/lib.rs b/crabidy-core/src/lib.rs index f6521cd..7e53bed 100644 --- a/crabidy-core/src/lib.rs +++ b/crabidy-core/src/lib.rs @@ -1,3 +1,4 @@ +#[cfg(not(target_arch = "wasm32"))] use std::{ fs::{create_dir_all, read_to_string, File}, io::Write, @@ -5,6 +6,7 @@ use std::{ }; use async_trait::async_trait; +#[cfg(not(target_arch = "wasm32"))] pub use clap_serde_derive::{self, clap, serde, ClapSerde}; use proto::crabidy::{LibraryNode, LibraryNodeChild, Track}; @@ -237,6 +239,7 @@ pub enum QueueError { NotQueable, } +#[cfg(not(target_arch = "wasm32"))] pub fn init_config(config_file_name: &str) -> T where T: Default + ClapSerde + serde::Serialize + std::fmt::Debug, diff --git a/crabidy-server/Cargo.toml b/crabidy-server/Cargo.toml index c15b663..0b91bc3 100644 --- a/crabidy-server/Cargo.toml +++ b/crabidy-server/Cargo.toml @@ -7,13 +7,22 @@ edition.workspace = true name = "crabidy-server" path = "src/main.rs" +[features] +# The embedded web client (architecture/web-client.md). On by default; +# disable for a headless-only binary without the bundle. +default = ["web-ui"] +web-ui = ["dep:tonic-web", "dep:include_dir"] + [dependencies] anyhow.workspace = true argon2.workspace = true async-trait.workspace = true +axum.workspace = true base64.workspace = true clap.workspace = true http.workspace = true +include_dir = { workspace = true, optional = true } +tonic-web = { workspace = true, optional = true } tower.workspace = true audio-player.workspace = true crabidy-core.workspace = true @@ -29,11 +38,15 @@ tidaldy.workspace = true tokio = { workspace = true, features = ["full"] } toml.workspace = true tokio-stream = { workspace = true, features = ["sync"] } -tonic.workspace = true +tonic = { workspace = true, features = ["router", "transport", "codegen"] } tracing.workspace = true tracing-appender.workspace = true tracing-subscriber.workspace = true ytdy.workspace = true [dev-dependencies] +argon2.workspace = true +base64.workspace = true +http.workspace = true tempfile.workspace = true +tower.workspace = true diff --git a/crabidy-server/build.rs b/crabidy-server/build.rs new file mode 100644 index 0000000..459d09b --- /dev/null +++ b/crabidy-server/build.rs @@ -0,0 +1,59 @@ +//! Stages the web client bundle for embedding (feature `web-ui`, +//! architecture/web-client.md): copies `cbd-web/dist` (the trunk +//! output) into `OUT_DIR/webdist`, or generates a placeholder page +//! when the bundle has not been built — a plain `cargo build` must +//! neither fail nor require the wasm toolchain. Deliberately no +//! cargo-in-cargo: this never invokes trunk itself. + +use std::path::Path; + +fn main() { + // Rerun when the bundle changes (or appears). + println!("cargo:rerun-if-changed=../cbd-web/dist"); + if std::env::var_os("CARGO_FEATURE_WEB_UI").is_none() { + return; + } + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set for build scripts"); + let staged = Path::new(&out_dir).join("webdist"); + // Start fresh so removed assets do not linger across builds. + if staged.exists() { + std::fs::remove_dir_all(&staged).expect("clean staged webdist"); + } + std::fs::create_dir_all(&staged).expect("create staged webdist"); + + let dist = Path::new(env!("CARGO_MANIFEST_DIR")).join("../cbd-web/dist"); + if dist.join("index.html").is_file() { + copy_dir(&dist, &staged); + } else { + println!( + "cargo:warning=cbd-web/dist not found - embedding a placeholder web UI \ + (build the bundle with: devenv shell -- build-web)" + ); + std::fs::write( + staged.join("index.html"), + "crabidy\ + \ +

crabidy web UI not built

\ +

This server binary was compiled without the web bundle. \ + Build it with devenv shell -- build-web and \ + rebuild the server.

", + ) + .expect("write placeholder index.html"); + } +} + +/// Copies `from` into `to` recursively (regular files only — the trunk +/// output contains nothing else). +fn copy_dir(from: &Path, to: &Path) { + for entry in std::fs::read_dir(from).expect("read dist dir") { + let entry = entry.expect("dist dir entry"); + let target = to.join(entry.file_name()); + let path = entry.path(); + if path.is_dir() { + std::fs::create_dir_all(&target).expect("create staged subdir"); + copy_dir(&path, &target); + } else { + std::fs::copy(&path, &target).expect("copy dist file"); + } + } +} diff --git a/crabidy-server/src/lib.rs b/crabidy-server/src/lib.rs index f6c391b..76ea8a5 100644 --- a/crabidy-server/src/lib.rs +++ b/crabidy-server/src/lib.rs @@ -1,5 +1,8 @@ pub mod auth; pub mod bookmark_store; +#[cfg(feature = "web-ui")] +pub mod web; + pub mod capture; pub mod capture_store; pub mod playback; @@ -98,16 +101,45 @@ pub async fn serve( playback.run(); info!("playback started"); + let router = build_router(crabidy_service, authenticator); + info!(%addr, "grpc server listening"); - tonic::transport::Server::builder() - .layer(auth::AuthLayer::new(authenticator)) - .add_service(CrabidyServiceServer::new(crabidy_service)) - .serve(addr) - .await?; + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, router).await?; Ok(()) } +/// Composes the one axum router that serves everything on one port: the +/// gRPC service (native HTTP/2 for the TUI *and*, with `web-ui`, +/// gRPC-web for the browser through the tonic-web layer) plus, with +/// `web-ui`, the embedded web client as the fallback route +/// (architecture/web-client.md). +/// +/// The auth layer wraps only the gRPC route — its default-deny is for +/// RPC methods; the app shell itself is public, like any login page. +/// Kept separate from [`serve`] so the routing/auth composition is +/// testable without a live provider backend. +pub fn build_router( + crabidy_service: rpc::RpcService, + authenticator: Arc, +) -> axum::Router { + let builder = tower::ServiceBuilder::new().layer(auth::AuthLayer::new(authenticator)); + #[cfg(feature = "web-ui")] + let builder = builder.layer(tonic_web::GrpcWebLayer::new()); + let grpc = builder.service(CrabidyServiceServer::new(crabidy_service)); + let router = axum::Router::new().route_service( + &format!( + "/{}/{{*method}}", + as tonic::server::NamedService>::NAME + ), + grpc, + ); + #[cfg(feature = "web-ui")] + let router = router.fallback(web::serve_asset); + router +} + /// Forwards player engine events into the playback message loop. #[instrument(skip(rx, tx))] fn poll_play_bus(rx: flume::Receiver, tx: flume::Sender) { diff --git a/crabidy-server/src/web.rs b/crabidy-server/src/web.rs new file mode 100644 index 0000000..0a493bc --- /dev/null +++ b/crabidy-server/src/web.rs @@ -0,0 +1,133 @@ +//! Serves the embedded web client (feature `web-ui`, +//! architecture/web-client.md): the trunk bundle staged by `build.rs` +//! ships inside the binary and answers every request the gRPC route +//! did not claim. Assets are public by design — the app shell is a +//! login page at worst; every RPC behind it stays gated by the auth +//! layer. + +use axum::body::Body; +use axum::response::Response; +use http::{header, HeaderValue, Method, Request, StatusCode, Uri}; +use include_dir::{include_dir, Dir}; + +/// The staged trunk bundle (or the build.rs placeholder page). +static DIST: Dir<'_> = include_dir!("$OUT_DIR/webdist"); + +/// Content type by file extension. The bundle is fully known at build +/// time, so an unknown extension is a programmer omission — served as +/// octet-stream rather than panicking. +fn content_type(path: &str) -> &'static str { + match path.rsplit_once('.').map(|(_, ext)| ext) { + Some("html") => "text/html; charset=utf-8", + Some("css") => "text/css", + Some("js") => "application/javascript", + Some("wasm") => "application/wasm", + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("ico") => "image/x-icon", + Some("txt") => "text/plain; charset=utf-8", + _ => "application/octet-stream", + } +} + +/// The asset for `uri`, falling back to `index.html` for pathless GETs +/// (the app owns its own view state; deep links reload the shell). +fn lookup(uri: &Uri) -> (&'static str, &'static [u8]) { + let path = uri.path().trim_start_matches('/'); + let file = if path.is_empty() { + None + } else { + DIST.get_file(path) + }; + match file { + Some(file) => (content_type(path), file.contents()), + None => ( + "text/html; charset=utf-8", + DIST.get_file("index.html") + .map(include_dir::File::contents) + // The build script always stages an index.html; an empty + // page is the harmless fallback if it ever did not. + .unwrap_or(b""), + ), + } +} + +/// The fallback handler: serves bundle assets for GET/HEAD, 404s +/// everything else (non-GET traffic belongs to the gRPC route). +pub async fn serve_asset(request: Request) -> Response { + if request.method() != Method::GET && request.method() != Method::HEAD { + return Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::empty()) + .expect("static response"); + } + let (content_type, bytes) = lookup(request.uri()); + let body = if request.method() == Method::HEAD { + Body::empty() + } else { + Body::from(bytes) + }; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, HeaderValue::from_static(content_type)) + .body(body) + .expect("static response") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn get(path: &str) -> Request { + Request::builder() + .method(Method::GET) + .uri(path) + .body(Body::empty()) + .expect("request") + } + + async fn body_string(response: Response) -> String { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + String::from_utf8_lossy(&bytes).into_owned() + } + + #[tokio::test] + async fn the_root_serves_the_app_shell() { + let response = serve_asset(get("/")).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + "text/html; charset=utf-8" + ); + let html = body_string(response).await; + assert!(html.contains("crabidy"), "app shell served"); + } + + #[tokio::test] + async fn unknown_paths_fall_back_to_the_shell_get_only() { + let response = serve_asset(get("/some/deep/link")).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + "text/html; charset=utf-8" + ); + + let post = Request::builder() + .method(Method::POST) + .uri("/not-grpc") + .body(Body::empty()) + .expect("request"); + let response = serve_asset(post).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn content_types_cover_the_bundle() { + assert_eq!(content_type("a.wasm"), "application/wasm"); + assert_eq!(content_type("a.js"), "application/javascript"); + assert_eq!(content_type("a.css"), "text/css"); + assert_eq!(content_type("weird.bin"), "application/octet-stream"); + } +} diff --git a/crabidy-server/tests/web_server.rs b/crabidy-server/tests/web_server.rs new file mode 100644 index 0000000..f5a9537 --- /dev/null +++ b/crabidy-server/tests/web_server.rs @@ -0,0 +1,196 @@ +//! Integration test for the one-port router composition +//! (architecture/web-client.md): static assets, the gRPC-web route, and +//! the auth layer must coexist. Driven through `tower::oneshot` so no +//! socket, no provider backend, and no Tidal device login are needed. +//! +//! Only meaningful with the `web-ui` feature (the static fallback and +//! gRPC-web layer live behind it); a no-web build has nothing to route. +#![cfg(feature = "web-ui")] + +use std::sync::Arc; + +use base64::Engine; +use crabidy_server::auth::Authenticator; +use crabidy_server::rpc::RpcService; +use crabidy_server::settings::AuthSettings; +use http::{header, Method, Request, StatusCode}; +use tower::ServiceExt; + +/// A service wired to dead channels: enough to build the router and +/// exercise routing and the pre-handler auth layer (the two things +/// under test); no RPC that reaches a handler is sent. +fn service() -> RpcService { + let (update_tx, _) = tokio::sync::broadcast::channel(4); + let (playback_tx, _playback_rx) = flume::unbounded(); + let (provider_tx, _provider_rx) = flume::unbounded(); + RpcService::new(update_tx, playback_tx, provider_tx) +} + +fn hash(password: &str) -> String { + use argon2::password_hash::{rand_core::OsRng, SaltString}; + use argon2::{Argon2, PasswordHasher}; + let params = argon2::Params::new(8, 1, 1, None).expect("params"); + let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); + argon2 + .hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng)) + .expect("hash") + .to_string() +} + +#[tokio::test] +async fn the_root_serves_the_embedded_app_shell() { + let router = crabidy_server::build_router( + service(), + Arc::new(Authenticator::new(&AuthSettings::default())), + ); + let response = router + .oneshot( + Request::builder() + .uri("/") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let content_type = response.headers()[header::CONTENT_TYPE].to_str().unwrap(); + assert!(content_type.starts_with("text/html"), "{content_type}"); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert!( + String::from_utf8_lossy(&bytes).contains("crabidy"), + "app shell (or placeholder) served" + ); +} + +#[tokio::test] +async fn unknown_get_paths_fall_back_to_the_shell() { + let router = crabidy_server::build_router( + service(), + Arc::new(Authenticator::new(&AuthSettings::default())), + ); + let response = router + .oneshot( + Request::builder() + .uri("/library/deep/link") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers()[header::CONTENT_TYPE] + .to_str() + .unwrap() + .starts_with("text/html")); +} + +#[tokio::test] +async fn grpc_web_calls_route_through_the_auth_layer() { + // A credentialed server: an unauthenticated gRPC-web POST must be + // rejected by the layer (gRPC status UNAUTHENTICATED = 16) *before* + // reaching a handler — so the dead channels never matter. + let auth = Authenticator::new(&AuthSettings { + owner: Some(hash("pw")), + queue_owner: None, + queue_appender: None, + }); + let router = crabidy_server::build_router(service(), Arc::new(auth)); + let response = router + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/crabidy.v1.CrabidyService/Init") + .header(header::CONTENT_TYPE, "application/grpc-web+proto") + .header("x-grpc-web", "1") + .body(axum::body::Body::from(vec![0u8, 0, 0, 0, 0])) + .unwrap(), + ) + .await + .expect("response"); + // gRPC-web reports the status in a header (trailers-only), HTTP 200. + let grpc_status = response + .headers() + .get("grpc-status") + .and_then(|v| v.to_str().ok()); + assert_eq!(grpc_status, Some("16"), "unauthenticated gRPC-web call"); + + // With a valid owner credential the layer passes the request + // through to the service; owner is allowed for Init, so it is not + // rejected. The stub handler then fails on its dead channels — any + // outcome other than the auth codes proves the request cleared the + // layer and reached the handler. + let authed = base64::engine::general_purpose::STANDARD.encode("owner:pw"); + let response = router + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/crabidy.v1.CrabidyService/Init") + .header(header::CONTENT_TYPE, "application/grpc-web+proto") + .header("x-grpc-web", "1") + .header(header::AUTHORIZATION, format!("Basic {authed}")) + .body(axum::body::Body::from(vec![0u8, 0, 0, 0, 0])) + .unwrap(), + ) + .await + .expect("response"); + let grpc_status = response + .headers() + .get("grpc-status") + .and_then(|v| v.to_str().ok()); + assert_ne!( + grpc_status, + Some("16"), + "authorized call must not be UNAUTHENTICATED" + ); + assert_ne!( + grpc_status, + Some("7"), + "owner must not be PERMISSION_DENIED for Init" + ); +} + +/// The TUI speaks native gRPC (HTTP/2 prior knowledge, no TLS). Moving +/// the server from `tonic::transport::Server` to `axum::serve` must not +/// break that: bind the real router to a socket and call it with a +/// native tonic client. A gRPC *status* back (rather than a transport +/// error) proves h2c negotiated and the request reached the service. +#[tokio::test] +async fn native_grpc_still_works_through_the_axum_server() { + use crabidy_core::proto::crabidy::crabidy_service_client::CrabidyServiceClient; + use crabidy_core::proto::crabidy::GetLibraryNodeRequest; + + let router = crabidy_server::build_router( + service(), + Arc::new(Authenticator::new(&AuthSettings::default())), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + + let channel = tonic::transport::Endpoint::from_shared(format!("http://{addr}")) + .unwrap() + .connect() + .await + .expect("native h2c connect"); + let mut client = CrabidyServiceClient::new(channel); + // Dead channels make the handler fail fast; we only assert the + // round trip produced a gRPC status, i.e. the transport worked. + let result = tokio::time::timeout( + std::time::Duration::from_secs(2), + client.get_library_node(GetLibraryNodeRequest { + path: "/".to_string(), + }), + ) + .await + .expect("no transport hang"); + assert!( + result.is_err(), + "the stub handler errors on dead channels — but the call round-tripped" + ); + server.abort(); +} diff --git a/devenv.nix b/devenv.nix index 717e301..8891ad1 100644 --- a/devenv.nix +++ b/devenv.nix @@ -14,15 +14,26 @@ let d2 pkg-config protobuf + cargo-cross # Stream-URL sidecar for the ytdy provider: YouTube caps tokenless # stream URLs at ~1 MiB and yt-dlp is the only maintained cipher # solver (architecture/youtube-rustypipe.md, D2-revised). yt-dlp + # Web client toolchain (architecture/web-client.md): trunk builds + # cbd-web to wasm, wasm-bindgen-cli must match the crate version, + # binaryen provides wasm-opt for release builds. + trunk + wasm-bindgen-cli + binaryen ]; in { imports = [ ./devenv-rust.nix ]; + # The wasm target for cbd-web; merges with the languages.rust + # settings in devenv-rust.nix. + languages.rust.targets = [ "wasm32-unknown-unknown" ]; + env = { LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath commonLibs; }; @@ -41,6 +52,19 @@ in echo Welcome to rust devenv ''; + # Builds the web client bundle (cbd-web/dist), which crabidy-server + # embeds on its next build (architecture/web-client.md). RUSTFLAGS is + # cleared because the mold linker flag from the native toolchain + # (RUSTFLAGS wins over target-specific config) breaks rust-lld. + scripts.build-web.exec = '' + cd "$DEVENV_ROOT/cbd-web" && RUSTFLAGS="" trunk build --release "$@" + ''; + # Dev loop: live-reloading trunk server proxying gRPC-web to a + # locally running crabidy-server. + scripts.serve-web.exec = '' + cd "$DEVENV_ROOT/cbd-web" && RUSTFLAGS="" trunk serve "$@" + ''; + enterShell = ""; # https://devenv.sh/tasks/ diff --git a/plan/summary.md b/plan/summary.md index 2ce3db9..417c466 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -632,3 +632,73 @@ with PHC password hashes in the new `crabidy-server.toml`. the fact and all hold. - Denied-action UX in the TUI stays a logged no-op, as recorded in the architecture's open questions. + +## web-client (2026-07-21) + +A Leptos/WASM browser client with TUI feature parity, served by +crabidy-server itself. Full dev-flow run: `architecture/web-client.md`, +`quality/web-client.md`, `plan/web-client.md`. + +New workspace member **cbd-web** (CSR Leptos): + +- `state.rs` / `keymap.rs` — the TUI's pane logic and bindings ported + as pure, DOM-free modules with native `#[test]`s (18 tests). Same + semantics: tracks-before-children, cursor memory, marks-win, capture + progress lines, `is_cacheable`, capture-delete confirmation rule. +- `rpc.rs` — gRPC-web (`tonic-web-wasm-client`) over the same + `crabidy-core` generated client and types as the TUI, with the same + basic-auth header interceptor. +- `app.rs` — one signal store fed by the update stream (reconnecting + backoff), one dispatcher mirroring the TUI dispatch, thin components: + library/queue panes, transport bar, name/confirm/login/help dialogs, + global keyboard wiring. +- `style.css` — pure modern CSS, single `--accent` crab orange-red with + `color-mix` derivations, light/dark via `color-scheme`+`light-dark()` + plus a persisted toggle, phone breakpoint. + +crabidy-server changes: + +- `web-ui` cargo feature (**default on**); `--no-default-features` = + headless gRPC-only. +- `build.rs` stages `cbd-web/dist` into `OUT_DIR` (or a placeholder + page — plain `cargo build` needs no wasm toolchain), embedded via + `include_dir`. +- `web.rs` serves the embedded bundle (GET/HEAD, index fallback). +- `serve()` refactored to `build_router()`: one axum router with the + gRPC service (auth layer → `tonic-web` GrpcWebLayer → service) as a + route and the web bundle as fallback; `axum::serve` replaces + `tonic::transport::Server`. + +Cross-cutting: + +- crabidy-core builds for `wasm32-unknown-unknown`: workspace `tonic` + set `default-features = false`, this crate takes codegen-only, native + binaries re-enable transport/router/channel; `build.rs` uses + `build_transport(false)`; config loading gated to non-wasm. +- devenv: `trunk`, `wasm-bindgen-cli`, `binaryen`, the wasm target, and + `build-web`/`serve-web` scripts (which clear `RUSTFLAGS` — the mold + linker flag breaks `rust-lld`). + +### Deviations from plan / architecture (web-client) + +- **No CRDT / local-first sync layer** (the example template's + automerge/loro): this app is a remote control for one live server + state, so "local first" was scoped to CSR + no-CDN assets + in-memory + caching + localStorage prefs + reconnect. Recorded in the + architecture doc up front. +- **`build_router()` extracted** from `serve()` (not in the plan) so + the three-way routing + auth composition is testable without a live + provider backend (`tests/web_server.rs`, incl. a native-gRPC-over- + axum h2c check). +- **Native dead-code allow** on the cbd-web binary target: the pure + modules are used by wasm + tests, not the native stub binary. + +### Verification + +202→ tests green across the workspace plus 4 new server routing tests +and 18 cbd-web logic tests; native and wasm clippy `-D warnings` clean; +fmt + markdownlint clean. Live smoke test (server with the real +embedded bundle + stubbed Tidal): `/` serves the shell, the 1.77 MB +wasm/js/css assets serve with correct content-types, deep links fall +back to the shell, unauthenticated and wrong-role gRPC-web calls return +UNAUTHENTICATED, and a native tonic client round-trips over axum. diff --git a/plan/web-client.md b/plan/web-client.md new file mode 100644 index 0000000..cad153d --- /dev/null +++ b/plan/web-client.md @@ -0,0 +1,60 @@ +# Plan — web client + +From `architecture/web-client.md` and `quality/web-client.md`. + +## Toolchain & skeleton (done during api-design) + +- [x] devenv: trunk, wasm-bindgen-cli, binaryen, wasm32 target; + `build-web`/`serve-web` scripts (RUSTFLAGS cleared for rust-lld). +- [x] crabidy-core on wasm: tonic codegen-only (workspace tonic + default-features=false, members re-enable), `build_transport(false)`, + native-only config gated. Verify: `cargo check -p crabidy-core + --target wasm32-unknown-unknown`. +- [x] cbd-web crate: Trunk.toml (+ dev proxy), index.html, style.css + skeleton, `state.rs` (pane logic ports + tests), `keymap.rs` + (TUI bindings port + tests), `rpc.rs` (gRPC-web client + basic + auth header), app shell. Verify: wasm check + native tests + + `trunk build --release`. +- [x] crabidy-server: `web-ui` feature (default on), build.rs staging + (dist or placeholder), `web.rs` static fallback (+tests), serve() + on one axum router: auth → grpc-web → service, assets public. + Verify: check with/without feature, tests. + +## Implementation + +- [x] **Stream task**: connect `GetUpdateStream` on startup, apply + updates to signals (queue, mods, play state, volume, mute, + position, capture board), reconnect with capped backoff + + `connected` signal. Gate: parity/stream. +- [x] **Library pane**: listing from `LibraryPane` state, click = + select, double-click/`l` = dive, breadcrumb/`h` = ascend, marks, + capability badges (`%`/`[e]`/`[d]` equivalents), skipped red. + Library cache honoring `is_cacheable`. Gate: parity/semantics. +- [x] **Queue pane**: track list with current highlight + resolving + indicator, select/play/remove/clear/save, insert-here from + library selection. Gate: parity. +- [x] **Transport bar**: play/pause, prev/next, restart, stop-aware + play state, volume slider + mute, shuffle/repeat toggles, + progress gauge from `TrackPosition`. Gate: parity. +- [x] **Dialogs**: name input (create/rename/save-queue/capture with + slow-warning label), capture-delete y/N (red), help overlay + (`?`), login form on `UNAUTHENTICATED` (localStorage-backed). + All modal: keys bypass the keymap. Gate: parity + security. +- [x] **Keyboard wiring**: global keydown listener → `keymap::lookup` + → actions; input elements exempt (typing in dialogs). Gate: + parity. +- [x] **Capture progress lines**: board fed by stream, rendered at the + library pane bottom, errors red, linger semantics from state.rs. + Gate: parity. +- [x] **CSS**: full styling — layout grid, pane focus ring, selection + bar, accent `--accent` (crab orange-red) with color-mix + derivations, light/dark via light-dark() + persisted toggle, + phone breakpoint. Gate: styling. +- [x] **README + docs**: cbd-web/README.md (build, dev loop, config), + root README (web UI section, feature flag, build-web), update + architecture doc if the implementation deviates. Gate: build. +- [x] **Verification**: full workspace tests + clippy (native and + wasm) + fmt + markdownlint; live smoke test: server with bundle + → browser fetch of `/`, gRPC-web call, TUI gRPC call, auth + denial over gRPC-web. Tick `quality/web-client.md`; write + `plan/summary.md` section; commit. diff --git a/quality/web-client.md b/quality/web-client.md new file mode 100644 index 0000000..2fbc5de --- /dev/null +++ b/quality/web-client.md @@ -0,0 +1,73 @@ +# Quality gates — web client + +LLM-verified gates for `architecture/web-client.md`. Automatic tests: +`cbd-web/src/{state,keymap}.rs` (native target), +`crabidy-server/src/web.rs`, plus the existing auth-layer suite. + +## Parity + +- [x] Every TUI binding has a web equivalent (keyboard *and* + clickable): browse/ascend/dive, marks, `%`/`e`/`d`, `w`/`W`, + queue replace/append/queue-next/insert-here, queue select/play/ + remove/clear(s)/save, play/pause/restart/next/prev, volume, + mute, shuffle, repeat, help overlay. +- [x] Semantics ported, not approximated: tracks before children, + empty non-creatable nodes not entered, marks win over cursor, + per-path cursor memory, capture-progress lines identical, skipped + tracks red and flagged, capture deletes ask y/N, cheap deletes do + not, mutable roots never cached (`state::is_cacheable` mirrors + the TUI rule). +- [x] The update stream feeds queue/play-state/volume/mute/mods/ + position/capture progress; a broken stream reconnects with + backoff and shows a disconnected banner until then. + +## Security + +- [x] The auth layer wraps the gRPC route in both transports: a + gRPC-web call without credentials is `UNAUTHENTICATED`, with + insufficient role `PERMISSION_DENIED` — verified against a live + server. +- [x] Static assets are served without auth (public shell), and only + via GET/HEAD; nothing under `/crabidy.v1.CrabidyService/` is + served statically. +- [x] Credentials live in `localStorage` only; never in URLs, never + logged to the console; the login form is the only place that + reads them back. +- [x] `UNAUTHENTICATED` responses open the login dialog instead of a + silent failure loop. + +## Build & packaging + +- [x] `cargo build`/`test` (no wasm toolchain) succeeds with the + default `web-ui` feature: missing `cbd-web/dist` embeds the + placeholder page with the build hint, a present dist embeds the + real bundle on the next build (`rerun-if-changed`). +- [x] `--no-default-features` yields a gRPC-only server (no embedded + assets, no tonic-web), and it still compiles and passes tests. +- [x] `devenv shell -- build-web` produces `cbd-web/dist` (RUSTFLAGS + cleared: mold breaks rust-lld); `cargo check -p cbd-web --target + wasm32-unknown-unknown` and native `cargo test -p cbd-web` both + pass. +- [x] The TUI's native gRPC still works through the axum server (h2c + prior knowledge) — verified live alongside gRPC-web. + +## Styling + +- [x] Pure CSS, no framework, no external requests (fonts, CDNs); + everything ships in the bundle. +- [x] Light and dark themes: `color-scheme` + `light-dark()` following + the OS by default, manual toggle persisted; both themes keep + readable contrast for dim text, accent, and the red + skipped/danger tones. +- [x] The crab orange-red accent is a single custom property + (`--accent`), derived tones via `color-mix` — no hard-coded + copies. +- [x] Usable on a phone viewport (panes stack/switch) and desktop. + +## Code shape + +- [x] Components stay thin; logic lives in `state.rs`/`keymap.rs` with + native unit tests. +- [x] No panics on server errors: every RPC result is handled (status + surfaces in the UI or the console at worst); stream reconnect + never busy-loops.