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:
parent
4e59e50943
commit
435af91d9c
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
16
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"] }
|
||||
|
|
|
|||
31
README.md
31
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://<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
|
||||
|
||||
`cbd` and `cbd-tui` log to `~/.local/state/crabidy/` (daily files);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
/dist
|
||||
|
|
@ -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",
|
||||
] }
|
||||
|
|
@ -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`).
|
||||
|
|
@ -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"
|
||||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"] }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T>(config_file_name: &str) -> T
|
||||
where
|
||||
T: Default + ClapSerde + serde::Serialize + std::fmt::Debug,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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.
|
||||
#[instrument(skip(rx, tx))]
|
||||
fn poll_play_bus(rx: flume::Receiver<PlayerMessage>, tx: flume::Sender<PlaybackMessage>) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
24
devenv.nix
24
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/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue