Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7d59dde
feat(preview): add ALPN metadata channel for ticket-based thumbnail p…
WangJie-Mant Mar 1, 2026
7cad09a
feat(preview): fetch metadata via dedicated ALPN channel without infl…
WangJie-Mant Mar 2, 2026
c65af4b
chore(format): run formatter
WangJie-Mant Mar 2, 2026
410002c
style: use custom file icons instead of lucide for file preview
WangJie-Mant Mar 2, 2026
f48d8b6
chore(i18n): add placeholder for optional description
WangJie-Mant Mar 2, 2026
0914b6b
change: replace preview icons
WangJie-Mant Mar 4, 2026
c788920
chore: run 'npm run format'
WangJie-Mant Mar 4, 2026
225cc71
fix: cleanup dependencies and gitignore
WangJie-Mant Mar 4, 2026
13f69c9
chore(format): Remove unused dependencies and run format
WangJie-Mant Mar 4, 2026
67c0623
Merge branch 'main' into feature/add-thumbnail-support
tonyantony300 Mar 4, 2026
0084c17
refactor: remove preview metadata description field
WangJie-Mant Mar 6, 2026
72b72b4
fix(ci): align ShareActionCard props with sender types
WangJie-Mant Mar 6, 2026
3cdc763
tested: seprate network metadata relay transmition
WangJie-Mant Mar 5, 2026
ac27c9e
fix: align tauri entrypoint and remove backup file
WangJie-Mant Mar 6, 2026
018ba81
refactor: remove preview metadata description field
WangJie-Mant Mar 6, 2026
7b54515
fix(dependencies, thumbnail, icon): fixed relative issues, clear up w…
WangJie-Mant Mar 6, 2026
6f6af38
fix: clean merge artifacts in useDragDrop
WangJie-Mant Mar 6, 2026
b2483e4
style: run format before commit
WangJie-Mant Mar 6, 2026
11695ae
fix(video thumbnail): fix video thumbnail in macos and windows, extra…
WangJie-Mant Mar 6, 2026
f879b50
fix(macos): resolve macos memory leak. run format
WangJie-Mant Mar 7, 2026
44d4a1d
fix(tracing): merge warnings to simplify terminal log
WangJie-Mant Mar 7, 2026
89f0797
fix(macos): fix dependency and features lost when macro expansion
WangJie-Mant Mar 7, 2026
655c7af
fix(macos): harden macOS thumbnail FFI memory handling
WangJie-Mant Mar 7, 2026
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
263 changes: 260 additions & 3 deletions sendme/src/core/receive.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
use crate::core::types::{get_or_create_secret, AppHandle, ReceiveOptions, ReceiveResult};
use iroh::{discovery::dns::DnsDiscovery, Endpoint};
use crate::core::send::METADATA_ALPN;
use crate::core::types::{
get_or_create_secret, AppHandle, FileMetadata, ReceiveOptions, ReceiveResult,
};
use iroh::{discovery::dns::DnsDiscovery, Endpoint, TransportAddr};
use iroh_blobs::{
api::{
blobs::{ExportMode, ExportOptions, ExportProgressItem},
Expand All @@ -15,7 +18,11 @@ use n0_future::StreamExt;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Instant;
use tokio::select;
use tokio::{
io::AsyncReadExt,
select,
time::{timeout, Duration},
};

// Helper function to emit events through the app handle
fn emit_event(app_handle: &AppHandle, event_name: &str) {
Expand Down Expand Up @@ -58,6 +65,56 @@ fn emit_event_with_payload(app_handle: &AppHandle, event_name: &str, payload: &s
}
}

/// # Description
/// Receives metadata. This function will connect to the sender, request metadata, and return it without downloading
/// the file data.
/// # Returns
/// A `FileMetadata` struct containing the file name, size, thumbnail (if any), and MIME type (if any).
async fn receive_metadata<S: AsyncReadExt + Unpin>(
stream: &mut S,
app_handle: &AppHandle,
) -> anyhow::Result<FileMetadata> {
// Read the length of the metadata (first 4 bytes)
let mut len_buf = [0u8; 4];
stream
.read_exact(&mut len_buf)
.await
.map_err(|e| anyhow::anyhow!("metadata read length failed: {e}"))?;
let meta_len = u32::from_be_bytes(len_buf) as usize;
tracing::debug!(meta_len, "receive_metadata: length prefix received");

const MAX_METADATA_BYTES: usize = 8 * 1024 * 1024;
anyhow::ensure!(
meta_len > 0 && meta_len <= MAX_METADATA_BYTES,
"invalid metadata length: {meta_len}"
);

// Read the metadata JSON based on the length
let mut meta_buf = vec![0u8; meta_len];
stream
.read_exact(&mut meta_buf)
.await
.map_err(|e| anyhow::anyhow!("metadata read body failed: {e}"))?;
tracing::debug!(bytes = meta_buf.len(), "receive_metadata: body received");

// Deserialize the metadata from JSON
let metadata: FileMetadata = serde_json::from_slice(&meta_buf)
.map_err(|e| anyhow::anyhow!("metadata json decode failed: {e}"))?;

// Emit event with file metadata
if let Some(emitter) = app_handle {
if let Ok(payload) = serde_json::to_string(&metadata) {
if let Err(e) = emitter.emit_event_with_payload("receive-file-metadata", &payload) {
tracing::warn!("Failed to emit file metadata event: {}", e);
}
} else {
tracing::warn!("Failed to serialize file metadata for event payload");
}
}

Ok(metadata)
}

pub async fn download(
ticket_str: String,
options: ReceiveOptions,
Expand Down Expand Up @@ -258,6 +315,153 @@ pub async fn download(
})
}

/// # Description
/// Fetches metadata for a given ticket without downloading the file data. This is used to display file information (name, size, thumbnail) in the UI before the user decides to download.
/// # Returns
/// A `FileMetadata` struct containing the file name, size, and preview metadata (if any).
pub async fn fetch_metadata(
ticket_str: String,
options: ReceiveOptions,
) -> anyhow::Result<FileMetadata> {
// parse ticket and extract address
let ticket = BlobTicket::from_str(&ticket_str)?;
let addr = ticket.addr().clone();

// Create a temporary endpoint to connect and fetch metadata
let secret_key = get_or_create_secret()?;

let mut builder = Endpoint::builder()
// METADATA_ALPN only to indicate a metadata fetch
.alpns(vec![METADATA_ALPN.to_vec()])
.secret_key(secret_key)
.relay_mode(options.relay_mode.into());

if ticket.addr().relay_urls().count() == 0 && ticket.addr().ip_addrs().count() == 0 {
builder = builder.discovery(DnsDiscovery::n0_dns());
}
if let Some(addr) = options.magic_ipv4_addr {
builder = builder.bind_addr_v4(addr);
}
if let Some(addr) = options.magic_ipv6_addr {
builder = builder.bind_addr_v6(addr);
}

let endpoint = builder.bind().await?;

// Attempt connection and metadata fetch up to 3 times
let mut attempt_plan: Vec<(usize, &'static str, iroh::EndpointAddr)> = vec![
(1, "default", addr.clone()),
(2, "default", addr.clone()),
(3, "default", addr.clone()),
];

// Relay-only attempt if relay addresses are avaliable
let mut relay_only_addr = addr.clone();
relay_only_addr
.addrs
.retain(|transport_addr| matches!(transport_addr, TransportAddr::Relay(_)));
if !relay_only_addr.addrs.is_empty() {
attempt_plan[2] = (3, "relay-only", relay_only_addr);
}

let mut last_error: Option<anyhow::Error> = None;
let mut attempt_errors: Vec<(usize, &'static str, String)> = Vec::new();

for (attempt, path, target_addr) in attempt_plan {
tracing::info!(attempt, path, "fetch_metadata: connecting to sender");

let result: anyhow::Result<FileMetadata> = async {
let connection = timeout(
Duration::from_secs(15),
endpoint.connect(target_addr, METADATA_ALPN),
)
.await
.map_err(|_| anyhow::anyhow!("metadata connect timeout"))??;

tracing::debug!(attempt, path, "fetch_metadata: connection established");

let (mut send_stream, mut recv_stream) =
timeout(Duration::from_secs(20), connection.open_bi())
.await
.map_err(|_| anyhow::anyhow!("metadata open_bi timeout"))??;

tracing::debug!(attempt, path, "fetch_metadata: bi stream opened");

// Send 1 byte as a marker to indicate metadata request
timeout(Duration::from_secs(10), send_stream.write_all(&[1]))
.await
.map_err(|_| anyhow::anyhow!("metadata request write timeout"))??;

tracing::debug!(attempt, path, "fetch_metadata: request marker sent");

let metadata = timeout(
Duration::from_secs(20),
receive_metadata(&mut recv_stream, &None),
)
.await
.map_err(|_| anyhow::anyhow!("metadata read timeout"))??;

// Finish send_stream only AFTER receiving the metadata.
// signals the server that we are safely done and it can drop the connection.
let _ = send_stream.finish();

Ok(metadata)
}
.await;

match result {
Ok(metadata) => {
tracing::info!(
attempt,
path,
retries = attempt_errors.len(),
file_name = %metadata.file_name,
size = metadata.size,
"fetch_metadata: received metadata"
);
endpoint.close().await;
return Ok(metadata);
}
Err(err) => {
let will_retry = attempt < 3;
tracing::debug!(
attempt,
path,
will_retry,
error = %err,
"fetch_metadata attempt failed"
);
attempt_errors.push((attempt, path, err.to_string()));
last_error = Some(err);
if will_retry {
tokio::time::sleep(Duration::from_millis(300)).await;
}
}
}
}

endpoint.close().await;

if !attempt_errors.is_empty() {
let failure_summary = attempt_errors
.iter()
.map(|(attempt, path, err)| format!("#{attempt}({path}): {err}"))
.collect::<Vec<_>>()
.join(" | ");

if let Some(ref err) = last_error {
tracing::warn!(
attempts = attempt_errors.len(),
error = %err,
failure_summary = %failure_summary,
"fetch_metadata: failed to connect to sender"
);
}
}

Err(last_error.unwrap_or_else(|| anyhow::anyhow!("metadata fetch failed")))
}

async fn export(db: &Store, collection: Collection, output_dir: &Path) -> anyhow::Result<()> {
for (_i, (name, hash)) in collection.iter().enumerate() {
let target = get_export_path(output_dir, name)?;
Expand Down Expand Up @@ -318,6 +522,59 @@ fn validate_path_component(component: &str) -> anyhow::Result<()> {
mod tests {
use super::*;

#[tokio::test]
async fn test_fetch_metadata_e2e() {
use crate::core::send::start_share;
use crate::core::types::{
AddrInfoOptions, FileMetadata, ReceiveOptions, RelayModeOption, SendOptions,
};
use std::io::Write;
use tempfile::NamedTempFile;

// Create a dummy file to share
let mut temp_file = NamedTempFile::new().unwrap();
write!(temp_file, "metadata e2e test content").unwrap();
let temp_path = temp_file.path().to_path_buf();

// Setup metadata
let expected_metadata = FileMetadata {
file_name: "test_e2e_file.txt".into(),
size: 25,
thumbnail: Some("data:image/jpeg;base64,e2e_test_thumbnail=".into()),
mime_type: Some("text/plain".into()),
};

let send_opts = SendOptions {
relay_mode: RelayModeOption::Default,
ticket_type: AddrInfoOptions::RelayAndAddresses,
magic_ipv4_addr: None,
magic_ipv6_addr: None,
};

// Start share
let result = start_share(temp_path, send_opts, None, Some(expected_metadata.clone()))
.await
.expect("Failed to start share");

// Fetch metadata via ALPN protocol
let recv_opts = ReceiveOptions {
output_dir: None,
relay_mode: RelayModeOption::Default,
magic_ipv4_addr: None,
magic_ipv6_addr: None,
};

let fetched = fetch_metadata(result.ticket, recv_opts)
.await
.expect("Failed to fetch metadata from node");

// Verify received data matches exactly
assert_eq!(fetched.file_name, expected_metadata.file_name);
assert_eq!(fetched.size, expected_metadata.size);
assert_eq!(fetched.thumbnail, expected_metadata.thumbnail);
assert_eq!(fetched.mime_type, expected_metadata.mime_type);
}

#[test]
fn validate_rejects_empty() {
assert!(validate_path_component("").is_err());
Expand Down
Loading