Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Distinguish "shared from here" from "shared from another device"
Daniel Norman committed 7 days ago
commit 1922e6e53461d405cc1ed9520a38168a9a860cc1
parent a3f03afe9cafc599f556cfae203553fa923e551b
5 files changed +107 -27
modified crates/radicle-tauri/src/commands/cob/release.rs
@@ -14,30 +14,54 @@ use crate::AppState;
#[tauri::command]
pub async fn list_releases(
    ctx: tauri::State<'_, AppState>,
+
    iroh: tauri::State<'_, IrohState>,
    rid: RepoId,
) -> Result<Vec<release::Release>, Error> {
+
    let our_did = radicle::identity::Did::from(ctx.profile.public_key);
+
    let endpoint_id = iroh.iroh_router.endpoint().id().to_string();
    let ctx = ctx.inner().clone();
-
    tauri::async_runtime::spawn_blocking(move || ctx.list_releases(rid)).await?
+
    let mut releases =
+
        tauri::async_runtime::spawn_blocking(move || ctx.list_releases(rid)).await??;
+
    for release in &mut releases {
+
        release::set_endpoint_flags(release, &our_did, &endpoint_id);
+
    }
+
    Ok(releases)
}

#[tauri::command]
pub async fn release_by_id(
    ctx: tauri::State<'_, AppState>,
+
    iroh: tauri::State<'_, IrohState>,
    rid: RepoId,
    release_id: String,
) -> Result<Option<release::Release>, Error> {
+
    let our_did = radicle::identity::Did::from(ctx.profile.public_key);
+
    let endpoint_id = iroh.iroh_router.endpoint().id().to_string();
    let ctx = ctx.inner().clone();
-
    tauri::async_runtime::spawn_blocking(move || ctx.release_by_id(rid, release_id)).await?
+
    let mut release =
+
        tauri::async_runtime::spawn_blocking(move || ctx.release_by_id(rid, release_id)).await??;
+
    if let Some(r) = release.as_mut() {
+
        release::set_endpoint_flags(r, &our_did, &endpoint_id);
+
    }
+
    Ok(release)
}

#[tauri::command]
pub async fn releases_by_commit(
    ctx: tauri::State<'_, AppState>,
+
    iroh: tauri::State<'_, IrohState>,
    rid: RepoId,
    sha: git::Oid,
) -> Result<Vec<release::Release>, Error> {
+
    let our_did = radicle::identity::Did::from(ctx.profile.public_key);
+
    let endpoint_id = iroh.iroh_router.endpoint().id().to_string();
    let ctx = ctx.inner().clone();
-
    tauri::async_runtime::spawn_blocking(move || ctx.releases_by_commit(rid, sha)).await?
+
    let mut releases =
+
        tauri::async_runtime::spawn_blocking(move || ctx.releases_by_commit(rid, sha)).await??;
+
    for release in &mut releases {
+
        release::set_endpoint_flags(release, &our_did, &endpoint_id);
+
    }
+
    Ok(releases)
}

/// CID computation can be expensive on large files; off-load to the
modified crates/radicle-types/bindings/cob/release/Artifact.ts
@@ -13,11 +13,18 @@ export type Artifact = {
  attestations: Array<Author>;
  redactions: Array<Redaction>;
  /**
-
   * True when at least one of the location URLs we (the local DID) wrote
-
   * to this artifact uses the `iroh://` scheme — i.e. we are advertising
-
   * ourselves as an iroh provider for it.
+
   * True when this device's iroh endpoint id appears as the host of
+
   * one of the location URLs we (the local DID) wrote — i.e. we are
+
   * actively advertising the artifact from the running process.
   */
-
  sharedByUs: boolean;
+
  sharedFromHere: boolean;
+
  /**
+
   * True when our DID has at least one `iroh://` URL on the COB whose
+
   * host is *not* this device's iroh endpoint. Usually means we (or a
+
   * past install on a different machine, e.g. the CLI) advertised this
+
   * artifact from somewhere else. The bytes are not necessarily local.
+
   */
+
  sharedFromOther: boolean;
  /**
   * Free-form key/value annotations contributed by the artifact author
   * or repo delegates. Authorization is enforced upstream.
modified crates/radicle-types/src/cobs/release.rs
@@ -52,10 +52,15 @@ pub struct Artifact {
    pub locations: Vec<Location>,
    pub attestations: Vec<cobs::Author>,
    pub redactions: Vec<Redaction>,
-
    /// True when at least one of the location URLs we (the local DID) wrote
-
    /// to this artifact uses the `iroh://` scheme — i.e. we are advertising
-
    /// ourselves as an iroh provider for it.
-
    pub shared_by_us: bool,
+
    /// True when this device's iroh endpoint id appears as the host of
+
    /// one of the location URLs we (the local DID) wrote — i.e. we are
+
    /// actively advertising the artifact from the running process.
+
    pub shared_from_here: bool,
+
    /// True when our DID has at least one `iroh://` URL on the COB whose
+
    /// host is *not* this device's iroh endpoint. Usually means we (or a
+
    /// past install on a different machine, e.g. the CLI) advertised this
+
    /// artifact from somewhere else. The bytes are not necessarily local.
+
    pub shared_from_other: bool,
    /// Free-form key/value annotations contributed by the artifact author
    /// or repo delegates. Authorization is enforced upstream.
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
@@ -118,7 +123,6 @@ impl Release {
    pub fn new(
        id: radicle_artifact::ReleaseId,
        release: &radicle_artifact::Release,
-
        our_did: &Did,
        aliases: &impl AliasStore,
        tag_name: Option<String>,
        commit_summary: Option<String>,
@@ -126,7 +130,7 @@ impl Release {
        let artifacts = release
            .artifacts()
            .iter()
-
            .map(|(cid, artifact)| Artifact::new(cid, artifact, our_did, aliases))
+
            .map(|(cid, artifact)| Artifact::new(cid, artifact, aliases))
            .collect();

        Self {
@@ -146,7 +150,6 @@ impl Artifact {
    pub fn new(
        cid: &cid::Cid,
        artifact: &radicle_artifact::Artifact,
-
        our_did: &Did,
        aliases: &impl AliasStore,
    ) -> Self {
        let kind = match cid_utils::artifact_kind(cid) {
@@ -179,10 +182,10 @@ impl Artifact {
            })
            .collect();

-
        let shared_by_us = artifact
-
            .locations_of(our_did)
-
            .is_some_and(|urls| urls.iter().any(|u| u.scheme() == "iroh"));
-

+
        // We don't yet know which iroh endpoint id is "us-here" — that
+
        // lives in the iroh state, not in this crate. Default both flags
+
        // to false and let `set_endpoint_flags` refine them at the
+
        // driver layer when an endpoint id is available.
        Self {
            cid: cid.to_string(),
            name: artifact.name().to_string(),
@@ -191,12 +194,47 @@ impl Artifact {
            locations,
            attestations,
            redactions,
-
            shared_by_us,
+
            shared_from_here: false,
+
            shared_from_other: false,
            metadata: artifact.metadata().clone(),
        }
    }
}

+
/// Classify the `iroh://` URLs under our DID into `shared_from_here`
+
/// (host matches `endpoint_id`) and `shared_from_other` (any other host
+
/// or a host-less URL). Drivers that have an iroh endpoint call this on
+
/// every release they hand to the UI so the flags reflect *this*
+
/// process, not just "we wrote an iroh URL once."
+
pub fn set_endpoint_flags(release: &mut Release, our_did: &Did, endpoint_id: &str) {
+
    let our_did_str = our_did.to_string();
+
    for artifact in &mut release.artifacts {
+
        let mut here = false;
+
        let mut other = false;
+
        for loc in &artifact.locations {
+
            if loc.peer.did().to_string() != our_did_str {
+
                continue;
+
            }
+
            for url in &loc.urls {
+
                if !url.starts_with("iroh://") {
+
                    continue;
+
                }
+
                // `iroh://<endpoint>` — strip the scheme to compare hosts.
+
                let host = url.trim_start_matches("iroh://");
+
                // Trailing slash from url::Url::to_string is tolerated.
+
                let host = host.trim_end_matches('/');
+
                if host == endpoint_id {
+
                    here = true;
+
                } else {
+
                    other = true;
+
                }
+
            }
+
        }
+
        artifact.shared_from_here = here;
+
        artifact.shared_from_other = other;
+
    }
+
}
+

// Quiet the dead-code warning: this map alias keeps the upstream type names
// aligned with the DTO at a glance, and may be used later by trait helpers.
#[allow(dead_code)]
modified crates/radicle-types/src/traits/release.rs
@@ -19,7 +19,6 @@ pub trait Releases: Profile {
        let profile = self.profile();
        let repo = profile.storage.repository(rid)?;
        let aliases = profile.aliases();
-
        let our_did = identity::Did::from(profile.public_key);

        let surf_repo = surf::Repository::open(repo.path())?;
        // Build the tag-OID → refname index once so the list view can show
@@ -37,7 +36,6 @@ pub trait Releases: Profile {
            out.push(release::Release::new(
                radicle_artifact::ReleaseId::from(id),
                &release,
-
                &our_did,
                &aliases,
                tag_name,
                commit_summary,
@@ -58,7 +56,6 @@ pub trait Releases: Profile {
        let profile = self.profile();
        let repo = profile.storage.repository(rid)?;
        let aliases = profile.aliases();
-
        let our_did = identity::Did::from(profile.public_key);

        let id = radicle_artifact::ReleaseId::from_str(&release_id)?;
        let releases = ReleasesStore::open(&repo)?;
@@ -75,7 +72,6 @@ pub trait Releases: Profile {
        Ok(Some(release::Release::new(
            id,
            &release,
-
            &our_did,
            &aliases,
            tag_name,
            commit_summary,
@@ -90,7 +86,6 @@ pub trait Releases: Profile {
        let profile = self.profile();
        let repo = profile.storage.repository(rid)?;
        let aliases = profile.aliases();
-
        let our_did = identity::Did::from(profile.public_key);

        let surf_repo = surf::Repository::open(repo.path())?;
        let tag_index = build_tag_index(&surf_repo);
@@ -106,7 +101,6 @@ pub trait Releases: Profile {
            out.push(release::Release::new(
                id,
                &release,
-
                &our_did,
                &aliases,
                tag_name,
                commit_summary,
modified src/views/repo/Release.svelte
@@ -184,12 +184,16 @@
    }
  }

+
  // Strict — only reflect *this* device's state. Falling back to the
+
  // DTO's flag used to flicker the wrong answer for the "seeded on
+
  // another machine" case, where the COB says we shared an iroh URL
+
  // but the bytes aren't in this process's store.
  function isShared(a: Artifact): boolean {
-
    return localShared[a.cid] ?? a.sharedByUs;
+
    return localShared[a.cid] ?? a.sharedFromHere;
  }

  function isAvailableLocally(a: Artifact): boolean {
-
    return localAvailable[a.cid] ?? a.sharedByUs;
+
    return localAvailable[a.cid] === true;
  }

  async function seed(artifact: Artifact) {
@@ -464,6 +468,11 @@
    border-color: var(--color-feedback-success-border);
    color: var(--color-feedback-success-text);
  }
+
  .pill.other-device {
+
    background-color: var(--color-feedback-warning-bg);
+
    border-color: var(--color-feedback-warning-border);
+
    color: var(--color-feedback-warning-text);
+
  }
  .actions {
    margin-left: auto;
    display: flex;
@@ -802,6 +811,14 @@
                  Local
                </span>
              {/if}
+
              {#if !isAvailableLocally(artifact) && artifact.sharedFromOther}
+
                <span
+
                  class="pill other-device"
+
                  title="You advertised this artifact via iroh from a different device (e.g. the CLI). The bytes aren't in this app's store — that peer must be online to serve them.">
+
                  <Icon name="device" />
+
                  Other device
+
                </span>
+
              {/if}
              <div class="actions">
                <button
                  onclick={() => download(artifact)}