Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Try HTTP before iroh for blob downloads with web URLs
Daniel Norman committed 7 days ago
commit 6e84f2091d7ee66f546c5956bc9ebf48377298d5
parent b25722e9b0d10217ac668b1ee79c0a36406004f7
5 files changed +168 -25
modified Cargo.lock
@@ -6347,6 +6347,7 @@ dependencies = [
 "tree-sitter-toml-ng",
 "tree-sitter-typescript",
 "ts-rs",
+
 "ureq",
 "url",
]

modified crates/radicle-tauri/src/commands/cob/release.rs
@@ -237,9 +237,13 @@ pub async fn list_seeded_artifacts(
use radicle_types::fetch;
use tauri::Emitter;

-
/// Fetch an artifact from any of the iroh providers in its COB locations,
-
/// then export the verified bytes to `dest`. Streams `artifact_progress`
-
/// events keyed by `cid` so the UI can render a progress bar per row.
+
/// Fetch an artifact from its COB locations and write it to `dest`. For
+
/// blobs that have HTTP/HTTPS locations, HTTP is tried first because it
+
/// is fast and typically succeeds without NAT traversal; iroh is used
+
/// when HTTP fails or no HTTP URLs are listed. Collections always go
+
/// through iroh (radicle-artifact does not implement HTTP collection
+
/// fetch). Streams `artifact_progress` events keyed by `cid` so the UI
+
/// can render a progress bar per row.
#[tauri::command]
pub async fn download_artifact(
    app: tauri::AppHandle,
@@ -262,13 +266,45 @@ pub async fn download_artifact(

    let (parsed_cid, hash, kind) = fetch::cid_to_hash_str(&cid)?;

+
    let emit = |stage: &str, bytes: Option<u64>| {
+
        let mut payload = serde_json::json!({ "cid": cid, "stage": stage });
+
        if let Some(b) = bytes {
+
            payload["bytes"] = serde_json::json!(b);
+
        }
+
        let _ = app.emit("artifact_progress", payload);
+
    };
+

+
    emit("connecting", None);
+

+
    let mut errors: Vec<String> = Vec::new();
+
    let http_urls = fetch::http_urls(&locations);
+
    let try_http_first = !http_urls.is_empty() && matches!(kind, fetch::ArtifactKind::Blob);
+

+
    if try_http_first {
+
        for url in &http_urls {
+
            let url_clone = url.clone();
+
            let cid_clone = parsed_cid;
+
            let dest_clone = dest.clone();
+
            match tauri::async_runtime::spawn_blocking(move || {
+
                fetch::fetch_http_blob(&url_clone, &cid_clone, &dest_clone)
+
            })
+
            .await?
+
            {
+
                Ok(()) => {
+
                    emit("done", None);
+
                    return Ok(());
+
                }
+
                Err(e) => errors.push(format!("{url}: {e}")),
+
            }
+
        }
+
    }
+

+
    // Either no HTTP URLs, or every HTTP attempt failed — fall through to
+
    // the iroh path, which loads bytes into the persistent store and then
+
    // exports them to the destination.
    let cid_for_progress = cid.clone();
    let app_for_progress = app.clone();
-
    let _ = app.emit(
-
        "artifact_progress",
-
        serde_json::json!({ "cid": cid, "stage": "connecting" }),
-
    );
-
    fetch::fetch_artifact(
+
    let iroh_result = fetch::fetch_artifact(
        &iroh.blobs,
        iroh.iroh_router.endpoint(),
        &parsed_cid,
@@ -284,22 +320,31 @@ pub async fn download_artifact(
                    "bytes": bytes,
                }),
            };
-
            // Window may have closed mid-download; ignore emit errors.
            let _ = app_for_progress.emit("artifact_progress", payload);
        },
    )
-
    .await?;
-

-
    let _ = app.emit(
-
        "artifact_progress",
-
        serde_json::json!({ "cid": cid, "stage": "writing" }),
-
    );
-
    fetch::export(&iroh.blobs, hash, kind, &dest).await?;
-
    let _ = app.emit(
-
        "artifact_progress",
-
        serde_json::json!({ "cid": cid, "stage": "done" }),
-
    );
-
    Ok(())
+
    .await;
+

+
    match iroh_result {
+
        Ok(()) => {
+
            emit("writing", None);
+
            fetch::export(&iroh.blobs, hash, kind, &dest).await?;
+
            emit("done", None);
+
            Ok(())
+
        }
+
        Err(e) => {
+
            errors.push(format!("iroh: {e}"));
+
            // If HTTP wasn't even an option and iroh failed, surface the
+
            // raw iroh error so the user sees something actionable.
+
            if errors.len() == 1 {
+
                return Err(e);
+
            }
+
            Err(Error::AllTransportsFailed {
+
                cid: cid.clone(),
+
                reasons: errors.join("; "),
+
            })
+
        }
+
    }
}

// File picker ---------------------------------------------------------------
modified crates/radicle-types/Cargo.toml
@@ -22,6 +22,7 @@ radicle-surf = { version = "0.27.1", features = ["serde"] }
iroh = { version = "1.0.0-rc.0" }
iroh-blobs = { version = "0.101", features = ["fs-store"] }
cid = { version = "0.11" }
+
ureq = { version = "3.3.0" }
futures-lite = { version = "2" }
serde = { version = "1.0.0", features = ["derive"] }
serde_json = { version = "1.0.0" }
modified crates/radicle-types/src/error.rs
@@ -204,6 +204,16 @@ pub enum Error {
    #[error("no iroh providers reachable for {cid}")]
    NoIrohProviders { cid: String },

+
    /// The artifact has no usable locations of any supported scheme.
+
    #[error("no locations available for {cid}")]
+
    NoLocations { cid: String },
+

+
    /// All transport attempts (iroh + HTTP) failed for the requested CID.
+
    /// The aggregated messages from each attempt are joined into one string
+
    /// for surfacing in the UI.
+
    #[error("all transports failed for {cid}: {reasons}")]
+
    AllTransportsFailed { cid: String, reasons: String },
+

    /// Release with the given id was not found in the COB store.
    #[error("release {release_id} not found")]
    ReleaseNotFound { release_id: String },
@@ -248,6 +258,8 @@ impl Error {
            Error::FileTooLarge(_) => "PayloadError.TooLarge",
            Error::DialogClosed => "DialogError.Closed",
            Error::NoIrohProviders { .. } => "ArtifactError.NoProviders",
+
            Error::NoLocations { .. } => "ArtifactError.NoLocations",
+
            Error::AllTransportsFailed { .. } => "ArtifactError.AllTransportsFailed",
            Error::ReleaseNotFound { .. } => "ReleaseError.NotFound",
            Error::ArtifactNotInRelease { .. } => "ReleaseError.ArtifactNotFound",
            Error::CidMismatch { .. } => "ArtifactError.CidMismatch",
modified crates/radicle-types/src/fetch.rs
@@ -15,6 +15,10 @@ use url::Url;

use crate::error::Error;

+
// Re-export so downstream crates (radicle-tauri) can branch on artifact
+
// kind without depending on radicle-artifact directly.
+
pub use radicle_artifact::share::cid_utils::ArtifactKind;
+

/// Progress stages reported during an in-flight fetch. The frontend uses
/// these to render per-CID download status.
pub enum FetchStage {
@@ -22,10 +26,21 @@ pub enum FetchStage {
    Downloading { bytes: u64 },
}

-
/// Fetch an artifact (blob or collection) from any of the iroh providers
-
/// reachable through `locations`. Streams `on_progress` events as the
-
/// download progresses. Bytes land in `store` and are verified against the
-
/// CID via iroh-blobs' built-in hash check.
+
/// Outcome of an artifact fetch. `IrohStore` means the bytes are now in
+
/// the iroh blob store and the caller needs to `export` them to disk;
+
/// `DiskDirect` means the bytes were written directly to the user's
+
/// destination (HTTP path) and no export step is required.
+
pub enum FetchOutcome {
+
    IrohStore,
+
    DiskDirect,
+
}
+

+
/// Fetch an artifact's bytes into the iroh blob store from any of the
+
/// iroh providers reachable through `locations`. Streams `on_progress`
+
/// events as the download progresses. Bytes are verified against the CID
+
/// via iroh-blobs' built-in hash check. Returns `Err(NoIrohProviders)`
+
/// when none of the locations are iroh URLs — callers handle HTTP
+
/// fallback via [`fetch_http_blob`].
pub async fn fetch_artifact<F>(
    store: &Store,
    endpoint: &Endpoint,
@@ -73,6 +88,75 @@ where
    Ok(())
}

+
/// Extract every HTTP/HTTPS URL from `locations`, preserving insertion
+
/// order so the caller can try them in a deterministic sequence.
+
pub fn http_urls(locations: &BTreeMap<Did, BTreeSet<Url>>) -> Vec<Url> {
+
    let mut out = Vec::new();
+
    for urls in locations.values() {
+
        for url in urls {
+
            if matches!(url.scheme(), "http" | "https") {
+
                out.push(url.clone());
+
            }
+
        }
+
    }
+
    out
+
}
+

+
/// Download a blob artifact via HTTP directly to disk. Writes to a
+
/// `.partial` sibling first, verifies the CID against the file contents,
+
/// and only then renames into place — matching the safety pattern in
+
/// `radicle_artifact::share::fetch::download`. Run on the blocking pool;
+
/// `ureq` is synchronous.
+
pub fn fetch_http_blob(url: &Url, expected_cid: &Cid, dest: &Path) -> Result<(), Error> {
+
    use std::io::{BufWriter, Read, Write};
+

+
    let agent = ureq::Agent::new_with_config(
+
        ureq::Agent::config_builder()
+
            .timeout_connect(Some(std::time::Duration::from_secs(10)))
+
            .build(),
+
    );
+
    let mut response = agent
+
        .get(url.as_str())
+
        .call()
+
        .map_err(|e| Error::Iroh(format!("http get {url}: {e}")))?;
+

+
    let partial = dest.with_extension("partial");
+
    if let Some(parent) = partial.parent() {
+
        std::fs::create_dir_all(parent)?;
+
    }
+
    {
+
        let file = std::fs::File::create(&partial)?;
+
        let mut writer = BufWriter::new(file);
+
        let mut buf = [0u8; 64 * 1024];
+
        let mut body = response.body_mut().as_reader();
+
        loop {
+
            let n = body.read(&mut buf).map_err(|e| {
+
                std::fs::remove_file(&partial).ok();
+
                Error::Iroh(format!("http read {url}: {e}"))
+
            })?;
+
            if n == 0 {
+
                break;
+
            }
+
            writer.write_all(&buf[..n]).map_err(|e| {
+
                std::fs::remove_file(&partial).ok();
+
                Error::Io(e)
+
            })?;
+
        }
+
        writer.flush().map_err(|e| {
+
            std::fs::remove_file(&partial).ok();
+
            Error::Io(e)
+
        })?;
+
    }
+

+
    if let Err(e) = cid_utils::verify_cid_file(&partial, expected_cid) {
+
        std::fs::remove_file(&partial).ok();
+
        return Err(Error::Iroh(format!("cid verification: {e}")));
+
    }
+

+
    std::fs::rename(&partial, dest)?;
+
    Ok(())
+
}
+

/// Resolve the iroh-blobs `Hash` and content kind for a CID. Useful when
/// the caller needs both the format (for fetch / export) and the kind
/// (for branching blob vs collection).