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) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-21 22:43:06 +02:00
parent 4e59e50943
commit 435af91d9c
28 changed files with 4864 additions and 15 deletions

926
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ members = [
"audio-player", "audio-player",
"cbd", "cbd",
"cbd-tui", "cbd-tui",
"cbd-web",
"crabidy-core", "crabidy-core",
"crabidy-server", "crabidy-server",
"fsdy", "fsdy",
@ -22,13 +23,18 @@ async-trait = "0.1"
base64 = "0.22" base64 = "0.22"
bytes = "1" bytes = "1"
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
axum = "0.8"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
clap-serde-derive = "0.2" clap-serde-derive = "0.2"
console_error_panic_hook = "0.1"
crossterm = "0.29" crossterm = "0.29"
dirs = "6" dirs = "6"
flume = "0.12" flume = "0.12"
futures = "0.3" futures = "0.3"
gloo-timers = { version = "0.3", features = ["futures"] }
http = "1" http = "1"
include_dir = "0.7"
leptos = { version = "0.8", default-features = false, features = ["csr"] }
notify-rust = "4" notify-rust = "4"
percent-encoding = "2" percent-encoding = "2"
prost = "0.14" prost = "0.14"
@ -64,10 +70,18 @@ thiserror = "2"
tokio = "1" tokio = "1"
tokio-stream = "0.1" tokio-stream = "0.1"
toml = "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 = "0.14"
tonic-prost-build = "0.14" tonic-prost-build = "0.14"
tonic-web = "0.14"
tonic-web-wasm-client = "0.9"
tower = "0.5" tower = "0.5"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = "0.3"
tracing = "0.1" tracing = "0.1"
tracing-appender = "0.2" tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@ -18,12 +18,17 @@ each mounted as a subtree of one library:
## Binaries ## Binaries
- `crabidy-server` — the server: providers, queue, playback, gRPC on - `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 - `cbd-tui` — the terminal client. Press `?` inside for all key
bindings. bindings.
- `cbd` — both in one process: starts the server, waits until it - `cbd` — both in one process: starts the server, waits until it
accepts connections, then runs the TUI. Adopts an already-running accepts connections, then runs the TUI. Adopts an already-running
server instead of failing on an occupied port. 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 ## Quick start
@ -125,6 +130,30 @@ renames, `d` deletes.
Press `?` for the full binding table. 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://<server>: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 ## Logs
`cbd` and `cbd-tui` log to `~/.local/state/crabidy/` (daily files); `cbd` and `cbd-tui` log to `~/.local/state/crabidy/` (daily files);

192
architecture/web-client.md Normal file
View File

@ -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.

View File

@ -14,7 +14,7 @@ ratatui.workspace = true
serde.workspace = true serde.workspace = true
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }
tokio-stream.workspace = true tokio-stream.workspace = true
tonic.workspace = true tonic = { workspace = true, features = ["channel", "codegen"] }
tracing.workspace = true tracing.workspace = true
tracing-appender.workspace = true tracing-appender.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true

1
cbd-web/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/dist

30
cbd-web/Cargo.toml Normal file
View File

@ -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",
] }

86
cbd-web/README.md Normal file
View File

@ -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`).

14
cbd-web/Trunk.toml Normal file
View File

@ -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"

11
cbd-web/index.html Normal file
View File

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>crabidy</title>
<link data-trunk rel="css" href="style.css" />
</head>
<body></body>
</html>

1165
cbd-web/src/app.rs Normal file

File diff suppressed because it is too large Load Diff

391
cbd-web/src/keymap.rs Normal file
View File

@ -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<Action> {
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"));
}
}

35
cbd-web/src/main.rs Normal file
View File

@ -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"
);
}

302
cbd-web/src/rpc.rs Normal file
View File

