Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 32 additions & 10 deletions src-tauri/crates/core/src/plugin/host_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,18 +332,17 @@ fn sum_state_dir_bytes(

// ----- waveflow:host/http -------------------------------------------------

/// Render an error *with its cause chain* for a guest to surface.
/// Formats an error and its cause chain for display to a guest.
///
/// `reqwest::Error`'s `Display` stops at the outermost layer — a
/// failed request reads only "error sending request for url (…)",
/// which says the transport failed but never says why. The reason
/// (DNS lookup, connection refused, timeout, TLS handshake) lives in
/// `source()`, and the frontend shows this string verbatim, so
/// without unwinding it a bug report arrives with no way to tell
/// those cases apart. That is exactly what happened in issue #452.
/// Repeated adjacent cause messages are omitted, and excessively deep cause
/// chains are truncated with an ellipsis.
///
/// The chain is depth-capped: `source()` is author-controlled and
/// nothing forbids a cyclic or absurdly deep implementation.
/// # Examples
///
/// ```
/// let error = std::io::Error::other("connection refused");
/// assert_eq!(describe_error(&error), "connection refused");
/// ```
fn describe_error(err: &dyn std::error::Error) -> String {
const MAX_DEPTH: usize = 8;
let mut out = err.to_string();
Expand Down Expand Up @@ -377,6 +376,18 @@ fn describe_error(err: &dyn std::error::Error) -> String {
}

impl wit_host::http::Host for HostCtx {
/// Sends an HTTP request subject to offline-mode and URL permission checks.
///
/// Offline mode produces an empty `503` response. Denied URLs, invalid methods,
/// transport failures, read failures, and oversized response bodies are returned
/// as error strings.
///
/// # Examples
///
/// ```rust,ignore
/// let response = host.send(request)?;
/// assert_eq!(response.status, 200);
/// ```
fn send(
&mut self,
req: wit_host::http::Request,
Expand Down Expand Up @@ -849,6 +860,17 @@ mod tests {
}

impl std::error::Error for Chained {
/// Provides the wrapped error as the cause, when one is present.
///
/// # Examples
///
/// ```
/// use std::error::Error;
///
/// let cause = std::io::Error::other("cause");
/// let wrapped: Box<dyn Error> = Box::new(cause);
/// assert_eq!(wrapped.to_string(), "cause");
/// ```
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source
.as_deref()
Expand Down
102 changes: 72 additions & 30 deletions src-tauri/plugins/web-radio/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,19 @@ impl Guest for WebRadio {
])
}

/// Translate an entry token (or a free-form search string from
/// the host's search box) into a list of tracks. Limited to
/// [`PAGE_LIMIT`] per call — the UI paginates from there.
/// Resolves an entry token or free-form search term into playable radio tracks.
///
/// # Errors
///
/// Returns an error if the query is invalid, the radio-browser request fails, or
/// the response cannot be parsed.
///
/// # Examples
///
/// ```no_run
/// let tracks = WebRadio::resolve("top".to_owned())?;
/// # Ok::<(), String>(())
/// ```
fn resolve(query: String) -> Result<Vec<Track>, String> {
log::emit(Level::Debug, &format!("web-radio resolve: {query}"));
let path = build_path(&query)?;
Expand All @@ -122,6 +132,15 @@ impl Guest for WebRadio {

bindings::export!(WebRadio with_types_in bindings);

/// Creates an entry with the given display label and query token.
///
/// # Examples
///
/// ```
/// let item = entry("Top stations", "top");
/// assert_eq!(item.label, "Top stations");
/// assert_eq!(item.query, "top");
/// ```
fn entry(label: &str, query: &str) -> Entry {
Entry {
label: label.to_string(),
Expand All @@ -130,18 +149,19 @@ fn entry(label: &str, query: &str) -> Entry {
}
}

/// Decode the entry / search token into a **host-relative**
/// radio-browser API path — [`fetch_json`] prefixes whichever host in
/// [`HOSTS`] answers, so the path must not carry one.
/// Tokens we understand:
/// Converts a category or search token into a host-relative radio-browser API path.
///
/// Empty queries and malformed country tokens return an error. Unprefixed non-empty
/// tokens are treated as station-name searches.
///
/// # Examples
///
/// - `top` → top-voted stations
/// - `trending` → most-recently-updated stations
/// - `tag:<name>` → stations tagged `<name>`, ordered by votes
/// - `search:<term>` → explicit name search prefix
/// - anything else non-empty → treated as a free-text search term
/// (the host's search box hands its raw input here and expects
/// matches against station names)
/// ```
/// assert_eq!(
/// build_path("top").unwrap(),
/// "/json/stations/topvote/50?hidebroken=true"
/// );
/// ```
fn build_path(query: &str) -> Result<String, String> {
let trimmed = query.trim();
if trimmed.is_empty() {
Expand Down Expand Up @@ -197,25 +217,47 @@ fn build_path(query: &str) -> Result<String, String> {
))
}

/// Issue a GET for `path` against each host in [`HOSTS`] until one
/// answers, and surface the body bytes.
/// Fetches JSON response bytes from the first available radio-browser host.

///

/// Client errors other than HTTP 429 are returned immediately. Transport

/// failures, HTTP 429 responses, and other non-success responses are retried

/// against subsequent hosts.

///

/// # Examples

///

/// ```no_run

/// let body = fetch_json("/json/stations/topvote/50?hidebroken=true")?;

/// # Ok::<(), String>(())

/// ```

///

/// # Errors

///
/// The host enforces the manifest's HTTP allowlist + the 10 MB
/// response cap + the offline short-circuit, so failure modes here
/// are limited to network errors and non-2xx HTTP statuses.

/// Returns an error when the request receives a non-retriable client status

/// or when every host fails.

///
/// **What is and isn't retried.** A transport failure or a 5xx means
/// "this node couldn't serve it" — worth asking the next one. A 4xx
/// is the API rejecting the request itself (bad tag, malformed
/// query); every node would answer the same way, so it returns
/// immediately rather than burning a second round-trip to be told
/// the same thing twice.

/// # Arguments

///
/// Offline mode surfaces as the host's `503` sentinel. It is left in
/// the retry path deliberately: it costs one extra no-op call (the
/// host short-circuits before touching the network), and special-
/// casing the value here would couple the guest to a host
/// implementation detail it has no contract for.

/// * `path` - Host-relative API path to request.
fn fetch_json(path: &str) -> Result<Vec<u8>, String> {
let mut last_err = String::from("no radio-browser host reachable");
for host in HOSTS {
Expand Down
Loading