Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add download_artifact command + export helper
Daniel Norman committed 7 days ago
commit 6896359a3c4125272a2bc676e637009f081e1aef
parent 2545f4ee5ec5c88577b6432063289f1821f3a892
4 files changed +164 -1
modified crates/radicle-tauri/src/commands/cob/release.rs
@@ -199,6 +199,77 @@ pub async fn is_seeding(iroh: tauri::State<'_, IrohState>, cid: String) -> Resul
    seeder::is_seeded_str(&iroh.blobs, &cid).await
}

+
// Download ------------------------------------------------------------------
+

+
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.
+
#[tauri::command]
+
pub async fn download_artifact(
+
    app: tauri::AppHandle,
+
    ctx: tauri::State<'_, AppState>,
+
    iroh: tauri::State<'_, IrohState>,
+
    rid: RepoId,
+
    release_id: String,
+
    cid: String,
+
    dest: PathBuf,
+
) -> Result<(), Error> {
+
    // Pull the (did, urls) snapshot synchronously so we can hold no COB
+
    // locks during the network call.
+
    let cid_for_locations = cid.clone();
+
    let release_id_for_locations = release_id.clone();
+
    let ctx_clone = ctx.inner().clone();
+
    let locations = tauri::async_runtime::spawn_blocking(move || {
+
        ctx_clone.artifact_locations(rid, release_id_for_locations, cid_for_locations)
+
    })
+
    .await
+
    .map_err(|e| Error::Iroh(format!("join: {e}")))??;
+

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

+
    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(
+
        &iroh.blobs,
+
        iroh.iroh_router.endpoint(),
+
        &parsed_cid,
+
        &locations,
+
        |stage| {
+
            let payload = match stage {
+
                fetch::FetchStage::Connecting => {
+
                    serde_json::json!({ "cid": cid_for_progress, "stage": "connecting" })
+
                }
+
                fetch::FetchStage::Downloading { bytes } => serde_json::json!({
+
                    "cid": cid_for_progress,
+
                    "stage": "downloading",
+
                    "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(())
+
}
+

// Settings ------------------------------------------------------------------

#[tauri::command]
modified crates/radicle-tauri/src/lib.rs
@@ -57,6 +57,7 @@ pub fn run() {
            cob::release::seed_artifact,
            cob::release::unseed_artifact,
            cob::release::is_seeding,
+
            cob::release::download_artifact,
            cob::release::get_auto_seed_artifacts,
            cob::release::set_auto_seed_artifacts,
            cob::save_embed_by_bytes,
modified crates/radicle-types/src/fetch.rs
@@ -1,4 +1,5 @@
use std::collections::{BTreeMap, BTreeSet};
+
use std::path::Path;
use std::str::FromStr;

use cid::Cid;
@@ -6,7 +7,8 @@ use futures_lite::StreamExt;
use iroh::Endpoint;
use iroh_blobs::api::downloader::{DownloadProgressItem, Downloader};
use iroh_blobs::api::Store;
-
use iroh_blobs::{BlobFormat, HashAndFormat};
+
use iroh_blobs::format::collection::Collection;
+
use iroh_blobs::{BlobFormat, Hash, HashAndFormat};
use radicle::identity::Did;
use radicle_artifact::share::{cid_utils, keys};
use url::Url;
@@ -72,6 +74,65 @@ where
    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).
+
pub fn cid_to_hash(cid: &Cid) -> Result<(Hash, cid_utils::ArtifactKind), Error> {
+
    let kind = cid_utils::artifact_kind(cid).map_err(|e| Error::Iroh(format!("cid: {e}")))?;
+
    let hash = cid_utils::cid_to_blake3_hash(cid).map_err(|e| Error::Iroh(format!("cid: {e}")))?;
+
    Ok((hash, kind))
+
}
+

+
/// String-taking variant for callers (Tauri commands) that don't pull in
+
/// the `cid` crate directly.
+
pub fn cid_to_hash_str(cid: &str) -> Result<(Cid, Hash, cid_utils::ArtifactKind), Error> {
+
    let parsed = Cid::from_str(cid).map_err(|e| Error::Iroh(format!("invalid cid: {e}")))?;
+
    let (hash, kind) = cid_to_hash(&parsed)?;
+
    Ok((parsed, hash, kind))
+
}
+

+
/// Write a previously-fetched artifact from the store to a user-chosen
+
/// destination on disk. Blobs land at `dest`; collections create `dest`
+
/// as a directory and write each entry under its declared name.
+
pub async fn export(
+
    store: &Store,
+
    hash: Hash,
+
    kind: cid_utils::ArtifactKind,
+
    dest: &Path,
+
) -> Result<(), Error> {
+
    let blobs = store.blobs();
+
    match kind {
+
        cid_utils::ArtifactKind::Blob => {
+
            let bytes = blobs
+
                .get_bytes(hash)
+
                .await
+
                .map_err(|e| Error::Iroh(format!("read blob: {e}")))?;
+
            if let Some(parent) = dest.parent() {
+
                std::fs::create_dir_all(parent)?;
+
            }
+
            std::fs::write(dest, &bytes)?;
+
        }
+
        cid_utils::ArtifactKind::Collection => {
+
            let collection = Collection::load(hash, store)
+
                .await
+
                .map_err(|e| Error::Iroh(format!("load collection: {e}")))?;
+
            std::fs::create_dir_all(dest)?;
+
            for (name, file_hash) in collection.iter() {
+
                let target = dest.join(name);
+
                if let Some(parent) = target.parent() {
+
                    std::fs::create_dir_all(parent)?;
+
                }
+
                let bytes = blobs
+
                    .get_bytes(*file_hash)
+
                    .await
+
                    .map_err(|e| Error::Iroh(format!("read entry {name}: {e}")))?;
+
                std::fs::write(&target, &bytes)?;
+
            }
+
        }
+
    }
+
    Ok(())
+
}
+

/// Walk every (did, url) pair on an artifact and recover an iroh provider
/// public key. Two URL conventions are supported:
///
modified crates/radicle-types/src/traits/release.rs
@@ -1,3 +1,4 @@
+
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::str::FromStr;

@@ -88,4 +89,33 @@ pub trait Releases: Profile {
        };
        Ok(cid.to_string())
    }
+

+
    /// Snapshot the COB locations for a single artifact in a release. Used
+
    /// by the download command to build a provider list before running
+
    /// the iroh-blobs Downloader; cloned out so the COB lock is released
+
    /// before the (long) async fetch begins.
+
    fn artifact_locations(
+
        &self,
+
        rid: identity::RepoId,
+
        release_id: String,
+
        cid: String,
+
    ) -> Result<BTreeMap<identity::Did, BTreeSet<url::Url>>, Error> {
+
        let profile = self.profile();
+
        let repo = profile.storage.repository(rid)?;
+

+
        let id = radicle_artifact::ReleaseId::from_str(&release_id)
+
            .map_err(|e| Error::Iroh(format!("invalid release id: {e}")))?;
+
        let parsed_cid =
+
            cid::Cid::from_str(&cid).map_err(|e| Error::Iroh(format!("invalid cid: {e}")))?;
+

+
        let releases = ReleasesStore::open(&repo).map_err(|e| Error::Iroh(e.to_string()))?;
+
        let release = releases
+
            .get(&id)
+
            .map_err(|e| Error::Iroh(e.to_string()))?
+
            .ok_or_else(|| Error::Iroh(format!("release {release_id} not found")))?;
+
        let artifact = release
+
            .artifact(&parsed_cid)
+
            .ok_or_else(|| Error::Iroh(format!("artifact {cid} not in release {release_id}")))?;
+
        Ok(artifact.locations().clone())
+
    }
}