@ -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<MetadataValue<tonic::metadata::Ascii>>,
}
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<Self> {
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<Request<()>, 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<InterceptedService<WasmClient, AuthInterceptor>>;
/// 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<Self> {
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<Streaming<GetUpdateStreamResponse>, Status> {
let response = self
.client
.get_update_stream(Request::new(GetUpdateStreamRequest {}))
.await?;
Ok(response.into_inner())
}
pub async fn init(&mut self) -> Result<crabidy_core::proto::crabidy::InitResponse, Status> {
Ok(self
.client
.init(Request::new(InitRequest {}))
.await?
.into_inner())
}
pub async fn get_library_node(&mut self, path: &str) -> Result<Option<LibraryNode>, 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<Option<LibraryNode>, 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<Option<LibraryNode>, 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<Option<LibraryNode>, 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<String>) -> Result<(), Status> {
let _ = self
.client
.replace(Request::new(ReplaceRequest { paths }))
.await?;
Ok(())
}
pub async fn append_tracks(&mut self, paths: Vec<String>) -> Result<(), Status> {
let _ = self
.client
.append(Request::new(AppendRequest { paths }))
.await?;
Ok(())
}
pub async fn queue_tracks(&mut self, paths: Vec<String>) -> Result<(), Status> {
let _ = self
.client
.queue(Request::new(QueueRequest { paths }))
.await?;
Ok(())
}
pub async fn insert_tracks(&mut self, position: u32, paths: Vec<String>) -> 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<u32>) -> 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(())
}
}

517
cbd-web/src/state.rs Normal file
View File

@ -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<String>,
pub is_creatable: bool,
pub items: Vec<UiItem>,
pub selected: usize,
positions: HashMap<String, usize>,
}
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<Vec<String>> {
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<f64>,
}
/// Live capture progress lines, keyed by capture name.
#[derive(Default)]
pub struct CaptureBoard {
entries: Vec<CaptureEntry>,
}
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");
}
}

463
cbd-web/style.css Normal file
View File

@ -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
<html>, 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;
}
}
}

View File

@ -5,17 +5,25 @@ edition.workspace = true
[dependencies] [dependencies]
async-trait.workspace = true async-trait.workspace = true
clap-serde-derive.workspace = true
dirs.workspace = true
flume.workspace = true flume.workspace = true
percent-encoding.workspace = true percent-encoding.workspace = true
prost.workspace = true prost.workspace = true
serde.workspace = true serde.workspace = true
toml.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 tracing.workspace = true
tonic-prost.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] [dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt"] } tokio = { workspace = true, features = ["macros", "rt"] }

View File

@ -1,4 +1,10 @@
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
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(()) Ok(())
} }

View File

@ -1,3 +1,4 @@
#[cfg(not(target_arch = "wasm32"))]
use std::{ use std::{
fs::{create_dir_all, read_to_string, File}, fs::{create_dir_all, read_to_string, File},
io::Write, io::Write,
@ -5,6 +6,7 @@ use std::{
}; };
use async_trait::async_trait; use async_trait::async_trait;
#[cfg(not(target_arch = "wasm32"))]
pub use clap_serde_derive::{self, clap, serde, ClapSerde}; pub use clap_serde_derive::{self, clap, serde, ClapSerde};
use proto::crabidy::{LibraryNode, LibraryNodeChild, Track}; use proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
@ -237,6 +239,7 @@ pub enum QueueError {
NotQueable, NotQueable,
} }
#[cfg(not(target_arch = "wasm32"))]
pub fn init_config<T>(config_file_name: &str) -> T pub fn init_config<T>(config_file_name: &str) -> T
where where
T: Default + ClapSerde + serde::Serialize + std::fmt::Debug, T: Default + ClapSerde + serde::Serialize + std::fmt::Debug,

View File

@ -7,13 +7,22 @@ edition.workspace = true
name = "crabidy-server" name = "crabidy-server"
path = "src/main.rs" 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] [dependencies]
anyhow.workspace = true anyhow.workspace = true
argon2.workspace = true argon2.workspace = true
async-trait.workspace = true async-trait.workspace = true
axum.workspace = true
base64.workspace = true base64.workspace = true
clap.workspace = true clap.workspace = true
http.workspace = true http.workspace = true
include_dir = { workspace = true, optional = true }
tonic-web = { workspace = true, optional = true }
tower.workspace = true tower.workspace = true
audio-player.workspace = true audio-player.workspace = true
crabidy-core.workspace = true crabidy-core.workspace = true
@ -29,11 +38,15 @@ tidaldy.workspace = true
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }
toml.workspace = true toml.workspace = true
tokio-stream = { workspace = true, features = ["sync"] } tokio-stream = { workspace = true, features = ["sync"] }
tonic.workspace = true tonic = { workspace = true, features = ["router", "transport", "codegen"] }
tracing.workspace = true tracing.workspace = true
tracing-appender.workspace = true tracing-appender.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
ytdy.workspace = true ytdy.workspace = true
[dev-dependencies] [dev-dependencies]
argon2.workspace = true
base64.workspace = true
http.workspace = true
tempfile.workspace = true tempfile.workspace = true
tower.workspace = true

59
crabidy-server/build.rs Normal file
View File

@ -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"),
"<!doctype html><meta charset=\"utf-8\"><title>crabidy</title>\
<body style=\"font:16px system-ui;padding:2rem\">\
<h1>crabidy web UI not built</h1>\
<p>This server binary was compiled without the web bundle. \
Build it with <code>devenv shell -- build-web</code> and \
rebuild the server.</p>",
)
.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");
}
}
}

View File

@ -1,5 +1,8 @@
pub mod auth; pub mod auth;
pub mod bookmark_store; pub mod bookmark_store;
#[cfg(feature = "web-ui")]
pub mod web;
pub mod capture; pub mod capture;
pub mod capture_store; pub mod capture_store;
pub mod playback; pub mod playback;
@ -98,16 +101,45 @@ pub async fn serve(
playback.run(); playback.run();
info!("playback started"); info!("playback started");
let router = build_router(crabidy_service, authenticator);
info!(%addr, "grpc server listening"); info!(%addr, "grpc server listening");
tonic::transport::Server::builder() let listener = tokio::net::TcpListener::bind(addr).await?;
.layer(auth::AuthLayer::new(authenticator)) axum::serve(listener, router).await?;
.add_service(CrabidyServiceServer::new(crabidy_service))
.serve(addr)
.await?;
Ok(()) 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<auth::Authenticator>,
) -> 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}}",
<CrabidyServiceServer<rpc::RpcService> 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. /// Forwards player engine events into the playback message loop.
#[instrument(skip(rx, tx))] #[instrument(skip(rx, tx))]
fn poll_play_bus(rx: flume::Receiver<PlayerMessage>, tx: flume::Sender<PlaybackMessage>) { fn poll_play_bus(rx: flume::Receiver<PlayerMessage>, tx: flume::Sender<PlaybackMessage>) {

133
crabidy-server/src/web.rs Normal file
View File

@ -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<Body>) -> 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<Body> {
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");
}
}

View File

@ -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();
}

View File

@ -14,15 +14,26 @@ let
d2 d2
pkg-config pkg-config
protobuf protobuf
cargo-cross
# Stream-URL sidecar for the ytdy provider: YouTube caps tokenless # Stream-URL sidecar for the ytdy provider: YouTube caps tokenless
# stream URLs at ~1 MiB and yt-dlp is the only maintained cipher # stream URLs at ~1 MiB and yt-dlp is the only maintained cipher
# solver (architecture/youtube-rustypipe.md, D2-revised). # solver (architecture/youtube-rustypipe.md, D2-revised).
yt-dlp 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 in
{ {
imports = [ ./devenv-rust.nix ]; 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 = { env = {
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath commonLibs; LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath commonLibs;
}; };
@ -41,6 +52,19 @@ in
echo Welcome to rust devenv 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 = ""; enterShell = "";
# https://devenv.sh/tasks/ # https://devenv.sh/tasks/

View File

@ -632,3 +632,73 @@ with PHC password hashes in the new `crabidy-server.toml`.
the fact and all hold. the fact and all hold.
- Denied-action UX in the TUI stays a logged no-op, as recorded in the - Denied-action UX in the TUI stays a logged no-op, as recorded in the
architecture's open questions. 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.

60
plan/web-client.md Normal file
View File

@ -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.

73
quality/web-client.md Normal file
View File

@ -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.