193 lines
8.3 KiB
Markdown
193 lines
8.3 KiB
Markdown
# 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 (proactive + forced), 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.
|