From ff23ea3327a46806b96e8e396ea761b4583b7c74 Mon Sep 17 00:00:00 2001 From: Pushkar Mishra Date: Tue, 14 Oct 2025 13:31:24 +0530 Subject: [PATCH 01/17] Implement ready-to-receive and send-files-to functionality in drop-core CLI - Added `run_ready_to_receive` and `run_send_files_to` functions to facilitate file transfers. - Introduced `ReadyToReceiveSubscriber` and `SendFilesToSubscriber` traits for event handling. - Enhanced CLI with new arguments for waiting to receive files and sending files to a receiver. - Updated Cargo.toml dependencies and added new modules for handling ready-to-receive operations. - Improved logging and progress tracking during file transfers. This commit enhances the user experience by allowing receivers to generate QR codes for easy connection and enabling senders to connect to waiting receivers using tickets and confirmation codes. Signed-off-by: Pushkar Mishra --- drop-core/cli/src/lib.rs | 461 ++++++++++++++ drop-core/cli/src/main.rs | 107 +++- drop-core/exchanges/receiver/Cargo.toml | 5 +- drop-core/exchanges/receiver/src/lib.rs | 13 +- .../receiver/src/ready_to_receive/handler.rs | 546 +++++++++++++++++ .../receiver/src/ready_to_receive/mod.rs | 334 +++++++++++ drop-core/exchanges/sender/src/lib.rs | 14 +- .../exchanges/sender/src/send_files_to.rs | 565 ++++++++++++++++++ 8 files changed, 2031 insertions(+), 14 deletions(-) create mode 100644 drop-core/exchanges/receiver/src/ready_to_receive/handler.rs create mode 100644 drop-core/exchanges/receiver/src/ready_to_receive/mod.rs create mode 100644 drop-core/exchanges/sender/src/send_files_to.rs diff --git a/drop-core/cli/src/lib.rs b/drop-core/cli/src/lib.rs index 69520ea1..6742db7b 100644 --- a/drop-core/cli/src/lib.rs +++ b/drop-core/cli/src/lib.rs @@ -67,12 +67,22 @@ use base64::{Engine, engine::general_purpose}; use dropx_receiver::{ ReceiveFilesConnectingEvent, ReceiveFilesFile, ReceiveFilesReceivingEvent, ReceiveFilesRequest, ReceiveFilesSubscriber, ReceiverProfile, + ready_to_receive::{ + ReadyToReceiveBubble, ReadyToReceiveConnectingEvent, + ReadyToReceiveReceivingEvent, ReadyToReceiveRequest, + ReadyToReceiveSubscriber, ready_to_receive, ReadyToReceiveConfig, + ReadyToReceiveFile, + }, receive_files, }; use dropx_sender::{ SendFilesConnectingEvent, SendFilesRequest, SendFilesSendingEvent, SendFilesSubscriber, SenderConfig, SenderFile, SenderFileData, SenderProfile, send_files, + send_files_to::{ + SendFilesToBubble, SendFilesToConnectingEvent, SendFilesToRequest, + SendFilesToSendingEvent, SendFilesToSubscriber, send_files_to, + }, }; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use serde::{Deserialize, Serialize}; @@ -1122,3 +1132,454 @@ pub fn clear_default_receive_dir() -> Result<()> { config.default_receive_dir = None; config.save() } + +// QR-to-receive helper functions + +async fn wait_for_ready_to_receive_completion(bubble: &ReadyToReceiveBubble) { + loop { + if bubble.is_finished() { + break; + } + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } +} + +async fn wait_for_send_files_to_completion(bubble: &SendFilesToBubble) { + loop { + if bubble.is_finished() { + break; + } + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } +} + +struct ReadyToReceiveSubscriberImpl { + id: String, + receiving_path: PathBuf, + files: RwLock>, + verbose: bool, + mp: MultiProgress, + bars: RwLock>, + received: RwLock>, +} + +impl ReadyToReceiveSubscriberImpl { + fn new(receiving_path: PathBuf, verbose: bool) -> Self { + Self { + id: Uuid::new_v4().to_string(), + receiving_path, + files: RwLock::new(Vec::new()), + verbose, + mp: MultiProgress::new(), + bars: RwLock::new(HashMap::new()), + received: RwLock::new(HashMap::new()), + } + } + + fn bar_style() -> ProgressStyle { + ProgressStyle::with_template( + "{spinner:.green} {msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})", + ) + .unwrap() + .progress_chars("#>-") + } +} + +impl ReadyToReceiveSubscriber for ReadyToReceiveSubscriberImpl { + fn get_id(&self) -> String { + self.id.clone() + } + + fn log(&self, message: String) { + if self.verbose { + let _ = self.mp.println(format!("🔍 {}", message)); + } + } + + fn notify_receiving(&self, event: ReadyToReceiveReceivingEvent) { + let files = match self.files.read() { + Ok(files) => files, + Err(e) => { + eprintln!("❌ Error accessing files list: {}", e); + return; + } + }; + let file = match files.iter().find(|f| f.id == event.id) { + Some(file) => file, + None => { + eprintln!("❌ File not found with ID: {}", event.id); + return; + } + }; + + let mut bars = self.bars.write().unwrap(); + let pb = bars.entry(event.id.clone()).or_insert_with(|| { + let pb = self.mp.add(ProgressBar::new_spinner()); + pb.set_style( + ProgressStyle::with_template( + "{spinner:.green} {msg} {bytes} ({bytes_per_sec})", + ) + .unwrap(), + ); + pb.enable_steady_tick(std::time::Duration::from_millis(100)); + pb.set_message(format!("Receiving {}", file.name)); + pb + }); + + { + let mut recvd = self.received.write().unwrap(); + let entry = recvd.entry(event.id.clone()).or_insert(0); + *entry += event.data.len() as u64; + pb.inc(event.data.len() as u64); + } + + let file_path = self.receiving_path.join(&file.name); + match fs::File::options() + .create(true) + .append(true) + .open(&file_path) + { + Ok(mut file_stream) => { + if let Err(e) = file_stream.write_all(&event.data) { + eprintln!("❌ Error writing to file {}: {}", file.name, e); + return; + } + if let Err(e) = file_stream.flush() { + eprintln!("❌ Error flushing file {}: {}", file.name, e); + return; + } + } + Err(e) => { + eprintln!("❌ Error opening file {}: {}", file.name, e); + } + } + } + + fn notify_connecting(&self, event: ReadyToReceiveConnectingEvent) { + let _ = self + .mp + .println("🔗 Connected to sender:".to_string()); + let _ = self + .mp + .println(format!(" 📛 Name: {}", event.sender.name)); + let _ = self + .mp + .println(format!(" 🆔 ID: {}", event.sender.id)); + let _ = self + .mp + .println(format!(" 📁 Files to receive: {}", event.files.len())); + + for f in &event.files { + let _ = self.mp.println(format!(" 📄 {}", f.name)); + } + + match self.files.write() { + Ok(mut files) => { + files.extend(event.files.clone()); + + let mut bars = self.bars.write().unwrap(); + for f in &*files { + let pb = self.mp.add(ProgressBar::new(f.len)); + pb.set_style(Self::bar_style()); + pb.set_message(format!("Receiving {}", f.name)); + bars.insert(f.id.clone(), pb); + } + } + Err(e) => { + eprintln!("❌ Error updating files list: {}", e); + } + } + } +} + +struct SendFilesToSubscriberImpl { + id: String, + verbose: bool, + mp: MultiProgress, + bars: RwLock>, +} + +impl SendFilesToSubscriberImpl { + fn new(verbose: bool) -> Self { + Self { + id: Uuid::new_v4().to_string(), + verbose, + mp: MultiProgress::new(), + bars: RwLock::new(HashMap::new()), + } + } + + fn bar_style() -> ProgressStyle { + ProgressStyle::with_template( + "{spinner:.green} {msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})", + ) + .unwrap() + .progress_chars("#>-") + } +} + +impl SendFilesToSubscriber for SendFilesToSubscriberImpl { + fn get_id(&self) -> String { + self.id.clone() + } + + fn log(&self, message: String) { + if self.verbose { + let _ = self.mp.println(format!("🔍 {}", message)); + } + } + + fn notify_sending(&self, event: SendFilesToSendingEvent) { + let mut bars = self.bars.write().unwrap(); + let pb = bars.entry(event.name.clone()).or_insert_with(|| { + let total = event.sent + event.remaining; + let pb = if total > 0 { + let pb = self.mp.add(ProgressBar::new(total)); + pb.set_style(Self::bar_style()); + pb + } else { + let pb = self.mp.add(ProgressBar::new_spinner()); + pb.set_style( + ProgressStyle::with_template( + "{spinner:.green} {msg} {bytes} ({bytes_per_sec})", + ) + .unwrap(), + ); + pb.enable_steady_tick(std::time::Duration::from_millis(100)); + pb + }; + pb.set_message(format!("Sending {}", event.name)); + pb + }); + + let total = event.sent + event.remaining; + if total > 0 { + pb.set_length(total); + pb.set_position(event.sent); + } + + if event.remaining == 0 { + pb.finish_with_message(format!("✅ Sent {}", event.name)); + } else { + pb.set_message(format!("Sending {}", event.name)); + } + } + + fn notify_connecting(&self, event: SendFilesToConnectingEvent) { + let _ = self + .mp + .println("🔗 Connected to receiver:".to_string()); + let _ = self + .mp + .println(format!(" 📛 Name: {}", event.receiver.name)); + let _ = self + .mp + .println(format!(" 🆔 ID: {}", event.receiver.id)); + } +} + +/// Run ready-to-receive operation (receiver initiates, generates QR code). +/// +/// This function creates a receiving session that generates a ticket and +/// confirmation code, prints them as a QR code and text, then waits for a +/// sender to connect. +/// +/// Parameters: +/// - output_dir: Optional parent directory to store received files. +/// - profile: The local user profile to present to the sender. +/// - verbose: Enables transport logs and extra diagnostics. +/// - save_dir: If true and `output_dir` is Some, saves it as the default. +/// +/// Errors: +/// - If the transfer setup or I/O fails. +pub async fn run_ready_to_receive( + output_dir: Option, + profile: Profile, + verbose: bool, + save_dir: bool, +) -> Result<()> { + // Determine the output directory + let final_output_dir = match output_dir { + Some(dir) => { + let path = PathBuf::from(&dir); + if save_dir { + let mut config = CliConfig::load()?; + config + .set_default_receive_dir(dir.clone()) + .with_context(|| "Failed to save default receive directory")?; + println!("💾 Saved '{}' as default receive directory", dir); + } + path + } + None => { + let config = CliConfig::load()?; + match config.get_default_receive_dir() { + Some(default_dir) => PathBuf::from(default_dir), + None => default_receive_dir_fallback(), + } + } + }; + + // Create output directory if it doesn't exist + if !final_output_dir.exists() { + fs::create_dir_all(&final_output_dir).with_context(|| { + format!( + "Failed to create output directory: {}", + final_output_dir.display() + ) + })?; + } + + // Create unique subdirectory for this transfer + let receiving_path = final_output_dir.join(Uuid::new_v4().to_string()); + fs::create_dir(&receiving_path).with_context(|| { + format!( + "Failed to create receiving directory: {}", + receiving_path.display() + ) + })?; + + let request = ReadyToReceiveRequest { + profile: ReceiverProfile { + name: profile.name.clone(), + avatar_b64: profile.avatar_b64.clone(), + }, + config: ReadyToReceiveConfig::default(), + }; + + let bubble = ready_to_receive(request) + .await + .context("Failed to initiate ready-to-receive")?; + + let ticket = bubble.get_ticket(); + let confirmation = bubble.get_confirmation(); + + println!("📦 Ready to receive files!"); + println!("🎫 Ticket: \"{}\"", ticket); + println!("🔑 Confirmation: \"{}\"", confirmation); + println!(); + println!("Scan this QR code with the sender:"); + println!(); + + // Print QR code (simplified version - you may want to use a QR library) + let qr_data = format!("{}:{}", ticket, confirmation); + println!("QR Data: {}", qr_data); + println!(); + + println!("📁 Files will be saved to: {}", receiving_path.display()); + println!("⏳ Waiting for sender... (Press Ctrl+C to cancel)"); + + let subscriber = ReadyToReceiveSubscriberImpl::new(receiving_path.clone(), verbose); + bubble.subscribe(Arc::new(subscriber)); + + tokio::select! { + _ = tokio::signal::ctrl_c() => { + println!("🚫 Cancelling file transfer..."); + let _ = bubble.cancel().await; + println!("✅ Transfer cancelled"); + } + _ = wait_for_ready_to_receive_completion(&bubble) => { + println!("✅ All files received successfully!"); + } + } + + Ok(()) +} + +/// Run send-files-to operation (sender connects to waiting receiver). +/// +/// This function sends files to a receiver that has already initiated a +/// ready-to-receive session and provided their ticket and confirmation code. +/// +/// Parameters: +/// - file_paths: Paths to regular files to be sent. Each path must exist. +/// - ticket: The ticket provided by the waiting receiver. +/// - confirmation: The numeric confirmation code. +/// - profile: The local user profile to present to the receiver. +/// - verbose: Enables transport logs and extra diagnostics. +/// +/// Errors: +/// - If any path is invalid or if the transport fails to initialize. +pub async fn run_send_files_to( + file_paths: Vec, + ticket: String, + confirmation: String, + profile: Profile, + verbose: bool, +) -> Result<()> { + if file_paths.is_empty() { + return Err(anyhow!("Cannot send an empty list of files")); + } + + let paths: Vec = file_paths + .into_iter() + .map(PathBuf::from) + .collect(); + + // Validate all files exist before starting + for path in &paths { + if !path.exists() { + return Err(anyhow!("File does not exist: {}", path.display())); + } + if !path.is_file() { + return Err(anyhow!("Path is not a file: {}", path.display())); + } + } + + let confirmation_code = u8::from_str(&confirmation) + .with_context(|| format!("Invalid confirmation code: {}", confirmation))?; + + // Create sender files + let mut files = Vec::new(); + for path in paths { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| anyhow!("Invalid file name: {}", path.display()))? + .to_string(); + + let data = FileData::new(path)?; + files.push(SenderFile { + name, + data: Arc::new(data), + }); + } + + let request = SendFilesToRequest { + ticket, + confirmation: confirmation_code, + files, + profile: SenderProfile { + name: profile.name.clone(), + avatar_b64: profile.avatar_b64.clone(), + }, + config: SenderConfig::default(), + }; + + let bubble = send_files_to(request) + .await + .context("Failed to initiate send-files-to")?; + + let subscriber = SendFilesToSubscriberImpl::new(verbose); + bubble.subscribe(Arc::new(subscriber)); + + println!("📤 Connecting to waiting receiver..."); + + bubble + .start() + .context("Failed to start send-files-to")?; + + println!("⏳ Sending files... (Press Ctrl+C to cancel)"); + + tokio::select! { + _ = tokio::signal::ctrl_c() => { + println!("🚫 Cancelling file transfer..."); + println!("✅ Transfer cancelled"); + } + _ = wait_for_send_files_to_completion(&bubble) => { + println!("✅ All files sent successfully!"); + } + } + + Ok(()) +} diff --git a/drop-core/cli/src/main.rs b/drop-core/cli/src/main.rs index b36e3c3c..c7c1d7df 100644 --- a/drop-core/cli/src/main.rs +++ b/drop-core/cli/src/main.rs @@ -3,7 +3,7 @@ use clap::{Arg, ArgMatches, Command}; use drop_cli::{ Profile, clear_default_receive_dir, get_default_receive_dir, run_receive_files, run_send_files, set_default_receive_dir, - suggested_default_receive_dir, + suggested_default_receive_dir, run_ready_to_receive, run_send_files_to, }; use std::path::PathBuf; @@ -50,6 +50,21 @@ fn build_cli() -> Command { .num_args(1..) .value_parser(clap::value_parser!(PathBuf)) ) + .arg( + Arg::new("to-ticket") + .long("to") + .help("Send to a waiting receiver's ticket") + .value_name("TICKET") + .requires("to-confirmation") + ) + .arg( + Arg::new("to-confirmation") + .long("confirmation") + .short('c') + .help("Receiver's confirmation code (use with --to)") + .value_name("CODE") + .requires("to-ticket") + ) .arg( Arg::new("name") .long("name") @@ -74,16 +89,23 @@ fn build_cli() -> Command { .subcommand( Command::new("receive") .about("Receive files from another user") + .arg( + Arg::new("wait") + .long("wait") + .help("Generate QR code and wait for sender to connect") + .action(clap::ArgAction::SetTrue) + .conflicts_with_all(["ticket", "confirmation"]) + ) .arg( Arg::new("ticket") - .help("Transfer ticket") - .required(true) + .help("Transfer ticket (from sender)") + .required_unless_present("wait") .index(1) ) .arg( Arg::new("confirmation") - .help("Confirmation code") - .required(true) + .help("Confirmation code (from sender)") + .required_unless_present("wait") .index(2) ) .arg( @@ -153,9 +175,43 @@ async fn handle_send_command(matches: &ArgMatches) -> Result<()> { .collect(); let verbose: bool = matches.get_flag("verbose"); - let profile = build_profile(matches)?; + // Check if this is a send-to operation (--to flag) + if let Some(ticket) = matches.get_one::("to-ticket") { + let confirmation = matches.get_one::("to-confirmation").unwrap(); + + println!("📤 Preparing to send {} file(s) to waiting receiver...", files.len()); + for file in &files { + println!(" 📄 {}", file.display()); + } + + if let Some(name) = profile.name.strip_prefix("drop-cli-") { + println!("👤 Sender name: {}", name); + } else { + println!("👤 Sender name: {}", profile.name); + } + + if profile.avatar_b64.is_some() { + println!("🖼️ Avatar: Set"); + } + + let file_strings: Vec = files + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + + return run_send_files_to( + file_strings, + ticket.clone(), + confirmation.clone(), + profile, + verbose, + ) + .await; + } + + // Regular send operation println!("📤 Preparing to send {} file(s)...", files.len()); for file in &files { println!(" 📄 {}", file.display()); @@ -180,15 +236,46 @@ async fn handle_send_command(matches: &ArgMatches) -> Result<()> { } async fn handle_receive_command(matches: &ArgMatches) -> Result<()> { + let verbose = matches.get_flag("verbose"); + let save_dir = matches.get_flag("save-dir"); + let profile = build_profile(matches)?; + + // Check if this is a ready-to-receive operation (--wait flag) + if matches.get_flag("wait") { + let output_dir = matches + .get_one::("output") + .map(|p| p.to_string_lossy().to_string()); + + println!("📥 Preparing to receive files..."); + + if let Some(ref dir) = output_dir { + println!("📁 Output directory: {}", dir); + } else if let Some(default_dir) = get_default_receive_dir()? { + println!("📁 Using default directory: {}", default_dir); + } else { + let fallback = suggested_default_receive_dir(); + println!("📁 Using default directory: {}", fallback.display()); + } + + if let Some(name) = profile.name.strip_prefix("drop-cli-") { + println!("👤 Receiver name: {}", name); + } else { + println!("👤 Receiver name: {}", profile.name); + } + + if profile.avatar_b64.is_some() { + println!("🖼️ Avatar: Set"); + } + + return run_ready_to_receive(output_dir, profile, verbose, save_dir).await; + } + + // Regular receive operation let output_dir = matches .get_one::("output") .map(|p| p.to_string_lossy().to_string()); let ticket = matches.get_one::("ticket").unwrap(); let confirmation = matches.get_one::("confirmation").unwrap(); - let verbose = matches.get_flag("verbose"); - let save_dir = matches.get_flag("save-dir"); - - let profile = build_profile(matches)?; println!("📥 Preparing to receive files..."); diff --git a/drop-core/exchanges/receiver/Cargo.toml b/drop-core/exchanges/receiver/Cargo.toml index 685a86b3..97450ed7 100644 --- a/drop-core/exchanges/receiver/Cargo.toml +++ b/drop-core/exchanges/receiver/Cargo.toml @@ -19,4 +19,7 @@ serde_json = "1.0.142" anyhow = "1.0.98" iroh-base = "0.91.1" flate2 = "1.0" -tracing = "0.1" \ No newline at end of file +tracing = "0.1" +chrono = "0.4.41" +futures = "0.3" +rand = "0.9.0" \ No newline at end of file diff --git a/drop-core/exchanges/receiver/src/lib.rs b/drop-core/exchanges/receiver/src/lib.rs index 1c31f62b..eb6c6fa8 100644 --- a/drop-core/exchanges/receiver/src/lib.rs +++ b/drop-core/exchanges/receiver/src/lib.rs @@ -7,7 +7,9 @@ //! - Events and subscription mechanisms (see `receive_files` module) to observe //! connection and per-chunk progress. //! -//! Typical flow: +//! Two modes of operation: +//! +//! ## Standard Mode (Receiver connects to Sender) //! 1. Build a `ReceiveFilesRequest` with a sender ticket, confirmation code, //! your `ReceiverProfile`, and an optional `ReceiverConfig`. //! 2. Call `receive_files::receive_files` to obtain a `ReceiveFilesBubble`. @@ -15,7 +17,16 @@ //! 4. Start the transfer with `ReceiveFilesBubble::start()`. //! 5. Optionally cancel with `ReceiveFilesBubble::cancel()`. //! 6. When finished, the session is closed and resources cleaned up. +//! +//! ## QR-to-Receive Mode (Sender connects to Receiver) +//! 1. Build a `ReadyToReceiveRequest` with your `ReceiverProfile` and config. +//! 2. Call `ready_to_receive::ready_to_receive` to obtain a +//! `ReadyToReceiveBubble`. +//! 3. Display the ticket and confirmation code (e.g., as QR code) for sender. +//! 4. Subscribe to events to observe when sender connects and file reception. +//! 5. Optionally cancel with `ReadyToReceiveBubble::cancel()`. +pub mod ready_to_receive; mod receive_files; use std::{ diff --git a/drop-core/exchanges/receiver/src/ready_to_receive/handler.rs b/drop-core/exchanges/receiver/src/ready_to_receive/handler.rs new file mode 100644 index 00000000..d32d1a96 --- /dev/null +++ b/drop-core/exchanges/receiver/src/ready_to_receive/handler.rs @@ -0,0 +1,546 @@ +//! Internal protocol handler for waiting to receive files. +//! +//! This module implements `iroh::protocol::ProtocolHandler` to accept a single +//! sender, exchange handshakes, negotiate configuration, and receive file data +//! using unidirectional streams. It provides an observer API via +//! `ReadyToReceiveSubscriber` to report logs, connection metadata, and per-file +//! chunk arrivals. + +use anyhow::Result; +use drop_entities::Profile; +use dropx_common::{ + handshake::{ + HandshakeConfig, HandshakeProfile, NegotiatedConfig, ReceiverHandshake, + SenderHandshake, + }, + projection::FileProjection, +}; +use futures::Future; +use iroh::{ + endpoint::{Connection, RecvStream, SendStream, VarInt}, + protocol::ProtocolHandler, +}; +use std::{ + collections::HashMap, + fmt::Debug, + sync::{Arc, RwLock, atomic::AtomicBool}, +}; +use tokio::task::JoinSet; + +use super::ReadyToReceiveConfig; + +/// Observer interface for transfer logs and progress. +/// +/// Implementors must be thread-safe (`Send + Sync`) since notifications are +/// dispatched from async tasks. +pub trait ReadyToReceiveSubscriber: Send + Sync { + /// A stable unique identifier for this subscriber (used as a map key). + fn get_id(&self) -> String; + + /// Receives diagnostic log lines from the transfer pipeline. + fn log(&self, message: String); + + /// Receives chunk data for each file being received. + /// + /// Multiple events can arrive out of order across files. + fn notify_receiving(&self, event: ReadyToReceiveReceivingEvent); + + /// Notified when a sender connects and completes the handshake. + fn notify_connecting(&self, event: ReadyToReceiveConnectingEvent); +} + +/// Per-chunk receiving event. +/// +/// Contains the file ID and raw chunk data. +#[derive(Clone)] +pub struct ReadyToReceiveReceivingEvent { + pub id: String, + pub data: Vec, +} + +/// Connection event carrying the sender's profile and files list as reported +/// during handshake. +pub struct ReadyToReceiveConnectingEvent { + pub sender: ReadyToReceiveSenderProfile, + pub files: Vec, +} + +/// Sender profile details surfaced to subscribers. +#[derive(Clone)] +pub struct ReadyToReceiveSenderProfile { + pub id: String, + pub name: String, + pub avatar_b64: Option, +} + +/// File information provided by sender during handshake. +#[derive(Clone)] +pub struct ReadyToReceiveFile { + pub id: String, + pub name: String, + pub len: u64, +} + +/// Protocol handler responsible for accepting a single sender and receiving +/// data. +/// +/// A `ReadyToReceiveHandler`: +/// - Enforces single-consumption of the incoming connection. +/// - Performs JSON-based handshake exchange. +/// - Negotiates chunking and concurrency parameters. +/// - Receives files over unidirectional streams. +/// - Emits events to registered subscribers. +pub struct ReadyToReceiveHandler { + is_consumed: AtomicBool, + is_finished: Arc, + profile: Profile, + config: ReadyToReceiveConfig, + subscribers: + Arc>>>, +} +impl Debug for ReadyToReceiveHandler { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ReadyToReceiveHandler") + .field("is_consumed", &self.is_consumed) + .field("is_finished", &self.is_finished) + .field("profile", &self.profile) + .field("config", &self.config) + .finish() + } +} +impl ReadyToReceiveHandler { + /// Constructs a new handler for the given profile and configuration. + pub fn new(profile: Profile, config: ReadyToReceiveConfig) -> Self { + Self { + is_consumed: AtomicBool::new(false), + is_finished: Arc::new(AtomicBool::new(false)), + profile, + config, + subscribers: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Returns true if a connection has already been accepted. + /// + /// This handler accepts at most one sender for a bubble. + pub fn is_consumed(&self) -> bool { + let consumed = self + .is_consumed + .load(std::sync::atomic::Ordering::Relaxed); + self.log(format!("is_consumed check: {consumed}")); + consumed + } + + /// Returns true if the transfer has finished or the handler has been shut + /// down. + pub fn is_finished(&self) -> bool { + let finished = self + .is_finished + .load(std::sync::atomic::Ordering::Relaxed); + self.log(format!("is_finished check: {finished}")); + finished + } + + /// Broadcasts a log message to all subscribers. + pub fn log(&self, message: String) { + self.subscribers + .read() + .unwrap() + .iter() + .for_each(|(_, subscriber)| { + subscriber.log(message.clone()); + }); + } + + /// Registers a new subscriber or replaces an existing one with the same + /// ID. + pub fn subscribe(&self, subscriber: Arc) { + let subscriber_id = subscriber.get_id(); + self.log(format!( + "Subscribing new subscriber with ID: {subscriber_id}" + )); + + self.subscribers + .write() + .unwrap() + .insert(subscriber_id.clone(), subscriber); + + self.log(format!( + "Subscriber {} successfully subscribed. Total subscribers: {}", + subscriber_id, + self.subscribers.read().unwrap().len() + )); + } + + /// Unregisters a subscriber by its ID. + pub fn unsubscribe(&self, subscriber: Arc) { + let subscriber_id = subscriber.get_id(); + self.log(format!("Unsubscribing subscriber with ID: {subscriber_id}")); + + let removed = self + .subscribers + .write() + .unwrap() + .remove(&subscriber_id); + + if removed.is_some() { + self.log(format!("Subscriber {subscriber_id} successfully unsubscribed. Remaining subscribers: {}", self.subscribers.read().unwrap().len())); + } else { + self.log(format!( + "Subscriber {subscriber_id} was not found during unsubscribe operation" + )); + } + } +} +impl ProtocolHandler for ReadyToReceiveHandler { + fn on_connecting( + &self, + connecting: iroh::endpoint::Connecting, + ) -> impl Future< + Output = std::result::Result, + > + Send { + self.log("on_connecting: New connection attempt received".to_string()); + + let is_consumed = self + .is_consumed + .compare_exchange( + false, + true, + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Relaxed, + ) + .unwrap_or(true); + + async move { + if is_consumed { + return Err(iroh::protocol::AcceptError::NotAllowed {}); + } + + let connection = connecting.await?; + Ok(connection) + } + } + + fn shutdown(&self) -> impl Future + Send { + self.log("shutdown: Initiating handler shutdown".to_string()); + let is_finished = self.is_finished.clone(); + + async move { + is_finished.store(true, std::sync::atomic::Ordering::Relaxed); + } + } + + fn accept( + &self, + connection: Connection, + ) -> impl Future< + Output = std::result::Result<(), iroh::protocol::AcceptError>, + > + Send { + self.log("accept: Creating carrier for file reception".to_string()); + + let carrier = Carrier { + is_finished: self.is_finished.clone(), + config: self.config.clone(), + negotiated_config: None, + profile: self.profile.clone(), + connection, + subscribers: self.subscribers.clone(), + }; + + async move { + let mut carrier = carrier; + if (carrier.greet().await).is_err() { + return Err(iroh::protocol::AcceptError::NotAllowed {}); + } + + if (carrier.receive_files().await).is_err() { + return Err(iroh::protocol::AcceptError::NotAllowed {}); + } + + carrier.finish(); + Ok(()) + } + } +} + +/// Helper that performs handshake, configuration negotiation, and streaming. +/// +/// Not exposed publicly; used internally by `ReadyToReceiveHandler`. +struct Carrier { + is_finished: Arc, + config: ReadyToReceiveConfig, + negotiated_config: Option, + profile: Profile, + connection: Connection, + subscribers: + Arc>>>, +} +impl Carrier { + /// Performs the bidirectional handshake exchange and notifies subscribers + /// about the sender identity and files. + async fn greet(&mut self) -> Result<()> { + let mut bi = self.connection.accept_bi().await?; + + self.receive_handshake(&mut bi).await?; + self.send_handshake(&mut bi).await?; + + bi.0.stopped().await?; + + self.log("greet: Handshake completed successfully".to_string()); + Ok(()) + } + + /// Receives the sender handshake and computes the negotiated + /// configuration. + async fn receive_handshake( + &mut self, + bi: &mut (SendStream, RecvStream), + ) -> Result<()> { + let mut header = [0u8; 4]; + bi.1.read_exact(&mut header).await?; + let len = u32::from_be_bytes(header); + + let mut buffer = vec![0u8; len as usize]; + bi.1.read_exact(&mut buffer).await?; + + let handshake: SenderHandshake = serde_json::from_slice(&buffer)?; + + // Negotiate configuration + let receiver_config = HandshakeConfig { + chunk_size: self.config.chunk_size, + parallel_streams: self.config.parallel_streams, + }; + + self.negotiated_config = Some(NegotiatedConfig::negotiate( + &handshake.config, + &receiver_config, + )); + + // Prepare data structures + let profile = ReadyToReceiveSenderProfile { + id: handshake.profile.id, + name: handshake.profile.name, + avatar_b64: handshake.profile.avatar_b64, + }; + + let files: Vec = handshake + .files + .into_iter() + .map(|f| ReadyToReceiveFile { + id: f.id, + name: f.name, + len: f.len, + }) + .collect(); + + // Notify subscribers + self.subscribers + .read() + .unwrap() + .iter() + .for_each(|(_, s)| { + s.notify_connecting(ReadyToReceiveConnectingEvent { + sender: profile.clone(), + files: files.clone(), + }); + }); + + Ok(()) + } + + /// Sends the receiver's profile and preferred configuration. + async fn send_handshake( + &self, + bi: &mut (SendStream, RecvStream), + ) -> Result<()> { + let handshake = ReceiverHandshake { + profile: HandshakeProfile { + id: self.profile.id.clone(), + name: self.profile.name.clone(), + avatar_b64: self.profile.avatar_b64.clone(), + }, + config: HandshakeConfig { + chunk_size: self.config.chunk_size, + parallel_streams: self.config.parallel_streams, + }, + }; + + // Pre-allocate vector with estimated capacity + let mut buffer = Vec::with_capacity(256); + serde_json::to_writer(&mut buffer, &handshake)?; + + let len_bytes = (buffer.len() as u32).to_be_bytes(); + + // Single write operation + let mut combined = Vec::with_capacity(4 + buffer.len()); + combined.extend_from_slice(&len_bytes); + combined.extend_from_slice(&buffer); + + bi.0.write_all(&combined).await?; + Ok(()) + } + + /// Receives all files using unidirectional streams and the negotiated + /// settings. + async fn receive_files(&self) -> Result<()> { + let mut join_set = JoinSet::new(); + + // Use negotiated configuration or fallback to defaults + let (chunk_size, parallel_streams) = + if let Some(config) = &self.negotiated_config { + (config.chunk_size, config.parallel_streams) + } else { + (self.config.chunk_size, self.config.parallel_streams) + }; + + let expected_close = iroh::endpoint::ConnectionError::ApplicationClosed( + iroh::endpoint::ApplicationClose { + error_code: VarInt::from_u32(200), + reason: "finished".into(), + }, + ); + + 'files_iterator: loop { + let connection = self.connection.clone(); + let subscribers = self.subscribers.clone(); + + join_set.spawn(async move { + Self::receive_single_file(chunk_size, connection, subscribers) + .await + }); + + // Limit concurrent streams to negotiated number + while join_set.len() >= parallel_streams as usize { + if let Some(result) = join_set.join_next().await + && let Err(err) = result? + { + // Check for expected close + if let Some(connection_err) = + err.downcast_ref::() + && connection_err == &expected_close + { + break 'files_iterator; + } + self.log(format!("receive_files: Stream failed: {err}")); + return Err(err); + } + } + } + + // Wait for all remaining streams to complete + while let Some(result) = join_set.join_next().await { + if let Err(err) = result? { + if let Some(connection_err) = + err.downcast_ref::() + && connection_err == &expected_close + { + continue; + } + self.log(format!("receive_single_file: Stream failed: {err}")); + return Err(err); + } + } + + self.log("receive_files: All files received successfully".to_string()); + Ok(()) + } + + /// Receives a single file in JSON-framed chunks: + /// - 4-byte big-endian length header + /// - JSON payload containing `FileProjection { id, data }` + async fn receive_single_file( + chunk_size: u64, + connection: Connection, + subscribers: Arc< + RwLock>>, + >, + ) -> Result<()> { + let mut uni = connection.accept_uni().await?; + + let mut buffer = + Vec::with_capacity((chunk_size + 256 * 1024).try_into().unwrap()); + + loop { + buffer.clear(); + + let len = + match Self::read_serialized_projection_len(&mut uni).await? { + Some(l) => l, + None => break, // Stream finished + }; + + buffer.resize(len, 0); + + uni.read_exact(&mut buffer).await?; + + let projection: FileProjection = serde_json::from_slice(&buffer)?; + + // Notify subscribers about received chunk + let event = ReadyToReceiveReceivingEvent { + id: projection.id, + data: projection.data, + }; + + subscribers + .read() + .unwrap() + .iter() + .for_each(|(_, s)| { + s.notify_receiving(event.clone()); + }); + } + + Ok(()) + } + + /// Read a 4-byte big-endian length prefix from a unidirectional stream. + /// + /// Returns: + /// - `Ok(Some(len))` when a length was read, + /// - `Ok(None)` if the stream has finished normally, + /// - `Err(e)` for I/O errors. + async fn read_serialized_projection_len( + uni: &mut RecvStream, + ) -> Result> { + let mut header = [0u8; 4]; + + match uni.read_exact(&mut header).await { + Ok(()) => { + let len = u32::from_be_bytes(header) as usize; + Ok(Some(len)) + } + Err(e) => { + use iroh::endpoint::ReadExactError; + match e { + ReadExactError::FinishedEarly(_) => Ok(None), + ReadExactError::ReadError(io_error) => Err(io_error.into()), + } + } + } + } + + /// Marks the handler as finished and closes the connection with a code and + /// reason. + fn finish(&self) { + self.log("finish: Starting transfer finish process".to_string()); + + self.is_finished + .store(true, std::sync::atomic::Ordering::Relaxed); + self.log("finish: Transfer finished flag set to true".to_string()); + + self.log("finish: Connection closed".to_string()); + self.connection + .close(VarInt::from_u32(200), "finished".as_bytes()); + + self.log("finish: Transfer process completed successfully".to_string()); + } + + /// Internal logger that prefixes subscriber IDs. + fn log(&self, message: String) { + self.subscribers.read().unwrap().iter().for_each( + |(_id, subscriber)| { + subscriber.log(message.clone()); + }, + ); + } +} diff --git a/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs b/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs new file mode 100644 index 00000000..e1feabb6 --- /dev/null +++ b/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs @@ -0,0 +1,334 @@ +//! High-level ready-to-receive operation. +//! +//! This module contains the user-facing entry point `ready_to_receive` and the +//! `ReadyToReceiveBubble` handle returned to the caller. The bubble exposes the +//! ticket and confirmation code, supports cancellation, status queries, and +//! observer subscription for logging and chunk arrivals. + +mod handler; + +use anyhow::Result; +use drop_entities::Profile; +use chrono::{DateTime, Utc}; +use handler::ReadyToReceiveHandler; +use iroh::{Endpoint, Watcher, protocol::Router}; +use iroh_base::ticket::NodeTicket; +use rand::Rng; +use std::sync::Arc; +use uuid::Uuid; + +use super::ReceiverProfile; + +pub use handler::{ + ReadyToReceiveConnectingEvent, ReadyToReceiveFile, + ReadyToReceiveReceivingEvent, ReadyToReceiveSenderProfile, + ReadyToReceiveSubscriber, +}; + +/// All inputs required to start waiting for a sender. +/// +/// Construct this and pass it to [`ready_to_receive`]. +pub struct ReadyToReceiveRequest { + /// Receiver profile data shown to the sender during handshake. + pub profile: ReceiverProfile, + /// Preferred receive configuration. Actual values may be negotiated. + pub config: ReadyToReceiveConfig, +} + +/// Tunable settings for waiting to receive files. +/// +/// Similar to `ReceiverConfig` but used in the ready-to-receive flow. +#[derive(Clone, Debug)] +pub struct ReadyToReceiveConfig { + /// Target chunk size in bytes for incoming file projections. + pub chunk_size: u64, + /// Number of unidirectional streams to process concurrently. + pub parallel_streams: u64, +} + +impl Default for ReadyToReceiveConfig { + /// Returns the balanced preset: + /// - 512 KiB chunks + /// - 4 parallel streams + fn default() -> Self { + Self { + chunk_size: 1024 * 512, // 512KB chunks + parallel_streams: 4, // 4 parallel streams + } + } +} + +impl ReadyToReceiveConfig { + /// Preset optimized for higher bandwidth and modern hardware: + /// - 512 KiB chunks + /// - 8 parallel streams + pub fn high_performance() -> Self { + Self { + chunk_size: 1024 * 512, // 512KB chunks + parallel_streams: 8, // 8 parallel streams + } + } + + /// Alias of `Default::default()` returning a balanced configuration. + pub fn balanced() -> Self { + Self::default() + } + + /// Preset tuned for constrained or lossy networks: + /// - 64 KiB chunks + /// - 2 parallel streams + pub fn low_bandwidth() -> Self { + Self { + chunk_size: 1024 * 64, // 64KB chunks + parallel_streams: 2, // 2 parallel streams + } + } +} + +/// A waiting receive session. +/// +/// Returned by [`ready_to_receive`]. It exposes the ticket and a numeric +/// confirmation code the sender must present to connect. You can subscribe to +/// progress updates, cancel the waiting, and poll the connection state. +pub struct ReadyToReceiveBubble { + ticket: String, + confirmation: u8, + router: Router, + handler: Arc, + created_at: DateTime, +} + +impl ReadyToReceiveBubble { + /// Create a new bubble. Internal use only. + pub fn new( + ticket: String, + confirmation: u8, + router: Router, + handler: Arc, + ) -> Self { + Self { + ticket, + confirmation, + router, + handler, + created_at: Utc::now(), + } + } + + /// Returns the iroh node ticket used by the sender to dial this receiver. + pub fn get_ticket(&self) -> String { + self.ticket.clone() + } + + /// Returns the confirmation code (0–99) that the sender must echo during + /// the acceptance flow. Meant to prevent accidental connections. + pub fn get_confirmation(&self) -> u8 { + self.confirmation + } + + /// Asynchronously cancels the waiting, shutting down the router and + /// preventing any new connections. + pub async fn cancel(&self) -> Result<()> { + self.handler + .log("cancel: Initiating receive wait cancellation".to_string()); + let result = self + .router + .shutdown() + .await + .map_err(|e| anyhow::Error::msg(e.to_string())); + + match &result { + Ok(_) => { + self.handler.log( + "cancel: Receive wait cancelled successfully".to_string(), + ); + } + Err(e) => { + self.handler + .log(format!("cancel: Error during cancellation: {e}")); + } + } + + result + } + + /// Returns true when the router has been shut down or the handler has + /// finished receiving. If finished, it ensures the router is shut down. + pub fn is_finished(&self) -> bool { + let router = self.router.clone(); + let is_router_shutdown = router.is_shutdown(); + let is_handler_finished = self.handler.is_finished(); + let is_finished = is_router_shutdown || is_handler_finished; + + self.handler.log(format!("is_finished: Router shutdown: {is_router_shutdown}, Handler finished: {is_handler_finished}, Overall finished: {is_finished}")); + + if is_finished { + self.handler.log( + "is_finished: Transfer is finished, ensuring router shutdown" + .to_string(), + ); + + tokio::spawn(async move { + let _ = router.shutdown().await; + }); + } + + is_finished + } + + /// Returns true if a sender has connected and been accepted (i.e., + /// the handler has consumed the single allowed connection). + pub fn is_connected(&self) -> bool { + let finished = self.is_finished(); + if finished { + self.handler.log( + "is_connected: Transfer is finished, returning false" + .to_string(), + ); + return false; + } + + let consumed = self.handler.is_consumed(); + self.handler + .log(format!("is_connected: Handler consumed: {consumed}")); + + consumed + } + + /// Returns the RFC3339 timestamp marking when this bubble was created. + pub fn get_created_at(&self) -> String { + self.created_at.to_rfc3339() + } + + /// Register a subscriber to receive logs and chunk notifications. + /// + /// Subscribers must be `Send + Sync`. Duplicate IDs will replace previous + /// subscribers with the same ID. + pub fn subscribe(&self, subscriber: Arc) { + let subscriber_id = subscriber.get_id(); + self.handler.log(format!( + "subscribe: Subscribing new subscriber with ID: {subscriber_id}" + )); + self.handler.subscribe(subscriber); + } + + /// Remove a previously registered subscriber. + pub fn unsubscribe(&self, subscriber: Arc) { + let subscriber_id = subscriber.get_id(); + self.handler.log(format!( + "unsubscribe: Unsubscribing subscriber with ID: {subscriber_id}" + )); + self.handler.unsubscribe(subscriber); + } +} + +/// Starts waiting for a sender and returns a [`ReadyToReceiveBubble`] handle. +/// +/// The function: +/// - Builds an iroh endpoint with discovery enabled. +/// - Generates a random human-check confirmation code (0–99). +/// - Spawns a protocol router that accepts exactly one sender matching the +/// confirmation code. +/// - Returns the ticket and handle used to monitor or cancel the waiting. +/// +/// Errors if the endpoint fails to bind or the router cannot be spawned. +/// +/// Example: +/// ```rust no_run +/// use std::sync::Arc; +/// use arkdropx_receiver::{ +/// ready_to_receive::*, ReceiverProfile, +/// }; +/// +/// struct Logger; +/// impl ReadyToReceiveSubscriber for Logger { +/// fn get_id(&self) -> String { "logger".into() } +/// fn log(&self, msg: String) { println!("[log] {msg}"); } +/// fn notify_receiving(&self, e: ReadyToReceiveReceivingEvent) { +/// println!("chunk for {}: {} bytes", e.id, e.data.len()); +/// } +/// fn notify_connecting(&self, e: ReadyToReceiveConnectingEvent) { +/// println!("sender: {}, files: {}", e.sender.name, e.files.len()); +/// } +/// } +/// +/// # async fn run() -> anyhow::Result<()> { +/// let bubble = ready_to_receive(ReadyToReceiveRequest { +/// profile: ReceiverProfile { name: "Receiver".into(), avatar_b64: None }, +/// config: ReadyToReceiveConfig::balanced(), +/// }).await?; +/// +/// bubble.subscribe(Arc::new(Logger)); +/// println!("Ticket: {}", bubble.get_ticket()); +/// println!("Confirmation: {}", bubble.get_confirmation()); +/// +/// // ... wait for sender connection and file reception ... +/// # Ok(()) +/// # } +/// ``` +pub async fn ready_to_receive( + request: ReadyToReceiveRequest, +) -> Result { + let profile = Profile { + id: Uuid::new_v4().to_string(), + name: request.profile.name.clone(), + avatar_b64: request.profile.avatar_b64.clone(), + }; + + let handler = + Arc::new(ReadyToReceiveHandler::new(profile, request.config.clone())); + + handler.log( + "ready_to_receive: Starting receive wait initialization".to_string(), + ); + handler.log(format!( + "ready_to_receive: Chunk size configuration: {} bytes", + request.config.chunk_size + )); + + handler.log( + "ready_to_receive: Creating endpoint builder with discovery_n0" + .to_string(), + ); + let endpoint_builder = Endpoint::builder().discovery_n0(); + + handler.log("ready_to_receive: Binding endpoint".to_string()); + let endpoint = endpoint_builder.bind().await?; + handler.log("ready_to_receive: Endpoint bound successfully".to_string()); + + handler.log("ready_to_receive: Initializing node address".to_string()); + let node_addr = endpoint.node_addr().initialized().await; + handler.log(format!( + "ready_to_receive: Node address initialized: {node_addr:?}" + )); + + handler.log( + "ready_to_receive: Generating random confirmation code".to_string(), + ); + let confirmation: u8 = rand::rng().random_range(0..=99); + handler.log(format!( + "ready_to_receive: Generated confirmation code: {confirmation}" + )); + + handler.log("ready_to_receive: Creating router with handler".to_string()); + let router = Router::builder(endpoint) + .accept([confirmation], handler.clone()) + .spawn(); + handler.log( + "ready_to_receive: Router created and spawned successfully".to_string(), + ); + + let ticket = NodeTicket::new(node_addr).to_string(); + handler.log(format!("ready_to_receive: Generated ticket: {ticket}")); + handler.log( + "ready_to_receive: Receive wait initialization completed successfully" + .to_string(), + ); + + Ok(ReadyToReceiveBubble::new( + ticket, + confirmation, + router, + handler, + )) +} diff --git a/drop-core/exchanges/sender/src/lib.rs b/drop-core/exchanges/sender/src/lib.rs index 9d2e0cfe..df04a203 100644 --- a/drop-core/exchanges/sender/src/lib.rs +++ b/drop-core/exchanges/sender/src/lib.rs @@ -8,16 +8,26 @@ //! - A `SendFilesBubble` handle that lets you observe progress, subscribe to //! events, and cancel or query the transfer. //! -//! Typical usage: +//! Two modes of operation: +//! +//! ## Standard Mode (Sender initiates, generates QR) //! - Implement `SenderFileData` for your source (bytes in memory, file on disk, //! etc.). //! - Construct `SenderFile` values for each file. //! - Choose a `SenderConfig` (or the default). //! - Call `send_files` to start a transfer and get a `SendFilesBubble`. +//! - Display ticket/confirmation for receiver to scan. +//! +//! ## QR-to-Receive Mode (Sender connects to waiting receiver) +//! - Scan receiver's QR code to get ticket and confirmation. +//! - Construct `SendFilesToRequest` with receiver's ticket, files, and profile. +//! - Call `send_files_to` to connect and get a `SendFilesToBubble`. +//! - Call `start()` to begin transfer. //! -//! See `send_files` module for the operational flow and events. +//! See `send_files` and `send_files_to` modules for the operational flows. mod send_files; +pub mod send_files_to; use drop_entities::Data; use std::sync::Arc; diff --git a/drop-core/exchanges/sender/src/send_files_to.rs b/drop-core/exchanges/sender/src/send_files_to.rs new file mode 100644 index 00000000..06addcaf --- /dev/null +++ b/drop-core/exchanges/sender/src/send_files_to.rs @@ -0,0 +1,565 @@ +//! Send files to a waiting receiver. +//! +//! This module provides the `send_files_to` function which connects to a +//! receiver's ticket (from ready_to_receive) and sends files. This is the +//! complement to the receiver's ready_to_receive flow. + +use crate::{SenderConfig, SenderFile, SenderFileDataAdapter, SenderProfile}; +use anyhow::Result; +use drop_entities::{File, Profile}; +use dropx_common::{ + handshake::{ + HandshakeConfig, HandshakeFile, HandshakeProfile, NegotiatedConfig, + ReceiverHandshake, SenderHandshake, + }, + projection::FileProjection, +}; +use iroh::{ + Endpoint, + endpoint::{Connection, RecvStream, SendStream, VarInt}, +}; +use iroh_base::ticket::NodeTicket; +use std::{ + collections::HashMap, + sync::{Arc, RwLock, atomic::AtomicBool}, +}; +use tokio::task::JoinSet; +use uuid::Uuid; + +/// All inputs required to send files to a waiting receiver. +/// +/// Construct this and pass it to [`send_files_to`]. +pub struct SendFilesToRequest { + /// Receiver's ticket (obtained from their QR code or directly). + pub ticket: String, + /// Receiver's confirmation code (0–99). + pub confirmation: u8, + /// Sender profile data shown to the receiver during handshake. + pub profile: SenderProfile, + /// Files to transfer. Each file must provide a `SenderFileData` source. + pub files: Vec, + /// Preferred transfer configuration. Actual values may be negotiated. + pub config: SenderConfig, +} + +/// A running send-to-receiver session. +/// +/// Returned by [`send_files_to`]. You can subscribe to progress updates and +/// poll the connection state. +pub struct SendFilesToBubble { + endpoint: Endpoint, + connection: Connection, + profile: Profile, + files: Vec, + config: SenderConfig, + is_running: Arc, + is_finished: Arc, + subscribers: Arc>>>, +} + +impl SendFilesToBubble { + /// Create a new bubble. Internal use only. + pub fn new( + endpoint: Endpoint, + connection: Connection, + profile: Profile, + files: Vec, + config: SenderConfig, + ) -> Self { + Self { + endpoint, + connection, + profile, + files, + config, + is_running: Arc::new(AtomicBool::new(false)), + is_finished: Arc::new(AtomicBool::new(false)), + subscribers: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Start the send-to-receiver transfer asynchronously. + /// + /// - Performs handshake, then begins sending file data. + /// - Returns an error if the bubble has already been started. + /// - Progress is published to subscribers. + pub fn start(&self) -> Result<()> { + self.log("start: Checking if transfer can be started".to_string()); + + let is_running = self + .is_running + .load(std::sync::atomic::Ordering::Acquire); + + if is_running { + self.log( + "start: Cannot start transfer, already running".to_string(), + ); + return Err(anyhow::Error::msg("Already running.")); + } + + self.log("start: Setting running flag".to_string()); + self.is_running + .store(true, std::sync::atomic::Ordering::Release); + + self.log("start: Creating carrier for file sending".to_string()); + let carrier = Carrier { + profile: self.profile.clone(), + config: self.config.clone(), + negotiated_config: None, + connection: self.connection.clone(), + files: self.files.clone(), + is_finished: self.is_finished.clone(), + subscribers: self.subscribers.clone(), + }; + + self.log("start: Spawning async task for file sending".to_string()); + let endpoint = self.endpoint.clone(); + tokio::spawn(async move { + let mut carrier = carrier; + if let Err(e) = carrier.greet().await { + carrier.log(format!("start: Handshake failed: {e}")); + carrier.finish(&endpoint).await; + return; + } + + let result = carrier.send_files().await; + if let Err(e) = result { + carrier.log(format!("start: File sending failed: {e}")); + } else { + carrier.log( + "start: File sending completed successfully".to_string(), + ); + } + + carrier.finish(&endpoint).await; + }); + + Ok(()) + } + + /// Returns `true` when the session has completed cleanup. + pub fn is_finished(&self) -> bool { + let finished = self + .is_finished + .load(std::sync::atomic::Ordering::Relaxed); + self.log(format!("is_finished check: {finished}")); + finished + } + + /// Register a subscriber to receive log and progress events. + pub fn subscribe(&self, subscriber: Arc) { + let subscriber_id = subscriber.get_id(); + self.log(format!( + "subscribe: Subscribing new subscriber with ID: {subscriber_id}" + )); + + self.subscribers + .write() + .unwrap() + .insert(subscriber_id.clone(), subscriber); + + self.log(format!("subscribe: Subscriber {subscriber_id} successfully subscribed. Total subscribers: {}", self.subscribers.read().unwrap().len())); + } + + /// Remove a previously registered subscriber. + pub fn unsubscribe(&self, subscriber: Arc) { + let subscriber_id = subscriber.get_id(); + self.log(format!( + "unsubscribe: Unsubscribing subscriber with ID: {subscriber_id}" + )); + + let removed = self + .subscribers + .write() + .unwrap() + .remove(&subscriber_id); + + if removed.is_some() { + self.log(format!("unsubscribe: Subscriber {subscriber_id} successfully unsubscribed. Remaining subscribers: {}", self.subscribers.read().unwrap().len())); + } else { + self.log(format!("unsubscribe: Subscriber {subscriber_id} was not found during unsubscribe operation")); + } + } + + fn log(&self, message: String) { + self.subscribers.read().unwrap().iter().for_each( + |(_id, subscriber)| { + subscriber.log(message.clone()); + }, + ); + } +} + +/// Subscriber interface for observing send-to-receiver transfer. +pub trait SendFilesToSubscriber: Send + Sync { + /// Stable identifier for this subscriber (used as a map key). + fn get_id(&self) -> String; + /// Receive diagnostic log messages. + fn log(&self, message: String); + /// Receive progress updates for each file being sent. + fn notify_sending(&self, event: SendFilesToSendingEvent); + /// Notified when receiver connection is established. + fn notify_connecting(&self, event: SendFilesToConnectingEvent); +} + +/// Per-file progress event. +#[derive(Clone)] +pub struct SendFilesToSendingEvent { + pub id: String, + pub name: String, + pub sent: u64, + pub remaining: u64, +} + +/// Connection event carrying the receiver's profile. +pub struct SendFilesToConnectingEvent { + pub receiver: SendFilesToReceiverProfile, +} + +/// Receiver profile details surfaced to subscribers. +#[derive(Clone)] +pub struct SendFilesToReceiverProfile { + pub id: String, + pub name: String, + pub avatar_b64: Option, +} + +/// Helper that performs handshake, configuration negotiation, and streaming. +struct Carrier { + profile: Profile, + config: SenderConfig, + negotiated_config: Option, + connection: Connection, + files: Vec, + is_finished: Arc, + subscribers: Arc>>>, +} + +impl Carrier { + /// Performs the bidirectional handshake exchange. + async fn greet(&mut self) -> Result<()> { + let mut bi = self.connection.open_bi().await?; + + self.send_handshake(&mut bi).await?; + self.receive_handshake(&mut bi).await?; + + bi.0.finish()?; + bi.1.stop(VarInt::from_u32(0))?; + + self.log("greet: Handshake completed successfully".to_string()); + Ok(()) + } + + /// Sends the sender's profile, file list, and preferred configuration. + async fn send_handshake( + &self, + bi: &mut (SendStream, RecvStream), + ) -> Result<()> { + let handshake = SenderHandshake { + profile: HandshakeProfile { + id: self.profile.id.clone(), + name: self.profile.name.clone(), + avatar_b64: self.profile.avatar_b64.clone(), + }, + files: self + .files + .iter() + .map(|f| HandshakeFile { + id: f.id.clone(), + name: f.name.clone(), + len: f.data.len(), + }) + .collect(), + config: HandshakeConfig { + chunk_size: self.config.chunk_size, + parallel_streams: self.config.parallel_streams, + }, + }; + + let mut buffer = Vec::with_capacity(512); + serde_json::to_writer(&mut buffer, &handshake)?; + + let len_bytes = (buffer.len() as u32).to_be_bytes(); + + let mut combined = Vec::with_capacity(4 + buffer.len()); + combined.extend_from_slice(&len_bytes); + combined.extend_from_slice(&buffer); + + bi.0.write_all(&combined).await?; + Ok(()) + } + + /// Receives the receiver handshake and computes the negotiated + /// configuration. + async fn receive_handshake( + &mut self, + bi: &mut (SendStream, RecvStream), + ) -> Result<()> { + let mut header = [0u8; 4]; + bi.1.read_exact(&mut header).await?; + let len = u32::from_be_bytes(header); + + let mut buffer = vec![0u8; len as usize]; + bi.1.read_exact(&mut buffer).await?; + + let handshake: ReceiverHandshake = serde_json::from_slice(&buffer)?; + + // Negotiate configuration + let sender_config = HandshakeConfig { + chunk_size: self.config.chunk_size, + parallel_streams: self.config.parallel_streams, + }; + + self.negotiated_config = Some(NegotiatedConfig::negotiate( + &sender_config, + &handshake.config, + )); + + // Notify subscribers + let profile = SendFilesToReceiverProfile { + id: handshake.profile.id, + name: handshake.profile.name, + avatar_b64: handshake.profile.avatar_b64, + }; + + self.subscribers + .read() + .unwrap() + .iter() + .for_each(|(_, s)| { + s.notify_connecting(SendFilesToConnectingEvent { + receiver: profile.clone(), + }); + }); + + Ok(()) + } + + /// Streams all files using unidirectional streams. + async fn send_files(&self) -> Result<()> { + let mut join_set = JoinSet::new(); + + let (chunk_size, parallel_streams) = + if let Some(config) = &self.negotiated_config { + (config.chunk_size, config.parallel_streams) + } else { + (self.config.chunk_size, self.config.parallel_streams) + }; + + for file in self.files.clone() { + let connection = self.connection.clone(); + let subscribers = self.subscribers.clone(); + + join_set.spawn(async move { + return Self::send_single_file( + &file, + chunk_size, + connection, + subscribers, + ) + .await; + }); + + if join_set.len() >= parallel_streams as usize + && let Some(result) = join_set.join_next().await + && let Err(err) = result? + { + self.log(format!("send_files: Stream failed: {err}")); + return Err(err); + } + } + + while let Some(result) = join_set.join_next().await { + if let Err(err) = result? { + self.log(format!("send_single_file: Stream failed: {err}")); + return Err(err); + } + } + + self.log("send_files: All files transferred successfully".to_string()); + Ok(()) + } + + /// Streams a single file in JSON-framed chunks. + async fn send_single_file( + file: &File, + chunk_size: u64, + connection: Connection, + subscribers: Arc< + RwLock>>, + >, + ) -> Result<()> { + let total_len = file.data.len(); + let mut sent = 0u64; + let mut remaining = total_len; + let mut chunk_buffer = + Vec::with_capacity((chunk_size + 1024).try_into().unwrap()); + + let mut uni = connection.open_uni().await?; + + Self::notify_progress(&file, sent, remaining, subscribers.clone()); + + loop { + chunk_buffer.clear(); + + let chunk_data = file.data.read_chunk(chunk_size); + if chunk_data.is_empty() { + break; + } + let projection = FileProjection { + id: file.id.clone(), + data: chunk_data, + }; + + serde_json::to_writer(&mut chunk_buffer, &projection)?; + let len_bytes = (chunk_buffer.len() as u32).to_be_bytes(); + + uni.write_all(&len_bytes).await?; + uni.write_all(&chunk_buffer).await?; + + let data_len = projection.data.len() as u64; + sent += data_len; + remaining = remaining.saturating_sub(data_len); + + Self::notify_progress(&file, sent, remaining, subscribers.clone()); + } + + uni.finish()?; + uni.stopped().await?; + + Ok(()) + } + + /// Marks the transfer as finished and closes the connection and endpoint. + async fn finish(&self, endpoint: &Endpoint) { + self.log("finish: Starting transfer finish process".to_string()); + + self.is_finished + .store(true, std::sync::atomic::Ordering::Relaxed); + self.log("finish: Transfer finished flag set to true".to_string()); + + self.log("finish: Closing connection".to_string()); + self.connection + .close(VarInt::from_u32(200), "finished".as_bytes()); + + self.log("finish: Closing endpoint".to_string()); + endpoint.close().await; + + self.log("finish: Transfer process completed successfully".to_string()); + } + + fn log(&self, message: String) { + self.subscribers.read().unwrap().iter().for_each( + |(_id, subscriber)| { + subscriber.log(message.clone()); + }, + ); + } + + fn notify_progress( + file: &File, + sent: u64, + remaining: u64, + subscribers: Arc< + RwLock>>, + >, + ) { + let event = SendFilesToSendingEvent { + id: file.id.clone(), + name: file.name.clone(), + sent, + remaining, + }; + + subscribers + .read() + .unwrap() + .iter() + .for_each(|(_, s)| { + s.notify_sending(event.clone()); + }); + } +} + +/// Connects to a waiting receiver and sends files. +/// +/// This function: +/// - Parses the provided receiver `ticket`, +/// - Creates and binds a new iroh `Endpoint`, +/// - Connects to the receiver using the confirmation token, +/// - Returns a `SendFilesToBubble` that you can `start()` and subscribe to for +/// events. +/// +/// Example: +/// ```rust no_run +/// use std::sync::Arc; +/// use arkdropx_sender::{ +/// send_files_to::*, SenderProfile, SenderConfig, SenderFile, +/// }; +/// +/// struct Logger; +/// impl SendFilesToSubscriber for Logger { +/// fn get_id(&self) -> String { "logger".into() } +/// fn log(&self, msg: String) { println!("[log] {msg}"); } +/// fn notify_sending(&self, e: SendFilesToSendingEvent) { +/// println!("sent {}/{} for {}", e.sent, e.sent + e.remaining, e.name); +/// } +/// fn notify_connecting(&self, e: SendFilesToConnectingEvent) { +/// println!("connected to receiver: {}", e.receiver.name); +/// } +/// } +/// +/// # async fn run() -> anyhow::Result<()> { +/// let bubble = send_files_to(SendFilesToRequest { +/// ticket: "".into(), +/// confirmation: 42, +/// profile: SenderProfile { name: "Sender".into(), avatar_b64: None }, +/// files: vec![/* ... */], +/// config: SenderConfig::balanced(), +/// }).await?; +/// +/// bubble.subscribe(Arc::new(Logger)); +/// bubble.start()?; +/// +/// // ... await completion ... +/// # Ok(()) +/// # } +/// ``` +pub async fn send_files_to( + request: SendFilesToRequest, +) -> Result { + let ticket: NodeTicket = request.ticket.parse()?; + + let endpoint_builder = Endpoint::builder().discovery_n0(); + let endpoint = endpoint_builder.bind().await?; + let connection = endpoint + .connect(ticket, &[request.confirmation]) + .await?; + + let profile = Profile { + id: Uuid::new_v4().to_string(), + name: request.profile.name, + avatar_b64: request.profile.avatar_b64, + }; + + let files: Vec = request + .files + .into_iter() + .map(|f| { + let data = SenderFileDataAdapter { inner: f.data }; + File { + id: Uuid::new_v4().to_string(), + name: f.name, + data: Arc::new(data), + } + }) + .collect(); + + Ok(SendFilesToBubble::new( + endpoint, + connection, + profile, + files, + request.config, + )) +} From acf0554981733baa54f608f27de9fb02b89a17ae Mon Sep 17 00:00:00 2001 From: Pushkar Mishra Date: Fri, 17 Oct 2025 15:48:20 +0530 Subject: [PATCH 02/17] Enhance drop-core CLI with QR code support and credential handling - Added `prompt_for_credentials` function for asynchronous user input of peer credentials. - Introduced QR code generation for ticket and confirmation codes to facilitate file transfers. - Updated CLI commands to allow for manual credential entry and improved user prompts. - Enhanced logging and error handling throughout the file transfer process. - Updated dependencies in `Cargo.toml` to include `qrcode` for QR code functionality. These changes improve the user experience by providing a seamless way to connect senders and receivers through QR codes or manual input of credentials. Signed-off-by: Pushkar Mishra --- drop-core/cli/Cargo.toml | 3 +- drop-core/cli/src/handshake.rs | 32 ++ drop-core/cli/src/lib.rs | 507 ++++++++++++------ drop-core/cli/src/main.rs | 270 ++++------ .../receiver/src/ready_to_receive/mod.rs | 2 +- 5 files changed, 499 insertions(+), 315 deletions(-) create mode 100644 drop-core/cli/src/handshake.rs diff --git a/drop-core/cli/Cargo.toml b/drop-core/cli/Cargo.toml index 268db74b..eb55b526 100644 --- a/drop-core/cli/Cargo.toml +++ b/drop-core/cli/Cargo.toml @@ -27,8 +27,9 @@ clap = { version = "4.0", features = ["derive"] } base64 = "0.21" serde = { version = "1.0", features = ["derive"] } toml = "0.8" +qrcode = "0.14" -dropx-receiver = { path = "../exchanges/receiver" } +dropx-receiver = { path = "../exchanges/receiver" } dropx-sender = { path = "../exchanges/sender" } indicatif = "0.18.0" diff --git a/drop-core/cli/src/handshake.rs b/drop-core/cli/src/handshake.rs new file mode 100644 index 00000000..a9908a2c --- /dev/null +++ b/drop-core/cli/src/handshake.rs @@ -0,0 +1,32 @@ +use anyhow::{Context, Result}; +use std::io::Write; +use tokio::io::{AsyncBufReadExt, BufReader}; + +/// Prompt user for peer's credentials (async version using tokio) +pub async fn prompt_for_credentials() -> Result<(String, u8)> { + print!("\nEnter peer's ticket: "); + std::io::stdout().flush()?; + + let stdin = tokio::io::stdin(); + let mut reader = BufReader::new(stdin); + + let mut ticket = String::new(); + reader.read_line(&mut ticket).await?; + let ticket = ticket.trim().to_string(); + + if ticket.is_empty() { + anyhow::bail!("Ticket cannot be empty"); + } + + print!("Enter peer's confirmation code: "); + std::io::stdout().flush()?; + + let mut confirmation = String::new(); + reader.read_line(&mut confirmation).await?; + let confirmation = confirmation + .trim() + .parse::() + .context("Invalid confirmation code - must be a number 0-255")?; + + Ok((ticket, confirmation)) +} diff --git a/drop-core/cli/src/lib.rs b/drop-core/cli/src/lib.rs index 6742db7b..71983fff 100644 --- a/drop-core/cli/src/lib.rs +++ b/drop-core/cli/src/lib.rs @@ -68,10 +68,10 @@ use dropx_receiver::{ ReceiveFilesConnectingEvent, ReceiveFilesFile, ReceiveFilesReceivingEvent, ReceiveFilesRequest, ReceiveFilesSubscriber, ReceiverProfile, ready_to_receive::{ - ReadyToReceiveBubble, ReadyToReceiveConnectingEvent, + ReadyToReceiveBubble, ReadyToReceiveConfig, + ReadyToReceiveConnectingEvent, ReadyToReceiveFile, ReadyToReceiveReceivingEvent, ReadyToReceiveRequest, - ReadyToReceiveSubscriber, ready_to_receive, ReadyToReceiveConfig, - ReadyToReceiveFile, + ReadyToReceiveSubscriber, ready_to_receive, }, receive_files, }; @@ -88,6 +88,57 @@ use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +// ============================================================================ +// QR Code Generation and Display +// ============================================================================ + +/// Generate ASCII QR code for ticket and confirmation +fn generate_qr_code(ticket: &str, confirmation: u8) -> Result { + use qrcode::{QrCode, render::unicode}; + + let data = format!("{}:{}", ticket, confirmation); + let code = + QrCode::new(data.as_bytes()).context("Failed to generate QR code")?; + + let image = code + .render::() + .dark_color(unicode::Dense1x2::Light) + .light_color(unicode::Dense1x2::Dark) + .build(); + + Ok(image) +} + +/// Display QR code and credentials for a transfer session +fn display_session_info(ticket: &str, confirmation: u8, role: &str) { + println!("\n========================================"); + println!("ARK Drop - {}", role); + println!("========================================\n"); + + // Generate QR code + match generate_qr_code(ticket, confirmation) { + Ok(qr) => { + println!("{}\n", qr); + } + Err(e) => { + eprintln!("Warning: Could not generate QR code: {}", e); + println!("Ticket and confirmation are shown below:\n"); + } + } + + println!("Ticket: {}", ticket); + println!("Confirmation: {}", confirmation); + println!("\n========================================"); + println!("Waiting for connection..."); + println!("Press 'c' + Enter to enter peer's credentials instead"); + println!("Press Ctrl+C to cancel"); + println!("========================================\n"); +} + +// ============================================================================ +// Configuration +// ============================================================================ + /// Configuration for the CLI application. /// /// This structure is persisted to TOML and stores user preferences for the CLI @@ -338,23 +389,25 @@ impl FileSender { let subscriber = FileSendSubscriber::new(verbose); bubble.subscribe(Arc::new(subscriber)); - println!("📦 Ready to send files!"); - println!("🎫 Ticket: \"{}\"", bubble.get_ticket()); - println!("🔑 Confirmation: \"{}\"", bubble.get_confirmation()); - println!("⏳ Waiting for receiver... (Press Ctrl+C to cancel)"); + // Display QR code and session info + display_session_info( + &bubble.get_ticket(), + bubble.get_confirmation(), + "Sender", + ); tokio::select! { _ = tokio::signal::ctrl_c() => { - println!("🚫 Cancelling file transfer..."); + println!("Cancelling file transfer..."); let _ = bubble.cancel().await; - println!("✅ Transfer cancelled"); + println!("Transfer cancelled"); + std::process::exit(0); } _ = wait_for_send_completion(&bubble) => { - println!("✅ All files sent successfully!"); + println!("All files sent successfully!"); + std::process::exit(0); } } - - Ok(()) } /// Converts file paths into SenderFile entries backed by FileData. @@ -466,27 +519,27 @@ impl FileReceiver { FileReceiveSubscriber::new(receiving_path.clone(), verbose); bubble.subscribe(Arc::new(subscriber)); - println!("📥 Starting file transfer..."); - println!("📁 Files will be saved to: {}", receiving_path.display()); + println!("Starting file transfer..."); + println!("Files will be saved to: {}", receiving_path.display()); bubble .start() .context("Failed to start file receiving")?; - println!("⏳ Receiving files... (Press Ctrl+C to cancel)"); + println!("Receiving files... (Press Ctrl+C to cancel)"); tokio::select! { _ = tokio::signal::ctrl_c() => { - println!("🚫 Cancelling file transfer..."); + println!("Cancelling file transfer..."); bubble.cancel(); - println!("✅ Transfer cancelled"); + println!("Transfer cancelled"); + std::process::exit(0); } _ = wait_for_receive_completion(&bubble) => { - println!("✅ All files received successfully!"); + println!("All files received successfully!"); + std::process::exit(0); } } - - Ok(()) } /// Returns a ReceiverProfile derived from this FileReceiver's Profile. @@ -539,7 +592,7 @@ impl FileSendSubscriber { ProgressStyle::with_template( "{spinner:.green} {msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})", ) - .unwrap() + .unwrap_or_else(|_| ProgressStyle::default_bar()) .progress_chars("#>-") } } @@ -551,13 +604,19 @@ impl SendFilesSubscriber for FileSendSubscriber { fn log(&self, message: String) { if self.verbose { - let _ = self.mp.println(format!("🔍 {}", message)); + let _ = self.mp.println(format!("[DEBUG] {}", message)); } } fn notify_sending(&self, event: SendFilesSendingEvent) { // Get or create a progress bar for this file (by name) - let mut bars = self.bars.write().unwrap(); + let mut bars = match self.bars.write() { + Ok(bars) => bars, + Err(e) => { + eprintln!("Error accessing progress bars: {}", e); + return; + } + }; let pb = bars.entry(event.name.clone()).or_insert_with(|| { let total = event.sent + event.remaining; let pb = if total > 0 { @@ -570,7 +629,7 @@ impl SendFilesSubscriber for FileSendSubscriber { ProgressStyle::with_template( "{spinner:.green} {msg} {bytes} ({bytes_per_sec})", ) - .unwrap(), + .unwrap_or_else(|_| ProgressStyle::default_spinner()), ); pb.enable_steady_tick(std::time::Duration::from_millis(100)); pb @@ -587,7 +646,7 @@ impl SendFilesSubscriber for FileSendSubscriber { } if event.remaining == 0 { - pb.finish_with_message(format!("✅ Sent {}", event.name)); + pb.finish_with_message(format!("[DONE] Sent {}", event.name)); } else { pb.set_message(format!("Sending {}", event.name)); } @@ -596,13 +655,13 @@ impl SendFilesSubscriber for FileSendSubscriber { fn notify_connecting(&self, event: SendFilesConnectingEvent) { let _ = self .mp - .println("🔗 Connected to receiver:".to_string()); + .println("Connected to receiver:".to_string()); let _ = self .mp - .println(format!(" 📛 Name: {}", event.receiver.name)); + .println(format!(" Name: {}", event.receiver.name)); let _ = self .mp - .println(format!(" 🆔 ID: {}", event.receiver.id)); + .println(format!(" ID: {}", event.receiver.id)); } } @@ -614,6 +673,8 @@ struct FileReceiveSubscriber { mp: MultiProgress, bars: RwLock>, received: RwLock>, + // Cache file handles to avoid reopening on every chunk + file_handles: RwLock>, } impl FileReceiveSubscriber { fn new(receiving_path: PathBuf, verbose: bool) -> Self { @@ -625,6 +686,7 @@ impl FileReceiveSubscriber { mp: MultiProgress::new(), bars: RwLock::new(HashMap::new()), received: RwLock::new(HashMap::new()), + file_handles: RwLock::new(HashMap::new()), } } @@ -632,7 +694,7 @@ impl FileReceiveSubscriber { ProgressStyle::with_template( "{spinner:.green} {msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})", ) - .unwrap() + .unwrap_or_else(|_| ProgressStyle::default_bar()) .progress_chars("#>-") } } @@ -643,7 +705,7 @@ impl ReceiveFilesSubscriber for FileReceiveSubscriber { fn log(&self, message: String) { if self.verbose { - let _ = self.mp.println(format!("🔍 {}", message)); + let _ = self.mp.println(format!("[DEBUG] {}", message)); } } @@ -652,47 +714,52 @@ impl ReceiveFilesSubscriber for FileReceiveSubscriber { let files = match self.files.read() { Ok(files) => files, Err(e) => { - eprintln!("❌ Error accessing files list: {}", e); + eprintln!("[ERROR] Error accessing files list: {}", e); return; } }; let file = match files.iter().find(|f| f.id == event.id) { Some(file) => file, None => { - eprintln!("❌ File not found with ID: {}", event.id); + eprintln!("[ERROR] File not found with ID: {}", event.id); return; } }; // Create/find progress bar for this file - let mut bars = self.bars.write().unwrap(); - let pb = bars.entry(event.id.clone()).or_insert_with(|| { - // Try to use total size if available; fallback to spinner - #[allow(unused_mut)] - let mut total_opt: Option = None; - - if let Some(total) = total_opt { - let pb = self.mp.add(ProgressBar::new(total)); - pb.set_style(Self::bar_style()); - pb.set_message(format!("Receiving {}", file.name)); - pb - } else { - let pb = self.mp.add(ProgressBar::new_spinner()); - pb.set_style( - ProgressStyle::with_template( - "{spinner:.green} {msg} {bytes} ({bytes_per_sec})", - ) - .unwrap(), - ); - pb.enable_steady_tick(std::time::Duration::from_millis(100)); - pb.set_message(format!("Receiving {}", file.name)); - pb + let mut bars = match self.bars.write() { + Ok(bars) => bars, + Err(e) => { + eprintln!("[ERROR] Error accessing progress bars: {}", e); + return; } + }; + let pb = bars.entry(event.id.clone()).or_insert_with(|| { + // Use spinner for receivers (file size not known initially) + let pb = self.mp.add(ProgressBar::new_spinner()); + pb.set_style( + ProgressStyle::with_template( + "{spinner:.green} {msg} {bytes} ({bytes_per_sec})", + ) + .unwrap_or_else(|_| ProgressStyle::default_spinner()), + ); + pb.enable_steady_tick(std::time::Duration::from_millis(100)); + pb.set_message(format!("Receiving {}", file.name)); + pb }); // Update received byte count { - let mut recvd = self.received.write().unwrap(); + let mut recvd = match self.received.write() { + Ok(recvd) => recvd, + Err(e) => { + eprintln!( + "[ERROR] Error accessing received bytes tracker: {}", + e + ); + return; + } + }; let entry = recvd.entry(event.id.clone()).or_insert(0); *entry += event.data.len() as u64; @@ -701,7 +768,7 @@ impl ReceiveFilesSubscriber for FileReceiveSubscriber { pb.set_position(*entry); if *entry >= len { pb.finish_with_message(format!( - "✅ Received {}", + "[DONE] Received {}", file.name )); } @@ -712,43 +779,56 @@ impl ReceiveFilesSubscriber for FileReceiveSubscriber { let file_path = self.receiving_path.join(&file.name); - match fs::File::options() - .create(true) - .append(true) - .open(&file_path) - { - Ok(mut file_stream) => { - if let Err(e) = file_stream.write_all(&event.data) { - eprintln!("❌ Error writing to file {}: {}", file.name, e); - return; - } - if let Err(e) = file_stream.flush() { - eprintln!("❌ Error flushing file {}: {}", file.name, e); - return; - } - } + // Get or create cached file handle + let mut file_handles = match self.file_handles.write() { + Ok(handles) => handles, Err(e) => { - eprintln!("❌ Error opening file {}: {}", file.name, e); + eprintln!("[ERROR] Error accessing file handles: {}", e); + return; } + }; + let file_handle = file_handles + .entry(event.id.clone()) + .or_insert_with(|| { + fs::File::options() + .create(true) + .append(true) + .open(&file_path) + .unwrap_or_else(|e| { + panic!( + "Failed to open file {}: {}", + file_path.display(), + e + ) + }) + }); + + // Write to the cached file handle + if let Err(e) = file_handle.write_all(&event.data) { + eprintln!("[ERROR] Error writing to file {}: {}", file.name, e); + return; + } + if let Err(e) = file_handle.flush() { + eprintln!("[ERROR] Error flushing file {}: {}", file.name, e); } } fn notify_connecting(&self, event: ReceiveFilesConnectingEvent) { let _ = self .mp - .println("🔗 Connected to sender:".to_string()); + .println("Connected to sender:".to_string()); let _ = self .mp - .println(format!(" 📛 Name: {}", event.sender.name)); + .println(format!(" Name: {}", event.sender.name)); let _ = self .mp - .println(format!(" 🆔 ID: {}", event.sender.id)); + .println(format!(" ID: {}", event.sender.id)); let _ = self .mp - .println(format!(" 📁 Files to receive: {}", event.files.len())); + .println(format!(" Files to receive: {}", event.files.len())); for f in &event.files { - let _ = self.mp.println(format!(" 📄 {}", f.name)); + let _ = self.mp.println(format!(" - {}", f.name)); } // Keep the list of files and prepare bars if sizes are known @@ -756,7 +836,16 @@ impl ReceiveFilesSubscriber for FileReceiveSubscriber { Ok(mut files) => { files.extend(event.files.clone()); - let mut bars = self.bars.write().unwrap(); + let mut bars = match self.bars.write() { + Ok(bars) => bars, + Err(e) => { + eprintln!( + "[ERROR] Error accessing progress bars: {}", + e + ); + return; + } + }; for f in &*files { let pb = self.mp.add(ProgressBar::new(f.len)); pb.set_style(Self::bar_style()); @@ -765,7 +854,7 @@ impl ReceiveFilesSubscriber for FileReceiveSubscriber { } } Err(e) => { - eprintln!("❌ Error updating files list: {}", e); + eprintln!("[ERROR] Error updating files list: {}", e); } } } @@ -786,6 +875,8 @@ struct FileData { is_finished: AtomicBool, path: PathBuf, reader: RwLock>, + // Dedicated file handle for positioned chunk reads + chunk_reader: std::sync::Mutex>, size: u64, bytes_read: std::sync::atomic::AtomicU64, } @@ -804,6 +895,7 @@ impl FileData { is_finished: AtomicBool::new(false), path, reader: RwLock::new(None), + chunk_reader: std::sync::Mutex::new(None), size: metadata.len(), bytes_read: std::sync::atomic::AtomicU64::new(0), }) @@ -828,14 +920,38 @@ impl SenderFileData for FileData { return None; } - if self.reader.read().unwrap().is_none() { + let is_reader_none = match self.reader.read() { + Ok(guard) => guard.is_none(), + Err(e) => { + eprintln!( + "Error acquiring read lock for file {}: {}", + self.path.display(), + e + ); + self.is_finished + .store(true, std::sync::atomic::Ordering::Relaxed); + return None; + } + }; + + if is_reader_none { match std::fs::File::open(&self.path) { - Ok(file) => { - *self.reader.write().unwrap() = Some(file); - } + Ok(file) => match self.reader.write() { + Ok(mut guard) => *guard = Some(file), + Err(e) => { + eprintln!( + "Error acquiring write lock for file {}: {}", + self.path.display(), + e + ); + self.is_finished + .store(true, std::sync::atomic::Ordering::Relaxed); + return None; + } + }, Err(e) => { eprintln!( - "❌ Error opening file {}: {}", + "[ERROR] Error opening file {}: {}", self.path.display(), e ); @@ -847,7 +963,19 @@ impl SenderFileData for FileData { } // Read next byte - let mut reader = self.reader.write().unwrap(); + let mut reader = match self.reader.write() { + Ok(guard) => guard, + Err(e) => { + eprintln!( + "Error acquiring write lock for file {}: {}", + self.path.display(), + e + ); + self.is_finished + .store(true, std::sync::atomic::Ordering::Relaxed); + return None; + } + }; if let Some(file) = reader.as_mut() { let mut buffer = [0u8; 1]; match file.read(&mut buffer) { @@ -863,7 +991,7 @@ impl SenderFileData for FileData { } Err(e) => { eprintln!( - "❌ Error reading from file {}: {}", + "[ERROR] Error reading from file {}: {}", self.path.display(), e ); @@ -908,12 +1036,12 @@ impl SenderFileData for FileData { let remaining = self.size - current_position; let to_read = std::cmp::min(size, remaining) as usize; - // Open a new file handle for this read operation - let mut file = match std::fs::File::open(&self.path) { - Ok(file) => file, + // Get or create the cached file handle + let mut chunk_reader_guard = match self.chunk_reader.lock() { + Ok(guard) => guard, Err(e) => { eprintln!( - "❌ Error opening file {}: {}", + "[ERROR] Error acquiring lock for file {}: {}", self.path.display(), e ); @@ -922,10 +1050,32 @@ impl SenderFileData for FileData { } }; + // Open file handle if not already open + if chunk_reader_guard.is_none() { + match std::fs::File::open(&self.path) { + Ok(file) => { + *chunk_reader_guard = Some(file); + } + Err(e) => { + eprintln!( + "[ERROR] Error opening file {}: {}", + self.path.display(), + e + ); + self.is_finished.store(true, Ordering::Release); + return Vec::new(); + } + } + } + + let file = chunk_reader_guard + .as_mut() + .expect("File handle must exist after initialization"); + // Seek to the claimed position if let Err(e) = file.seek(SeekFrom::Start(current_position)) { eprintln!( - "❌ Error seeking to position {} in file {}: {}", + "[ERROR] Error seeking to position {} in file {}: {}", current_position, self.path.display(), e @@ -947,7 +1097,7 @@ impl SenderFileData for FileData { } Err(e) => { eprintln!( - "❌ Error reading chunk from file {}: {}", + "[ERROR] Error reading chunk from file {}: {}", self.path.display(), e ); @@ -1051,7 +1201,7 @@ pub async fn run_receive_files( .with_context( || "Failed to save default receive directory", )?; - println!("💾 Saved '{}' as default receive directory", dir); + println!("Saved '{}' as default receive directory", dir); } path @@ -1161,6 +1311,8 @@ struct ReadyToReceiveSubscriberImpl { mp: MultiProgress, bars: RwLock>, received: RwLock>, + // Cache file handles to avoid reopening on every chunk + file_handles: RwLock>, } impl ReadyToReceiveSubscriberImpl { @@ -1173,6 +1325,7 @@ impl ReadyToReceiveSubscriberImpl { mp: MultiProgress::new(), bars: RwLock::new(HashMap::new()), received: RwLock::new(HashMap::new()), + file_handles: RwLock::new(HashMap::new()), } } @@ -1180,7 +1333,7 @@ impl ReadyToReceiveSubscriberImpl { ProgressStyle::with_template( "{spinner:.green} {msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})", ) - .unwrap() + .unwrap_or_else(|_| ProgressStyle::default_bar()) .progress_chars("#>-") } } @@ -1192,7 +1345,7 @@ impl ReadyToReceiveSubscriber for ReadyToReceiveSubscriberImpl { fn log(&self, message: String) { if self.verbose { - let _ = self.mp.println(format!("🔍 {}", message)); + let _ = self.mp.println(format!("[DEBUG] {}", message)); } } @@ -1200,26 +1353,32 @@ impl ReadyToReceiveSubscriber for ReadyToReceiveSubscriberImpl { let files = match self.files.read() { Ok(files) => files, Err(e) => { - eprintln!("❌ Error accessing files list: {}", e); + eprintln!("[ERROR] Error accessing files list: {}", e); return; } }; let file = match files.iter().find(|f| f.id == event.id) { Some(file) => file, None => { - eprintln!("❌ File not found with ID: {}", event.id); + eprintln!("[ERROR] File not found with ID: {}", event.id); return; } }; - let mut bars = self.bars.write().unwrap(); + let mut bars = match self.bars.write() { + Ok(bars) => bars, + Err(e) => { + eprintln!("Error accessing progress bars: {}", e); + return; + } + }; let pb = bars.entry(event.id.clone()).or_insert_with(|| { let pb = self.mp.add(ProgressBar::new_spinner()); pb.set_style( ProgressStyle::with_template( "{spinner:.green} {msg} {bytes} ({bytes_per_sec})", ) - .unwrap(), + .unwrap_or_else(|_| ProgressStyle::default_spinner()), ); pb.enable_steady_tick(std::time::Duration::from_millis(100)); pb.set_message(format!("Receiving {}", file.name)); @@ -1227,57 +1386,83 @@ impl ReadyToReceiveSubscriber for ReadyToReceiveSubscriberImpl { }); { - let mut recvd = self.received.write().unwrap(); + let mut recvd = match self.received.write() { + Ok(recvd) => recvd, + Err(e) => { + eprintln!("Error accessing received bytes tracker: {}", e); + return; + } + }; let entry = recvd.entry(event.id.clone()).or_insert(0); *entry += event.data.len() as u64; pb.inc(event.data.len() as u64); } let file_path = self.receiving_path.join(&file.name); - match fs::File::options() - .create(true) - .append(true) - .open(&file_path) - { - Ok(mut file_stream) => { - if let Err(e) = file_stream.write_all(&event.data) { - eprintln!("❌ Error writing to file {}: {}", file.name, e); - return; - } - if let Err(e) = file_stream.flush() { - eprintln!("❌ Error flushing file {}: {}", file.name, e); - return; - } - } + + // Get or create cached file handle + let mut file_handles = match self.file_handles.write() { + Ok(handles) => handles, Err(e) => { - eprintln!("❌ Error opening file {}: {}", file.name, e); + eprintln!("Error accessing file handles: {}", e); + return; } + }; + let file_handle = file_handles + .entry(event.id.clone()) + .or_insert_with(|| { + fs::File::options() + .create(true) + .append(true) + .open(&file_path) + .unwrap_or_else(|e| { + panic!( + "Failed to open file {}: {}", + file_path.display(), + e + ) + }) + }); + + // Write to the cached file handle + if let Err(e) = file_handle.write_all(&event.data) { + eprintln!("[ERROR] Error writing to file {}: {}", file.name, e); + return; + } + if let Err(e) = file_handle.flush() { + eprintln!("[ERROR] Error flushing file {}: {}", file.name, e); } } fn notify_connecting(&self, event: ReadyToReceiveConnectingEvent) { let _ = self .mp - .println("🔗 Connected to sender:".to_string()); + .println("Connected to sender:".to_string()); let _ = self .mp - .println(format!(" 📛 Name: {}", event.sender.name)); + .println(format!(" Name: {}", event.sender.name)); let _ = self .mp - .println(format!(" 🆔 ID: {}", event.sender.id)); + .println(format!(" ID: {}", event.sender.id)); let _ = self .mp - .println(format!(" 📁 Files to receive: {}", event.files.len())); + .println(format!(" Files to receive: {}", event.files.len())); for f in &event.files { - let _ = self.mp.println(format!(" 📄 {}", f.name)); + let _ = self.mp.println(format!(" - {}", f.name)); } match self.files.write() { Ok(mut files) => { files.extend(event.files.clone()); - let mut bars = self.bars.write().unwrap(); + let mut bars = match self.bars.write() { + Ok(bars) => bars, + Err(e) => { + eprintln!("Error accessing progress bars: {}", e); + return; + } + }; for f in &*files { let pb = self.mp.add(ProgressBar::new(f.len)); pb.set_style(Self::bar_style()); @@ -1286,7 +1471,7 @@ impl ReadyToReceiveSubscriber for ReadyToReceiveSubscriberImpl { } } Err(e) => { - eprintln!("❌ Error updating files list: {}", e); + eprintln!("[ERROR] Error updating files list: {}", e); } } } @@ -1313,7 +1498,7 @@ impl SendFilesToSubscriberImpl { ProgressStyle::with_template( "{spinner:.green} {msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})", ) - .unwrap() + .unwrap_or_else(|_| ProgressStyle::default_bar()) .progress_chars("#>-") } } @@ -1325,12 +1510,18 @@ impl SendFilesToSubscriber for SendFilesToSubscriberImpl { fn log(&self, message: String) { if self.verbose { - let _ = self.mp.println(format!("🔍 {}", message)); + let _ = self.mp.println(format!("[DEBUG] {}", message)); } } fn notify_sending(&self, event: SendFilesToSendingEvent) { - let mut bars = self.bars.write().unwrap(); + let mut bars = match self.bars.write() { + Ok(bars) => bars, + Err(e) => { + eprintln!("Error accessing progress bars: {}", e); + return; + } + }; let pb = bars.entry(event.name.clone()).or_insert_with(|| { let total = event.sent + event.remaining; let pb = if total > 0 { @@ -1343,7 +1534,7 @@ impl SendFilesToSubscriber for SendFilesToSubscriberImpl { ProgressStyle::with_template( "{spinner:.green} {msg} {bytes} ({bytes_per_sec})", ) - .unwrap(), + .unwrap_or_else(|_| ProgressStyle::default_spinner()), ); pb.enable_steady_tick(std::time::Duration::from_millis(100)); pb @@ -1359,7 +1550,7 @@ impl SendFilesToSubscriber for SendFilesToSubscriberImpl { } if event.remaining == 0 { - pb.finish_with_message(format!("✅ Sent {}", event.name)); + pb.finish_with_message(format!("[DONE] Sent {}", event.name)); } else { pb.set_message(format!("Sending {}", event.name)); } @@ -1368,13 +1559,13 @@ impl SendFilesToSubscriber for SendFilesToSubscriberImpl { fn notify_connecting(&self, event: SendFilesToConnectingEvent) { let _ = self .mp - .println("🔗 Connected to receiver:".to_string()); + .println("Connected to receiver:".to_string()); let _ = self .mp - .println(format!(" 📛 Name: {}", event.receiver.name)); + .println(format!(" Name: {}", event.receiver.name)); let _ = self .mp - .println(format!(" 🆔 ID: {}", event.receiver.id)); + .println(format!(" ID: {}", event.receiver.id)); } } @@ -1406,8 +1597,10 @@ pub async fn run_ready_to_receive( let mut config = CliConfig::load()?; config .set_default_receive_dir(dir.clone()) - .with_context(|| "Failed to save default receive directory")?; - println!("💾 Saved '{}' as default receive directory", dir); + .with_context( + || "Failed to save default receive directory", + )?; + println!("Saved '{}' as default receive directory", dir); } path } @@ -1454,36 +1647,27 @@ pub async fn run_ready_to_receive( let ticket = bubble.get_ticket(); let confirmation = bubble.get_confirmation(); - println!("📦 Ready to receive files!"); - println!("🎫 Ticket: \"{}\"", ticket); - println!("🔑 Confirmation: \"{}\"", confirmation); - println!(); - println!("Scan this QR code with the sender:"); - println!(); + // Display QR code and session info + display_session_info(&ticket, confirmation, "Receiver"); - // Print QR code (simplified version - you may want to use a QR library) - let qr_data = format!("{}:{}", ticket, confirmation); - println!("QR Data: {}", qr_data); - println!(); + println!("Files will be saved to: {}", receiving_path.display()); - println!("📁 Files will be saved to: {}", receiving_path.display()); - println!("⏳ Waiting for sender... (Press Ctrl+C to cancel)"); - - let subscriber = ReadyToReceiveSubscriberImpl::new(receiving_path.clone(), verbose); + let subscriber = + ReadyToReceiveSubscriberImpl::new(receiving_path.clone(), verbose); bubble.subscribe(Arc::new(subscriber)); tokio::select! { _ = tokio::signal::ctrl_c() => { - println!("🚫 Cancelling file transfer..."); + println!("Cancelling file transfer..."); let _ = bubble.cancel().await; - println!("✅ Transfer cancelled"); + println!("Transfer cancelled"); + std::process::exit(0); } _ = wait_for_ready_to_receive_completion(&bubble) => { - println!("✅ All files received successfully!"); + println!("All files received successfully!"); + std::process::exit(0); } } - - Ok(()) } /// Run send-files-to operation (sender connects to waiting receiver). @@ -1526,8 +1710,9 @@ pub async fn run_send_files_to( } } - let confirmation_code = u8::from_str(&confirmation) - .with_context(|| format!("Invalid confirmation code: {}", confirmation))?; + let confirmation_code = u8::from_str(&confirmation).with_context(|| { + format!("Invalid confirmation code: {}", confirmation) + })?; // Create sender files let mut files = Vec::new(); @@ -1563,23 +1748,23 @@ pub async fn run_send_files_to( let subscriber = SendFilesToSubscriberImpl::new(verbose); bubble.subscribe(Arc::new(subscriber)); - println!("📤 Connecting to waiting receiver..."); + println!("Connecting to waiting receiver..."); bubble .start() .context("Failed to start send-files-to")?; - println!("⏳ Sending files... (Press Ctrl+C to cancel)"); + println!("Sending files... (Press Ctrl+C to cancel)"); tokio::select! { _ = tokio::signal::ctrl_c() => { - println!("🚫 Cancelling file transfer..."); - println!("✅ Transfer cancelled"); + println!("Cancelling file transfer..."); + println!("Transfer cancelled"); + std::process::exit(0); } _ = wait_for_send_files_to_completion(&bubble) => { - println!("✅ All files sent successfully!"); + println!("All files sent successfully!"); + std::process::exit(0); } } - - Ok(()) } diff --git a/drop-core/cli/src/main.rs b/drop-core/cli/src/main.rs index c7c1d7df..22c9b3a2 100644 --- a/drop-core/cli/src/main.rs +++ b/drop-core/cli/src/main.rs @@ -2,10 +2,13 @@ use anyhow::{Context, Result, anyhow}; use clap::{Arg, ArgMatches, Command}; use drop_cli::{ Profile, clear_default_receive_dir, get_default_receive_dir, - run_receive_files, run_send_files, set_default_receive_dir, - suggested_default_receive_dir, run_ready_to_receive, run_send_files_to, + run_ready_to_receive, run_receive_files, run_send_files, run_send_files_to, + set_default_receive_dir, suggested_default_receive_dir, }; use std::path::PathBuf; +use tokio::io::{AsyncBufReadExt, BufReader}; + +mod handshake; #[tokio::main] async fn main() -> Result<()> { @@ -20,7 +23,9 @@ async fn main() -> Result<()> { handle_config_command(sub_matches).await } _ => { - eprintln!("❌ Invalid command. Use --help for usage information."); + eprintln!( + "Error: Invalid command. Use --help for usage information." + ); std::process::exit(1); } } @@ -42,7 +47,7 @@ fn build_cli() -> Command { ) .subcommand( Command::new("send") - .about("Send files to another user") + .about("Send files - generates QR code or accepts receiver's credentials") .arg( Arg::new("files") .help("Files to send") @@ -50,27 +55,12 @@ fn build_cli() -> Command { .num_args(1..) .value_parser(clap::value_parser!(PathBuf)) ) - .arg( - Arg::new("to-ticket") - .long("to") - .help("Send to a waiting receiver's ticket") - .value_name("TICKET") - .requires("to-confirmation") - ) - .arg( - Arg::new("to-confirmation") - .long("confirmation") - .short('c') - .help("Receiver's confirmation code (use with --to)") - .value_name("CODE") - .requires("to-ticket") - ) .arg( Arg::new("name") .long("name") .short('n') .help("Your display name") - .default_value("drop-cli-sender") + .default_value("arkdrop-sender") ) .arg( Arg::new("avatar") @@ -88,26 +78,7 @@ fn build_cli() -> Command { ) .subcommand( Command::new("receive") - .about("Receive files from another user") - .arg( - Arg::new("wait") - .long("wait") - .help("Generate QR code and wait for sender to connect") - .action(clap::ArgAction::SetTrue) - .conflicts_with_all(["ticket", "confirmation"]) - ) - .arg( - Arg::new("ticket") - .help("Transfer ticket (from sender)") - .required_unless_present("wait") - .index(1) - ) - .arg( - Arg::new("confirmation") - .help("Confirmation code (from sender)") - .required_unless_present("wait") - .index(2) - ) + .about("Receive files - generates QR code or accepts sender's credentials") .arg( Arg::new("output") .help("Output directory for received files (optional if default is set)") @@ -127,7 +98,7 @@ fn build_cli() -> Command { .long("name") .short('n') .help("Your display name") - .default_value("drop-cli-receiver") + .default_value("arkdrop-receiver") ) .arg( Arg::new("avatar") @@ -177,62 +148,63 @@ async fn handle_send_command(matches: &ArgMatches) -> Result<()> { let verbose: bool = matches.get_flag("verbose"); let profile = build_profile(matches)?; - // Check if this is a send-to operation (--to flag) - if let Some(ticket) = matches.get_one::("to-ticket") { - let confirmation = matches.get_one::("to-confirmation").unwrap(); - - println!("📤 Preparing to send {} file(s) to waiting receiver...", files.len()); - for file in &files { - println!(" 📄 {}", file.display()); - } - - if let Some(name) = profile.name.strip_prefix("drop-cli-") { - println!("👤 Sender name: {}", name); - } else { - println!("👤 Sender name: {}", profile.name); - } - - if profile.avatar_b64.is_some() { - println!("🖼️ Avatar: Set"); - } - - let file_strings: Vec = files - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(); - - return run_send_files_to( - file_strings, - ticket.clone(), - confirmation.clone(), - profile, - verbose, - ) - .await; - } - - // Regular send operation - println!("📤 Preparing to send {} file(s)...", files.len()); + // Display info + println!("\nPreparing to send {} file(s):", files.len()); for file in &files { - println!(" 📄 {}", file.display()); + println!(" - {}", file.display()); } - - if let Some(name) = profile.name.strip_prefix("drop-cli-") { - println!("👤 Sender name: {}", name); - } else { - println!("👤 Sender name: {}", profile.name); - } - + println!("Sender: {}", profile.name); if profile.avatar_b64.is_some() { - println!("🖼️ Avatar: Set"); + println!("Avatar: Set"); } + println!(); let file_strings: Vec = files .into_iter() .map(|p| p.to_string_lossy().to_string()) .collect(); - run_send_files(file_strings, profile, verbose).await + // Start QR flow and listen for 'c' press concurrently + let qr_task = tokio::spawn({ + let file_strings = file_strings.clone(); + let profile = profile.clone(); + async move { run_send_files(file_strings, profile, verbose).await } + }); + + let stdin = tokio::io::stdin(); + let mut reader = BufReader::new(stdin); + let mut input = String::new(); + + tokio::select! { + result = qr_task => { + // QR flow completed (peer connected and files sent) + match result { + Ok(Ok(())) => Ok(()), + Ok(Err(e)) => Err(e), + Err(e) => Err(anyhow!("Task error: {}", e)), + } + } + _ = reader.read_line(&mut input) => { + // User pressed something - check if it's 'c' + if input.trim().eq_ignore_ascii_case("c") { + println!("\nSwitching to manual credential entry...\n"); + // Get peer's credentials + let (ticket, confirmation) = handshake::prompt_for_credentials().await?; + // Send files to peer + run_send_files_to( + file_strings, + ticket, + confirmation.to_string(), + profile, + verbose, + ) + .await + } else { + // Ignore other input, keep waiting + Ok(()) + } + } + } } async fn handle_receive_command(matches: &ArgMatches) -> Result<()> { @@ -240,86 +212,80 @@ async fn handle_receive_command(matches: &ArgMatches) -> Result<()> { let save_dir = matches.get_flag("save-dir"); let profile = build_profile(matches)?; - // Check if this is a ready-to-receive operation (--wait flag) - if matches.get_flag("wait") { - let output_dir = matches - .get_one::("output") - .map(|p| p.to_string_lossy().to_string()); - - println!("📥 Preparing to receive files..."); - - if let Some(ref dir) = output_dir { - println!("📁 Output directory: {}", dir); - } else if let Some(default_dir) = get_default_receive_dir()? { - println!("📁 Using default directory: {}", default_dir); - } else { - let fallback = suggested_default_receive_dir(); - println!("📁 Using default directory: {}", fallback.display()); - } - - if let Some(name) = profile.name.strip_prefix("drop-cli-") { - println!("👤 Receiver name: {}", name); - } else { - println!("👤 Receiver name: {}", profile.name); - } - - if profile.avatar_b64.is_some() { - println!("🖼️ Avatar: Set"); - } - - return run_ready_to_receive(output_dir, profile, verbose, save_dir).await; - } - - // Regular receive operation let output_dir = matches .get_one::("output") .map(|p| p.to_string_lossy().to_string()); - let ticket = matches.get_one::("ticket").unwrap(); - let confirmation = matches.get_one::("confirmation").unwrap(); - - println!("📥 Preparing to receive files..."); + // Display info + println!("\nPreparing to receive files..."); if let Some(ref dir) = output_dir { - println!("📁 Output directory: {}", dir); + println!("Output directory: {}", dir); } else if let Some(default_dir) = get_default_receive_dir()? { - println!("📁 Using default directory: {}", default_dir); + println!("Using default directory: {}", default_dir); } else { let fallback = suggested_default_receive_dir(); - println!("📁 Using default directory: {}", fallback.display()); + println!("Using default directory: {}", fallback.display()); } - - println!("🎫 Ticket: {}", ticket); - println!("🔑 Confirmation: {}", confirmation); - - if let Some(name) = profile.name.strip_prefix("drop-cli-") { - println!("👤 Receiver name: {}", name); - } else { - println!("👤 Receiver name: {}", profile.name); - } - + println!("Receiver: {}", profile.name); if profile.avatar_b64.is_some() { - println!("🖼️ Avatar: Set"); + println!("Avatar: Set"); + } + println!(); + + // Start QR flow and listen for 'c' press concurrently + let qr_task = tokio::spawn({ + let output_dir = output_dir.clone(); + let profile = profile.clone(); + async move { + run_ready_to_receive(output_dir, profile, verbose, save_dir).await + } + }); + + let stdin = tokio::io::stdin(); + let mut reader = BufReader::new(stdin); + let mut input = String::new(); + + tokio::select! { + result = qr_task => { + // QR flow completed (peer connected and files received) + match result { + Ok(Ok(())) => Ok(()), + Ok(Err(e)) => Err(e), + Err(e) => Err(anyhow!("Task error: {}", e)), + } + } + _ = reader.read_line(&mut input) => { + // User pressed something - check if it's 'c' + if input.trim().eq_ignore_ascii_case("c") { + println!("\nSwitching to manual credential entry...\n"); + // Get peer's credentials + let (ticket, confirmation) = handshake::prompt_for_credentials().await?; + // Receive files from peer + run_receive_files( + output_dir, + ticket, + confirmation.to_string(), + profile, + verbose, + save_dir, + ) + .await + } else { + // Ignore other input, keep waiting + Ok(()) + } + } } - - run_receive_files( - output_dir, - ticket.clone(), - confirmation.clone(), - profile, - verbose, - save_dir, - ) - .await } async fn handle_config_command(matches: &ArgMatches) -> Result<()> { match matches.subcommand() { Some(("show", _)) => match get_default_receive_dir()? { Some(dir) => { - println!("📁 Default receive directory: {}", dir); + println!("Default receive directory: {}", dir); } None => { - println!("📁 No default receive directory set"); + println!("No default receive directory set"); } }, Some(("set-receive-dir", sub_matches)) => { @@ -331,7 +297,7 @@ async fn handle_config_command(matches: &ArgMatches) -> Result<()> { // Validate directory exists or can be created if !directory.exists() { match std::fs::create_dir_all(directory) { - Ok(_) => println!("📁 Created directory: {}", dir_str), + Ok(_) => println!("Created directory: {}", dir_str), Err(e) => { return Err(anyhow!( "Failed to create directory '{}': {}", @@ -343,15 +309,15 @@ async fn handle_config_command(matches: &ArgMatches) -> Result<()> { } set_default_receive_dir(dir_str.clone())?; - println!("✅ Set default receive directory to: {}", dir_str); + println!("Set default receive directory to: {}", dir_str); } Some(("clear-receive-dir", _)) => { clear_default_receive_dir()?; - println!("✅ Cleared default receive directory"); + println!("Cleared default receive directory"); } _ => { eprintln!( - "❌ Invalid config command. Use --help for usage information." + "Error: Invalid config command. Use --help for usage information." ); std::process::exit(1); } diff --git a/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs b/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs index e1feabb6..a69366da 100644 --- a/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs +++ b/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs @@ -8,8 +8,8 @@ mod handler; use anyhow::Result; -use drop_entities::Profile; use chrono::{DateTime, Utc}; +use drop_entities::Profile; use handler::ReadyToReceiveHandler; use iroh::{Endpoint, Watcher, protocol::Router}; use iroh_base::ticket::NodeTicket; From 6b9fb3eb1ea66c002e520d874976573c06522c15 Mon Sep 17 00:00:00 2001 From: Pushkar Mishra Date: Fri, 17 Oct 2025 15:49:48 +0530 Subject: [PATCH 03/17] clippy Signed-off-by: Pushkar Mishra --- .../uniffi/src/receiver/receive_files.rs | 38 +++++++++--------- drop-core/uniffi/src/sender.rs | 6 +-- drop-core/uniffi/src/sender/send_files.rs | 40 +++++++++---------- 3 files changed, 42 insertions(+), 42 deletions(-) diff --git a/drop-core/uniffi/src/receiver/receive_files.rs b/drop-core/uniffi/src/receiver/receive_files.rs index b3bb989c..791fc3b1 100644 --- a/drop-core/uniffi/src/receiver/receive_files.rs +++ b/drop-core/uniffi/src/receiver/receive_files.rs @@ -30,34 +30,34 @@ impl ReceiveFilesBubble { /// This method blocks on the internal runtime until setup finishes or an /// error is returned. On success, subscribers will receive chunks/events. pub fn start(&self) -> Result<(), DropError> { - return self + self .runtime .block_on(async { - return self.inner.start(); + self.inner.start() }) - .map_err(|e| DropError::TODO(e.to_string())); + .map_err(|e| DropError::TODO(e.to_string())) } /// Cancel the session. No further progress will occur. pub fn cancel(&self) { - return self.inner.cancel(); + self.inner.cancel() } /// True when the session has completed (successfully or not). pub fn is_finished(&self) -> bool { - return self.inner.is_finished(); + self.inner.is_finished() } /// True if the session has been explicitly canceled. pub fn is_cancelled(&self) -> bool { - return self.inner.is_cancelled(); + self.inner.is_cancelled() } /// Register an observer for logs, chunk payloads, and connection events. pub fn subscribe(&self, subscriber: Arc) { let adapted_subscriber = ReceiveFilesSubscriberAdapter { inner: subscriber }; - return self.inner.subscribe(Arc::new(adapted_subscriber)); + self.inner.subscribe(Arc::new(adapted_subscriber)) } /// Unregister a previously subscribed observer. @@ -66,9 +66,9 @@ impl ReceiveFilesBubble { pub fn unsubscribe(&self, subscriber: Arc) { let adapted_subscriber = ReceiveFilesSubscriberAdapter { inner: subscriber }; - return self + self .inner - .unsubscribe(Arc::new(adapted_subscriber)); + .unsubscribe(Arc::new(adapted_subscriber)) } } @@ -120,7 +120,7 @@ struct ReceiveFilesSubscriberAdapter { } impl dropx_receiver::ReceiveFilesSubscriber for ReceiveFilesSubscriberAdapter { fn get_id(&self) -> String { - return self.inner.get_id(); + self.inner.get_id() } fn log(&self, message: String) { @@ -132,19 +132,19 @@ impl dropx_receiver::ReceiveFilesSubscriber for ReceiveFilesSubscriberAdapter { &self, event: dropx_receiver::ReceiveFilesReceivingEvent, ) { - return self + self .inner .notify_receiving(ReceiveFilesReceivingEvent { id: event.id, data: event.data, - }); + }) } fn notify_connecting( &self, event: dropx_receiver::ReceiveFilesConnectingEvent, ) { - return self + self .inner .notify_connecting(ReceiveFilesConnectingEvent { sender: ReceiveFilesProfile { @@ -161,7 +161,7 @@ impl dropx_receiver::ReceiveFilesSubscriber for ReceiveFilesSubscriberAdapter { len: f.len, }) .collect(), - }); + }) } } @@ -178,13 +178,13 @@ pub async fn receive_files( let bubble = runtime .block_on(async { let adapted_request = create_adapted_request(request); - return dropx_receiver::receive_files(adapted_request).await; + dropx_receiver::receive_files(adapted_request).await }) .map_err(|e| DropError::TODO(e.to_string()))?; - return Ok(Arc::new(ReceiveFilesBubble { + Ok(Arc::new(ReceiveFilesBubble { inner: bubble, runtime, - })); + })) } /// Convert the high-level request into the dropx_receiver request format. @@ -204,10 +204,10 @@ fn create_adapted_request( chunk_size: c.chunk_size, parallel_streams: c.parallel_streams, }); - return dropx_receiver::ReceiveFilesRequest { + dropx_receiver::ReceiveFilesRequest { profile, ticket: request.ticket, confirmation: request.confirmation, config, - }; + } } diff --git a/drop-core/uniffi/src/sender.rs b/drop-core/uniffi/src/sender.rs index 5f3422b5..51a19827 100644 --- a/drop-core/uniffi/src/sender.rs +++ b/drop-core/uniffi/src/sender.rs @@ -45,15 +45,15 @@ struct SenderFileDataAdapter { } impl dropx_sender::SenderFileData for SenderFileDataAdapter { fn len(&self) -> u64 { - return self.inner.len(); + self.inner.len() } fn read(&self) -> Option { - return self.inner.read(); + self.inner.read() } fn read_chunk(&self, size: u64) -> Vec { - return self.inner.read_chunk(size.try_into().unwrap()); + self.inner.read_chunk(size.try_into().unwrap()) } } diff --git a/drop-core/uniffi/src/sender/send_files.rs b/drop-core/uniffi/src/sender/send_files.rs index 5aeb5dc0..4168910e 100644 --- a/drop-core/uniffi/src/sender/send_files.rs +++ b/drop-core/uniffi/src/sender/send_files.rs @@ -25,12 +25,12 @@ pub struct SendFilesBubble { impl SendFilesBubble { /// Returns the ticket that the receiver must provide to connect. pub fn get_ticket(&self) -> String { - return self.inner.get_ticket(); + self.inner.get_ticket() } /// Returns the short confirmation code required during pairing. pub fn get_confirmation(&self) -> u8 { - return self.inner.get_confirmation(); + self.inner.get_confirmation() } /// Cancel the session asynchronously. @@ -47,17 +47,17 @@ impl SendFilesBubble { /// True once all files are sent or the session has been canceled. pub fn is_finished(&self) -> bool { - return self.inner.is_finished(); + self.inner.is_finished() } /// True once a receiver has connected and handshake has completed. pub fn is_connected(&self) -> bool { - return self.inner.is_connected(); + self.inner.is_connected() } /// ISO-8601 timestamp for when the session was created. pub fn get_created_at(&self) -> String { - return self.inner.get_created_at(); + self.inner.get_created_at() } /// Register an observer for logs and progress/connect events. @@ -66,7 +66,7 @@ impl SendFilesBubble { pub fn subscribe(&self, subscriber: Arc) { let adapted_subscriber = SendFilesSubscriberAdapter { inner: subscriber }; - return self.inner.subscribe(Arc::new(adapted_subscriber)); + self.inner.subscribe(Arc::new(adapted_subscriber)) } /// Unregister a previously subscribed observer. @@ -75,9 +75,9 @@ impl SendFilesBubble { pub fn unsubscribe(&self, subscriber: Arc) { let adapted_subscriber = SendFilesSubscriberAdapter { inner: subscriber }; - return self + self .inner - .unsubscribe(Arc::new(adapted_subscriber)); + .unsubscribe(Arc::new(adapted_subscriber)) } } @@ -120,7 +120,7 @@ struct SendFilesSubscriberAdapter { } impl dropx_sender::SendFilesSubscriber for SendFilesSubscriberAdapter { fn get_id(&self) -> String { - return self.inner.get_id(); + self.inner.get_id() } fn log(&self, message: String) { @@ -129,15 +129,15 @@ impl dropx_sender::SendFilesSubscriber for SendFilesSubscriberAdapter { } fn notify_sending(&self, event: dropx_sender::SendFilesSendingEvent) { - return self.inner.notify_sending(SendFilesSendingEvent { + self.inner.notify_sending(SendFilesSendingEvent { name: event.name, sent: event.sent, remaining: event.remaining, - }); + }) } fn notify_connecting(&self, event: dropx_sender::SendFilesConnectingEvent) { - return self + self .inner .notify_connecting(SendFilesConnectingEvent { receiver: SendFilesProfile { @@ -145,7 +145,7 @@ impl dropx_sender::SendFilesSubscriber for SendFilesSubscriberAdapter { name: event.receiver.name, avatar_b64: event.receiver.avatar_b64, }, - }); + }) } } @@ -162,13 +162,13 @@ pub async fn send_files( let bubble = runtime .block_on(async { let adapted_request = create_adapted_request(request); - return dropx_sender::send_files(adapted_request).await; + dropx_sender::send_files(adapted_request).await }) .map_err(|e| DropError::TODO(e.to_string()))?; - return Ok(Arc::new(SendFilesBubble { + Ok(Arc::new(SendFilesBubble { inner: bubble, _runtime: runtime, - })); + })) } /// Convert the high-level request into the dropx_sender request format. @@ -188,10 +188,10 @@ fn create_adapted_request( .into_iter() .map(|f| { let data = SenderFileDataAdapter { inner: f.data }; - return dropx_sender::SenderFile { + dropx_sender::SenderFile { name: f.name, data: Arc::new(data), - }; + } }) .collect(); let config = match request.config { @@ -201,9 +201,9 @@ fn create_adapted_request( }, None => dropx_sender::SenderConfig::default(), }; - return dropx_sender::SendFilesRequest { + dropx_sender::SendFilesRequest { profile, files, config, - }; + } } From 7ddecd035cf0316790e2aa7b778aed20d41f9be6 Mon Sep 17 00:00:00 2001 From: Pushkar Mishra Date: Fri, 17 Oct 2025 16:10:22 +0530 Subject: [PATCH 04/17] cargo fmt Signed-off-by: Pushkar Mishra --- drop-core/uniffi/src/receiver/receive_files.rs | 16 +++++----------- drop-core/uniffi/src/sender/send_files.rs | 6 ++---- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/drop-core/uniffi/src/receiver/receive_files.rs b/drop-core/uniffi/src/receiver/receive_files.rs index 791fc3b1..706f8fdb 100644 --- a/drop-core/uniffi/src/receiver/receive_files.rs +++ b/drop-core/uniffi/src/receiver/receive_files.rs @@ -30,11 +30,8 @@ impl ReceiveFilesBubble { /// This method blocks on the internal runtime until setup finishes or an /// error is returned. On success, subscribers will receive chunks/events. pub fn start(&self) -> Result<(), DropError> { - self - .runtime - .block_on(async { - self.inner.start() - }) + self.runtime + .block_on(async { self.inner.start() }) .map_err(|e| DropError::TODO(e.to_string())) } @@ -66,8 +63,7 @@ impl ReceiveFilesBubble { pub fn unsubscribe(&self, subscriber: Arc) { let adapted_subscriber = ReceiveFilesSubscriberAdapter { inner: subscriber }; - self - .inner + self.inner .unsubscribe(Arc::new(adapted_subscriber)) } } @@ -132,8 +128,7 @@ impl dropx_receiver::ReceiveFilesSubscriber for ReceiveFilesSubscriberAdapter { &self, event: dropx_receiver::ReceiveFilesReceivingEvent, ) { - self - .inner + self.inner .notify_receiving(ReceiveFilesReceivingEvent { id: event.id, data: event.data, @@ -144,8 +139,7 @@ impl dropx_receiver::ReceiveFilesSubscriber for ReceiveFilesSubscriberAdapter { &self, event: dropx_receiver::ReceiveFilesConnectingEvent, ) { - self - .inner + self.inner .notify_connecting(ReceiveFilesConnectingEvent { sender: ReceiveFilesProfile { id: event.sender.id, diff --git a/drop-core/uniffi/src/sender/send_files.rs b/drop-core/uniffi/src/sender/send_files.rs index 4168910e..1ed5fed9 100644 --- a/drop-core/uniffi/src/sender/send_files.rs +++ b/drop-core/uniffi/src/sender/send_files.rs @@ -75,8 +75,7 @@ impl SendFilesBubble { pub fn unsubscribe(&self, subscriber: Arc) { let adapted_subscriber = SendFilesSubscriberAdapter { inner: subscriber }; - self - .inner + self.inner .unsubscribe(Arc::new(adapted_subscriber)) } } @@ -137,8 +136,7 @@ impl dropx_sender::SendFilesSubscriber for SendFilesSubscriberAdapter { } fn notify_connecting(&self, event: dropx_sender::SendFilesConnectingEvent) { - self - .inner + self.inner .notify_connecting(SendFilesConnectingEvent { receiver: SendFilesProfile { id: event.receiver.id, From 0990177abb261236369495b33bd848f2d9f4f019 Mon Sep 17 00:00:00 2001 From: Pushkar Mishra Date: Fri, 17 Oct 2025 16:50:47 +0530 Subject: [PATCH 05/17] fix CI Signed-off-by: Pushkar Mishra --- drop-core/cli/src/lib.rs | 26 +++---------- drop-core/entities/src/data.rs | 5 +++ drop-core/exchanges/receiver/src/lib.rs | 37 ++++++++++++------- .../receiver/src/ready_to_receive/mod.rs | 2 +- .../exchanges/receiver/src/receive_files.rs | 31 +++++++--------- drop-core/exchanges/sender/src/lib.rs | 11 ++++-- drop-core/exchanges/sender/src/send_files.rs | 2 +- .../sender/src/send_files/handler.rs | 31 ++++++++-------- .../exchanges/sender/src/send_files_to.rs | 10 ++--- drop-core/uniffi/src/sender.rs | 6 +++ 10 files changed, 84 insertions(+), 77 deletions(-) diff --git a/drop-core/cli/src/lib.rs b/drop-core/cli/src/lib.rs index 71983fff..5427a5f1 100644 --- a/drop-core/cli/src/lib.rs +++ b/drop-core/cli/src/lib.rs @@ -149,19 +149,11 @@ fn display_session_info(ticket: &str, confirmation: u8, role: &str) { /// $HOME/.config/drop-cli/config.toml /// - macOS: $HOME/Library/Application Support/drop-cli/config.toml /// - Windows: %APPDATA%\drop-cli\config.toml -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] struct CliConfig { default_receive_dir: Option, } -impl Default for CliConfig { - fn default() -> Self { - Self { - default_receive_dir: None, - } - } -} - impl CliConfig { /// Returns the configuration directory path, creating a path under the /// user's platform-appropriate config directory. @@ -653,9 +645,7 @@ impl SendFilesSubscriber for FileSendSubscriber { } fn notify_connecting(&self, event: SendFilesConnectingEvent) { - let _ = self - .mp - .println("Connected to receiver:".to_string()); + let _ = self.mp.println("Connected to receiver:"); let _ = self .mp .println(format!(" Name: {}", event.receiver.name)); @@ -814,9 +804,7 @@ impl ReceiveFilesSubscriber for FileReceiveSubscriber { } fn notify_connecting(&self, event: ReceiveFilesConnectingEvent) { - let _ = self - .mp - .println("Connected to sender:".to_string()); + let _ = self.mp.println("Connected to sender:"); let _ = self .mp .println(format!(" Name: {}", event.sender.name)); @@ -1435,9 +1423,7 @@ impl ReadyToReceiveSubscriber for ReadyToReceiveSubscriberImpl { } fn notify_connecting(&self, event: ReadyToReceiveConnectingEvent) { - let _ = self - .mp - .println("Connected to sender:".to_string()); + let _ = self.mp.println("Connected to sender:"); let _ = self .mp .println(format!(" Name: {}", event.sender.name)); @@ -1557,9 +1543,7 @@ impl SendFilesToSubscriber for SendFilesToSubscriberImpl { } fn notify_connecting(&self, event: SendFilesToConnectingEvent) { - let _ = self - .mp - .println("Connected to receiver:".to_string()); + let _ = self.mp.println("Connected to receiver:"); let _ = self .mp .println(format!(" Name: {}", event.receiver.name)); diff --git a/drop-core/entities/src/data.rs b/drop-core/entities/src/data.rs index 4af6480c..c0d512e9 100644 --- a/drop-core/entities/src/data.rs +++ b/drop-core/entities/src/data.rs @@ -35,6 +35,11 @@ pub trait Data: Send + Sync { /// the known total length at creation time. fn len(&self) -> u64; + /// Returns true if the data has zero length. + fn is_empty(&self) -> bool { + self.len() == 0 + } + /// Reads the next byte from the current position. /// /// Returns: diff --git a/drop-core/exchanges/receiver/src/lib.rs b/drop-core/exchanges/receiver/src/lib.rs index eb6c6fa8..ccdab054 100644 --- a/drop-core/exchanges/receiver/src/lib.rs +++ b/drop-core/exchanges/receiver/src/lib.rs @@ -30,7 +30,7 @@ pub mod ready_to_receive; mod receive_files; use std::{ - io::{Bytes, Read}, + io::{BufReader, Bytes, Read}, sync::{RwLock, atomic::AtomicBool}, }; @@ -136,16 +136,16 @@ pub struct ReceiverFile { pub struct ReceiverFileData { is_finished: AtomicBool, path: std::path::PathBuf, - reader: RwLock>>, + reader: RwLock>>>, } impl ReceiverFileData { /// Create a new `ReceiverFileData` from a filesystem path. pub fn new(path: std::path::PathBuf) -> Self { - return Self { + Self { is_finished: AtomicBool::new(false), path, reader: RwLock::new(None), - }; + } } /// Return the file length in bytes by counting the iterator. @@ -154,8 +154,15 @@ impl ReceiverFileData { /// If possible, prefer using `std::fs::metadata(&path)?.len()` in your own /// code where you have direct access to the path. pub fn len(&self) -> u64 { + use std::io::BufReader; let file = std::fs::File::open(&self.path).unwrap(); - return file.bytes().count() as u64; + BufReader::new(file).bytes().count() as u64 + } + + /// Returns true if the data has zero length. + #[allow(clippy::len_without_is_empty)] + pub fn is_empty(&self) -> bool { + self.len() == 0 } /// Read the next byte from the file, returning `None` at EOF or after @@ -164,6 +171,8 @@ impl ReceiverFileData { /// This initializes an internal iterator on first use and cleans it up /// when EOF is reached. Subsequent calls after completion return `None`. pub fn read(&self) -> Option { + use std::io::BufReader; + if self .is_finished .load(std::sync::atomic::Ordering::Relaxed) @@ -172,7 +181,10 @@ impl ReceiverFileData { } if self.reader.read().unwrap().is_none() { let file = std::fs::File::open(&self.path).unwrap(); - self.reader.write().unwrap().replace(file.bytes()); + self.reader + .write() + .unwrap() + .replace(BufReader::new(file).bytes()); } let next = self .reader @@ -181,15 +193,14 @@ impl ReceiverFileData { .as_mut() .unwrap() .next(); - if next.is_some() { - let read_result = next.unwrap(); - if read_result.is_ok() { - return Some(read_result.unwrap()); - } + if let Some(read_result) = next + && let Ok(byte) = read_result + { + return Some(byte); } - self.reader.write().unwrap().as_mut().take(); + *self.reader.write().unwrap() = None; self.is_finished .store(true, std::sync::atomic::Ordering::Relaxed); - return None; + None } } diff --git a/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs b/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs index a69366da..129b3b7c 100644 --- a/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs +++ b/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs @@ -236,7 +236,7 @@ impl ReadyToReceiveBubble { /// Example: /// ```rust no_run /// use std::sync::Arc; -/// use arkdropx_receiver::{ +/// use dropx_receiver::{ /// ready_to_receive::*, ReceiverProfile, /// }; /// diff --git a/drop-core/exchanges/receiver/src/receive_files.rs b/drop-core/exchanges/receiver/src/receive_files.rs index 8c31dea0..4355050d 100644 --- a/drop-core/exchanges/receiver/src/receive_files.rs +++ b/drop-core/exchanges/receiver/src/receive_files.rs @@ -422,20 +422,18 @@ impl Carrier { }); // Clean up completed tasks periodically - while join_set.len() >= parallel_streams as usize { - if let Some(result) = join_set.join_next().await { - if let Err(err) = result? { - // Downcast anyhow::Error to ConnectionError - if let Some(connection_err) = - err.downcast_ref::() - { - if connection_err == &expected_close { - break 'files_iterator; - } - } - return Err(err); - } + if join_set.len() >= parallel_streams as usize + && let Some(result) = join_set.join_next().await + && let Err(err) = result? + { + // Downcast anyhow::Error to ConnectionError + if let Some(connection_err) = + err.downcast_ref::() + && connection_err == &expected_close + { + break 'files_iterator; } + return Err(err); } } @@ -444,16 +442,15 @@ impl Carrier { // Downcast anyhow::Error to ConnectionError if let Some(connection_err) = err.downcast_ref::() + && connection_err == &expected_close { - if connection_err == &expected_close { - continue; - } + continue; } return Err(err); } } - return Ok(()); + Ok(()) } /// Process a single unidirectional stream and emit receiving events per diff --git a/drop-core/exchanges/sender/src/lib.rs b/drop-core/exchanges/sender/src/lib.rs index df04a203..a530619d 100644 --- a/drop-core/exchanges/sender/src/lib.rs +++ b/drop-core/exchanges/sender/src/lib.rs @@ -74,6 +74,11 @@ pub trait SenderFileData: Send + Sync { /// Total length in bytes. fn len(&self) -> u64; + /// Returns true if the data has zero length. + fn is_empty(&self) -> bool { + self.len() == 0 + } + /// Read a single byte if available. fn read(&self) -> Option; @@ -90,15 +95,15 @@ struct SenderFileDataAdapter { } impl Data for SenderFileDataAdapter { fn len(&self) -> u64 { - return self.inner.len(); + self.inner.len() } fn read(&self) -> Option { - return self.inner.read(); + self.inner.read() } fn read_chunk(&self, size: u64) -> Vec { - return self.inner.read_chunk(size); + self.inner.read_chunk(size) } } diff --git a/drop-core/exchanges/sender/src/send_files.rs b/drop-core/exchanges/sender/src/send_files.rs index 605a2029..bbcadda5 100644 --- a/drop-core/exchanges/sender/src/send_files.rs +++ b/drop-core/exchanges/sender/src/send_files.rs @@ -115,7 +115,7 @@ impl SendFilesBubble { "is_finished: Transfer is finished, ensuring router shutdown" .to_string(), ); - let _ = self.router.shutdown(); + std::mem::drop(self.router.shutdown()); } is_finished diff --git a/drop-core/exchanges/sender/src/send_files/handler.rs b/drop-core/exchanges/sender/src/send_files/handler.rs index d843aae1..8c6c9e0b 100644 --- a/drop-core/exchanges/sender/src/send_files/handler.rs +++ b/drop-core/exchanges/sender/src/send_files/handler.rs @@ -111,14 +111,14 @@ impl SendFilesHandler { files: Vec, config: SenderConfig, ) -> Self { - return Self { + Self { is_consumed: AtomicBool::new(false), is_finished: Arc::new(AtomicBool::new(false)), profile, files: files.clone(), config, subscribers: Arc::new(RwLock::new(HashMap::new())), - }; + } } /// Returns true if a connection has already been accepted. @@ -255,11 +255,11 @@ impl ProtocolHandler for SendFilesHandler { async move { let mut carrier = carrier; - if let Err(_) = carrier.greet().await { + if carrier.greet().await.is_err() { return Err(iroh::protocol::AcceptError::NotAllowed {}); } - if let Err(_) = carrier.send_files().await { + if carrier.send_files().await.is_err() { return Err(iroh::protocol::AcceptError::NotAllowed {}); } @@ -401,23 +401,22 @@ impl Carrier { let subscribers = self.subscribers.clone(); join_set.spawn(async move { - return Self::send_single_file( + Self::send_single_file( &file, chunk_size, connection, subscribers, ) - .await; + .await }); // Limit concurrent streams to negotiated number - if join_set.len() >= parallel_streams as usize { - if let Some(result) = join_set.join_next().await { - if let Err(err) = result? { - self.log(format!("send_files: Stream failed: {}", err)); - return Err(err); - } - } + if join_set.len() >= parallel_streams as usize + && let Some(result) = join_set.join_next().await + && let Err(err) = result? + { + self.log(format!("send_files: Stream failed: {}", err)); + return Err(err); } } @@ -430,7 +429,7 @@ impl Carrier { } self.log("send_files: All files transferred successfully".to_string()); - return Ok(()); + Ok(()) } /// Streams a single file in JSON-framed chunks: @@ -442,7 +441,7 @@ impl Carrier { connection: Connection, subscribers: Arc>>>, ) -> Result<()> { - let total_len = file.data.len() as u64; + let total_len = file.data.len(); let mut sent = 0u64; let mut remaining = total_len; let mut chunk_buffer = @@ -486,7 +485,7 @@ impl Carrier { uni.finish()?; uni.stopped().await?; - return Ok(()); + Ok(()) } /// Marks the handler as finished and closes the connection with a code and diff --git a/drop-core/exchanges/sender/src/send_files_to.rs b/drop-core/exchanges/sender/src/send_files_to.rs index 06addcaf..6266cd79 100644 --- a/drop-core/exchanges/sender/src/send_files_to.rs +++ b/drop-core/exchanges/sender/src/send_files_to.rs @@ -351,13 +351,13 @@ impl Carrier { let subscribers = self.subscribers.clone(); join_set.spawn(async move { - return Self::send_single_file( + Self::send_single_file( &file, chunk_size, connection, subscribers, ) - .await; + .await }); if join_set.len() >= parallel_streams as usize @@ -397,7 +397,7 @@ impl Carrier { let mut uni = connection.open_uni().await?; - Self::notify_progress(&file, sent, remaining, subscribers.clone()); + Self::notify_progress(file, sent, remaining, subscribers.clone()); loop { chunk_buffer.clear(); @@ -421,7 +421,7 @@ impl Carrier { sent += data_len; remaining = remaining.saturating_sub(data_len); - Self::notify_progress(&file, sent, remaining, subscribers.clone()); + Self::notify_progress(file, sent, remaining, subscribers.clone()); } uni.finish()?; @@ -493,7 +493,7 @@ impl Carrier { /// Example: /// ```rust no_run /// use std::sync::Arc; -/// use arkdropx_sender::{ +/// use dropx_sender::{ /// send_files_to::*, SenderProfile, SenderConfig, SenderFile, /// }; /// diff --git a/drop-core/uniffi/src/sender.rs b/drop-core/uniffi/src/sender.rs index 51a19827..e2ea41d5 100644 --- a/drop-core/uniffi/src/sender.rs +++ b/drop-core/uniffi/src/sender.rs @@ -32,6 +32,12 @@ pub struct SenderFile { pub trait SenderFileData: Send + Sync { /// Total number of bytes available. fn len(&self) -> u64; + + /// Returns true if the data has zero length. + fn is_empty(&self) -> bool { + self.len() == 0 + } + /// Read the next byte, or None at EOF. fn read(&self) -> Option; /// Read up to `size` bytes; fewer may be returned at EOF. From 69e299a04ae3b5fc97bf647aa8c6154c741b9179 Mon Sep 17 00:00:00 2001 From: Pushkar Mishra Date: Fri, 28 Nov 2025 23:01:51 +0530 Subject: [PATCH 06/17] Refactor drop-core CLI and exchanges to unify dependencies and enhance QR code functionality - Updated `Cargo.toml` to streamline dependency paths and remove duplicates. - Refactored imports in CLI and exchanges to use `arkdrop` namespaces consistently. - Improved QR code display and handling in file transfer processes for better user experience. - Cleaned up unused code and improved error handling in file receiving and sending logic. These changes enhance the maintainability of the codebase and improve the overall user experience during file transfers. Signed-off-by: Pushkar Mishra --- drop-core/cli/Cargo.toml | 10 +- drop-core/cli/src/lib.rs | 108 ++++++++---------- drop-core/common/src/lib.rs | 2 +- .../receiver/src/ready_to_receive/handler.rs | 4 +- .../receiver/src/ready_to_receive/mod.rs | 4 +- .../exchanges/receiver/src/receive_files.rs | 18 ++- drop-core/exchanges/sender/src/lib.rs | 6 +- .../sender/src/send_files/handler.rs | 4 +- .../exchanges/sender/src/send_files_to.rs | 6 +- .../uniffi/src/receiver/receive_files.rs | 4 +- drop-core/uniffi/src/sender.rs | 2 +- drop-core/uniffi/src/sender/send_files.rs | 11 +- 12 files changed, 79 insertions(+), 100 deletions(-) diff --git a/drop-core/cli/Cargo.toml b/drop-core/cli/Cargo.toml index 64dffb48..fcb4c347 100644 --- a/drop-core/cli/Cargo.toml +++ b/drop-core/cli/Cargo.toml @@ -20,19 +20,13 @@ name = "arkdrop-cli" path = "src/main.rs" [dependencies] -arkdrop-common = { path = "../common" } +arkdrop-common = { path = "../common" } arkdropx-sender = { path = "../exchanges/sender" } -arkdropx-receiver = { path = "../exchanges/receiver" } +arkdropx-receiver = { path = "../exchanges/receiver" } toml = "0.8" anyhow = "1.0" base64 = "0.21" -serde = { version = "1.0", features = ["derive"] } -toml = "0.8" -qrcode = "0.14" - -dropx-receiver = { path = "../exchanges/receiver" } -dropx-sender = { path = "../exchanges/sender" } clap = "4.5.47" uuid = "1.18.1" tokio = "1.47.1" diff --git a/drop-core/cli/src/lib.rs b/drop-core/cli/src/lib.rs index 97fff7b3..15d2be1c 100644 --- a/drop-core/cli/src/lib.rs +++ b/drop-core/cli/src/lib.rs @@ -80,10 +80,10 @@ use arkdropx_receiver::{ }, receive_files, }; -use dropx_sender::{ - SendFilesConnectingEvent, SendFilesRequest, SendFilesSendingEvent, - SendFilesSubscriber, SenderConfig, SenderFile, SenderFileData, - SenderProfile, send_files, +use arkdropx_sender::{ + SendFilesBubble, SendFilesConnectingEvent, SendFilesRequest, + SendFilesSendingEvent, SendFilesSubscriber, SenderConfig, SenderFile, + SenderFileData, SenderProfile, send_files, send_files_to::{ SendFilesToBubble, SendFilesToConnectingEvent, SendFilesToRequest, SendFilesToSendingEvent, SendFilesToSubscriber, send_files_to, @@ -153,12 +153,9 @@ impl FileSender { let subscriber = FileSendSubscriber::new(verbose); bubble.subscribe(Arc::new(subscriber)); - // Display QR code and session info - display_session_info( - &bubble.get_ticket(), - bubble.get_confirmation(), - "Sender", - ); + println!("📦 Ready to send files!"); + print_qr_to_console(&bubble)?; + println!("⏳ Waiting for receiver... (Press Ctrl+C to cancel)"); tokio::select! { _ = tokio::signal::ctrl_c() => { @@ -221,6 +218,25 @@ fn print_qr_to_console(bubble: &SendFilesBubble) -> Result<()> { Ok(()) } +fn print_ready_to_receive_qr(ticket: &str, confirmation: u8) -> Result<()> { + let data = + format!("drop://send?ticket={ticket}&confirmation={confirmation}"); + + let code = QrCode::new(&data)?; + let image = code + .render::() + .quiet_zone(false) + .module_dimensions(2, 1) + .build(); + + println!("\nQR Code for Transfer:"); + println!("{}", image); + println!("🎫 Ticket: {ticket}"); + println!("🔒 Confirmation: {confirmation}\n"); + + Ok(()) +} + async fn wait_for_send_completion(bubble: &arkdropx_sender::SendFilesBubble) { loop { if bubble.is_finished() { @@ -967,34 +983,16 @@ pub async fn run_receive_files( format!("Invalid confirmation code: {confirmation}") })?; - // Determine the output directory - let final_output_dir = match output_dir { - Some(dir) => { - let path = PathBuf::from(&dir); - - // Save this directory as default if requested - if save_dir { - let mut config = CliConfig::load()?; - config - .set_default_receive_dir(dir.clone()) - .with_context( - || "Failed to save default receive directory", - )?; - println!("Saved '{}' as default receive directory", dir); - } - - path - } - None => { - // Try to use saved default directory; otherwise use sensible - // fallback - let config = CliConfig::load()?; - match config.get_default_receive_dir() { - Some(default_dir) => PathBuf::from(default_dir), - None => default_receive_dir_fallback(), - } - } - }; + if save_out { + let mut config = AppConfig::load()?; + config.set_out_dir(out_dir.clone()).with_context( + || "Failed to save default output receive directory", + )?; + println!( + "💾 Saved '{}' as default output receive directory", + out_dir.display() + ); + } let receiver = FileReceiver::new(profile); receiver @@ -1618,23 +1616,12 @@ pub async fn run_ready_to_receive( Some(dir) => { let path = PathBuf::from(&dir); if save_dir { - let mut config = CliConfig::load()?; - config - .set_default_receive_dir(dir.clone()) - .with_context( - || "Failed to save default receive directory", - )?; - println!("Saved '{}' as default receive directory", dir); + set_default_out_dir(path.clone())?; + println!("💾 Saved '{}' as default receive directory", dir); } path } - None => { - let config = CliConfig::load()?; - match config.get_default_receive_dir() { - Some(default_dir) => PathBuf::from(default_dir), - None => default_receive_dir_fallback(), - } - } + None => get_default_out_dir(), }; // Create output directory if it doesn't exist @@ -1672,9 +1659,10 @@ pub async fn run_ready_to_receive( let confirmation = bubble.get_confirmation(); // Display QR code and session info - display_session_info(&ticket, confirmation, "Receiver"); - - println!("Files will be saved to: {}", receiving_path.display()); + println!("📦 Ready to receive files!"); + print_ready_to_receive_qr(&ticket, confirmation)?; + println!("📁 Files will be saved to: {}", receiving_path.display()); + println!("⏳ Waiting for sender... (Press Ctrl+C to cancel)"); let subscriber = ReadyToReceiveSubscriberImpl::new(receiving_path.clone(), verbose); @@ -1682,16 +1670,16 @@ pub async fn run_ready_to_receive( tokio::select! { _ = tokio::signal::ctrl_c() => { - println!("Cancelling file transfer..."); + println!("🚫 Cancelling file transfer..."); let _ = bubble.cancel().await; - println!("Transfer cancelled"); - std::process::exit(0); + println!("✅ Transfer cancelled"); } _ = wait_for_ready_to_receive_completion(&bubble) => { - println!("All files received successfully!"); - std::process::exit(0); + println!("✅ All files received successfully!"); } } + + Ok(()) } /// Run send-files-to operation (sender connects to waiting receiver). diff --git a/drop-core/common/src/lib.rs b/drop-core/common/src/lib.rs index c4d6e99f..856e3dcd 100644 --- a/drop-core/common/src/lib.rs +++ b/drop-core/common/src/lib.rs @@ -525,6 +525,6 @@ impl TransferFile { pub fn get_pct(&self) -> f64 { let raw_pct = self.len / self.expected_len; let pct: u32 = raw_pct.try_into().unwrap_or(0); - pct.try_into().unwrap_or(0.0) + pct.into() } } diff --git a/drop-core/exchanges/receiver/src/ready_to_receive/handler.rs b/drop-core/exchanges/receiver/src/ready_to_receive/handler.rs index d32d1a96..ff090b6c 100644 --- a/drop-core/exchanges/receiver/src/ready_to_receive/handler.rs +++ b/drop-core/exchanges/receiver/src/ready_to_receive/handler.rs @@ -7,8 +7,8 @@ //! chunk arrivals. use anyhow::Result; -use drop_entities::Profile; -use dropx_common::{ +use arkdrop_entities::Profile; +use arkdropx_common::{ handshake::{ HandshakeConfig, HandshakeProfile, NegotiatedConfig, ReceiverHandshake, SenderHandshake, diff --git a/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs b/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs index 129b3b7c..ede8758d 100644 --- a/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs +++ b/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs @@ -8,8 +8,8 @@ mod handler; use anyhow::Result; +use arkdrop_entities::Profile; use chrono::{DateTime, Utc}; -use drop_entities::Profile; use handler::ReadyToReceiveHandler; use iroh::{Endpoint, Watcher, protocol::Router}; use iroh_base::ticket::NodeTicket; @@ -236,7 +236,7 @@ impl ReadyToReceiveBubble { /// Example: /// ```rust no_run /// use std::sync::Arc; -/// use dropx_receiver::{ +/// use arkdropx_receiver::{ /// ready_to_receive::*, ReceiverProfile, /// }; /// diff --git a/drop-core/exchanges/receiver/src/receive_files.rs b/drop-core/exchanges/receiver/src/receive_files.rs index 332de15f..1f4aa745 100644 --- a/drop-core/exchanges/receiver/src/receive_files.rs +++ b/drop-core/exchanges/receiver/src/receive_files.rs @@ -430,16 +430,14 @@ impl Carrier { } } - while let Some(result) = join_set.join_next().await { - if let Err(err) = result? { - // Downcast anyhow::Error to ConnectionError - if let Some(connection_err) = - err.downcast_ref::() - && connection_err == &expected_close - { - continue; - } - return Err(err); + while let Some(result) = join_set.join_next().await + && let Err(err) = result? + { + // Downcast anyhow::Error to ConnectionError + if let Some(connection_err) = err.downcast_ref::() + && connection_err == &expected_close + { + continue; } return Err(err); } diff --git a/drop-core/exchanges/sender/src/lib.rs b/drop-core/exchanges/sender/src/lib.rs index 66c0131c..68e6da6c 100644 --- a/drop-core/exchanges/sender/src/lib.rs +++ b/drop-core/exchanges/sender/src/lib.rs @@ -68,7 +68,7 @@ pub struct SenderFile { /// - `read_chunk(size)` returns the next chunk up to `size` bytes; an empty /// vector signals EOF. /// - `read` is a single-byte variant primarily to satisfy the -/// `drop_entities::Data` trait; it can be implemented in terms of your +/// `arkdrop_entities::Data` trait; it can be implemented in terms of your /// internal reader if needed. pub trait SenderFileData: Send + Sync { /// Total length in bytes. @@ -86,10 +86,10 @@ pub trait SenderFileData: Send + Sync { fn read_chunk(&self, size: u64) -> Vec; } -/// Internal adapter to bridge `SenderFileData` with `drop_entities::Data`. +/// Internal adapter to bridge `SenderFileData` with `arkdrop_entities::Data`. /// /// This type is not exposed publicly; it allows the rest of the pipeline to -/// operate on the generic `drop_entities::File` type. +/// operate on the generic `arkdrop_entities::File` type. struct SenderFileDataAdapter { inner: Arc, } diff --git a/drop-core/exchanges/sender/src/send_files/handler.rs b/drop-core/exchanges/sender/src/send_files/handler.rs index 2af4ce6c..906540b2 100644 --- a/drop-core/exchanges/sender/src/send_files/handler.rs +++ b/drop-core/exchanges/sender/src/send_files/handler.rs @@ -445,7 +445,7 @@ impl Carrier { let mut uni = connection.open_uni().await?; - Self::notify_progress(&file, sent, remaining, subscribers.clone()); + Self::notify_progress(file, sent, remaining, subscribers.clone()); loop { chunk_buffer.clear(); @@ -470,7 +470,7 @@ impl Carrier { sent += data_len; remaining = remaining.saturating_sub(data_len); - Self::notify_progress(&file, sent, remaining, subscribers.clone()); + Self::notify_progress(file, sent, remaining, subscribers.clone()); } uni.finish()?; diff --git a/drop-core/exchanges/sender/src/send_files_to.rs b/drop-core/exchanges/sender/src/send_files_to.rs index 6266cd79..0d00050c 100644 --- a/drop-core/exchanges/sender/src/send_files_to.rs +++ b/drop-core/exchanges/sender/src/send_files_to.rs @@ -6,8 +6,8 @@ use crate::{SenderConfig, SenderFile, SenderFileDataAdapter, SenderProfile}; use anyhow::Result; -use drop_entities::{File, Profile}; -use dropx_common::{ +use arkdrop_entities::{File, Profile}; +use arkdropx_common::{ handshake::{ HandshakeConfig, HandshakeFile, HandshakeProfile, NegotiatedConfig, ReceiverHandshake, SenderHandshake, @@ -493,7 +493,7 @@ impl Carrier { /// Example: /// ```rust no_run /// use std::sync::Arc; -/// use dropx_sender::{ +/// use arkdropx_sender::{ /// send_files_to::*, SenderProfile, SenderConfig, SenderFile, /// }; /// diff --git a/drop-core/uniffi/src/receiver/receive_files.rs b/drop-core/uniffi/src/receiver/receive_files.rs index 9b2a4682..85eee420 100644 --- a/drop-core/uniffi/src/receiver/receive_files.rs +++ b/drop-core/uniffi/src/receiver/receive_files.rs @@ -174,7 +174,7 @@ pub async fn receive_files( let bubble = runtime .block_on(async { let adapted_request = create_adapted_request(request); - return arkdropx_receiver::receive_files(adapted_request).await; + arkdropx_receiver::receive_files(adapted_request).await }) .map_err(|e| DropError::TODO(e.to_string()))?; Ok(Arc::new(ReceiveFilesBubble { @@ -200,7 +200,7 @@ fn create_adapted_request( chunk_size: c.chunk_size, parallel_streams: c.parallel_streams, }); - return arkdropx_receiver::ReceiveFilesRequest { + arkdropx_receiver::ReceiveFilesRequest { profile, ticket: request.ticket, confirmation: request.confirmation, diff --git a/drop-core/uniffi/src/sender.rs b/drop-core/uniffi/src/sender.rs index 40184d76..d7a76b74 100644 --- a/drop-core/uniffi/src/sender.rs +++ b/drop-core/uniffi/src/sender.rs @@ -54,7 +54,7 @@ impl arkdropx_sender::SenderFileData for SenderFileDataAdapter { } fn is_empty(&self) -> bool { - return self.inner.is_empty(); + self.inner.is_empty() } fn read(&self) -> Option { diff --git a/drop-core/uniffi/src/sender/send_files.rs b/drop-core/uniffi/src/sender/send_files.rs index 13e8c59c..5db39800 100644 --- a/drop-core/uniffi/src/sender/send_files.rs +++ b/drop-core/uniffi/src/sender/send_files.rs @@ -129,7 +129,7 @@ impl arkdropx_sender::SendFilesSubscriber for SendFilesSubscriberAdapter { } fn notify_sending(&self, event: arkdropx_sender::SendFilesSendingEvent) { - return self.inner.notify_sending(SendFilesSendingEvent { + self.inner.notify_sending(SendFilesSendingEvent { id: event.id, name: event.name, sent: event.sent, @@ -141,8 +141,7 @@ impl arkdropx_sender::SendFilesSubscriber for SendFilesSubscriberAdapter { &self, event: arkdropx_sender::SendFilesConnectingEvent, ) { - return self - .inner + self.inner .notify_connecting(SendFilesConnectingEvent { receiver: SendFilesProfile { id: event.receiver.id, @@ -166,7 +165,7 @@ pub async fn send_files( let bubble = runtime .block_on(async { let adapted_request = create_adapted_request(request); - return arkdropx_sender::send_files(adapted_request).await; + arkdropx_sender::send_files(adapted_request).await }) .map_err(|e| DropError::TODO(e.to_string()))?; Ok(Arc::new(SendFilesBubble { @@ -192,7 +191,7 @@ fn create_adapted_request( .into_iter() .map(|f| { let data = SenderFileDataAdapter { inner: f.data }; - return arkdropx_sender::SenderFile { + arkdropx_sender::SenderFile { name: f.name, data: Arc::new(data), } @@ -205,7 +204,7 @@ fn create_adapted_request( }, None => arkdropx_sender::SenderConfig::default(), }; - return arkdropx_sender::SendFilesRequest { + arkdropx_sender::SendFilesRequest { profile, files, config, From d3f3ae73d726022a88d4f6de5e001fc215070b50 Mon Sep 17 00:00:00 2001 From: Pushkar Mishra Date: Fri, 28 Nov 2025 23:09:10 +0530 Subject: [PATCH 07/17] Refactor file transfer cancellation and completion handling in drop-core CLI and receiver - Replaced `std::process::exit(0)` with `Ok(())` in file transfer cancellation and completion logic to improve error handling and maintain control flow. - Updated the `Carrier` implementation to enhance error management during task completion. These changes enhance the robustness of the file transfer process by allowing for better error propagation and control flow management. Signed-off-by: Pushkar Mishra --- drop-core/cli/src/lib.rs | 12 +++++------ .../exchanges/receiver/src/receive_files.rs | 21 ++++++++++--------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/drop-core/cli/src/lib.rs b/drop-core/cli/src/lib.rs index 15d2be1c..7d3037c2 100644 --- a/drop-core/cli/src/lib.rs +++ b/drop-core/cli/src/lib.rs @@ -162,11 +162,11 @@ impl FileSender { println!("Cancelling file transfer..."); let _ = bubble.cancel().await; println!("Transfer cancelled"); - std::process::exit(0); + Ok(()) } _ = wait_for_send_completion(&bubble) => { println!("All files sent successfully!"); - std::process::exit(0); + Ok(()) } } } @@ -334,11 +334,11 @@ impl FileReceiver { println!("Cancelling file transfer..."); bubble.cancel(); println!("Transfer cancelled"); - std::process::exit(0); + Ok(()) } _ = wait_for_receive_completion(&bubble) => { println!("All files received successfully!"); - std::process::exit(0); + Ok(()) } } } @@ -1772,11 +1772,11 @@ pub async fn run_send_files_to( _ = tokio::signal::ctrl_c() => { println!("Cancelling file transfer..."); println!("Transfer cancelled"); - std::process::exit(0); + Ok(()) } _ = wait_for_send_files_to_completion(&bubble) => { println!("All files sent successfully!"); - std::process::exit(0); + Ok(()) } } } diff --git a/drop-core/exchanges/receiver/src/receive_files.rs b/drop-core/exchanges/receiver/src/receive_files.rs index 1f4aa745..c87b7cac 100644 --- a/drop-core/exchanges/receiver/src/receive_files.rs +++ b/drop-core/exchanges/receiver/src/receive_files.rs @@ -415,18 +415,19 @@ impl Carrier { }); // Clean up completed tasks periodically - if join_set.len() >= parallel_streams as usize - && let Some(result) = join_set.join_next().await - && let Err(err) = result? - { - // Downcast anyhow::Error to ConnectionError - if let Some(connection_err) = - err.downcast_ref::() - && connection_err == &expected_close + while join_set.len() >= parallel_streams as usize { + if let Some(result) = join_set.join_next().await + && let Err(err) = result? { - break 'files_iterator; + // Downcast anyhow::Error to ConnectionError + if let Some(connection_err) = + err.downcast_ref::() + && connection_err == &expected_close + { + break 'files_iterator; + } + return Err(err); } - return Err(err); } } From 1288cfa46ee3bc3bef89b0b2e65f2d4e6df2df96 Mon Sep 17 00:00:00 2001 From: Pushkar Mishra Date: Tue, 9 Dec 2025 17:36:49 +0530 Subject: [PATCH 08/17] Add wait-to-receive and send-to commands to drop-core CLI - Introduced new subcommands `wait-to-receive` and `send-to` for improved file transfer functionality. - Implemented argument parsing for both commands, allowing users to specify output directories, display names, and avatar options. - Enhanced the handling of file transfers with dedicated functions for waiting to receive files and sending files to a receiver. These changes enhance the usability of the CLI by providing clear commands for file transfer operations, improving the overall user experience. Signed-off-by: Pushkar Mishra --- drop-core/cli/src/lib.rs | 149 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/drop-core/cli/src/lib.rs b/drop-core/cli/src/lib.rs index 7d3037c2..c89bd932 100644 --- a/drop-core/cli/src/lib.rs +++ b/drop-core/cli/src/lib.rs @@ -1045,6 +1045,12 @@ async fn run_cli_subcommand( Some(("config", sub_matches)) => { handle_config_command(sub_matches).await } + Some(("wait-to-receive", sub_matches)) => { + handle_wait_to_receive_command(sub_matches).await + } + Some(("send-to", sub_matches)) => { + handle_send_to_command(sub_matches).await + } _ => { eprintln!("❌ Invalid command. Use --help for usage information."); std::process::exit(1); @@ -1170,6 +1176,90 @@ pub fn build_cli() -> Command { .about("Clear default receive directory") ) ) + .subcommand( + Command::new("wait-to-receive") + .about("Wait for files from a sender (generates QR code for sender to scan)") + .arg( + Arg::new("output") + .help("Output directory for received files (optional if default is set)") + .long("output") + .short('o') + .value_parser(clap::value_parser!(PathBuf)) + ) + .arg( + Arg::new("save-output") + .long("save-output") + .short('u') + .help("Save the specified output directory as default for future use") + .action(clap::ArgAction::SetTrue) + .requires("output") + ) + .arg( + Arg::new("name") + .long("name") + .short('n') + .help("Your display name") + .default_value("arkdrop-receiver") + ) + .arg( + Arg::new("avatar") + .long("avatar") + .short('a') + .help("Path to avatar image file") + .value_parser(clap::value_parser!(PathBuf)) + ) + .arg( + Arg::new("avatar-b64") + .long("avatar-b64") + .short('b') + .help("Base64 encoded avatar image (alternative to --avatar)") + .conflicts_with("avatar") + ) + ) + .subcommand( + Command::new("send-to") + .about("Send files to a waiting receiver (scan receiver's QR code)") + .arg( + Arg::new("ticket") + .help("Transfer ticket from receiver's QR code") + .required(true) + .index(1) + ) + .arg( + Arg::new("confirmation") + .help("Confirmation code from receiver") + .required(true) + .index(2) + ) + .arg( + Arg::new("files") + .help("Files to send") + .required(true) + .index(3) + .num_args(1..) + .value_parser(clap::value_parser!(PathBuf)) + ) + .arg( + Arg::new("name") + .long("name") + .short('n') + .help("Your display name") + .default_value("arkdrop-sender") + ) + .arg( + Arg::new("avatar") + .long("avatar") + .short('a') + .help("Path to avatar image file") + .value_parser(clap::value_parser!(PathBuf)) + ) + .arg( + Arg::new("avatar-b64") + .long("avatar-b64") + .help("Base64 encoded avatar image (alternative to --avatar)") + .conflicts_with("avatar") + ) + ) } async fn handle_send_command(matches: &ArgMatches) -> Result<()> { @@ -1289,6 +1379,65 @@ async fn handle_config_command(matches: &ArgMatches) -> Result<()> { Ok(()) } +async fn handle_wait_to_receive_command(matches: &ArgMatches) -> Result<()> { + let out_dir = matches + .get_one::("output") + .map(|p| p.to_string_lossy().to_string()); + let verbose = matches.get_flag("verbose"); + let save_output = matches.get_flag("save-output"); + + let profile = build_profile(matches)?; + + println!("📥 Preparing to wait for files..."); + println!("👤 Receiver name: {}", profile.name); + + if profile.avatar_b64.is_some() { + println!("🖼️ Avatar: Set"); + } + + run_ready_to_receive(out_dir, profile, verbose, save_output).await +} + +async fn handle_send_to_command(matches: &ArgMatches) -> Result<()> { + let ticket = matches.get_one::("ticket").unwrap(); + let confirmation = matches.get_one::("confirmation").unwrap(); + let files: Vec = matches + .get_many::("files") + .unwrap() + .cloned() + .collect(); + let verbose = matches.get_flag("verbose"); + + let profile = build_profile(matches)?; + + println!( + "📤 Preparing to send {} file(s) to waiting receiver...", + files.len() + ); + for file in &files { + println!(" 📄 {}", file.display()); + } + println!("👤 Sender name: {}", profile.name); + + if profile.avatar_b64.is_some() { + println!("🖼️ Avatar: Set"); + } + + let file_strings: Vec = files + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + + run_send_files_to( + file_strings, + ticket.clone(), + confirmation.clone(), + profile, + verbose, + ) + .await +} + #[cfg(test)] mod tests { use super::*; From 5f501a3523812e77ff81e44b6cd04fa028692d65 Mon Sep 17 00:00:00 2001 From: Pushkar Mishra Date: Tue, 9 Dec 2025 18:18:26 +0530 Subject: [PATCH 09/17] Enhance error handling and logging in drop-core CLI and exchanges - Improved error messages for accessing progress bars, file handles, and directory creation, providing clearer feedback during file transfer operations. - Refactored file length retrieval to use metadata for efficiency, reducing the complexity of the `len` function. - Enhanced logging for handshake and file reception failures in the `ReadyToReceiveHandler`, improving traceability of issues. These changes improve the robustness and user experience of the file transfer process by ensuring better error reporting and handling. Signed-off-by: Pushkar Mishra --- drop-core/cli/src/lib.rs | 121 +++++++++++++----- drop-core/exchanges/receiver/src/lib.rs | 12 +- .../receiver/src/ready_to_receive/handler.rs | 6 +- .../receiver/src/ready_to_receive/mod.rs | 4 +- .../exchanges/sender/src/send_files_to.rs | 32 +++-- 5 files changed, 125 insertions(+), 50 deletions(-) diff --git a/drop-core/cli/src/lib.rs b/drop-core/cli/src/lib.rs index c89bd932..3f198591 100644 --- a/drop-core/cli/src/lib.rs +++ b/drop-core/cli/src/lib.rs @@ -405,7 +405,7 @@ impl SendFilesSubscriber for FileSendSubscriber { let mut bars = match self.bars.write() { Ok(bars) => bars, Err(e) => { - eprintln!("Error accessing progress bars: {}", e); + eprintln!("[ERROR] Error accessing progress bars: {}", e); return; } }; @@ -577,21 +577,41 @@ impl ReceiveFilesSubscriber for FileReceiveSubscriber { return; } }; - let file_handle = file_handles - .entry(event.id.clone()) - .or_insert_with(|| { - fs::File::options() + let file_handle = match file_handles.entry(event.id.clone()) { + std::collections::hash_map::Entry::Occupied(entry) => { + entry.into_mut() + } + std::collections::hash_map::Entry::Vacant(entry) => { + // Create parent directories if they don't exist + if let Some(parent) = file_path.parent() { + if !parent.exists() { + if let Err(e) = fs::create_dir_all(parent) { + eprintln!( + "[ERROR] Failed to create directory {}: {}", + parent.display(), + e + ); + return; + } + } + } + match fs::File::options() .create(true) .append(true) .open(&file_path) - .unwrap_or_else(|e| { - panic!( - "Failed to open file {}: {}", + { + Ok(f) => entry.insert(f), + Err(e) => { + eprintln!( + "[ERROR] Failed to open file {}: {}", file_path.display(), e - ) - }) - }); + ); + return; + } + } + } + }; // Write to the cached file handle if let Err(e) = file_handle.write_all(&event.data) { @@ -1295,7 +1315,7 @@ async fn handle_send_command(matches: &ArgMatches) -> Result<()> { async fn handle_receive_command(matches: &ArgMatches) -> Result<()> { let out_dir = matches .get_one::("output") - .map(|p| PathBuf::from(p)); + .map(PathBuf::from); let ticket = matches.get_one::("ticket").unwrap(); let confirmation = matches.get_one::("confirmation").unwrap(); let verbose = matches.get_flag("verbose"); @@ -1543,7 +1563,7 @@ impl ReadyToReceiveSubscriber for ReadyToReceiveSubscriberImpl { let mut bars = match self.bars.write() { Ok(bars) => bars, Err(e) => { - eprintln!("Error accessing progress bars: {}", e); + eprintln!("[ERROR] Error accessing progress bars: {}", e); return; } }; @@ -1564,13 +1584,28 @@ impl ReadyToReceiveSubscriber for ReadyToReceiveSubscriberImpl { let mut recvd = match self.received.write() { Ok(recvd) => recvd, Err(e) => { - eprintln!("Error accessing received bytes tracker: {}", e); + eprintln!( + "[ERROR] Error accessing received bytes tracker: {}", + e + ); return; } }; let entry = recvd.entry(event.id.clone()).or_insert(0); *entry += event.data.len() as u64; - pb.inc(event.data.len() as u64); + + // If we have a length bar, update position and maybe finish + if let Some(len) = pb.length() { + pb.set_position(*entry); + if *entry >= len { + pb.finish_with_message(format!( + "[DONE] Received {}", + file.name + )); + } + } else { + pb.inc(event.data.len() as u64); + } } let file_path = self.receiving_path.join(&file.name); @@ -1579,25 +1614,45 @@ impl ReadyToReceiveSubscriber for ReadyToReceiveSubscriberImpl { let mut file_handles = match self.file_handles.write() { Ok(handles) => handles, Err(e) => { - eprintln!("Error accessing file handles: {}", e); + eprintln!("[ERROR] Error accessing file handles: {}", e); return; } }; - let file_handle = file_handles - .entry(event.id.clone()) - .or_insert_with(|| { - fs::File::options() + let file_handle = match file_handles.entry(event.id.clone()) { + std::collections::hash_map::Entry::Occupied(entry) => { + entry.into_mut() + } + std::collections::hash_map::Entry::Vacant(entry) => { + // Create parent directories if they don't exist + if let Some(parent) = file_path.parent() { + if !parent.exists() { + if let Err(e) = fs::create_dir_all(parent) { + eprintln!( + "[ERROR] Failed to create directory {}: {}", + parent.display(), + e + ); + return; + } + } + } + match fs::File::options() .create(true) .append(true) .open(&file_path) - .unwrap_or_else(|e| { - panic!( - "Failed to open file {}: {}", + { + Ok(f) => entry.insert(f), + Err(e) => { + eprintln!( + "[ERROR] Failed to open file {}: {}", file_path.display(), e - ) - }) - }); + ); + return; + } + } + } + }; // Write to the cached file handle if let Err(e) = file_handle.write_all(&event.data) { @@ -1632,7 +1687,10 @@ impl ReadyToReceiveSubscriber for ReadyToReceiveSubscriberImpl { let mut bars = match self.bars.write() { Ok(bars) => bars, Err(e) => { - eprintln!("Error accessing progress bars: {}", e); + eprintln!( + "[ERROR] Error accessing progress bars: {}", + e + ); return; } }; @@ -1691,7 +1749,7 @@ impl SendFilesToSubscriber for SendFilesToSubscriberImpl { let mut bars = match self.bars.write() { Ok(bars) => bars, Err(e) => { - eprintln!("Error accessing progress bars: {}", e); + eprintln!("[ERROR] Error accessing progress bars: {}", e); return; } }; @@ -1919,12 +1977,13 @@ pub async fn run_send_files_to( tokio::select! { _ = tokio::signal::ctrl_c() => { - println!("Cancelling file transfer..."); - println!("Transfer cancelled"); + println!("🚫 Cancelling file transfer..."); + let _ = bubble.cancel().await; + println!("✅ Transfer cancelled"); Ok(()) } _ = wait_for_send_files_to_completion(&bubble) => { - println!("All files sent successfully!"); + println!("✅ All files sent successfully!"); Ok(()) } } diff --git a/drop-core/exchanges/receiver/src/lib.rs b/drop-core/exchanges/receiver/src/lib.rs index ccdab054..ed433304 100644 --- a/drop-core/exchanges/receiver/src/lib.rs +++ b/drop-core/exchanges/receiver/src/lib.rs @@ -148,15 +148,11 @@ impl ReceiverFileData { } } - /// Return the file length in bytes by counting the iterator. - /// - /// Note: This reads the entire file to count bytes and is therefore O(n). - /// If possible, prefer using `std::fs::metadata(&path)?.len()` in your own - /// code where you have direct access to the path. + /// Return the file length in bytes using file metadata (O(1)). pub fn len(&self) -> u64 { - use std::io::BufReader; - let file = std::fs::File::open(&self.path).unwrap(); - BufReader::new(file).bytes().count() as u64 + std::fs::metadata(&self.path) + .map(|m| m.len()) + .unwrap_or(0) } /// Returns true if the data has zero length. diff --git a/drop-core/exchanges/receiver/src/ready_to_receive/handler.rs b/drop-core/exchanges/receiver/src/ready_to_receive/handler.rs index ff090b6c..38e1945a 100644 --- a/drop-core/exchanges/receiver/src/ready_to_receive/handler.rs +++ b/drop-core/exchanges/receiver/src/ready_to_receive/handler.rs @@ -249,11 +249,13 @@ impl ProtocolHandler for ReadyToReceiveHandler { async move { let mut carrier = carrier; - if (carrier.greet().await).is_err() { + if let Err(e) = carrier.greet().await { + carrier.log(format!("accept: Handshake failed: {:?}", e)); return Err(iroh::protocol::AcceptError::NotAllowed {}); } - if (carrier.receive_files().await).is_err() { + if let Err(e) = carrier.receive_files().await { + carrier.log(format!("accept: File reception failed: {:?}", e)); return Err(iroh::protocol::AcceptError::NotAllowed {}); } diff --git a/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs b/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs index ede8758d..79e61b66 100644 --- a/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs +++ b/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs @@ -169,7 +169,9 @@ impl ReadyToReceiveBubble { ); tokio::spawn(async move { - let _ = router.shutdown().await; + if let Err(e) = router.shutdown().await { + eprintln!("[ERROR] Failed to shutdown router: {}", e); + } }); } diff --git a/drop-core/exchanges/sender/src/send_files_to.rs b/drop-core/exchanges/sender/src/send_files_to.rs index 0d00050c..7b076626 100644 --- a/drop-core/exchanges/sender/src/send_files_to.rs +++ b/drop-core/exchanges/sender/src/send_files_to.rs @@ -86,21 +86,23 @@ impl SendFilesToBubble { pub fn start(&self) -> Result<()> { self.log("start: Checking if transfer can be started".to_string()); - let is_running = self + // Use compare_exchange to atomically check and set is_running + if self .is_running - .load(std::sync::atomic::Ordering::Acquire); - - if is_running { + .compare_exchange( + false, + true, + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Relaxed, + ) + .is_err() + { self.log( "start: Cannot start transfer, already running".to_string(), ); return Err(anyhow::Error::msg("Already running.")); } - self.log("start: Setting running flag".to_string()); - self.is_running - .store(true, std::sync::atomic::Ordering::Release); - self.log("start: Creating carrier for file sending".to_string()); let carrier = Carrier { profile: self.profile.clone(), @@ -146,6 +148,20 @@ impl SendFilesToBubble { finished } + /// Cancel the send-to transfer. + /// + /// Closes the connection and marks the session as finished. + pub async fn cancel(&self) -> Result<()> { + self.log("cancel: Initiating send-to cancellation".to_string()); + self.is_finished + .store(true, std::sync::atomic::Ordering::Relaxed); + self.connection + .close(VarInt::from_u32(0), b"cancelled"); + self.endpoint.close().await; + self.log("cancel: Send-to cancelled successfully".to_string()); + Ok(()) + } + /// Register a subscriber to receive log and progress events. pub fn subscribe(&self, subscriber: Arc) { let subscriber_id = subscriber.get_id(); From 9a109c7c0d3d1337675e9348e883e73ffff2085b Mon Sep 17 00:00:00 2001 From: Pushkar Mishra Date: Thu, 11 Dec 2025 20:39:50 +0530 Subject: [PATCH 10/17] Implement send files to and ready to receive functionality in TUI - Added new managers for handling sending files to a receiver and ready to receive operations. - Introduced new TUI pages for sending files to a receiver and monitoring the progress of file transfers. - Enhanced the home application to include options for sending files to and waiting to receive files, with corresponding navigation and status updates. - Implemented QR code rendering utilities for better user experience during file transfers. These changes significantly improve the file transfer capabilities of the TUI, providing users with clear options and feedback during the process. Signed-off-by: Pushkar Mishra --- drop-core/tui/src/apps/home.rs | 102 +- drop-core/tui/src/apps/mod.rs | 3 + .../tui/src/apps/ready_to_receive_progress.rs | 865 ++++++++++++ drop-core/tui/src/apps/send_files_to.rs | 1164 +++++++++++++++++ .../tui/src/apps/send_files_to_progress.rs | 558 ++++++++ drop-core/tui/src/backend.rs | 46 +- drop-core/tui/src/layout.rs | 17 + drop-core/tui/src/lib.rs | 76 +- drop-core/tui/src/ready_to_receive_manager.rs | 80 ++ drop-core/tui/src/send_files_to_manager.rs | 86 ++ drop-core/tui/src/utilities/mod.rs | 1 + drop-core/tui/src/utilities/qr_renderer.rs | 151 +++ 12 files changed, 3141 insertions(+), 8 deletions(-) create mode 100644 drop-core/tui/src/apps/ready_to_receive_progress.rs create mode 100644 drop-core/tui/src/apps/send_files_to.rs create mode 100644 drop-core/tui/src/apps/send_files_to_progress.rs create mode 100644 drop-core/tui/src/ready_to_receive_manager.rs create mode 100644 drop-core/tui/src/send_files_to_manager.rs create mode 100644 drop-core/tui/src/utilities/qr_renderer.rs diff --git a/drop-core/tui/src/apps/home.rs b/drop-core/tui/src/apps/home.rs index 24c0e55b..5cf1f75f 100644 --- a/drop-core/tui/src/apps/home.rs +++ b/drop-core/tui/src/apps/home.rs @@ -16,6 +16,8 @@ use crate::{App, AppBackend, ControlCapture, Page}; enum MenuItem { SendFiles, ReceiveFiles, + SendFilesTo, + ReadyToReceive, Config, Help, } @@ -104,6 +106,14 @@ impl App for HomeApp { self.select_item(3); self.activate_current_item(); } + KeyCode::Char('5') => { + self.select_item(4); + self.activate_current_item(); + } + KeyCode::Char('6') => { + self.select_item(5); + self.activate_current_item(); + } KeyCode::Esc => { self.set_status_message( "Press Ctrl+Q to quit application", @@ -140,6 +150,8 @@ impl HomeApp { vec![ MenuItem::SendFiles, MenuItem::ReceiveFiles, + MenuItem::SendFilesTo, + MenuItem::ReadyToReceive, MenuItem::Config, MenuItem::Help, ] @@ -187,6 +199,12 @@ impl HomeApp { Some(MenuItem::ReceiveFiles) => { "Receive files from another device" } + Some(MenuItem::SendFilesTo) => { + "Scan QR to send files to a waiting receiver" + } + Some(MenuItem::ReadyToReceive) => { + "Generate QR code and wait for sender" + } Some(MenuItem::Config) => { "Configure your profile and preferences" } @@ -209,6 +227,12 @@ impl HomeApp { MenuItem::ReceiveFiles => { self.navigate_to_page(Page::ReceiveFiles); } + MenuItem::SendFilesTo => { + self.start_send_files_to(); + } + MenuItem::ReadyToReceive => { + self.start_ready_to_receive(); + } MenuItem::Config => { self.navigate_to_page(Page::Config); } @@ -224,6 +248,38 @@ impl HomeApp { self.b.get_navigation().navigate_to(page); } + fn start_send_files_to(&self) { + self.navigate_to_page(Page::SendFilesTo); + } + + fn start_ready_to_receive(&self) { + use arkdropx_receiver::ReceiverProfile; + use arkdropx_receiver::ready_to_receive::{ + ReadyToReceiveConfig, ReadyToReceiveRequest, + }; + + let config = self.b.get_config(); + let profile = ReceiverProfile { + name: config + .avatar_name + .unwrap_or("Receiver".to_string()), + avatar_b64: None, + }; + + let request = ReadyToReceiveRequest { + profile, + config: ReadyToReceiveConfig::balanced(), + }; + + self.b + .get_ready_to_receive_manager() + .ready_to_receive(request); + self.set_status_message("Starting Ready to Receive..."); + self.b + .get_navigation() + .navigate_to(Page::ReadyToReceiveProgress); + } + fn set_status_message(&self, message: &str) { *self.status_message.write().unwrap() = message.to_string(); } @@ -274,6 +330,46 @@ impl HomeApp { ), ]), ]), + ListItem::new(vec![ + Line::from(vec![ + Span::styled( + "🔗 ", + Style::default().fg(Color::Magenta).bold(), + ), + Span::styled( + "Send to QR", + Style::default().fg(Color::White).bold(), + ), + Span::styled(" (3)", Style::default().fg(Color::DarkGray)), + ]), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + "Scan QR to send to waiting receiver", + Style::default().fg(Color::Gray), + ), + ]), + ]), + ListItem::new(vec![ + Line::from(vec![ + Span::styled( + "📲 ", + Style::default().fg(Color::Cyan).bold(), + ), + Span::styled( + "Wait to Receive", + Style::default().fg(Color::White).bold(), + ), + Span::styled(" (4)", Style::default().fg(Color::DarkGray)), + ]), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + "Show QR for sender to connect", + Style::default().fg(Color::Gray), + ), + ]), + ]), ListItem::new(vec![ Line::from(vec![ Span::styled( @@ -284,7 +380,7 @@ impl HomeApp { "Configuration", Style::default().fg(Color::White).bold(), ), - Span::styled(" (3)", Style::default().fg(Color::DarkGray)), + Span::styled(" (5)", Style::default().fg(Color::DarkGray)), ]), Line::from(vec![ Span::styled(" ", Style::default()), @@ -298,13 +394,13 @@ impl HomeApp { Line::from(vec![ Span::styled( "❓ ", - Style::default().fg(Color::Magenta).bold(), + Style::default().fg(Color::LightMagenta).bold(), ), Span::styled( "Help", Style::default().fg(Color::White).bold(), ), - Span::styled(" (4)", Style::default().fg(Color::DarkGray)), + Span::styled(" (6)", Style::default().fg(Color::DarkGray)), ]), Line::from(vec![ Span::styled(" ", Style::default()), diff --git a/drop-core/tui/src/apps/mod.rs b/drop-core/tui/src/apps/mod.rs index 8f691668..75622f3a 100644 --- a/drop-core/tui/src/apps/mod.rs +++ b/drop-core/tui/src/apps/mod.rs @@ -2,7 +2,10 @@ pub mod config; pub mod file_browser; pub mod help; pub mod home; +pub mod ready_to_receive_progress; pub mod receive_files; pub mod receive_files_progress; pub mod send_files; pub mod send_files_progress; +pub mod send_files_to; +pub mod send_files_to_progress; diff --git a/drop-core/tui/src/apps/ready_to_receive_progress.rs b/drop-core/tui/src/apps/ready_to_receive_progress.rs new file mode 100644 index 00000000..ca0ffe06 --- /dev/null +++ b/drop-core/tui/src/apps/ready_to_receive_progress.rs @@ -0,0 +1,865 @@ +use std::{ + collections::HashMap, + fs, + io::Write, + sync::{Arc, RwLock, atomic::AtomicU32}, + time::Instant, +}; + +use crate::{ + App, AppBackend, ControlCapture, utilities::qr_renderer::QrCodeRenderer, +}; +use arkdropx_receiver::ready_to_receive::{ + ReadyToReceiveConnectingEvent, ReadyToReceiveReceivingEvent, + ReadyToReceiveSubscriber, +}; +use crossterm::event::KeyModifiers; +use ratatui::{ + Frame, + crossterm::event::{Event, KeyCode}, + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Style, Stylize}, + symbols::border, + text::{Line, Span}, + widgets::{Block, Borders, Gauge, List, ListItem, Paragraph}, +}; +use uuid::Uuid; + +#[derive(Clone)] +struct ProgressFile { + id: String, + name: String, + len: u64, + received: u64, + last_update: Instant, + bytes_per_second: f64, + status: FileTransferStatus, +} + +#[derive(Clone, PartialEq)] +enum FileTransferStatus { + Waiting, + Receiving, + Completed, + Error(String), +} + +impl FileTransferStatus { + fn icon(&self) -> &'static str { + match self { + FileTransferStatus::Waiting => "⏳", + FileTransferStatus::Receiving => "📥", + FileTransferStatus::Completed => "✅", + FileTransferStatus::Error(_) => "❌", + } + } + + fn color(&self) -> Color { + match self { + FileTransferStatus::Waiting => Color::Gray, + FileTransferStatus::Receiving => Color::Cyan, + FileTransferStatus::Completed => Color::Green, + FileTransferStatus::Error(_) => Color::Red, + } + } +} + +pub struct ReadyToReceiveProgressApp { + id: String, + b: Arc, + + progress_pct: AtomicU32, + operation_start_time: RwLock>, + + title_text: RwLock, + block_title_text: RwLock, + status_text: RwLock, + log_text: RwLock, + error_message: RwLock>, + + files: RwLock>, + total_transfer_speed: RwLock, + sender_name: RwLock, + total_chunks_received: RwLock, +} + +impl App for ReadyToReceiveProgressApp { + fn draw(&self, f: &mut Frame, area: ratatui::prelude::Rect) { + if self.has_transfer_started() { + self.draw_receiving_mode(f, area); + } else { + self.draw_waiting_mode(f, area); + } + } + + fn handle_control( + &self, + ev: &ratatui::crossterm::event::Event, + ) -> Option { + if let Event::Key(key) = ev { + let has_ctrl = key.modifiers == KeyModifiers::CONTROL; + + if has_ctrl { + match key.code { + KeyCode::Char('c') | KeyCode::Char('C') => { + self.b.get_ready_to_receive_manager().cancel(); + self.b.get_navigation().go_back(); + self.reset(); + } + _ => return None, + } + } else { + match key.code { + KeyCode::Esc => { + self.b.get_navigation().go_back(); + } + _ => return None, + } + } + + return Some(ControlCapture::new(ev)); + } + + None + } +} + +impl ReadyToReceiveSubscriber for ReadyToReceiveProgressApp { + fn get_id(&self) -> String { + self.id.clone() + } + + fn log(&self, message: String) { + self.set_log_text(message.as_str()); + } + + fn notify_receiving(&self, event: ReadyToReceiveReceivingEvent) { + self.increment_chunk_count(); + self.update_file(&event); + self.refresh_total_transfer_speed(); + self.refresh_overall_progress(); + self.write_file_to_fs(&event); + } + + fn notify_connecting(&self, event: ReadyToReceiveConnectingEvent) { + self.set_connecting_files(&event); + self.set_now_as_operation_start_time(); + self.set_title_text("📥 Receiving Files"); + self.set_block_title_text( + format!("Connected to {}", event.sender.name).as_str(), + ); + self.set_status_text( + format!("Receiving Files from {}", event.sender.name).as_str(), + ); + *self.sender_name.write().unwrap() = event.sender.name.clone(); + } +} + +impl ReadyToReceiveProgressApp { + pub fn new(b: Arc) -> Self { + Self { + id: Uuid::new_v4().to_string(), + b, + + progress_pct: AtomicU32::new(0), + operation_start_time: RwLock::new(None), + + title_text: RwLock::new("📥 Waiting for Sender".to_string()), + block_title_text: RwLock::new("Ready to Receive".to_string()), + status_text: RwLock::new("Waiting for connection".to_string()), + log_text: RwLock::new("Initializing...".to_string()), + error_message: RwLock::new(None), + + files: RwLock::new(HashMap::new()), + total_transfer_speed: RwLock::new(0.0), + sender_name: RwLock::new(String::new()), + total_chunks_received: RwLock::new(0), + } + } + + fn reset(&self) { + self.progress_pct + .store(0, std::sync::atomic::Ordering::Relaxed); + *self.operation_start_time.write().unwrap() = None; + *self.title_text.write().unwrap() = "📥 Waiting for Sender".to_string(); + *self.block_title_text.write().unwrap() = + "Ready to Receive".to_string(); + *self.status_text.write().unwrap() = + "Waiting for connection".to_string(); + *self.log_text.write().unwrap() = "Initializing...".to_string(); + *self.error_message.write().unwrap() = None; + self.files.write().unwrap().clear(); + *self.total_transfer_speed.write().unwrap() = 0.0; + *self.sender_name.write().unwrap() = String::new(); + *self.total_chunks_received.write().unwrap() = 0; + } + + fn has_transfer_started(&self) -> bool { + self.get_operation_start_time().is_some() + } + + fn set_title_text(&self, text: &str) { + *self.title_text.write().unwrap() = text.to_string(); + } + + fn set_block_title_text(&self, text: &str) { + *self.block_title_text.write().unwrap() = text.to_string(); + } + + fn set_status_text(&self, text: &str) { + *self.status_text.write().unwrap() = text.to_string(); + } + + fn set_log_text(&self, text: &str) { + *self.log_text.write().unwrap() = text.to_string(); + } + + fn set_now_as_operation_start_time(&self) { + self.operation_start_time + .write() + .unwrap() + .replace(Instant::now()); + } + + fn get_operation_start_time(&self) -> Option { + *self.operation_start_time.read().unwrap() + } + + fn get_title_text(&self) -> String { + self.title_text.read().unwrap().clone() + } + + fn get_block_title_text(&self) -> String { + self.block_title_text.read().unwrap().clone() + } + + fn get_progress_pct(&self) -> f64 { + let v = self + .progress_pct + .load(std::sync::atomic::Ordering::Relaxed); + f64::from(v) + } + + fn get_files(&self) -> Vec { + self.files + .read() + .unwrap() + .values() + .cloned() + .collect() + } + + fn get_total_transfer_speed(&self) -> f64 { + *self.total_transfer_speed.read().unwrap() + } + + fn increment_chunk_count(&self) { + let mut count = self.total_chunks_received.write().unwrap(); + *count += 1; + } + + fn update_file(&self, event: &ReadyToReceiveReceivingEvent) { + let now = Instant::now(); + let mut files = self.files.write().unwrap(); + + if let Some(file) = files.get_mut(&event.id) { + let time_diff = now.duration_since(file.last_update).as_secs_f64(); + let bytes_received = event.data.len() as u64; + + if time_diff > 0.0 { + file.bytes_per_second = bytes_received as f64 / time_diff; + } + + file.received += bytes_received; + file.last_update = now; + + if file.received >= file.len { + file.status = FileTransferStatus::Completed; + } else { + file.status = FileTransferStatus::Receiving; + } + } + } + + fn set_connecting_files(&self, event: &ReadyToReceiveConnectingEvent) { + let mut files = self.files.write().unwrap(); + files.clear(); + + for file in &event.files { + files.insert( + file.id.clone(), + ProgressFile { + id: file.id.clone(), + name: file.name.clone(), + len: file.len, + received: 0, + last_update: Instant::now(), + bytes_per_second: 0.0, + status: FileTransferStatus::Waiting, + }, + ); + } + } + + fn refresh_total_transfer_speed(&self) { + let files = self.files.read().unwrap(); + let total_speed: f64 = files + .values() + .filter(|f| f.status == FileTransferStatus::Receiving) + .map(|f| f.bytes_per_second) + .sum(); + *self.total_transfer_speed.write().unwrap() = total_speed; + } + + fn refresh_overall_progress(&self) { + let files = self.files.read().unwrap(); + let total_size: u64 = files.values().map(|f| f.len).sum(); + let total_received: u64 = files.values().map(|f| f.received).sum(); + + let progress_pct = if total_size > 0 { + ((total_received as f64 / total_size as f64) * 100.0).min(100.0) + } else { + 0.0 + }; + + self.progress_pct + .store(progress_pct as u32, std::sync::atomic::Ordering::Relaxed); + } + + fn write_file_to_fs(&self, event: &ReadyToReceiveReceivingEvent) { + let out_dir = self.b.get_config().get_out_dir(); + let file_name = { + let files = self.files.read().unwrap(); + files.get(&event.id).map(|f| f.name.clone()) + }; + + if let Some(name) = file_name { + let file_path = out_dir.join(&name); + + // Create parent directories if needed + if let Some(parent) = file_path.parent() { + if let Err(e) = fs::create_dir_all(parent) { + self.set_file_error( + &event.id, + format!("Failed to create directory: {}", e), + ); + return; + } + } + + let mut options = fs::OpenOptions::new(); + options.create(true).append(true); + + match options.open(&file_path) { + Ok(mut file) => { + if let Err(e) = file.write_all(&event.data) { + self.set_file_error( + &event.id, + format!("Failed to write data: {}", e), + ); + } + } + Err(e) => { + self.set_file_error( + &event.id, + format!("Failed to open file: {}", e), + ); + } + } + } + } + + fn set_file_error(&self, file_id: &str, error: String) { + // Update the file's status to Error + let mut files = self.files.write().unwrap(); + if let Some(file) = files.get_mut(file_id) { + file.status = FileTransferStatus::Error(error.clone()); + } + // Also set global error message for UI display + *self.error_message.write().unwrap() = Some(error); + } + + fn get_error_message(&self) -> Option { + self.error_message.read().unwrap().clone() + } + + fn format_bytes(&self, bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; + let mut size = bytes as f64; + let mut unit_index = 0; + + while size >= 1024.0 && unit_index < UNITS.len() - 1 { + size /= 1024.0; + unit_index += 1; + } + + if unit_index == 0 { + format!("{} {}", bytes, UNITS[unit_index]) + } else { + format!("{:.1} {}", size, UNITS[unit_index]) + } + } + + fn format_speed(&self, bytes_per_sec: f64) -> String { + if bytes_per_sec == 0.0 { + return "--".to_string(); + } + format!("{}/s", self.format_bytes(bytes_per_sec as u64)) + } + + // ── Waiting Mode (QR Code Display) ─────────────────────────────────────── + + fn draw_waiting_mode(&self, f: &mut Frame, area: ratatui::prelude::Rect) { + let blocks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(3), // Title + Constraint::Min(15), // QR Code + Constraint::Length(5), // Connection info + Constraint::Length(4), // Footer + ]) + .split(area); + + self.draw_waiting_title(f, blocks[0]); + self.draw_qr_code(f, blocks[1]); + self.draw_connection_info(f, blocks[2]); + self.draw_waiting_footer(f, blocks[3]); + } + + fn draw_waiting_title(&self, f: &mut Frame, area: ratatui::prelude::Rect) { + let title_content = vec![Line::from(vec![ + Span::styled("📥 ", Style::default().fg(Color::Cyan).bold()), + Span::styled( + "Ready to Receive", + Style::default().fg(Color::White).bold(), + ), + Span::styled( + " • Waiting for sender to connect", + Style::default().fg(Color::Gray), + ), + ])]; + + let title_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Waiting ") + .title_style(Style::default().fg(Color::White).bold()); + + let title_widget = Paragraph::new(title_content) + .block(title_block) + .alignment(Alignment::Center); + + f.render_widget(title_widget, area); + } + + fn draw_qr_code(&self, f: &mut Frame, area: ratatui::prelude::Rect) { + let qr_block = + QrCodeRenderer::create_qr_block("Scan to Send", Color::Cyan); + + if let Some(bubble) = self + .b + .get_ready_to_receive_manager() + .get_ready_to_receive_bubble() + { + let qr_data = format!( + "drop://send?ticket={}&confirmation={}", + bubble.get_ticket(), + bubble.get_confirmation() + ); + + QrCodeRenderer::render_qr_code(f, area, qr_block, &qr_data); + } else { + QrCodeRenderer::render_waiting( + f, + area, + qr_block, + "Preparing to receive...", + ); + } + } + + fn draw_connection_info( + &self, + f: &mut Frame, + area: ratatui::prelude::Rect, + ) { + let info_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Connection Details ") + .title_style(Style::default().fg(Color::White).bold()); + + let info_content = if let Some(bubble) = self + .b + .get_ready_to_receive_manager() + .get_ready_to_receive_bubble() + { + vec![ + Line::from(vec![ + Span::styled("🔑 ", Style::default().fg(Color::Yellow)), + Span::styled( + "Confirmation: ", + Style::default().fg(Color::Gray), + ), + Span::styled( + format!("{:02}", bubble.get_confirmation()), + Style::default().fg(Color::White).bold(), + ), + ]), + Line::from(vec![ + Span::styled("🎫 ", Style::default().fg(Color::Blue)), + Span::styled("Ticket: ", Style::default().fg(Color::Gray)), + Span::styled( + truncate_string(&bubble.get_ticket(), 40), + Style::default().fg(Color::White), + ), + ]), + ] + } else { + vec![Line::from(vec![Span::styled( + "Generating connection details...", + Style::default().fg(Color::Yellow), + )])] + }; + + let info_widget = Paragraph::new(info_content) + .block(info_block) + .alignment(Alignment::Left); + + f.render_widget(info_widget, area); + } + + fn draw_waiting_footer(&self, f: &mut Frame, area: ratatui::prelude::Rect) { + let footer_content = vec![ + Line::from(""), + Line::from(vec![ + Span::styled("⏳ ", Style::default().fg(Color::Yellow)), + Span::styled( + "Ctrl+C to cancel • ESC to go back", + Style::default().fg(Color::Yellow), + ), + ]), + ]; + + let footer_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Yellow)) + .title(" Status ") + .title_style(Style::default().fg(Color::White).bold()); + + let footer_widget = Paragraph::new(footer_content) + .block(footer_block) + .alignment(Alignment::Center); + + f.render_widget(footer_widget, area); + } + + // ── Receiving Mode ─────────────────────────────────────────────────────── + + fn draw_receiving_mode(&self, f: &mut Frame, area: ratatui::prelude::Rect) { + let blocks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(3), // Title + Constraint::Length(6), // Overall progress + Constraint::Min(8), // Individual files list + Constraint::Length(4), // Footer + ]) + .split(area); + + self.draw_receiving_title(f, blocks[0]); + self.draw_overall_progress(f, blocks[1]); + self.draw_files_list(f, blocks[2]); + self.draw_receiving_footer(f, blocks[3]); + } + + fn draw_receiving_title( + &self, + f: &mut Frame, + area: ratatui::prelude::Rect, + ) { + let progress_pct = self.get_progress_pct(); + let files = self.get_files(); + let completed_files = files + .iter() + .filter(|f| f.status == FileTransferStatus::Completed) + .count(); + let total_files = files.len(); + + let progress_icon = if progress_pct >= 100.0 { + "✅" + } else { + match (progress_pct as u8) % 4 { + 0 => "◐", + 1 => "◓", + 2 => "◑", + _ => "◒", + } + }; + + let title_content = vec![Line::from(vec![ + Span::styled( + format!("{} ", progress_icon), + Style::default().fg(Color::Cyan).bold(), + ), + Span::styled( + self.get_title_text(), + Style::default().fg(Color::White).bold(), + ), + Span::styled( + format!( + " • {}/{} files • {:.1}%", + completed_files, total_files, progress_pct + ), + Style::default().fg(Color::Cyan), + ), + ])]; + + let title_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Cyan)) + .title(self.get_block_title_text()) + .title_style(Style::default().fg(Color::White).bold()); + + let title_widget = Paragraph::new(title_content) + .block(title_block) + .alignment(Alignment::Center); + + f.render_widget(title_widget, area); + } + + fn draw_overall_progress( + &self, + f: &mut Frame, + area: ratatui::prelude::Rect, + ) { + let progress_pct = self.get_progress_pct(); + let files = self.get_files(); + let total_size: u64 = files.iter().map(|f| f.len).sum(); + let total_received: u64 = files.iter().map(|f| f.received).sum(); + let transfer_speed = self.get_total_transfer_speed(); + + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(70), + Constraint::Percentage(30), + ]) + .split(area); + + let progress_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Overall Progress ") + .title_style(Style::default().fg(Color::White).bold()); + + let progress = Gauge::default() + .block(progress_block) + .gauge_style( + Style::default() + .fg(if progress_pct >= 100.0 { + Color::Green + } else { + Color::Cyan + }) + .bg(Color::DarkGray), + ) + .percent(progress_pct as u16) + .label(format!("{:.1}%", progress_pct)); + + f.render_widget(progress, chunks[0]); + + let stats_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Stats ") + .title_style(Style::default().fg(Color::White).bold()); + + let stats_content = vec![ + Line::from(vec![ + Span::styled("📊 ", Style::default().fg(Color::Magenta)), + Span::styled( + format!( + "{} / {}", + self.format_bytes(total_received), + self.format_bytes(total_size) + ), + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled("⚡ ", Style::default().fg(Color::Yellow)), + Span::styled( + self.format_speed(transfer_speed), + Style::default().fg(Color::White), + ), + ]), + ]; + + let stats_widget = Paragraph::new(stats_content) + .block(stats_block) + .alignment(Alignment::Center); + + f.render_widget(stats_widget, chunks[1]); + } + + fn draw_files_list(&self, f: &mut Frame, area: ratatui::prelude::Rect) { + let files = self.get_files(); + + let file_items: Vec = files + .iter() + .map(|file| { + let progress_pct = if file.len > 0 { + (file.received as f64 / file.len as f64) * 100.0 + } else { + 0.0 + }; + + let name_color = match &file.status { + FileTransferStatus::Completed => Color::Green, + FileTransferStatus::Error(_) => Color::Red, + _ => Color::White, + }; + + let status_line = Line::from(vec![ + Span::styled( + format!("{} ", file.status.icon()), + Style::default().fg(file.status.color()), + ), + Span::styled( + file.name.clone(), + Style::default().fg(name_color), + ), + Span::styled( + format!("{:>6.1}%", progress_pct), + Style::default().fg(Color::Cyan), + ), + ]); + + let detail_text = match &file.status { + FileTransferStatus::Receiving => format!( + "{} / {} • {}", + self.format_bytes(file.received), + self.format_bytes(file.len), + self.format_speed(file.bytes_per_second) + ), + FileTransferStatus::Completed => format!( + "{} / {} • Complete", + self.format_bytes(file.received), + self.format_bytes(file.len) + ), + FileTransferStatus::Error(err) => { + format!("Error: {}", truncate_string(err, 40)) + } + FileTransferStatus::Waiting => format!( + "{} / {} • --", + self.format_bytes(file.received), + self.format_bytes(file.len) + ), + }; + + let detail_color = match &file.status { + FileTransferStatus::Error(_) => Color::Red, + _ => Color::Gray, + }; + + let detail_line = Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + detail_text, + Style::default().fg(detail_color), + ), + ]); + + ListItem::new(vec![status_line, detail_line]) + }) + .collect(); + + let files_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::White)) + .title(format!(" Files ({}) ", files.len())) + .title_style(Style::default().fg(Color::White).bold()); + + let files_list = List::new(file_items) + .block(files_block) + .style(Style::default().fg(Color::White)); + + f.render_widget(files_list, area); + } + + fn draw_receiving_footer( + &self, + f: &mut Frame, + area: ratatui::prelude::Rect, + ) { + let progress_pct = self.get_progress_pct(); + let error_message = self.get_error_message(); + + let (footer_text, footer_color, footer_icon) = + if let Some(err) = error_message { + ( + format!( + "Error: {} • Press ESC to go back", + truncate_string(&err, 50) + ), + Color::Red, + "❌", + ) + } else if progress_pct >= 100.0 { + ( + "All files received successfully! Press ESC to continue" + .to_string(), + Color::Green, + "✅", + ) + } else { + ( + "Ctrl+C to cancel • ESC to go back".to_string(), + Color::Cyan, + "📥", + ) + }; + + let footer_content = vec![ + Line::from(""), + Line::from(vec![ + Span::styled( + format!("{} ", footer_icon), + Style::default().fg(footer_color), + ), + Span::styled(footer_text, Style::default().fg(footer_color)), + ]), + ]; + + let footer_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(footer_color)) + .title(" Status ") + .title_style(Style::default().fg(Color::White).bold()); + + let footer_widget = Paragraph::new(footer_content) + .block(footer_block) + .alignment(Alignment::Center); + + f.render_widget(footer_widget, area); + } +} + +fn truncate_string(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + format!("{}...", &s[..max_len - 3]) + } +} diff --git a/drop-core/tui/src/apps/send_files_to.rs b/drop-core/tui/src/apps/send_files_to.rs new file mode 100644 index 00000000..4d6ba60b --- /dev/null +++ b/drop-core/tui/src/apps/send_files_to.rs @@ -0,0 +1,1164 @@ +use crate::{ + App, AppBackend, AppFileBrowserSaveEvent, AppFileBrowserSubscriber, + BrowserMode, ControlCapture, OpenFileBrowserRequest, Page, SortMode, +}; +use arkdrop_common::FileData; +use arkdropx_sender::send_files_to::SendFilesToRequest; +use arkdropx_sender::{SenderConfig, SenderFile, SenderProfile}; +use ratatui::{ + Frame, + crossterm::event::{Event, KeyCode, KeyModifiers}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Style, Stylize}, + symbols::border, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; + +use std::{ + path::PathBuf, + sync::{ + Arc, RwLock, + atomic::{AtomicBool, AtomicUsize}, + }, +}; + +#[derive(Clone, PartialEq)] +enum InputField { + Ticket, + Confirmation, + FilePath, + SendButton, +} + +pub struct SendFilesToApp { + b: Arc, + + // UI State + menu: RwLock, + selected_field: AtomicUsize, + + // Connection input fields + ticket_in: RwLock, + confirmation_in: RwLock, + + // File selection + selected_files_in: RwLock>, + + // Text input state (shared for all editable fields) + is_editing_field: Arc, + current_editing_field: Arc, + field_input_buffer: Arc>, + field_cursor_position: Arc, + + // Status and feedback + status_message: Arc>, +} + +impl App for SendFilesToApp { + fn draw(&self, f: &mut Frame, area: ratatui::layout::Rect) { + self.draw_main_view(f, area); + } + + fn handle_control(&self, ev: &Event) -> Option { + let is_editing = self.is_editing_field(); + + if is_editing { + return self.handle_text_input_controls(ev); + } else { + return self.handle_navigation_controls(ev); + } + } +} + +impl AppFileBrowserSubscriber for SendFilesToApp { + fn on_cancel(&self) { + self.b + .get_navigation() + .replace_with(Page::SendFilesTo); + } + + fn on_save(&self, ev: AppFileBrowserSaveEvent) { + self.b + .get_navigation() + .replace_with(Page::SendFilesTo); + + let mut selected_files = ev.selected_files; + let count = selected_files.len(); + self.selected_files_in + .write() + .unwrap() + .append(&mut selected_files); + + self.set_status_message(&format!("Added {} file(s)", count)); + } +} + +impl SendFilesToApp { + pub fn new(b: Arc) -> Self { + let mut menu = ListState::default(); + menu.select(Some(0)); + + Self { + b, + + menu: RwLock::new(menu), + selected_field: AtomicUsize::new(0), + + ticket_in: RwLock::new(String::new()), + confirmation_in: RwLock::new(String::new()), + selected_files_in: RwLock::new(Vec::new()), + + is_editing_field: Arc::new(AtomicBool::new(false)), + current_editing_field: Arc::new(AtomicUsize::new(0)), + field_input_buffer: Arc::new(RwLock::new(String::new())), + field_cursor_position: Arc::new(AtomicUsize::new(0)), + + status_message: Arc::new(RwLock::new( + "Enter ticket and confirmation from receiver's QR code" + .to_string(), + )), + } + } + + // ─── Input Handling ──────────────────────────────────────────────────── + + fn handle_text_input_controls(&self, ev: &Event) -> Option { + if let Event::Key(key) = ev { + let has_ctrl = key.modifiers == KeyModifiers::CONTROL; + + match key.code { + KeyCode::Enter => { + self.finish_editing_field(); + } + KeyCode::Esc => { + self.cancel_editing_field(); + } + KeyCode::Backspace => { + self.handle_backspace(); + } + KeyCode::Delete => { + self.handle_delete(); + } + KeyCode::Left => { + if has_ctrl { + self.move_cursor_left_by_word(); + } else { + self.move_cursor_left(); + } + } + KeyCode::Right => { + if has_ctrl { + self.move_cursor_right_by_word(); + } else { + self.move_cursor_right(); + } + } + KeyCode::Home => { + self.move_cursor_home(); + } + KeyCode::End => { + self.move_cursor_end(); + } + KeyCode::Char(c) => { + self.insert_char(c); + } + _ => return None, + } + + return Some(ControlCapture::new(ev)); + } + + None + } + + fn handle_navigation_controls(&self, ev: &Event) -> Option { + if let Event::Key(key) = ev { + let has_ctrl = key.modifiers == KeyModifiers::CONTROL; + + if has_ctrl { + match key.code { + KeyCode::Char('s') | KeyCode::Char('S') => { + self.send_files_to(); + } + KeyCode::Char('o') | KeyCode::Char('O') => { + self.open_file_browser(); + } + KeyCode::Char('c') | KeyCode::Char('C') => { + self.clear_all(); + } + _ => return None, + } + } else { + match key.code { + KeyCode::Up | KeyCode::BackTab => { + self.navigate_up(); + } + KeyCode::Down | KeyCode::Tab => { + self.navigate_down(); + } + KeyCode::Enter => { + self.activate_current_field(); + } + KeyCode::Delete => { + self.remove_last_file(); + } + KeyCode::Esc => { + self.b.get_navigation().go_back(); + } + _ => return None, + } + } + + return Some(ControlCapture::new(ev)); + } + + None + } + + // ─── Field Editing ───────────────────────────────────────────────────── + + fn is_editing_field(&self) -> bool { + self.is_editing_field + .load(std::sync::atomic::Ordering::Relaxed) + } + + fn get_current_editing_field(&self) -> usize { + self.current_editing_field + .load(std::sync::atomic::Ordering::Relaxed) + } + + fn start_editing_field(&self, field_idx: usize) { + let current_value = match field_idx { + 0 => self.ticket_in.read().unwrap().clone(), + 1 => self.confirmation_in.read().unwrap().clone(), + 2 => String::new(), // File path always starts empty + _ => String::new(), + }; + + *self.field_input_buffer.write().unwrap() = current_value.clone(); + self.field_cursor_position + .store(current_value.len(), std::sync::atomic::Ordering::Relaxed); + self.current_editing_field + .store(field_idx, std::sync::atomic::Ordering::Relaxed); + self.is_editing_field + .store(true, std::sync::atomic::Ordering::Relaxed); + + let field_name = match field_idx { + 0 => "ticket", + 1 => "confirmation code", + 2 => "file path", + _ => "field", + }; + + self.set_status_message(&format!( + "Editing {} - Enter to save, Esc to cancel", + field_name + )); + } + + fn finish_editing_field(&self) { + let input_text = self.field_input_buffer.read().unwrap().clone(); + let trimmed_text = input_text.trim(); + let field_index = self.get_current_editing_field(); + + match field_index { + 0 => { + *self.ticket_in.write().unwrap() = trimmed_text.to_string(); + if trimmed_text.is_empty() { + self.set_status_message("Ticket cleared"); + } else { + self.set_status_message("Ticket updated"); + } + } + 1 => { + *self.confirmation_in.write().unwrap() = + trimmed_text.to_string(); + if trimmed_text.is_empty() { + self.set_status_message("Confirmation code cleared"); + } else { + self.set_status_message("Confirmation code updated"); + } + } + 2 => { + // File path - try to add the file + if !trimmed_text.is_empty() { + if trimmed_text == "browse" { + self.is_editing_field + .store(false, std::sync::atomic::Ordering::Relaxed); + self.open_file_browser(); + return; + } + let path = PathBuf::from(trimmed_text); + if path.exists() { + self.add_file(path.clone()); + self.set_status_message(&format!( + "Added file: {}", + path.display() + )); + } else { + self.set_status_message(&format!( + "File not found: {}", + trimmed_text + )); + } + } + } + _ => {} + } + + self.is_editing_field + .store(false, std::sync::atomic::Ordering::Relaxed); + } + + fn cancel_editing_field(&self) { + self.is_editing_field + .store(false, std::sync::atomic::Ordering::Relaxed); + self.set_status_message("Field editing cancelled"); + } + + // ─── Text Cursor Operations ──────────────────────────────────────────── + + fn insert_char(&self, c: char) { + let mut buffer = self.field_input_buffer.write().unwrap(); + let cursor_pos = self + .field_cursor_position + .load(std::sync::atomic::Ordering::Relaxed); + + buffer.insert(cursor_pos, c); + self.field_cursor_position + .store(cursor_pos + 1, std::sync::atomic::Ordering::Relaxed); + } + + fn handle_backspace(&self) { + let mut buffer = self.field_input_buffer.write().unwrap(); + let cursor_pos = self + .field_cursor_position + .load(std::sync::atomic::Ordering::Relaxed); + + if cursor_pos > 0 { + buffer.remove(cursor_pos - 1); + self.field_cursor_position + .store(cursor_pos - 1, std::sync::atomic::Ordering::Relaxed); + } + } + + fn handle_delete(&self) { + let mut buffer = self.field_input_buffer.write().unwrap(); + let cursor_pos = self + .field_cursor_position + .load(std::sync::atomic::Ordering::Relaxed); + + if cursor_pos < buffer.len() { + buffer.remove(cursor_pos); + } + } + + fn move_cursor_left(&self) { + let cursor_pos = self + .field_cursor_position + .load(std::sync::atomic::Ordering::Relaxed); + if cursor_pos > 0 { + self.field_cursor_position + .store(cursor_pos - 1, std::sync::atomic::Ordering::Relaxed); + } + } + + fn move_cursor_left_by_word(&self) { + let buffer = self.field_input_buffer.read().unwrap(); + let cursor_pos = self + .field_cursor_position + .load(std::sync::atomic::Ordering::Relaxed); + + if cursor_pos == 0 { + return; + } + + let mut new_pos = cursor_pos; + let chars: Vec = buffer.chars().collect(); + + while new_pos > 0 && chars[new_pos - 1].is_whitespace() { + new_pos -= 1; + } + + while new_pos > 0 && !chars[new_pos - 1].is_whitespace() { + new_pos -= 1; + } + + self.field_cursor_position + .store(new_pos, std::sync::atomic::Ordering::Relaxed); + } + + fn move_cursor_right(&self) { + let buffer = self.field_input_buffer.read().unwrap(); + let cursor_pos = self + .field_cursor_position + .load(std::sync::atomic::Ordering::Relaxed); + if cursor_pos < buffer.len() { + self.field_cursor_position + .store(cursor_pos + 1, std::sync::atomic::Ordering::Relaxed); + } + } + + fn move_cursor_right_by_word(&self) { + let buffer = self.field_input_buffer.read().unwrap(); + let cursor_pos = self + .field_cursor_position + .load(std::sync::atomic::Ordering::Relaxed); + + if buffer.is_empty() || cursor_pos >= buffer.len() { + return; + } + + let mut new_pos = cursor_pos; + let chars: Vec = buffer.chars().collect(); + let last_pos = chars.len(); + + while new_pos < last_pos + && chars + .get(new_pos) + .map_or(false, |c| c.is_whitespace()) + { + new_pos += 1; + } + + while new_pos < last_pos + && chars + .get(new_pos) + .map_or(false, |c| !c.is_whitespace()) + { + new_pos += 1; + } + + self.field_cursor_position + .store(new_pos, std::sync::atomic::Ordering::Relaxed); + } + + fn move_cursor_home(&self) { + self.field_cursor_position + .store(0, std::sync::atomic::Ordering::Relaxed); + } + + fn move_cursor_end(&self) { + let buffer = self.field_input_buffer.read().unwrap(); + self.field_cursor_position + .store(buffer.len(), std::sync::atomic::Ordering::Relaxed); + } + + // ─── Navigation ──────────────────────────────────────────────────────── + + fn get_input_fields(&self) -> Vec { + vec![ + InputField::Ticket, + InputField::Confirmation, + InputField::FilePath, + InputField::SendButton, + ] + } + + fn get_selected_field(&self) -> usize { + self.selected_field + .load(std::sync::atomic::Ordering::Relaxed) + } + + fn navigate_up(&self) { + let fields = self.get_input_fields(); + let current = self.get_selected_field(); + let new_index = if current > 0 { + current - 1 + } else { + fields.len() - 1 + }; + + self.selected_field + .store(new_index, std::sync::atomic::Ordering::Relaxed); + self.menu.write().unwrap().select(Some(new_index)); + } + + fn navigate_down(&self) { + let fields = self.get_input_fields(); + let current = self.get_selected_field(); + let new_index = if current < fields.len() - 1 { + current + 1 + } else { + 0 + }; + + self.selected_field + .store(new_index, std::sync::atomic::Ordering::Relaxed); + self.menu.write().unwrap().select(Some(new_index)); + } + + fn activate_current_field(&self) { + let fields = self.get_input_fields(); + let current = self.get_selected_field(); + + if let Some(field) = fields.get(current) { + match field { + InputField::Ticket => { + self.start_editing_field(0); + } + InputField::Confirmation => { + self.start_editing_field(1); + } + InputField::FilePath => { + self.start_editing_field(2); + } + InputField::SendButton => { + self.send_files_to(); + } + } + } + } + + // ─── File Operations ─────────────────────────────────────────────────── + + fn add_file(&self, file: PathBuf) { + self.selected_files_in.write().unwrap().push(file); + } + + fn remove_last_file(&self) { + if let Some(removed_file) = + self.selected_files_in.write().unwrap().pop() + { + self.set_status_message(&format!( + "Removed file: {}", + removed_file + .file_name() + .unwrap_or_default() + .to_string_lossy() + )); + } else { + self.set_status_message("No files to remove"); + } + } + + fn clear_all(&self) { + let file_count = self.selected_files_in.read().unwrap().len(); + self.selected_files_in.write().unwrap().clear(); + *self.ticket_in.write().unwrap() = String::new(); + *self.confirmation_in.write().unwrap() = String::new(); + self.set_status_message(&format!( + "Cleared all fields and {} file(s)", + file_count + )); + } + + fn open_file_browser(&self) { + self.set_status_message("Opening file browser..."); + self.b + .get_file_browser_manager() + .open_file_browser(OpenFileBrowserRequest { + from: Page::SendFilesTo, + mode: BrowserMode::SelectMultiFiles, + sort: SortMode::Name, + }); + } + + // ─── Send Operation ──────────────────────────────────────────────────── + + fn send_files_to(&self) { + if let Some(req) = self.make_send_files_to_request() { + self.set_status_message("Connecting to receiver..."); + self.b + .get_send_files_to_manager() + .send_files_to(req); + self.b + .get_navigation() + .navigate_to(Page::SendFilesToProgress); + } else { + self.set_status_message( + "Missing required information - check ticket, confirmation, and files", + ); + } + } + + fn make_send_files_to_request(&self) -> Option { + if !self.can_send() { + return None; + } + + let files = self.get_sender_files(); + if files.is_empty() { + return None; + } + + let config = self.b.get_config(); + let confirmation: u8 = self.get_confirmation_in().parse().ok()?; + + Some(SendFilesToRequest { + ticket: self.get_ticket_in(), + confirmation, + files, + profile: SenderProfile { + name: config.get_avatar_name(), + avatar_b64: config.get_avatar_base64(), + }, + config: SenderConfig::default(), + }) + } + + fn get_sender_files(&self) -> Vec { + let selected_files_in = self.selected_files_in.read().unwrap(); + + if selected_files_in.is_empty() { + return Vec::new(); + } + + selected_files_in + .iter() + .filter_map(|f| { + if let Some(name) = f.file_name() { + if let Ok(data) = FileData::new(f.clone()) { + let name = name.to_string_lossy().to_string(); + return Some(SenderFile { + name, + data: Arc::new(data), + }); + } + } + None + }) + .collect() + } + + fn can_send(&self) -> bool { + let ticket = self.get_ticket_in(); + let confirmation = self.get_confirmation_in(); + let has_files = !self.selected_files_in.read().unwrap().is_empty(); + + !ticket.is_empty() + && !confirmation.is_empty() + && confirmation.parse::().is_ok() + && has_files + } + + // ─── Helpers ─────────────────────────────────────────────────────────── + + fn set_status_message(&self, message: &str) { + *self.status_message.write().unwrap() = message.to_string(); + } + + fn get_status_message(&self) -> String { + self.status_message.read().unwrap().clone() + } + + fn get_ticket_in(&self) -> String { + self.ticket_in.read().unwrap().clone() + } + + fn get_confirmation_in(&self) -> String { + self.confirmation_in.read().unwrap().clone() + } + + // ─── Drawing ─────────────────────────────────────────────────────────── + + fn draw_main_view(&self, f: &mut Frame, area: Rect) { + let blocks = Layout::default() + .direction(Direction::Horizontal) + .margin(1) + .constraints([ + Constraint::Percentage(55), // Left side - connection + files input + Constraint::Percentage(45), // Right side - file list + send button + ]) + .split(area); + + let left_blocks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Title + Constraint::Length(5), // Ticket field + Constraint::Length(5), // Confirmation field + Constraint::Length(6), // File path input + Constraint::Min(0), // Instructions + ]) + .split(blocks[0]); + + let right_blocks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(0), // Files list + Constraint::Length(5), // Send button + ]) + .split(blocks[1]); + + self.draw_title(f, left_blocks[0]); + self.draw_ticket_field(f, left_blocks[1]); + self.draw_confirmation_field(f, left_blocks[2]); + self.draw_file_input(f, left_blocks[3]); + self.draw_instructions(f, left_blocks[4]); + + self.draw_file_list(f, right_blocks[0]); + self.draw_send_button(f, right_blocks[1]); + } + + fn draw_title(&self, f: &mut Frame<'_>, area: Rect) { + let title_content = vec![Line::from(vec![ + Span::styled("🔗 ", Style::default().fg(Color::Magenta).bold()), + Span::styled( + "Send to QR", + Style::default().fg(Color::White).bold(), + ), + Span::styled( + " - Connect to waiting receiver", + Style::default().fg(Color::Gray), + ), + ])]; + + let title_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Magenta)) + .title(" Send Files To Receiver ") + .title_style(Style::default().fg(Color::White).bold()); + + let title = Paragraph::new(title_content) + .block(title_block) + .alignment(Alignment::Center); + + f.render_widget(title, area); + } + + fn draw_ticket_field(&self, f: &mut Frame<'_>, area: Rect) { + let is_focused = self.get_selected_field() == 0; + let is_editing = + self.is_editing_field() && self.get_current_editing_field() == 0; + let ticket_in = self.get_ticket_in(); + + let style = if is_focused || is_editing { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Gray) + }; + + let display_text = if is_editing { + let buffer = self.field_input_buffer.read().unwrap().clone(); + let cursor_pos = self + .field_cursor_position + .load(std::sync::atomic::Ordering::Relaxed); + + if buffer.is_empty() { + "|".to_string() + } else { + let mut display = buffer.clone(); + if cursor_pos <= display.len() { + display.insert(cursor_pos, '|'); + } + display + } + } else if ticket_in.is_empty() { + "Paste ticket from receiver's QR...".to_string() + } else { + truncate_string(&ticket_in, 45) + }; + + let ticket_content = vec![Line::from(vec![ + Span::styled( + if is_focused || is_editing { + ">" + } else { + " " + }, + Style::default().fg(Color::Yellow), + ), + Span::styled(" Ticket: ", Style::default().fg(Color::White)), + Span::styled( + display_text, + if is_editing { + Style::default().fg(Color::White) + } else if ticket_in.is_empty() { + Style::default().fg(Color::DarkGray).italic() + } else { + Style::default().fg(Color::Cyan) + }, + ), + ])]; + + let ticket_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(style) + .title(" Connection Ticket ") + .title_style(Style::default().fg(Color::White).bold()); + + let ticket_field = Paragraph::new(ticket_content) + .block(ticket_block) + .alignment(Alignment::Left); + + f.render_widget(ticket_field, area); + } + + fn draw_confirmation_field(&self, f: &mut Frame<'_>, area: Rect) { + let is_focused = self.get_selected_field() == 1; + let is_editing = + self.is_editing_field() && self.get_current_editing_field() == 1; + let confirmation_in = self.get_confirmation_in(); + + let style = if is_focused || is_editing { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Gray) + }; + + let display_text = if is_editing { + let buffer = self.field_input_buffer.read().unwrap().clone(); + let cursor_pos = self + .field_cursor_position + .load(std::sync::atomic::Ordering::Relaxed); + + if buffer.is_empty() { + "|".to_string() + } else { + let mut display = buffer.clone(); + if cursor_pos <= display.len() { + display.insert(cursor_pos, '|'); + } + display + } + } else if confirmation_in.is_empty() { + "Enter 2-digit code (0-99)...".to_string() + } else { + confirmation_in.clone() + }; + + let confirmation_content = vec![Line::from(vec![ + Span::styled( + if is_focused || is_editing { + ">" + } else { + " " + }, + Style::default().fg(Color::Yellow), + ), + Span::styled(" Confirmation: ", Style::default().fg(Color::White)), + Span::styled( + display_text, + if is_editing { + Style::default().fg(Color::White) + } else if confirmation_in.is_empty() { + Style::default().fg(Color::DarkGray).italic() + } else { + Style::default().fg(Color::Green).bold() + }, + ), + ])]; + + let confirmation_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(style) + .title(" Confirmation Code ") + .title_style(Style::default().fg(Color::White).bold()); + + let confirmation_field = Paragraph::new(confirmation_content) + .block(confirmation_block) + .alignment(Alignment::Left); + + f.render_widget(confirmation_field, area); + } + + fn draw_file_input(&self, f: &mut Frame<'_>, area: Rect) { + let is_focused = self.get_selected_field() == 2; + let is_editing = + self.is_editing_field() && self.get_current_editing_field() == 2; + + let style = if is_focused || is_editing { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Gray) + }; + + let display_text = if is_editing { + let buffer = self.field_input_buffer.read().unwrap().clone(); + let cursor_pos = self + .field_cursor_position + .load(std::sync::atomic::Ordering::Relaxed); + + if buffer.is_empty() { + "|".to_string() + } else { + let mut display = buffer.clone(); + if cursor_pos <= display.len() { + display.insert(cursor_pos, '|'); + } + display + } + } else { + "/path/to/file or 'browse'".to_string() + }; + + let content = vec![ + Line::from(vec![ + Span::styled( + if is_focused || is_editing { + ">" + } else { + " " + }, + Style::default().fg(Color::Yellow), + ), + Span::styled(" Add file: ", Style::default().fg(Color::White)), + Span::styled( + display_text, + if is_editing { + Style::default().fg(Color::White) + } else { + Style::default().fg(Color::DarkGray).italic() + }, + ), + ]), + Line::from(vec![ + Span::styled( + " Ctrl+O", + Style::default().fg(Color::Cyan).bold(), + ), + Span::styled(" browse | ", Style::default().fg(Color::Gray)), + Span::styled("Del", Style::default().fg(Color::Red).bold()), + Span::styled(" remove last", Style::default().fg(Color::Gray)), + ]), + ]; + + let block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(style) + .title(" Add Files ") + .title_style(Style::default().fg(Color::White).bold()); + + let file_input = Paragraph::new(content) + .block(block) + .alignment(Alignment::Left); + + f.render_widget(file_input, area); + } + + fn draw_file_list(&self, f: &mut Frame<'_>, area: Rect) { + let selected_files_in = self.selected_files_in.read().unwrap().clone(); + + let file_items: Vec = if selected_files_in.is_empty() { + vec![ListItem::new(vec![ + Line::from(vec![Span::styled( + " No files selected", + Style::default().fg(Color::DarkGray).italic(), + )]), + Line::from(""), + Line::from(vec![Span::styled( + " Add files to send to the receiver", + Style::default().fg(Color::Gray), + )]), + ])] + } else { + selected_files_in + .iter() + .enumerate() + .map(|(i, file)| { + let file_name = file + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Unknown"); + let file_path = file + .parent() + .and_then(|p| p.to_str()) + .unwrap_or("/"); + + ListItem::new(vec![ + Line::from(vec![ + Span::styled( + format!("{}. ", i + 1), + Style::default().fg(Color::Yellow).bold(), + ), + Span::styled( + " ", + Style::default().fg(Color::Blue), + ), + Span::styled( + file_name, + Style::default().fg(Color::White).bold(), + ), + ]), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + truncate_string(file_path, 35), + Style::default().fg(Color::Gray).italic(), + ), + ]), + ]) + }) + .collect() + }; + + let files_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(if selected_files_in.is_empty() { + Style::default().fg(Color::DarkGray) + } else { + Style::default().fg(Color::Magenta) + }) + .title(format!(" Files to Send ({}) ", selected_files_in.len())) + .title_style(Style::default().fg(Color::White).bold()); + + let files_list = List::new(file_items).block(files_block); + + f.render_widget(files_list, area); + } + + fn draw_instructions(&self, f: &mut Frame<'_>, area: Rect) { + let is_editing = self.is_editing_field(); + let can_send = self.can_send(); + let status_message = self.get_status_message(); + + let instructions_content = if is_editing { + vec![ + Line::from(vec![ + Span::styled( + " Editing - ", + Style::default().fg(Color::Green), + ), + Span::styled( + "Enter", + Style::default().fg(Color::Green).bold(), + ), + Span::styled(" save | ", Style::default().fg(Color::Gray)), + Span::styled("Esc", Style::default().fg(Color::Red).bold()), + Span::styled(" cancel", Style::default().fg(Color::Gray)), + ]), + Line::from(vec![ + Span::styled(" ", Style::default().fg(Color::Blue)), + Span::styled( + status_message, + Style::default().fg(Color::Gray), + ), + ]), + ] + } else if can_send { + vec![ + Line::from(vec![ + Span::styled( + " Ready! ", + Style::default().fg(Color::Green), + ), + Span::styled( + "Ctrl+S", + Style::default().fg(Color::Green).bold(), + ), + Span::styled(" send | ", Style::default().fg(Color::Gray)), + Span::styled( + "Ctrl+C", + Style::default().fg(Color::Red).bold(), + ), + Span::styled( + " clear all", + Style::default().fg(Color::Gray), + ), + ]), + Line::from(vec![ + Span::styled(" ", Style::default().fg(Color::Blue)), + Span::styled( + status_message, + Style::default().fg(Color::Gray), + ), + ]), + ] + } else { + vec![ + Line::from(vec![Span::styled( + " Enter ticket, confirmation, and add files", + Style::default().fg(Color::Yellow), + )]), + Line::from(vec![ + Span::styled(" ", Style::default().fg(Color::Blue)), + Span::styled( + status_message, + Style::default().fg(Color::Gray), + ), + ]), + ] + }; + + let instructions_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Gray)) + .title(" Status ") + .title_style(Style::default().fg(Color::White).bold()); + + let instructions = Paragraph::new(instructions_content) + .block(instructions_block) + .alignment(Alignment::Left); + + f.render_widget(instructions, area); + } + + fn draw_send_button(&self, f: &mut Frame<'_>, area: Rect) { + let is_focused = self.get_selected_field() == 3; + let can_send = self.can_send(); + + let button_style = if is_focused && can_send { + Style::default() + .fg(Color::Black) + .bg(Color::Magenta) + .bold() + } else if is_focused { + Style::default() + .fg(Color::DarkGray) + .bg(Color::Black) + .bold() + } else if can_send { + Style::default().fg(Color::Magenta) + } else { + Style::default().fg(Color::DarkGray) + }; + + let button_content = vec![ + Line::from(""), + Line::from(vec![ + Span::styled( + if is_focused { + "> " + } else { + " " + }, + Style::default().fg(Color::Yellow), + ), + Span::styled( + if can_send { + " Send Files" + } else { + " Cannot Send" + }, + button_style, + ), + ]), + Line::from(""), + ]; + + let button_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(if is_focused { + Style::default().fg(Color::Yellow) + } else if can_send { + Style::default().fg(Color::Magenta) + } else { + Style::default().fg(Color::DarkGray) + }) + .title(" Action ") + .title_style(Style::default().fg(Color::White).bold()); + + let button = Paragraph::new(button_content) + .block(button_block) + .alignment(Alignment::Center); + + f.render_widget(button, area); + } +} + +fn truncate_string(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + format!("{}...", &s[..max_len.saturating_sub(3)]) + } +} diff --git a/drop-core/tui/src/apps/send_files_to_progress.rs b/drop-core/tui/src/apps/send_files_to_progress.rs new file mode 100644 index 00000000..03f1838b --- /dev/null +++ b/drop-core/tui/src/apps/send_files_to_progress.rs @@ -0,0 +1,558 @@ +use std::{ + sync::{Arc, RwLock, atomic::AtomicU32}, + time::Instant, +}; + +use crate::{App, AppBackend, ControlCapture}; +use arkdropx_sender::send_files_to::{ + SendFilesToConnectingEvent, SendFilesToSendingEvent, SendFilesToSubscriber, +}; +use crossterm::event::KeyModifiers; +use ratatui::{ + Frame, + crossterm::event::{Event, KeyCode}, + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Style, Stylize}, + symbols::border, + text::{Line, Span}, + widgets::{Block, Borders, Gauge, List, ListItem, Paragraph}, +}; +use uuid::Uuid; + +#[derive(Clone)] +struct ProgressFile { + id: String, + name: String, + total_size: u64, + sent: u64, + status: FileTransferStatus, + transfer_speed: f64, + last_update: Instant, +} + +#[derive(Clone, PartialEq)] +enum FileTransferStatus { + Waiting, + Transferring, + Completed, +} + +impl FileTransferStatus { + fn icon(&self) -> &'static str { + match self { + FileTransferStatus::Waiting => "⏳", + FileTransferStatus::Transferring => "📤", + FileTransferStatus::Completed => "✅", + } + } + + fn color(&self) -> Color { + match self { + FileTransferStatus::Waiting => Color::Gray, + FileTransferStatus::Transferring => Color::Blue, + FileTransferStatus::Completed => Color::Green, + } + } +} + +pub struct SendFilesToProgressApp { + id: String, + b: Arc, + + progress_pct: AtomicU32, + operation_start_time: RwLock>, + + title_text: RwLock, + block_title_text: RwLock, + status_text: RwLock, + log_text: RwLock, + + files: RwLock>, + total_transfer_speed: RwLock, + receiver_name: RwLock, +} + +impl App for SendFilesToProgressApp { + fn draw(&self, f: &mut Frame, area: ratatui::prelude::Rect) { + let blocks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(3), // Title + Constraint::Length(6), // Overall progress + Constraint::Min(8), // Individual files list + Constraint::Length(4), // Footer + ]) + .split(area); + + self.draw_title(f, blocks[0]); + self.draw_overall_progress(f, blocks[1]); + self.draw_files_list(f, blocks[2]); + self.draw_footer(f, blocks[3]); + } + + fn handle_control( + &self, + ev: &ratatui::crossterm::event::Event, + ) -> Option { + if let Event::Key(key) = ev { + let has_ctrl = key.modifiers == KeyModifiers::CONTROL; + + if has_ctrl { + match key.code { + KeyCode::Char('c') | KeyCode::Char('C') => { + self.b.get_send_files_to_manager().cancel(); + self.b.get_navigation().go_back(); + self.reset(); + } + _ => return None, + } + } else { + match key.code { + KeyCode::Esc => { + self.b.get_navigation().go_back(); + } + _ => return None, + } + } + + return Some(ControlCapture::new(ev)); + } + + None + } +} + +impl SendFilesToSubscriber for SendFilesToProgressApp { + fn get_id(&self) -> String { + self.id.clone() + } + + fn log(&self, message: String) { + self.set_log_text(message.as_str()); + } + + fn notify_sending(&self, event: SendFilesToSendingEvent) { + let id = event.id; + let name = event.name; + let remaining = event.remaining; + let sent = event.sent; + let total_size = sent + remaining; + let now = Instant::now(); + + let mut files = self.files.write().unwrap(); + if let Some(file) = files.iter_mut().find(|f| f.id == id) { + let time_diff = now.duration_since(file.last_update).as_secs_f64(); + let bytes_diff = sent.saturating_sub(file.sent); + + if time_diff > 0.0 && bytes_diff > 0 { + file.transfer_speed = bytes_diff as f64 / time_diff; + } + + file.sent = sent; + file.status = if remaining == 0 { + FileTransferStatus::Completed + } else { + FileTransferStatus::Transferring + }; + file.last_update = now; + } else { + files.push(ProgressFile { + id: id.clone(), + name: name.clone(), + total_size, + sent, + status: if remaining == 0 { + FileTransferStatus::Completed + } else { + FileTransferStatus::Transferring + }, + transfer_speed: 0.0, + last_update: now, + }); + } + + let total_speed: f64 = files + .iter() + .filter(|f| f.status == FileTransferStatus::Transferring) + .map(|f| f.transfer_speed) + .sum(); + *self.total_transfer_speed.write().unwrap() = total_speed; + + let total_files_size: u64 = files.iter().map(|f| f.total_size).sum(); + let total_sent_size: u64 = files.iter().map(|f| f.sent).sum(); + let progress_pct = if total_files_size > 0 { + ((total_sent_size as f64 / total_files_size as f64) * 100.0) + .min(100.0) + } else { + 0.0 + }; + + self.progress_pct + .store(progress_pct as u32, std::sync::atomic::Ordering::Relaxed); + } + + fn notify_connecting(&self, event: SendFilesToConnectingEvent) { + let receiver = event.receiver; + let name = receiver.name.clone(); + + *self.receiver_name.write().unwrap() = name.clone(); + self.set_now_as_operation_start_time(); + self.set_title_text("📤 Sending Files"); + self.set_block_title_text(format!("Connected to {name}").as_str()); + self.set_status_text(format!("Sending Files to {name}").as_str()); + } +} + +impl SendFilesToProgressApp { + pub fn new(b: Arc) -> Self { + Self { + id: Uuid::new_v4().to_string(), + b, + + progress_pct: AtomicU32::new(0), + operation_start_time: RwLock::new(None), + + title_text: RwLock::new("📤 Sending Files".to_string()), + block_title_text: RwLock::new("Connecting to Receiver".to_string()), + status_text: RwLock::new("Establishing Connection".to_string()), + log_text: RwLock::new("Initializing transfer...".to_string()), + + files: RwLock::new(Vec::new()), + total_transfer_speed: RwLock::new(0.0), + receiver_name: RwLock::new(String::new()), + } + } + + fn reset(&self) { + self.progress_pct + .store(0, std::sync::atomic::Ordering::Relaxed); + *self.operation_start_time.write().unwrap() = None; + *self.title_text.write().unwrap() = "📤 Sending Files".to_string(); + *self.block_title_text.write().unwrap() = + "Connecting to Receiver".to_string(); + *self.status_text.write().unwrap() = + "Establishing Connection".to_string(); + *self.log_text.write().unwrap() = + "Initializing transfer...".to_string(); + self.files.write().unwrap().clear(); + *self.total_transfer_speed.write().unwrap() = 0.0; + *self.receiver_name.write().unwrap() = String::new(); + } + + fn set_title_text(&self, text: &str) { + *self.title_text.write().unwrap() = text.to_string(); + } + + fn set_block_title_text(&self, text: &str) { + *self.block_title_text.write().unwrap() = text.to_string(); + } + + fn set_status_text(&self, text: &str) { + *self.status_text.write().unwrap() = text.to_string(); + } + + fn set_log_text(&self, text: &str) { + *self.log_text.write().unwrap() = text.to_string(); + } + + fn set_now_as_operation_start_time(&self) { + self.operation_start_time + .write() + .unwrap() + .replace(Instant::now()); + } + + fn get_title_text(&self) -> String { + self.title_text.read().unwrap().clone() + } + + fn get_block_title_text(&self) -> String { + self.block_title_text.read().unwrap().clone() + } + + fn get_progress_pct(&self) -> f64 { + let v = self + .progress_pct + .load(std::sync::atomic::Ordering::Relaxed); + f64::from(v) + } + + fn get_files(&self) -> Vec { + self.files.read().unwrap().clone() + } + + fn get_total_transfer_speed(&self) -> f64 { + *self.total_transfer_speed.read().unwrap() + } + + fn format_bytes(&self, bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; + let mut size = bytes as f64; + let mut unit_index = 0; + + while size >= 1024.0 && unit_index < UNITS.len() - 1 { + size /= 1024.0; + unit_index += 1; + } + + if unit_index == 0 { + format!("{} {}", bytes, UNITS[unit_index]) + } else { + format!("{:.1} {}", size, UNITS[unit_index]) + } + } + + fn format_speed(&self, bytes_per_sec: f64) -> String { + if bytes_per_sec == 0.0 { + return "--".to_string(); + } + format!("{}/s", self.format_bytes(bytes_per_sec as u64)) + } + + fn draw_title(&self, f: &mut Frame, area: ratatui::prelude::Rect) { + let progress_pct = self.get_progress_pct(); + let files = self.get_files(); + let completed_files = files + .iter() + .filter(|f| f.status == FileTransferStatus::Completed) + .count(); + let total_files = files.len(); + + let progress_icon = if progress_pct >= 100.0 { + "✅" + } else { + match (progress_pct as u8) % 4 { + 0 => "◐", + 1 => "◓", + 2 => "◑", + _ => "◒", + } + }; + + let title_content = vec![Line::from(vec![ + Span::styled( + format!("{} ", progress_icon), + Style::default().fg(Color::Magenta).bold(), + ), + Span::styled( + self.get_title_text(), + Style::default().fg(Color::White).bold(), + ), + Span::styled( + format!( + " • {}/{} files • {:.1}%", + completed_files, total_files, progress_pct + ), + Style::default().fg(Color::Cyan), + ), + ])]; + + let title_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Magenta)) + .title(self.get_block_title_text()) + .title_style(Style::default().fg(Color::White).bold()); + + let title_widget = Paragraph::new(title_content) + .block(title_block) + .alignment(Alignment::Center); + + f.render_widget(title_widget, area); + } + + fn draw_overall_progress( + &self, + f: &mut Frame, + area: ratatui::prelude::Rect, + ) { + let progress_pct = self.get_progress_pct(); + let files = self.get_files(); + let total_size: u64 = files.iter().map(|f| f.total_size).sum(); + let total_sent: u64 = files.iter().map(|f| f.sent).sum(); + let transfer_speed = self.get_total_transfer_speed(); + + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(70), + Constraint::Percentage(30), + ]) + .split(area); + + let progress_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Magenta)) + .title(" Overall Progress ") + .title_style(Style::default().fg(Color::White).bold()); + + let progress = Gauge::default() + .block(progress_block) + .gauge_style( + Style::default() + .fg(if progress_pct >= 100.0 { + Color::Green + } else { + Color::Magenta + }) + .bg(Color::DarkGray), + ) + .percent(progress_pct as u16) + .label(format!("{:.1}%", progress_pct)); + + f.render_widget(progress, chunks[0]); + + let stats_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Magenta)) + .title(" Stats ") + .title_style(Style::default().fg(Color::White).bold()); + + let stats_content = vec![ + Line::from(vec![ + Span::styled("📊 ", Style::default().fg(Color::Cyan)), + Span::styled( + format!( + "{} / {}", + self.format_bytes(total_sent), + self.format_bytes(total_size) + ), + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled("⚡ ", Style::default().fg(Color::Yellow)), + Span::styled( + self.format_speed(transfer_speed), + Style::default().fg(Color::White), + ), + ]), + ]; + + let stats_widget = Paragraph::new(stats_content) + .block(stats_block) + .alignment(Alignment::Center); + + f.render_widget(stats_widget, chunks[1]); + } + + fn draw_files_list(&self, f: &mut Frame, area: ratatui::prelude::Rect) { + let files = self.get_files(); + + let file_items: Vec = files + .iter() + .map(|file| { + let progress_pct = if file.total_size > 0 { + (file.sent as f64 / file.total_size as f64) * 100.0 + } else { + 0.0 + }; + + let status_line = Line::from(vec![ + Span::styled( + format!("{} ", file.status.icon()), + Style::default().fg(file.status.color()), + ), + Span::styled( + file.name.clone(), + Style::default().fg( + if file.status == FileTransferStatus::Completed { + Color::Green + } else { + Color::White + }, + ), + ), + Span::styled( + format!("{:>6.1}%", progress_pct), + Style::default().fg(Color::Cyan), + ), + ]); + + let detail_line = Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + format!( + "{} / {} • {}", + self.format_bytes(file.sent), + self.format_bytes(file.total_size), + if file.status == FileTransferStatus::Transferring { + self.format_speed(file.transfer_speed) + } else { + match file.status { + FileTransferStatus::Completed => { + "Complete".to_string() + } + _ => "--".to_string(), + } + } + ), + Style::default().fg(Color::Gray), + ), + ]); + + ListItem::new(vec![status_line, detail_line]) + }) + .collect(); + + let files_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::White)) + .title(format!(" Files ({}) ", files.len())) + .title_style(Style::default().fg(Color::White).bold()); + + let files_list = List::new(file_items) + .block(files_block) + .style(Style::default().fg(Color::White)); + + f.render_widget(files_list, area); + } + + fn draw_footer(&self, f: &mut Frame, area: ratatui::prelude::Rect) { + let progress_pct = self.get_progress_pct(); + + let (footer_text, footer_color, footer_icon) = if progress_pct >= 100.0 + { + ( + "All files sent successfully! Press ESC to continue" + .to_string(), + Color::Green, + "✅", + ) + } else { + ( + "Ctrl+C to cancel • ESC to go back".to_string(), + Color::Magenta, + "📤", + ) + }; + + let footer_content = vec![ + Line::from(""), + Line::from(vec![ + Span::styled( + format!("{} ", footer_icon), + Style::default().fg(footer_color), + ), + Span::styled(footer_text, Style::default().fg(footer_color)), + ]), + ]; + + let footer_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(footer_color)) + .title(" Status ") + .title_style(Style::default().fg(Color::White).bold()); + + let footer_widget = Paragraph::new(footer_content) + .block(footer_block) + .alignment(Alignment::Center); + + f.render_widget(footer_widget, area); + } +} diff --git a/drop-core/tui/src/backend.rs b/drop-core/tui/src/backend.rs index c101c092..1134ea6b 100644 --- a/drop-core/tui/src/backend.rs +++ b/drop-core/tui/src/backend.rs @@ -3,14 +3,16 @@ use std::sync::{Arc, RwLock}; use arkdrop_common::AppConfig; use crate::{ - AppBackend, AppFileBrowserManager, AppNavigation, AppReceiveFilesManager, - AppSendFilesManager, + AppBackend, AppFileBrowserManager, AppNavigation, AppReadyToReceiveManager, + AppReceiveFilesManager, AppSendFilesManager, AppSendFilesToManager, }; pub struct MainAppBackend { send_files_manager: RwLock>>, receive_files_manager: RwLock>>, file_browser_manager: RwLock>>, + send_files_to_manager: RwLock>>, + ready_to_receive_manager: RwLock>>, navigation: RwLock>>, } @@ -40,6 +42,24 @@ impl AppBackend for MainAppBackend { .unwrap() } + fn get_send_files_to_manager(&self) -> Arc { + self.send_files_to_manager + .read() + .unwrap() + .clone() + .unwrap() + } + + fn get_ready_to_receive_manager( + &self, + ) -> Arc { + self.ready_to_receive_manager + .read() + .unwrap() + .clone() + .unwrap() + } + fn get_config(&self) -> AppConfig { AppConfig::load().unwrap_or(AppConfig::default()) } @@ -55,6 +75,8 @@ impl MainAppBackend { send_files_manager: RwLock::new(None), receive_files_manager: RwLock::new(None), file_browser_manager: RwLock::new(None), + send_files_to_manager: RwLock::new(None), + ready_to_receive_manager: RwLock::new(None), navigation: RwLock::new(None), } @@ -90,6 +112,26 @@ impl MainAppBackend { .replace(manager); } + pub fn set_send_files_to_manager( + &self, + manager: Arc, + ) { + self.send_files_to_manager + .write() + .unwrap() + .replace(manager); + } + + pub fn set_ready_to_receive_manager( + &self, + manager: Arc, + ) { + self.ready_to_receive_manager + .write() + .unwrap() + .replace(manager); + } + pub fn set_navigation(&self, nav: Arc) { self.navigation.write().unwrap().replace(nav); } diff --git a/drop-core/tui/src/layout.rs b/drop-core/tui/src/layout.rs index 573aaf93..40bbb658 100644 --- a/drop-core/tui/src/layout.rs +++ b/drop-core/tui/src/layout.rs @@ -360,6 +360,23 @@ impl LayoutApp { HelperFooterControl::new("CTRL-C", "Cancel"), HelperFooterControl::new("CTRL-Q", "Quit"), ])), + Page::SendFilesTo => Some(create_helper_footer(vec![ + HelperFooterControl::new("↑/↓/Tab", "Navigate"), + HelperFooterControl::new("Enter", "Edit/Send"), + HelperFooterControl::new("CTRL-O", "Browse"), + HelperFooterControl::new("ESC", "Back"), + HelperFooterControl::new("CTRL-Q", "Quit"), + ])), + Page::SendFilesToProgress => Some(create_helper_footer(vec![ + HelperFooterControl::new("ESC", "Back"), + HelperFooterControl::new("CTRL-C", "Cancel"), + HelperFooterControl::new("CTRL-Q", "Quit"), + ])), + Page::ReadyToReceiveProgress => Some(create_helper_footer(vec![ + HelperFooterControl::new("ESC", "Back"), + HelperFooterControl::new("CTRL-C", "Cancel"), + HelperFooterControl::new("CTRL-Q", "Quit"), + ])), Page::FileBrowser => None, }; diff --git a/drop-core/tui/src/lib.rs b/drop-core/tui/src/lib.rs index a63bf959..6624365e 100644 --- a/drop-core/tui/src/lib.rs +++ b/drop-core/tui/src/lib.rs @@ -1,16 +1,24 @@ mod apps; mod backend; mod layout; +mod ready_to_receive_manager; mod receive_files_manager; mod send_files_manager; +mod send_files_to_manager; mod utilities; use std::{path::PathBuf, sync::Arc, time::Duration}; use anyhow::Result; use arkdrop_common::AppConfig; -use arkdropx_receiver::{ReceiveFilesBubble, ReceiveFilesRequest}; -use arkdropx_sender::{SendFilesBubble, SendFilesRequest}; +use arkdropx_receiver::{ + ReceiveFilesBubble, ReceiveFilesRequest, + ready_to_receive::{ReadyToReceiveBubble, ReadyToReceiveRequest}, +}; +use arkdropx_sender::{ + SendFilesBubble, SendFilesRequest, + send_files_to::{SendFilesToBubble, SendFilesToRequest}, +}; use ratatui::{ Frame, Terminal, backend::CrosstermBackend, @@ -28,14 +36,19 @@ use ratatui::{ use crate::{ apps::{ config::ConfigApp, file_browser::FileBrowserApp, help::HelpApp, - home::HomeApp, receive_files::ReceiveFilesApp, + home::HomeApp, ready_to_receive_progress::ReadyToReceiveProgressApp, + receive_files::ReceiveFilesApp, receive_files_progress::ReceiveFilesProgressApp, send_files::SendFilesApp, send_files_progress::SendFilesProgressApp, + send_files_to::SendFilesToApp, + send_files_to_progress::SendFilesToProgressApp, }, backend::MainAppBackend, layout::{LayoutApp, LayoutChild}, + ready_to_receive_manager::MainAppReadyToReceiveManager, receive_files_manager::MainAppReceiveFilesManager, send_files_manager::MainAppSendFilesManager, + send_files_to_manager::MainAppSendFilesToManager, }; #[derive(Clone, Debug, PartialEq)] @@ -48,6 +61,9 @@ pub enum Page { ReceiveFiles, SendFilesProgress, ReceiveFilesProgress, + SendFilesTo, + SendFilesToProgress, + ReadyToReceiveProgress, } #[derive(Clone, Debug, PartialEq)] @@ -114,10 +130,25 @@ pub trait AppFileBrowserManager: Send + Sync { fn open_file_browser(&self, req: OpenFileBrowserRequest); } +pub trait AppSendFilesToManager: Send + Sync { + fn cancel(&self); + fn send_files_to(&self, req: SendFilesToRequest); + fn get_send_files_to_bubble(&self) -> Option>; +} + +pub trait AppReadyToReceiveManager: Send + Sync { + fn cancel(&self); + fn ready_to_receive(&self, req: ReadyToReceiveRequest); + fn get_ready_to_receive_bubble(&self) -> Option>; +} + pub trait AppBackend: Send + Sync { fn get_send_files_manager(&self) -> Arc; fn get_receive_files_manager(&self) -> Arc; fn get_file_browser_manager(&self) -> Arc; + fn get_send_files_to_manager(&self) -> Arc; + fn get_ready_to_receive_manager(&self) + -> Arc; fn get_config(&self) -> AppConfig; fn get_navigation(&self) -> Arc; @@ -161,27 +192,42 @@ pub fn run_tui() -> Result<()> { let send_files = Arc::new(SendFilesApp::new(backend.clone())); let receive_files = Arc::new(ReceiveFilesApp::new(backend.clone())); + let send_files_to = Arc::new(SendFilesToApp::new(backend.clone())); let send_files_progress = Arc::new(SendFilesProgressApp::new(backend.clone())); let receive_files_progress = Arc::new(ReceiveFilesProgressApp::new(backend.clone())); + let send_files_to_progress = + Arc::new(SendFilesToProgressApp::new(backend.clone())); + let ready_to_receive_progress = + Arc::new(ReadyToReceiveProgressApp::new(backend.clone())); let send_files_manager = Arc::new(MainAppSendFilesManager::new()); let receive_files_manager = Arc::new(MainAppReceiveFilesManager::new()); + let send_files_to_manager = Arc::new(MainAppSendFilesToManager::new()); + let ready_to_receive_manager = + Arc::new(MainAppReadyToReceiveManager::new()); layout.set_file_browser(file_browser.clone()); layout.file_browser_subscribe(Page::SendFiles, send_files.clone()); + layout.file_browser_subscribe(Page::SendFilesTo, send_files_to.clone()); layout.file_browser_subscribe(Page::Config, config.clone()); backend.set_navigation(layout.clone()); backend.set_file_browser_manager(layout.clone()); backend.set_send_files_manager(send_files_manager.clone()); backend.set_receive_files_manager(receive_files_manager.clone()); + backend.set_send_files_to_manager(send_files_to_manager.clone()); + backend.set_ready_to_receive_manager(ready_to_receive_manager.clone()); send_files_manager.set_send_files_subscriber(send_files_progress.clone()); receive_files_manager .set_receive_files_subscriber(receive_files_progress.clone()); + send_files_to_manager + .set_send_files_to_subscriber(send_files_to_progress.clone()); + ready_to_receive_manager + .set_ready_to_receive_subscriber(ready_to_receive_progress.clone()); layout.add_child(LayoutChild { page: Some(Page::Home), @@ -247,6 +293,30 @@ pub fn run_tui() -> Result<()> { control_index: 0, }); + layout.add_child(LayoutChild { + page: Some(Page::SendFilesTo), + app: send_files_to, + is_active: false, + z_index: 0, + control_index: 0, + }); + + layout.add_child(LayoutChild { + page: Some(Page::SendFilesToProgress), + app: send_files_to_progress, + is_active: false, + z_index: 0, + control_index: 0, + }); + + layout.add_child(LayoutChild { + page: Some(Page::ReadyToReceiveProgress), + app: ready_to_receive_progress, + is_active: false, + z_index: 0, + control_index: 0, + }); + loop { terminal.draw(|f| { let area = f.area(); diff --git a/drop-core/tui/src/ready_to_receive_manager.rs b/drop-core/tui/src/ready_to_receive_manager.rs new file mode 100644 index 00000000..3eef727d --- /dev/null +++ b/drop-core/tui/src/ready_to_receive_manager.rs @@ -0,0 +1,80 @@ +use std::sync::{Arc, RwLock}; + +use arkdropx_receiver::ready_to_receive::{ + ReadyToReceiveBubble, ReadyToReceiveRequest, ReadyToReceiveSubscriber, + ready_to_receive, +}; + +use crate::AppReadyToReceiveManager; + +pub struct MainAppReadyToReceiveManager { + bubble: Arc>>>, + sub: Arc>>>, +} + +impl AppReadyToReceiveManager for MainAppReadyToReceiveManager { + fn cancel(&self) { + let curr_bubble = self.bubble.clone(); + + tokio::spawn(async move { + let taken_bubble = curr_bubble.write().unwrap().take(); + if let Some(bub) = &taken_bubble { + let _ = bub.cancel().await; + } + }); + } + + fn ready_to_receive(&self, req: ReadyToReceiveRequest) { + let curr_sub = self.sub.clone(); + let curr_bubble = self.bubble.clone(); + + tokio::spawn(async move { + let bubble = ready_to_receive(req).await; + match bubble { + Ok(bub) => { + let bub = Arc::new(bub); + + if let Some(sub) = curr_sub.read().unwrap().clone() { + bub.subscribe(sub); + } + + // No explicit start needed - the bubble starts waiting immediately + curr_bubble.write().unwrap().replace(bub); + } + Err(e) => { + // Log error to subscriber if available + if let Some(sub) = curr_sub.read().unwrap().clone() { + sub.log(format!("[ERROR] Failed to start: {}", e)); + } + } + } + }); + } + + fn get_ready_to_receive_bubble(&self) -> Option> { + let bubble = self.bubble.read().unwrap(); + bubble.clone() + } +} + +impl Default for MainAppReadyToReceiveManager { + fn default() -> Self { + Self::new() + } +} + +impl MainAppReadyToReceiveManager { + pub fn new() -> Self { + Self { + bubble: Arc::new(RwLock::new(None)), + sub: Arc::new(RwLock::new(None)), + } + } + + pub fn set_ready_to_receive_subscriber( + &self, + sub: Arc, + ) { + self.sub.write().unwrap().replace(sub); + } +} diff --git a/drop-core/tui/src/send_files_to_manager.rs b/drop-core/tui/src/send_files_to_manager.rs new file mode 100644 index 00000000..79e62b27 --- /dev/null +++ b/drop-core/tui/src/send_files_to_manager.rs @@ -0,0 +1,86 @@ +use std::sync::{Arc, RwLock}; + +use arkdropx_sender::send_files_to::{ + SendFilesToBubble, SendFilesToRequest, SendFilesToSubscriber, send_files_to, +}; + +use crate::AppSendFilesToManager; + +pub struct MainAppSendFilesToManager { + bubble: Arc>>>, + sub: Arc>>>, +} + +impl AppSendFilesToManager for MainAppSendFilesToManager { + fn cancel(&self) { + let curr_bubble = self.bubble.clone(); + + tokio::spawn(async move { + let taken_bubble = curr_bubble.write().unwrap().take(); + if let Some(bub) = &taken_bubble { + let _ = bub.cancel().await; + } + }); + } + + fn send_files_to(&self, req: SendFilesToRequest) { + let curr_sub = self.sub.clone(); + let curr_bubble = self.bubble.clone(); + + tokio::spawn(async move { + let bubble = send_files_to(req).await; + match bubble { + Ok(bub) => { + let bub = Arc::new(bub); + + if let Some(sub) = curr_sub.read().unwrap().clone() { + bub.subscribe(sub.clone()); + + // Start the transfer after subscribing + if let Err(e) = bub.start() { + sub.log(format!( + "[ERROR] Failed to start transfer: {}", + e + )); + } + } + + curr_bubble.write().unwrap().replace(bub); + } + Err(e) => { + // Log error to subscriber if available + if let Some(sub) = curr_sub.read().unwrap().clone() { + sub.log(format!("[ERROR] Failed to connect: {}", e)); + } + } + } + }); + } + + fn get_send_files_to_bubble(&self) -> Option> { + let bubble = self.bubble.read().unwrap(); + bubble.clone() + } +} + +impl Default for MainAppSendFilesToManager { + fn default() -> Self { + Self::new() + } +} + +impl MainAppSendFilesToManager { + pub fn new() -> Self { + Self { + bubble: Arc::new(RwLock::new(None)), + sub: Arc::new(RwLock::new(None)), + } + } + + pub fn set_send_files_to_subscriber( + &self, + sub: Arc, + ) { + self.sub.write().unwrap().replace(sub); + } +} diff --git a/drop-core/tui/src/utilities/mod.rs b/drop-core/tui/src/utilities/mod.rs index f9623e82..a9d31189 100644 --- a/drop-core/tui/src/utilities/mod.rs +++ b/drop-core/tui/src/utilities/mod.rs @@ -1 +1,2 @@ pub mod helper_footer; +pub mod qr_renderer; diff --git a/drop-core/tui/src/utilities/qr_renderer.rs b/drop-core/tui/src/utilities/qr_renderer.rs new file mode 100644 index 00000000..d98e5601 --- /dev/null +++ b/drop-core/tui/src/utilities/qr_renderer.rs @@ -0,0 +1,151 @@ +use qrcode::QrCode; +use ratatui::{ + Frame, + layout::Alignment, + style::{Color, Style, Stylize}, + symbols::border, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; + +/// Error type for QR code rendering failures. +#[derive(Debug)] +pub struct QrRenderError { + pub message: String, +} + +impl std::fmt::Display for QrRenderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "QR render error: {}", self.message) + } +} + +impl std::error::Error for QrRenderError {} + +/// Reusable QR code rendering utilities for TUI applications. +pub struct QrCodeRenderer; + +impl QrCodeRenderer { + /// Generates QR code as a vector of styled lines for display. + /// + /// The QR code uses doubled-width blocks for better terminal visibility. + pub fn render_qr_lines( + data: &str, + ) -> Result>, QrRenderError> { + let qr_code = QrCode::new(data).map_err(|e| QrRenderError { + message: e.to_string(), + })?; + + let qr_matrix = qr_code + .render::() + .quiet_zone(false) + .module_dimensions(1, 1) + .build(); + + let lines: Vec> = qr_matrix + .lines() + .map(|line| { + Line::from(vec![Span::styled( + line.replace('█', "██").replace(' ', " "), + Style::default().fg(Color::White).bg(Color::Black), + )]) + }) + .collect(); + + Ok(lines) + } + + /// Creates a styled block for QR code display. + pub fn create_qr_block(title: &str, color: Color) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(color)) + .title(format!(" {} ", title)) + .title_style(Style::default().fg(Color::White).bold()) + } + + /// Renders a waiting state when QR code is not yet available. + pub fn render_waiting( + f: &mut Frame, + area: ratatui::prelude::Rect, + block: Block, + message: &str, + ) { + let waiting_content = vec![ + Line::from(""), + Line::from(vec![ + Span::styled("⏳ ", Style::default().fg(Color::Yellow)), + Span::styled( + message, + Style::default().fg(Color::Yellow).bold(), + ), + ]), + Line::from(""), + Line::from(vec![Span::styled( + "QR code will appear when ready", + Style::default().fg(Color::Gray), + )]), + ]; + + let waiting_widget = Paragraph::new(waiting_content) + .block(block) + .alignment(Alignment::Center); + + f.render_widget(waiting_widget, area); + } + + /// Renders an error state when QR code generation fails. + pub fn render_error( + f: &mut Frame, + area: ratatui::prelude::Rect, + block: Block, + error_message: &str, + ) { + let error_content = vec![ + Line::from(""), + Line::from(vec![ + Span::styled("❌ ", Style::default().fg(Color::Red)), + Span::styled( + "Failed to generate QR code", + Style::default().fg(Color::Red).bold(), + ), + ]), + Line::from(""), + Line::from(vec![Span::styled( + error_message, + Style::default().fg(Color::Gray), + )]), + ]; + + let error_widget = Paragraph::new(error_content) + .block(block) + .alignment(Alignment::Center); + + f.render_widget(error_widget, area); + } + + /// Renders a complete QR code with the given data. + /// + /// This is a convenience method that handles all rendering states: + /// - Shows the QR code if generation succeeds + /// - Shows an error message if generation fails + pub fn render_qr_code( + f: &mut Frame, + area: ratatui::prelude::Rect, + block: Block, + data: &str, + ) { + match Self::render_qr_lines(data) { + Ok(qr_lines) => { + let qr_widget = Paragraph::new(qr_lines) + .block(block) + .alignment(Alignment::Center); + f.render_widget(qr_widget, area); + } + Err(e) => { + Self::render_error(f, area, block, &e.message); + } + } + } +} From cb61f6f4f0aa023d3a503137cc2d0fb579d2ddbd Mon Sep 17 00:00:00 2001 From: Pushkar Mishra Date: Thu, 11 Dec 2025 21:11:21 +0530 Subject: [PATCH 11/17] Enhance TUI functionality with clipboard support and code improvements - Added clipboard utility for copying text to the system clipboard, enhancing user experience during file transfers. - Updated various TUI components to streamline return statements and improve code readability. - Introduced new shortcuts for copying ticket and confirmation codes in the ready-to-receive and send files progress screens. - Refactored layout and helper footer components for better organization and clarity. These changes improve the overall usability and maintainability of the TUI, providing users with more intuitive interactions during file transfer operations. Signed-off-by: Pushkar Mishra --- drop-core/tui/Cargo.toml | 1 + drop-core/tui/src/apps/config.rs | 34 ++-- drop-core/tui/src/apps/file_browser.rs | 57 +++--- .../tui/src/apps/ready_to_receive_progress.rs | 111 ++++++++++-- drop-core/tui/src/apps/receive_files.rs | 10 +- .../tui/src/apps/receive_files_progress.rs | 9 +- drop-core/tui/src/apps/send_files.rs | 30 ++-- drop-core/tui/src/apps/send_files_progress.rs | 162 ++++++++++++++---- drop-core/tui/src/apps/send_files_to.rs | 24 +-- drop-core/tui/src/backend.rs | 2 +- drop-core/tui/src/layout.rs | 41 ++--- drop-core/tui/src/lib.rs | 2 +- drop-core/tui/src/receive_files_manager.rs | 2 +- drop-core/tui/src/send_files_manager.rs | 2 +- drop-core/tui/src/utilities/clipboard.rs | 14 ++ drop-core/tui/src/utilities/helper_footer.rs | 12 +- drop-core/tui/src/utilities/mod.rs | 1 + 17 files changed, 354 insertions(+), 160 deletions(-) create mode 100644 drop-core/tui/src/utilities/clipboard.rs diff --git a/drop-core/tui/Cargo.toml b/drop-core/tui/Cargo.toml index 0975ef0a..c9537a56 100644 --- a/drop-core/tui/Cargo.toml +++ b/drop-core/tui/Cargo.toml @@ -33,3 +33,4 @@ base64 = "0.22.1" qrcode = "0.14.1" serde = "1.0.219" uuid = "1.18.1" +arboard = "3.4" diff --git a/drop-core/tui/src/apps/config.rs b/drop-core/tui/src/apps/config.rs index e4c44b11..84c83aac 100644 --- a/drop-core/tui/src/apps/config.rs +++ b/drop-core/tui/src/apps/config.rs @@ -103,9 +103,9 @@ impl App for ConfigApp { let is_editing_name = self.is_editing_name(); if is_editing_name { - return self.handle_name_input_control(ev); + self.handle_name_input_control(ev) } else { - return self.handle_navigation_control(ev); + self.handle_navigation_control(ev) } } } @@ -118,22 +118,22 @@ impl AppFileBrowserSubscriber for ConfigApp { .unwrap() .take(); - if let Some(field) = awaiting_field { - if let Some(selected_path) = event.selected_files.first() { - match field { - ConfigField::AvatarFile => { - self.set_avatar_file(selected_path.clone()); - self.process_avatar_preview(selected_path.clone()); - } - ConfigField::OutputDirectory => { - self.set_out_dir(selected_path.clone()); - self.set_status_message(&format!( - "Output directory set to: {}", - selected_path.display() - )); - } - _ => {} + if let Some(field) = awaiting_field + && let Some(selected_path) = event.selected_files.first() + { + match field { + ConfigField::AvatarFile => { + self.set_avatar_file(selected_path.clone()); + self.process_avatar_preview(selected_path.clone()); + } + ConfigField::OutputDirectory => { + self.set_out_dir(selected_path.clone()); + self.set_status_message(&format!( + "Output directory set to: {}", + selected_path.display() + )); } + _ => {} } } } diff --git a/drop-core/tui/src/apps/file_browser.rs b/drop-core/tui/src/apps/file_browser.rs index e4ee8439..f1a1afd5 100644 --- a/drop-core/tui/src/apps/file_browser.rs +++ b/drop-core/tui/src/apps/file_browser.rs @@ -234,7 +234,7 @@ impl FileBrowserApp { SortMode::Type => { let a_ext = a.path.extension().unwrap_or_default(); let b_ext = b.path.extension().unwrap_or_default(); - a_ext.cmp(&b_ext) + a_ext.cmp(b_ext) } } } @@ -305,12 +305,11 @@ impl FileBrowserApp { let menu = self.get_menu(); let items = self.get_items(); - if let Some(current_index) = menu.selected() { - if let Some(item) = items.get(current_index) { - if item.is_directory { - self.enter_item_path(item); - } - } + if let Some(current_index) = menu.selected() + && let Some(item) = items.get(current_index) + && item.is_directory + { + self.enter_item_path(item); } } @@ -324,20 +323,20 @@ impl FileBrowserApp { let mode = self.get_mode(); let menu = self.get_menu(); - if let Some(item_idx) = menu.selected() { - if let Some(item) = self.items.write().unwrap().get_mut(item_idx) { - match mode { - BrowserMode::SelectFile => { - self.select_file(item); - self.on_save(); - } - BrowserMode::SelectDirectory => { - self.select_dir(item); - self.on_save(); - } - BrowserMode::SelectMultiFiles => { - self.select_file(item); - } + if let Some(item_idx) = menu.selected() + && let Some(item) = self.items.write().unwrap().get_mut(item_idx) + { + match mode { + BrowserMode::SelectFile => { + self.select_file(item); + self.on_save(); + } + BrowserMode::SelectDirectory => { + self.select_dir(item); + self.on_save(); + } + BrowserMode::SelectMultiFiles => { + self.select_file(item); } } } @@ -361,9 +360,9 @@ impl FileBrowserApp { if enforced_extensions.is_empty() { return true; } - return enforced_extensions + enforced_extensions .iter() - .any(|ee| name.ends_with(&format!(".{ee}"))); + .any(|ee| name.ends_with(&format!(".{ee}"))) } fn is_hidden_valid(&self, is_hidden: bool) -> bool { @@ -396,12 +395,10 @@ impl FileBrowserApp { let mut dir_items: Vec = entries .filter_map(|entry| { match entry { - Ok(entry) => { - return self.transform_to_item(entry); - } + Ok(entry) => self.transform_to_item(entry), Err(_) => { // TODO: info | log exception on TUI - return None; + None } } }) @@ -659,16 +656,14 @@ impl FileBrowserApp { } fn get_layout_blocks(&self, area: Rect) -> Rc<[Rect]> { - let blocks = Layout::default() + Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(4), // Header with path and controls Constraint::Min(0), // File list Constraint::Length(3), // Footer with help ]) - .split(area); - - blocks + .split(area) } fn get_sub(&self) -> Option> { diff --git a/drop-core/tui/src/apps/ready_to_receive_progress.rs b/drop-core/tui/src/apps/ready_to_receive_progress.rs index ca0ffe06..bbc087d5 100644 --- a/drop-core/tui/src/apps/ready_to_receive_progress.rs +++ b/drop-core/tui/src/apps/ready_to_receive_progress.rs @@ -7,7 +7,8 @@ use std::{ }; use crate::{ - App, AppBackend, ControlCapture, utilities::qr_renderer::QrCodeRenderer, + App, AppBackend, ControlCapture, + utilities::{clipboard::copy_to_clipboard, qr_renderer::QrCodeRenderer}, }; use arkdropx_receiver::ready_to_receive::{ ReadyToReceiveConnectingEvent, ReadyToReceiveReceivingEvent, @@ -81,6 +82,9 @@ pub struct ReadyToReceiveProgressApp { total_transfer_speed: RwLock, sender_name: RwLock, total_chunks_received: RwLock, + + // Copy feedback for T/Y clipboard shortcuts + copy_feedback: RwLock>, } impl App for ReadyToReceiveProgressApp { @@ -113,6 +117,17 @@ impl App for ReadyToReceiveProgressApp { KeyCode::Esc => { self.b.get_navigation().go_back(); } + // T/Y copy shortcuts - only in waiting mode + KeyCode::Char('t') | KeyCode::Char('T') + if !self.has_transfer_started() => + { + self.copy_ticket_to_clipboard(); + } + KeyCode::Char('y') | KeyCode::Char('Y') + if !self.has_transfer_started() => + { + self.copy_confirmation_to_clipboard(); + } _ => return None, } } @@ -174,6 +189,7 @@ impl ReadyToReceiveProgressApp { total_transfer_speed: RwLock::new(0.0), sender_name: RwLock::new(String::new()), total_chunks_received: RwLock::new(0), + copy_feedback: RwLock::new(None), } } @@ -192,6 +208,7 @@ impl ReadyToReceiveProgressApp { *self.total_transfer_speed.write().unwrap() = 0.0; *self.sender_name.write().unwrap() = String::new(); *self.total_chunks_received.write().unwrap() = 0; + *self.copy_feedback.write().unwrap() = None; } fn has_transfer_started(&self) -> bool { @@ -337,14 +354,14 @@ impl ReadyToReceiveProgressApp { let file_path = out_dir.join(&name); // Create parent directories if needed - if let Some(parent) = file_path.parent() { - if let Err(e) = fs::create_dir_all(parent) { - self.set_file_error( - &event.id, - format!("Failed to create directory: {}", e), - ); - return; - } + if let Some(parent) = file_path.parent() + && let Err(e) = fs::create_dir_all(parent) + { + self.set_file_error( + &event.id, + format!("Failed to create directory: {}", e), + ); + return; } let mut options = fs::OpenOptions::new(); @@ -407,6 +424,51 @@ impl ReadyToReceiveProgressApp { format!("{}/s", self.format_bytes(bytes_per_sec as u64)) } + // ── Clipboard Copy Methods ─────────────────────────────────────────────── + + fn copy_ticket_to_clipboard(&self) { + if let Some(bubble) = self + .b + .get_ready_to_receive_manager() + .get_ready_to_receive_bubble() + { + match copy_to_clipboard(&bubble.get_ticket()) { + Ok(_) => self.set_copy_feedback("✓ Ticket copied!"), + Err(e) => self.set_copy_feedback(&format!("✗ {}", e)), + } + } + } + + fn copy_confirmation_to_clipboard(&self) { + if let Some(bubble) = self + .b + .get_ready_to_receive_manager() + .get_ready_to_receive_bubble() + { + let conf = format!("{:02}", bubble.get_confirmation()); + match copy_to_clipboard(&conf) { + Ok(_) => self.set_copy_feedback("✓ Code copied!"), + Err(e) => self.set_copy_feedback(&format!("✗ {}", e)), + } + } + } + + fn set_copy_feedback(&self, message: &str) { + *self.copy_feedback.write().unwrap() = + Some((message.to_string(), Instant::now())); + } + + fn get_copy_feedback(&self) -> Option { + let feedback = self.copy_feedback.read().unwrap(); + if let Some((msg, time)) = feedback.as_ref() { + // Show feedback for 2 seconds + if time.elapsed().as_secs() < 2 { + return Some(msg.clone()); + } + } + None + } + // ── Waiting Mode (QR Code Display) ─────────────────────────────────────── fn draw_waiting_mode(&self, f: &mut Frame, area: ratatui::prelude::Rect) { @@ -492,12 +554,14 @@ impl ReadyToReceiveProgressApp { .title(" Connection Details ") .title_style(Style::default().fg(Color::White).bold()); + let copy_feedback = self.get_copy_feedback(); + let info_content = if let Some(bubble) = self .b .get_ready_to_receive_manager() .get_ready_to_receive_bubble() { - vec![ + let mut lines = vec![ Line::from(vec![ Span::styled("🔑 ", Style::default().fg(Color::Yellow)), Span::styled( @@ -508,16 +572,39 @@ impl ReadyToReceiveProgressApp { format!("{:02}", bubble.get_confirmation()), Style::default().fg(Color::White).bold(), ), + Span::styled( + " [Y to copy]", + Style::default().fg(Color::DarkGray), + ), ]), Line::from(vec![ Span::styled("🎫 ", Style::default().fg(Color::Blue)), Span::styled("Ticket: ", Style::default().fg(Color::Gray)), Span::styled( - truncate_string(&bubble.get_ticket(), 40), + truncate_string(&bubble.get_ticket(), 30), Style::default().fg(Color::White), ), + Span::styled( + " [T to copy]", + Style::default().fg(Color::DarkGray), + ), ]), - ] + ]; + + // Show copy feedback if available + if let Some(feedback) = copy_feedback { + let color = if feedback.starts_with('✓') { + Color::Green + } else { + Color::Red + }; + lines.push(Line::from(vec![Span::styled( + feedback, + Style::default().fg(color).bold(), + )])); + } + + lines } else { vec![Line::from(vec![Span::styled( "Generating connection details...", diff --git a/drop-core/tui/src/apps/receive_files.rs b/drop-core/tui/src/apps/receive_files.rs index 1ad800b4..cf96b3e0 100644 --- a/drop-core/tui/src/apps/receive_files.rs +++ b/drop-core/tui/src/apps/receive_files.rs @@ -75,15 +75,15 @@ impl App for ReceiveFilesApp { let transfer_state = self.transfer_state.read().unwrap().clone(); match transfer_state { TransferState::OngoingTransfer => { - return self.handle_ongoing_transfer_controls(ev); + self.handle_ongoing_transfer_controls(ev) } _ => { let is_editing = self.is_editing_field(); if is_editing { - return self.handle_text_input_controls(ev); + self.handle_text_input_controls(ev) } else { - return self.handle_navigation_controls(ev); + self.handle_navigation_controls(ev) } } } @@ -580,7 +580,7 @@ impl ReceiveFilesApp { let config = self.b.get_config(); - return Some(ReceiveFilesRequest { + Some(ReceiveFilesRequest { ticket: self.get_ticket_in(), confirmation: self.get_confirmation_in().parse().unwrap(), profile: ReceiverProfile { @@ -588,7 +588,7 @@ impl ReceiveFilesApp { avatar_b64: config.get_avatar_base64(), }, config: None, - }); + }) } fn set_status_message(&self, message: &str) { diff --git a/drop-core/tui/src/apps/receive_files_progress.rs b/drop-core/tui/src/apps/receive_files_progress.rs index c89bbadd..b2f1c8cf 100644 --- a/drop-core/tui/src/apps/receive_files_progress.rs +++ b/drop-core/tui/src/apps/receive_files_progress.rs @@ -154,7 +154,7 @@ impl ReceiveFilesSubscriber for ReceiveFilesProgressApp { self.set_status_text( format!("Receiving Files from {}", &event.sender.name).as_str(), ); - self.set_sender_name(&event.sender.name.as_str()); + self.set_sender_name(event.sender.name.as_str()); } } @@ -242,11 +242,11 @@ impl ReceiveFilesProgressApp { let v = self .progress_pct .load(std::sync::atomic::Ordering::Relaxed); - return f64::from(v); + f64::from(v) } fn get_operation_start_time(&self) -> Option { - self.operation_start_time.read().unwrap().clone() + *self.operation_start_time.read().unwrap() } fn get_files(&self) -> Vec { @@ -505,8 +505,7 @@ impl ReceiveFilesProgressApp { // Create a mini progress bar using Unicode blocks let progress_width = 20.0; - let filled_width = - ((progress_pct / 100.0) * progress_width as f64) as f64; + let filled_width = (progress_pct / 100.0) * progress_width; let progress_bar = format!( "{}{}", "█".repeat(filled_width as usize), diff --git a/drop-core/tui/src/apps/send_files.rs b/drop-core/tui/src/apps/send_files.rs index 8ceed61b..db0dc420 100644 --- a/drop-core/tui/src/apps/send_files.rs +++ b/drop-core/tui/src/apps/send_files.rs @@ -76,15 +76,15 @@ impl App for SendFilesApp { let transfer_state = self.transfer_state.read().unwrap().clone(); match transfer_state { TransferState::OngoingTransfer => { - return self.handle_ongoing_transfer_controls(ev); + self.handle_ongoing_transfer_controls(ev) } _ => { let is_editing = self.is_editing_path(); if is_editing { - return self.handle_text_input_controls(ev); + self.handle_text_input_controls(ev) } else { - return self.handle_navigation_controls(ev); + self.handle_navigation_controls(ev) } } } @@ -592,23 +592,23 @@ impl SendFilesApp { return Vec::new(); } - return selected_files_in + selected_files_in .iter() .filter_map(|f| { - if let Some(name) = f.file_name() { - if let Ok(data) = FileData::new(f.clone()) { - let name = name.to_string_lossy().to_string(); - - return Some(SenderFile { - name, - data: Arc::new(data), - }); - } + if let Some(name) = f.file_name() + && let Ok(data) = FileData::new(f.clone()) + { + let name = name.to_string_lossy().to_string(); + + return Some(SenderFile { + name, + data: Arc::new(data), + }); } - return None; + None }) - .collect(); + .collect() } fn set_status_message(&self, message: &str) { diff --git a/drop-core/tui/src/apps/send_files_progress.rs b/drop-core/tui/src/apps/send_files_progress.rs index 91781402..666a232d 100644 --- a/drop-core/tui/src/apps/send_files_progress.rs +++ b/drop-core/tui/src/apps/send_files_progress.rs @@ -3,7 +3,9 @@ use std::{ time::Instant, }; -use crate::{App, AppBackend, ControlCapture}; +use crate::{ + App, AppBackend, ControlCapture, utilities::clipboard::copy_to_clipboard, +}; use arkdropx_sender::SendFilesSubscriber; use crossterm::event::KeyModifiers; use qrcode::QrCode; @@ -67,6 +69,9 @@ pub struct SendFilesProgressApp { files: RwLock>, total_transfer_speed: RwLock, + + // Copy feedback for T/Y clipboard shortcuts + copy_feedback: RwLock>, } impl App for SendFilesProgressApp { @@ -109,6 +114,17 @@ impl App for SendFilesProgressApp { KeyCode::Esc => { self.b.get_navigation().go_back(); } + // T/Y copy shortcuts - only in waiting mode (before transfer starts) + KeyCode::Char('t') | KeyCode::Char('T') + if !self.has_transfer_started() => + { + self.copy_ticket_to_clipboard(); + } + KeyCode::Char('y') | KeyCode::Char('Y') + if !self.has_transfer_started() => + { + self.copy_confirmation_to_clipboard(); + } _ => return None, } } @@ -225,6 +241,8 @@ impl SendFilesProgressApp { files: RwLock::new(Vec::new()), total_transfer_speed: RwLock::new(0.0), + + copy_feedback: RwLock::new(None), } } @@ -267,11 +285,11 @@ impl SendFilesProgressApp { let v = self .progress_pct .load(std::sync::atomic::Ordering::Relaxed); - return f64::from(v); + f64::from(v) } fn get_operation_start_time(&self) -> Option { - self.operation_start_time.read().unwrap().clone() + *self.operation_start_time.read().unwrap() } fn get_files(&self) -> Vec { @@ -282,6 +300,48 @@ impl SendFilesProgressApp { *self.total_transfer_speed.read().unwrap() } + fn copy_ticket_to_clipboard(&self) { + if let Some(bubble) = self + .b + .get_send_files_manager() + .get_send_files_bubble() + { + match copy_to_clipboard(&bubble.get_ticket()) { + Ok(_) => self.set_copy_feedback("✓ Ticket copied!"), + Err(e) => self.set_copy_feedback(&format!("✗ {}", e)), + } + } + } + + fn copy_confirmation_to_clipboard(&self) { + if let Some(bubble) = self + .b + .get_send_files_manager() + .get_send_files_bubble() + { + let conf = format!("{:02}", bubble.get_confirmation()); + match copy_to_clipboard(&conf) { + Ok(_) => self.set_copy_feedback("✓ Code copied!"), + Err(e) => self.set_copy_feedback(&format!("✗ {}", e)), + } + } + } + + fn set_copy_feedback(&self, message: &str) { + *self.copy_feedback.write().unwrap() = + Some((message.to_string(), Instant::now())); + } + + fn get_copy_feedback(&self) -> Option { + let feedback = self.copy_feedback.read().unwrap(); + if let Some((msg, time)) = feedback.as_ref() + && time.elapsed().as_secs() < 2 + { + return Some(msg.clone()); + } + None + } + fn format_bytes(&self, bytes: u64) -> String { const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; let mut size = bytes as f64; @@ -520,8 +580,7 @@ impl SendFilesProgressApp { // Create a mini progress bar using Unicode blocks let progress_width = 20.0; - let filled_width = - ((progress_pct / 100.0) * progress_width as f64) as f64; + let filled_width = (progress_pct / 100.0) * progress_width; let progress_bar = format!( "{}{}", "█".repeat(filled_width as usize), @@ -711,48 +770,86 @@ impl SendFilesProgressApp { fn draw_footer(&self, f: &mut Frame, area: ratatui::prelude::Rect) { let progress_pct = self.get_progress_pct(); + let copy_feedback = self.get_copy_feedback(); - let (footer_text, footer_color, footer_icon) = if progress_pct >= 100.0 - { + let (footer_lines, footer_color) = if progress_pct >= 100.0 { ( - "All files transferred successfully! Press ESC to continue" - .to_string(), + vec![ + Line::from(""), + Line::from(vec![ + Span::styled("✅ ", Style::default().fg(Color::Green)), + Span::styled( + "All files transferred successfully! Press ESC to continue", + Style::default().fg(Color::White), + ), + ]), + ], Color::Green, - "✅", ) } else if let Some(bubble) = self .b .get_send_files_manager() .get_send_files_bubble() { - ( - format!( - "Ticket: {} • Confirmation: {}", - bubble.get_ticket(), - bubble.get_confirmation() + // Show ticket and confirmation with copy hints when in waiting mode + let mut lines = vec![Line::from(vec![ + Span::styled("🔑 ", Style::default().fg(Color::Blue)), + Span::styled( + "Confirmation: ", + Style::default().fg(Color::Gray), ), - Color::Blue, - "🔑", - ) + Span::styled( + format!("{:02}", bubble.get_confirmation()), + Style::default().fg(Color::White).bold(), + ), + Span::styled( + " [Y to copy]", + Style::default().fg(Color::DarkGray), + ), + Span::styled(" • ", Style::default().fg(Color::Gray)), + Span::styled("Ticket: ", Style::default().fg(Color::Gray)), + Span::styled( + "(full)", + Style::default().fg(Color::DarkGray).italic(), + ), + Span::styled( + " [T to copy]", + Style::default().fg(Color::DarkGray), + ), + ])]; + + // Show copy feedback if available + if let Some(feedback) = copy_feedback { + let feedback_color = if feedback.starts_with('✓') { + Color::Green + } else { + Color::Red + }; + lines.push(Line::from(vec![Span::styled( + feedback, + Style::default().fg(feedback_color).bold(), + )])); + } else { + lines.push(Line::from("")); + } + + (lines, Color::Blue) } else { ( - "Preparing transfer... Press ESC to cancel".to_string(), + vec![ + Line::from(""), + Line::from(vec![ + Span::styled("⏳ ", Style::default().fg(Color::Yellow)), + Span::styled( + "Preparing transfer... Press ESC to cancel", + Style::default().fg(Color::White), + ), + ]), + ], Color::Yellow, - "⏳", ) }; - let footer_content = vec![ - Line::from(""), - Line::from(vec![ - Span::styled( - format!("{} ", footer_icon), - Style::default().fg(footer_color), - ), - Span::styled(footer_text, Style::default().fg(Color::White)), - ]), - ]; - let footer_block = Block::default() .borders(Borders::ALL) .border_set(border::ROUNDED) @@ -760,7 +857,7 @@ impl SendFilesProgressApp { .title(" Status ") .title_style(Style::default().fg(Color::White).bold()); - let footer = Paragraph::new(footer_content) + let footer = Paragraph::new(footer_lines) .block(footer_block) .alignment(Alignment::Center); @@ -770,5 +867,6 @@ impl SendFilesProgressApp { fn reset(&self) { *self.operation_start_time.write().unwrap() = None; *self.files.write().unwrap() = Vec::new(); + *self.copy_feedback.write().unwrap() = None; } } diff --git a/drop-core/tui/src/apps/send_files_to.rs b/drop-core/tui/src/apps/send_files_to.rs index 4d6ba60b..2e0cf6fa 100644 --- a/drop-core/tui/src/apps/send_files_to.rs +++ b/drop-core/tui/src/apps/send_files_to.rs @@ -64,9 +64,9 @@ impl App for SendFilesToApp { let is_editing = self.is_editing_field(); if is_editing { - return self.handle_text_input_controls(ev); + self.handle_text_input_controls(ev) } else { - return self.handle_navigation_controls(ev); + self.handle_navigation_controls(ev) } } } @@ -417,7 +417,7 @@ impl SendFilesToApp { while new_pos < last_pos && chars .get(new_pos) - .map_or(false, |c| c.is_whitespace()) + .is_some_and(|c| c.is_whitespace()) { new_pos += 1; } @@ -425,7 +425,7 @@ impl SendFilesToApp { while new_pos < last_pos && chars .get(new_pos) - .map_or(false, |c| !c.is_whitespace()) + .is_some_and(|c| !c.is_whitespace()) { new_pos += 1; } @@ -608,14 +608,14 @@ impl SendFilesToApp { selected_files_in .iter() .filter_map(|f| { - if let Some(name) = f.file_name() { - if let Ok(data) = FileData::new(f.clone()) { - let name = name.to_string_lossy().to_string(); - return Some(SenderFile { - name, - data: Arc::new(data), - }); - } + if let Some(name) = f.file_name() + && let Ok(data) = FileData::new(f.clone()) + { + let name = name.to_string_lossy().to_string(); + return Some(SenderFile { + name, + data: Arc::new(data), + }); } None }) diff --git a/drop-core/tui/src/backend.rs b/drop-core/tui/src/backend.rs index 1134ea6b..7c8680d9 100644 --- a/drop-core/tui/src/backend.rs +++ b/drop-core/tui/src/backend.rs @@ -61,7 +61,7 @@ impl AppBackend for MainAppBackend { } fn get_config(&self) -> AppConfig { - AppConfig::load().unwrap_or(AppConfig::default()) + AppConfig::load().unwrap_or_default() } fn get_navigation(&self) -> Arc { diff --git a/drop-core/tui/src/layout.rs b/drop-core/tui/src/layout.rs index 40bbb658..6a0d06fd 100644 --- a/drop-core/tui/src/layout.rs +++ b/drop-core/tui/src/layout.rs @@ -159,26 +159,23 @@ impl AppNavigation for LayoutApp { let mut updated_previous_pages = false; let mut children = self.children.write().unwrap(); - match last_page { - Some(page) => { - for child in children.iter_mut() { - if let Some(child_page) = &child.page { - if child_page == &page { - child.is_active = true; - *self.current_page.write().unwrap() = page.clone(); - updated_current_page = true; - } else if child_page == ¤t_page { - child.is_active = false; - updated_previous_pages = true; - } + if let Some(page) = last_page { + for child in children.iter_mut() { + if let Some(child_page) = &child.page { + if child_page == &page { + child.is_active = true; + *self.current_page.write().unwrap() = page.clone(); + updated_current_page = true; + } else if child_page == ¤t_page { + child.is_active = false; + updated_previous_pages = true; } + } - if updated_current_page && updated_previous_pages { - break; - } + if updated_current_page && updated_previous_pages { + break; } } - None => {} } } } @@ -232,7 +229,7 @@ impl LayoutApp { if p == page { return Some(s.clone()); } - return None; + None }) } @@ -269,7 +266,7 @@ impl LayoutApp { if c.is_active { return Some(c); } - return None; + None }) .collect() } @@ -277,13 +274,13 @@ impl LayoutApp { fn get_active_children_sort_by_z_index(&self) -> Vec { let mut children = self.get_active_children(); children.sort_by(|a, b| a.z_index.cmp(&b.z_index)); - return children; + children } fn get_active_children_sort_by_control_index(&self) -> Vec { let mut children = self.get_active_children(); children.sort_by(|a, b| a.control_index.cmp(&b.z_index)); - return children; + children } pub fn is_finished(&self) -> bool { @@ -351,6 +348,8 @@ impl LayoutApp { HelperFooterControl::new("CTRL-Q", "Quit"), ])), Page::SendFilesProgress => Some(create_helper_footer(vec![ + HelperFooterControl::new("T", "Copy Ticket"), + HelperFooterControl::new("Y", "Copy Code"), HelperFooterControl::new("ESC", "Back"), HelperFooterControl::new("CTRL-C", "Cancel"), HelperFooterControl::new("CTRL-Q", "Quit"), @@ -373,6 +372,8 @@ impl LayoutApp { HelperFooterControl::new("CTRL-Q", "Quit"), ])), Page::ReadyToReceiveProgress => Some(create_helper_footer(vec![ + HelperFooterControl::new("T", "Copy Ticket"), + HelperFooterControl::new("Y", "Copy Code"), HelperFooterControl::new("ESC", "Back"), HelperFooterControl::new("CTRL-C", "Cancel"), HelperFooterControl::new("CTRL-Q", "Quit"), diff --git a/drop-core/tui/src/lib.rs b/drop-core/tui/src/lib.rs index 6624365e..7a6b9850 100644 --- a/drop-core/tui/src/lib.rs +++ b/drop-core/tui/src/lib.rs @@ -98,7 +98,7 @@ pub struct ControlCapture { impl ControlCapture { pub fn new(ev: &Event) -> Self { - return Self { ev: ev.clone() }; + Self { ev: ev.clone() } } } diff --git a/drop-core/tui/src/receive_files_manager.rs b/drop-core/tui/src/receive_files_manager.rs index 354e282e..b119493c 100644 --- a/drop-core/tui/src/receive_files_manager.rs +++ b/drop-core/tui/src/receive_files_manager.rs @@ -47,7 +47,7 @@ impl AppReceiveFilesManager for MainAppReceiveFilesManager { &self, ) -> Option> { let bubble = self.bubble.read().unwrap(); - return bubble.clone(); + bubble.clone() } } diff --git a/drop-core/tui/src/send_files_manager.rs b/drop-core/tui/src/send_files_manager.rs index 1233ee1f..b2051b0e 100644 --- a/drop-core/tui/src/send_files_manager.rs +++ b/drop-core/tui/src/send_files_manager.rs @@ -46,7 +46,7 @@ impl AppSendFilesManager for MainAppSendFilesManager { &self, ) -> Option> { let send_files_bubble = self.bubble.read().unwrap(); - return send_files_bubble.clone(); + send_files_bubble.clone() } } diff --git a/drop-core/tui/src/utilities/clipboard.rs b/drop-core/tui/src/utilities/clipboard.rs new file mode 100644 index 00000000..7f27af67 --- /dev/null +++ b/drop-core/tui/src/utilities/clipboard.rs @@ -0,0 +1,14 @@ +use arboard::Clipboard; + +/// Copy text to the system clipboard. +/// Returns Ok(()) on success, or an error message string on failure. +pub fn copy_to_clipboard(text: &str) -> Result<(), String> { + let mut clipboard = Clipboard::new() + .map_err(|e| format!("Failed to access clipboard: {}", e))?; + + clipboard + .set_text(text) + .map_err(|e| format!("Failed to copy: {}", e))?; + + Ok(()) +} diff --git a/drop-core/tui/src/utilities/helper_footer.rs b/drop-core/tui/src/utilities/helper_footer.rs index 3b389536..79a8217e 100644 --- a/drop-core/tui/src/utilities/helper_footer.rs +++ b/drop-core/tui/src/utilities/helper_footer.rs @@ -13,10 +13,10 @@ pub struct HelperFooterControl { impl HelperFooterControl { pub fn new(title: &str, description: &str) -> Self { - return Self { + Self { title: title.to_string(), description: description.to_string(), - }; + } } } @@ -40,11 +40,9 @@ pub fn create_helper_footer( .title(" Controls ") .title_style(Style::default().fg(Color::White).bold()); - let footer = Paragraph::new(footer_content) + Paragraph::new(footer_content) .block(footer_block) - .alignment(Alignment::Center); - - return footer; + .alignment(Alignment::Center) } fn create_controls_text(controls: Vec) -> String { @@ -55,7 +53,7 @@ fn create_controls_text(controls: Vec) -> String { controls_text.push_str(" • "); } controls_text.push_str(&c.title); - controls_text.push_str(" "); + controls_text.push(' '); controls_text.push_str(&c.description); } diff --git a/drop-core/tui/src/utilities/mod.rs b/drop-core/tui/src/utilities/mod.rs index a9d31189..39c0fa65 100644 --- a/drop-core/tui/src/utilities/mod.rs +++ b/drop-core/tui/src/utilities/mod.rs @@ -1,2 +1,3 @@ +pub mod clipboard; pub mod helper_footer; pub mod qr_renderer; From 183261f9c0006e3960bcaade1c0be19224343079 Mon Sep 17 00:00:00 2001 From: Pushkar Mishra Date: Thu, 11 Dec 2025 21:16:04 +0530 Subject: [PATCH 12/17] Update TUI layout constraints for connection info and footer - Increased the length constraint for connection info from 5 to 6 to accommodate additional information. - Adjusted the footer length constraint from 4 to 5 for better alignment and presentation. These changes enhance the user interface of the TUI, ensuring that all necessary information is displayed clearly during file transfer operations. Signed-off-by: Pushkar Mishra --- drop-core/tui/src/apps/ready_to_receive_progress.rs | 2 +- drop-core/tui/src/apps/send_files_progress.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/drop-core/tui/src/apps/ready_to_receive_progress.rs b/drop-core/tui/src/apps/ready_to_receive_progress.rs index bbc087d5..6862a514 100644 --- a/drop-core/tui/src/apps/ready_to_receive_progress.rs +++ b/drop-core/tui/src/apps/ready_to_receive_progress.rs @@ -478,7 +478,7 @@ impl ReadyToReceiveProgressApp { .constraints([ Constraint::Length(3), // Title Constraint::Min(15), // QR Code - Constraint::Length(5), // Connection info + Constraint::Length(6), // Connection info Constraint::Length(4), // Footer ]) .split(area); diff --git a/drop-core/tui/src/apps/send_files_progress.rs b/drop-core/tui/src/apps/send_files_progress.rs index 666a232d..afbbb609 100644 --- a/drop-core/tui/src/apps/send_files_progress.rs +++ b/drop-core/tui/src/apps/send_files_progress.rs @@ -83,7 +83,7 @@ impl App for SendFilesProgressApp { Constraint::Length(3), // Title Constraint::Length(6), // Overall progress Constraint::Min(8), // Individual files list - Constraint::Length(4), // Footer + Constraint::Length(5), // Footer ]) .split(area); From de344950e35f563ad62488c6411dcbc5dd434118 Mon Sep 17 00:00:00 2001 From: Pushkar Mishra Date: Thu, 11 Dec 2025 21:20:22 +0530 Subject: [PATCH 13/17] Remove unused async function for prompting peer credentials in handshake module - Deleted the `prompt_for_credentials` function from the handshake module, as it is no longer needed. - This cleanup helps streamline the codebase by removing unnecessary components. Signed-off-by: Pushkar Mishra --- drop-core/cli/src/handshake.rs | 32 -------------------------------- 1 file changed, 32 deletions(-) delete mode 100644 drop-core/cli/src/handshake.rs diff --git a/drop-core/cli/src/handshake.rs b/drop-core/cli/src/handshake.rs deleted file mode 100644 index a9908a2c..00000000 --- a/drop-core/cli/src/handshake.rs +++ /dev/null @@ -1,32 +0,0 @@ -use anyhow::{Context, Result}; -use std::io::Write; -use tokio::io::{AsyncBufReadExt, BufReader}; - -/// Prompt user for peer's credentials (async version using tokio) -pub async fn prompt_for_credentials() -> Result<(String, u8)> { - print!("\nEnter peer's ticket: "); - std::io::stdout().flush()?; - - let stdin = tokio::io::stdin(); - let mut reader = BufReader::new(stdin); - - let mut ticket = String::new(); - reader.read_line(&mut ticket).await?; - let ticket = ticket.trim().to_string(); - - if ticket.is_empty() { - anyhow::bail!("Ticket cannot be empty"); - } - - print!("Enter peer's confirmation code: "); - std::io::stdout().flush()?; - - let mut confirmation = String::new(); - reader.read_line(&mut confirmation).await?; - let confirmation = confirmation - .trim() - .parse::() - .context("Invalid confirmation code - must be a number 0-255")?; - - Ok((ticket, confirmation)) -} From b3d8c81d8647d0d2f5c72de14fad7eb4db41791f Mon Sep 17 00:00:00 2001 From: Pushkar Mishra Date: Wed, 24 Dec 2025 19:44:16 +0530 Subject: [PATCH 14/17] Add send-files-to and ready-to-receive functionality in drop-core - Introduced new `SendFilesToRequest` and `ReadyToReceiveRequest` structures to facilitate sending files to a receiver and waiting for a sender, respectively. - Implemented `SendFilesToBubble` and `ReadyToReceiveBubble` interfaces to manage the lifecycle of these sessions, including methods for starting, canceling, and subscribing to events. - Added necessary subscriber interfaces for both sender and receiver to handle progress notifications and connection events. - Updated the `drop.udl` file to define the new data structures and interfaces, enhancing the overall file transfer capabilities. These changes significantly improve the file transfer process by providing clear mechanisms for both sending and receiving files, enhancing user experience and functionality. Signed-off-by: Pushkar Mishra --- drop-core/uniffi/src/drop.udl | 180 +++++++++++++- drop-core/uniffi/src/receiver.rs | 2 + .../uniffi/src/receiver/ready_to_receive.rs | 220 ++++++++++++++++++ drop-core/uniffi/src/sender.rs | 6 +- drop-core/uniffi/src/sender/send_files_to.rs | 207 ++++++++++++++++ 5 files changed, 610 insertions(+), 5 deletions(-) create mode 100644 drop-core/uniffi/src/receiver/ready_to_receive.rs create mode 100644 drop-core/uniffi/src/sender/send_files_to.rs diff --git a/drop-core/uniffi/src/drop.udl b/drop-core/uniffi/src/drop.udl index 4c8f6ae3..05fbbc7c 100644 --- a/drop-core/uniffi/src/drop.udl +++ b/drop-core/uniffi/src/drop.udl @@ -223,15 +223,189 @@ dictionary ReceiveFilesFile { u64 len; }; +// ============================================================================ +// SEND-FILES-TO FLOW (Sender connects to waiting Receiver) +// ============================================================================ + +/// Request to send files to a waiting receiver. +/// The receiver has already created a session and shared their ticket/confirmation. +dictionary SendFilesToRequest { + /// Ticket obtained from the receiver's QR code. + string ticket; + /// Short confirmation code from the receiver. + u8 confirmation; + /// Sender metadata. + SenderProfile profile; + /// Files to send. Order is preserved. + sequence files; + /// Optional tuning parameters. If null, sensible defaults are used. + SenderConfig? config; +}; + +/// Represents a send-to session (sender connecting to a waiting receiver). +/// Call start() to begin the transfer after connecting. +interface SendFilesToBubble { + /// Begin the handshake and file transfer. + [Throws=DropError] + void start(); + /// Cancel the session. No further progress will be made. + [Throws=DropError, Async] + void cancel(); + /// True when the session has completed (successfully or not). + boolean is_finished(); + /// Subscribe to log/progress/connection events. + void subscribe(SendFilesToSubscriber subscriber); + /// Unsubscribe a previously registered subscriber. + void unsubscribe(SendFilesToSubscriber subscriber); +}; + +/// Sender-side callbacks for send-to transfers. +[Trait, WithForeign] +interface SendFilesToSubscriber { + /// Stable unique id used for subscribe/unsubscribe identity. + string get_id(); + /// Debug/diagnostic logs. Only emitted in debug builds. + void log(string message); + /// Emitted as bytes are sent for a given file. + void notify_sending(SendFilesToSendingEvent event); + /// Emitted when connecting to the receiver. + void notify_connecting(SendFilesToConnectingEvent event); +}; + +/// Progress information for a file being sent. +dictionary SendFilesToSendingEvent { + string id; + /// File name being sent. + string name; + /// Bytes already sent. + u64 sent; + /// Bytes remaining. + u64 remaining; +}; + +/// Information about the receiver when connecting. +dictionary SendFilesToConnectingEvent { + /// Receiver metadata preview. + SendFilesToReceiverProfile receiver; +}; + +/// Receiver identity preview available to the sender. +dictionary SendFilesToReceiverProfile { + /// Receiver unique id (transport-specific). + string id; + /// Display name. + string name; + /// Optional base64 avatar. + string? avatar_b64; +}; + +// ============================================================================ +// READY-TO-RECEIVE FLOW (Receiver waits for Sender to connect) +// ============================================================================ + +/// Request to start waiting for a sender. +/// The receiver creates a session and displays ticket/confirmation for the sender. +dictionary ReadyToReceiveRequest { + /// Receiver metadata. + ReceiverProfile profile; + /// Optional tuning parameters. If null, sensible defaults are used. + ReceiverConfig? config; +}; + +/// Represents a ready-to-receive session. +/// After creation, display the ticket/confirmation (e.g., as QR code) for sender. +interface ReadyToReceiveBubble { + /// One-time ticket that the sender needs to connect. + string get_ticket(); + /// Short confirmation code the sender must provide to prevent mispairing. + u8 get_confirmation(); + /// Cancel the session. No further progress will be made. + [Throws=DropError, Async] + void cancel(); + /// True when the session has completed (all files received or canceled). + boolean is_finished(); + /// True once a sender has connected and handshake completed. + boolean is_connected(); + /// ISO-8601 timestamp of when the session was created. + string get_created_at(); + /// Subscribe to log/progress/connection events. + void subscribe(ReadyToReceiveSubscriber subscriber); + /// Unsubscribe a previously registered subscriber. + void unsubscribe(ReadyToReceiveSubscriber subscriber); +}; + +/// Receiver-side callbacks for ready-to-receive transfers. +[Trait, WithForeign] +interface ReadyToReceiveSubscriber { + /// Stable unique id used for subscribe/unsubscribe identity. + string get_id(); + /// Debug/diagnostic logs. Only emitted in debug builds. + void log(string message); + /// Emitted with streamed bytes for a specific file id. + void notify_receiving(ReadyToReceiveReceivingEvent event); + /// Emitted when sender connects and file manifest is known. + void notify_connecting(ReadyToReceiveConnectingEvent event); +}; + +/// Chunk payload for a specific file. +dictionary ReadyToReceiveReceivingEvent { + /// File id that this chunk belongs to. + string id; + /// Raw bytes of the chunk. + bytes data; +}; + +/// Connection info and file manifest received from the sender. +dictionary ReadyToReceiveConnectingEvent { + /// Sender metadata preview. + ReadyToReceiveSenderProfile sender; + /// List of files that will be received. + sequence files; +}; + +/// Sender identity preview available to the receiver. +dictionary ReadyToReceiveSenderProfile { + /// Sender unique id (transport-specific). + string id; + /// Display name. + string name; + /// Optional base64 avatar. + string? avatar_b64; +}; + +/// Information about a single file to be received. +dictionary ReadyToReceiveFile { + /// Transport/manifest id. + string id; + /// Original file name. + string name; + /// Total length in bytes. + u64 len; +}; + /// Top-level namespace for starting send/receive flows. /// -/// Both functions are async and return "bubbles" that control and observe +/// All functions are async and return "bubbles" that control and observe /// the lifetime of the session via methods and subscriptions. +/// +/// Standard flows: +/// - `send_files`: Sender creates session, displays QR. Receiver joins. +/// - `receive_files`: Receiver joins sender's session using ticket. +/// +/// QR-to-receive flows (receiver-initiated): +/// - `ready_to_receive`: Receiver creates session, displays QR. Sender joins. +/// - `send_files_to`: Sender joins receiver's session using ticket. namespace drop { - /// Start a new send session. + /// Start a new send session (sender-initiated). [Throws=DropError, Async] SendFilesBubble send_files(SendFilesRequest request); - /// Start a new receive session. + /// Start a new receive session (join sender's session). [Throws=DropError, Async] ReceiveFilesBubble receive_files(ReceiveFilesRequest request); + /// Start a send-to session (join receiver's waiting session). + [Throws=DropError, Async] + SendFilesToBubble send_files_to(SendFilesToRequest request); + /// Start waiting for a sender (receiver-initiated). + [Throws=DropError, Async] + ReadyToReceiveBubble ready_to_receive(ReadyToReceiveRequest request); }; diff --git a/drop-core/uniffi/src/receiver.rs b/drop-core/uniffi/src/receiver.rs index c55e0921..efaee63f 100644 --- a/drop-core/uniffi/src/receiver.rs +++ b/drop-core/uniffi/src/receiver.rs @@ -3,8 +3,10 @@ //! These are thin, typed wrappers around the lower-level `arkdropx_receiver` //! crate. +mod ready_to_receive; mod receive_files; +pub use ready_to_receive::*; pub use receive_files::*; /// Describes the receiver's identity, shown to the sender during handshake. diff --git a/drop-core/uniffi/src/receiver/ready_to_receive.rs b/drop-core/uniffi/src/receiver/ready_to_receive.rs new file mode 100644 index 00000000..ef472d35 --- /dev/null +++ b/drop-core/uniffi/src/receiver/ready_to_receive.rs @@ -0,0 +1,220 @@ +//! Binding adapter for the ready-to-receive (QR-to-receive) flow. +//! +//! In this mode, the **receiver** creates a session and displays a QR code. +//! The **sender** scans that QR, connects, and pushes files. The receiver +//! waits and receives chunks as they arrive. + +use std::sync::Arc; + +use super::{ReceiverConfig, ReceiverProfile}; +use crate::DropError; + +/// Request to start waiting for a sender. +/// +/// Provide the receiver's profile and optional tuning parameters. +/// If `config` is None, defaults from the lower-level transport will be used. +pub struct ReadyToReceiveRequest { + pub profile: ReceiverProfile, + pub config: Option, +} + +/// Handle to a ready-to-receive session ("bubble"). +/// +/// Wraps `arkdropx_receiver::ready_to_receive::ReadyToReceiveBubble` and holds +/// a dedicated Tokio runtime used to drive the session. +pub struct ReadyToReceiveBubble { + inner: arkdropx_receiver::ready_to_receive::ReadyToReceiveBubble, + _runtime: tokio::runtime::Runtime, +} +impl ReadyToReceiveBubble { + /// Returns the ticket that the sender must provide to connect. + pub fn get_ticket(&self) -> String { + self.inner.get_ticket() + } + + /// Returns the short confirmation code required during pairing. + pub fn get_confirmation(&self) -> u8 { + self.inner.get_confirmation() + } + + /// Cancel the session asynchronously. + /// + /// Errors are mapped into `DropError`. After cancellation, `is_finished()` + /// will eventually become true. + pub async fn cancel(&self) -> Result<(), DropError> { + self.inner + .cancel() + .await + .map_err(|e| DropError::TODO(e.to_string())) + } + + /// True once the session has completed (all files received or canceled). + pub fn is_finished(&self) -> bool { + self.inner.is_finished() + } + + /// True once a sender has connected and handshake has completed. + pub fn is_connected(&self) -> bool { + self.inner.is_connected() + } + + /// ISO-8601 timestamp for when the session was created. + pub fn get_created_at(&self) -> String { + self.inner.get_created_at() + } + + /// Register an observer for logs, chunk payloads, and connection events. + /// + /// The subscriber is adapted and passed to the underlying transport. + pub fn subscribe(&self, subscriber: Arc) { + let adapted_subscriber = + ReadyToReceiveSubscriberAdapter { inner: subscriber }; + self.inner.subscribe(Arc::new(adapted_subscriber)) + } + + /// Unregister a previously subscribed observer. + /// + /// Identity is determined by the subscriber's `get_id()`. + pub fn unsubscribe(&self, subscriber: Arc) { + let adapted_subscriber = + ReadyToReceiveSubscriberAdapter { inner: subscriber }; + self.inner.unsubscribe(Arc::new(adapted_subscriber)) + } +} + +/// Observer for ready-to-receive logs and events. +/// +/// Implementers should provide a stable `get_id()` used for +/// subscribe/unsubscribe identity. `log()` calls are only emitted in debug +/// builds. +pub trait ReadyToReceiveSubscriber: Send + Sync { + fn get_id(&self) -> String; + fn log(&self, message: String); + /// Emitted for each received chunk of a specific file id. + fn notify_receiving(&self, event: ReadyToReceiveReceivingEvent); + /// Emitted on connection and when file manifest is known. + fn notify_connecting(&self, event: ReadyToReceiveConnectingEvent); +} + +/// A streamed chunk of data for a specific file. +pub struct ReadyToReceiveReceivingEvent { + /// File id this chunk belongs to. + pub id: String, + /// Raw bytes of the chunk. + pub data: Vec, +} + +/// Connection information and file manifest received from the sender. +pub struct ReadyToReceiveConnectingEvent { + pub sender: ReadyToReceiveSenderProfile, + pub files: Vec, +} + +/// Sender identity preview available to the receiver. +pub struct ReadyToReceiveSenderProfile { + pub id: String, + pub name: String, + pub avatar_b64: Option, +} + +/// Information about a single file to be received. +pub struct ReadyToReceiveFile { + pub id: String, + pub name: String, + pub len: u64, +} + +/// Adapter bridging this crate's subscriber trait to the lower-level one. +struct ReadyToReceiveSubscriberAdapter { + inner: Arc, +} +impl arkdropx_receiver::ready_to_receive::ReadyToReceiveSubscriber + for ReadyToReceiveSubscriberAdapter +{ + fn get_id(&self) -> String { + self.inner.get_id() + } + + fn log(&self, message: String) { + #[cfg(debug_assertions)] + return self.inner.log(message.clone()); + } + + fn notify_receiving( + &self, + event: arkdropx_receiver::ready_to_receive::ReadyToReceiveReceivingEvent, + ) { + self.inner + .notify_receiving(ReadyToReceiveReceivingEvent { + id: event.id, + data: event.data, + }) + } + + fn notify_connecting( + &self, + event: arkdropx_receiver::ready_to_receive::ReadyToReceiveConnectingEvent, + ) { + self.inner + .notify_connecting(ReadyToReceiveConnectingEvent { + sender: ReadyToReceiveSenderProfile { + id: event.sender.id, + name: event.sender.name, + avatar_b64: event.sender.avatar_b64, + }, + files: event + .files + .iter() + .map(|f| ReadyToReceiveFile { + id: f.id.clone(), + name: f.name.clone(), + len: f.len, + }) + .collect(), + }) + } +} + +/// Start waiting for a sender and return a bubble. +/// +/// Internally creates a dedicated Tokio runtime to drive async operations. +/// The caller owns the returned bubble and should retain it for the session +/// lifetime. Display the ticket and confirmation code (e.g., as QR) for the +/// sender to scan. +pub async fn ready_to_receive( + request: ReadyToReceiveRequest, +) -> Result, DropError> { + let runtime = tokio::runtime::Runtime::new() + .map_err(|e| DropError::TODO(e.to_string()))?; + let bubble = runtime + .block_on(async { + let adapted_request = create_adapted_request(request); + arkdropx_receiver::ready_to_receive::ready_to_receive( + adapted_request, + ) + .await + }) + .map_err(|e| DropError::TODO(e.to_string()))?; + Ok(Arc::new(ReadyToReceiveBubble { + inner: bubble, + _runtime: runtime, + })) +} + +/// Convert the high-level request into the arkdropx_receiver request format. +fn create_adapted_request( + request: ReadyToReceiveRequest, +) -> arkdropx_receiver::ready_to_receive::ReadyToReceiveRequest { + let profile = arkdropx_receiver::ReceiverProfile { + name: request.profile.name, + avatar_b64: request.profile.avatar_b64, + }; + let config = match request.config { + Some(config) => arkdropx_receiver::ready_to_receive::ReadyToReceiveConfig { + chunk_size: config.chunk_size, + parallel_streams: config.parallel_streams, + }, + None => arkdropx_receiver::ready_to_receive::ReadyToReceiveConfig::default(), + }; + arkdropx_receiver::ready_to_receive::ReadyToReceiveRequest { profile, config } +} diff --git a/drop-core/uniffi/src/sender.rs b/drop-core/uniffi/src/sender.rs index d7a76b74..a4e6140f 100644 --- a/drop-core/uniffi/src/sender.rs +++ b/drop-core/uniffi/src/sender.rs @@ -4,10 +4,12 @@ //! file data is provided by the embedding app via the `SenderFileData` trait. mod send_files; +mod send_files_to; use std::sync::Arc; pub use send_files::*; +pub use send_files_to::*; /// Describes the sender's identity, shown to the receiver during handshake. pub struct SenderProfile { @@ -45,8 +47,8 @@ pub trait SenderFileData: Send + Sync { /// Adapter that bridges this crate's `SenderFileData` trait to the /// `arkdropx_sender::SenderFileData` trait expected by the lower-level crate. -struct SenderFileDataAdapter { - inner: Arc, +pub(crate) struct SenderFileDataAdapter { + pub(crate) inner: Arc, } impl arkdropx_sender::SenderFileData for SenderFileDataAdapter { fn len(&self) -> u64 { diff --git a/drop-core/uniffi/src/sender/send_files_to.rs b/drop-core/uniffi/src/sender/send_files_to.rs new file mode 100644 index 00000000..0beccc50 --- /dev/null +++ b/drop-core/uniffi/src/sender/send_files_to.rs @@ -0,0 +1,207 @@ +//! Binding adapter for the send-files-to (QR-to-receive) flow. +//! +//! In this mode, the **receiver** creates a session and displays a QR code. +//! The **sender** scans that QR, connects, and pushes files. + +use std::sync::Arc; + +use super::{SenderConfig, SenderFile, SenderFileDataAdapter, SenderProfile}; +use crate::DropError; + +/// Request to start a send-to session. +/// +/// Provide the ticket and confirmation obtained from the receiver's QR code, +/// the sender's profile, the files to send, and optional tuning parameters. +/// If `config` is None, defaults from the lower-level transport will be used. +pub struct SendFilesToRequest { + pub ticket: String, + pub confirmation: u8, + pub profile: SenderProfile, + pub files: Vec, + pub config: Option, +} + +/// Handle to a send-to session ("bubble"). +/// +/// Wraps `arkdropx_sender::send_files_to::SendFilesToBubble` and holds a +/// dedicated Tokio runtime used to drive the session. +pub struct SendFilesToBubble { + inner: arkdropx_sender::send_files_to::SendFilesToBubble, + _runtime: tokio::runtime::Runtime, +} +impl SendFilesToBubble { + /// Start the transfer. + /// + /// This method initiates the handshake and begins sending files. + /// Returns an error if the session has already been started. + pub fn start(&self) -> Result<(), DropError> { + self.inner + .start() + .map_err(|e| DropError::TODO(e.to_string())) + } + + /// Cancel the session. No further progress will occur. + pub async fn cancel(&self) -> Result<(), DropError> { + self.inner + .cancel() + .await + .map_err(|e| DropError::TODO(e.to_string())) + } + + /// True when the session has completed (successfully or not). + pub fn is_finished(&self) -> bool { + self.inner.is_finished() + } + + /// Register an observer for logs and progress/connect events. + /// + /// The subscriber is adapted and passed to the underlying transport. + pub fn subscribe(&self, subscriber: Arc) { + let adapted_subscriber = + SendFilesToSubscriberAdapter { inner: subscriber }; + self.inner.subscribe(Arc::new(adapted_subscriber)) + } + + /// Unregister a previously subscribed observer. + /// + /// Identity is determined by the subscriber's `get_id()`. + pub fn unsubscribe(&self, subscriber: Arc) { + let adapted_subscriber = + SendFilesToSubscriberAdapter { inner: subscriber }; + self.inner.unsubscribe(Arc::new(adapted_subscriber)) + } +} + +/// Observer for send-to-side logs and events. +/// +/// Implementers should provide a stable `get_id()` used for +/// subscribe/unsubscribe identity. `log()` calls are only emitted in debug +/// builds. +pub trait SendFilesToSubscriber: Send + Sync { + fn get_id(&self) -> String; + fn log(&self, message: String); + /// Periodic progress update while sending a file. + fn notify_sending(&self, event: SendFilesToSendingEvent); + /// Emitted when the receiver connection is established. + fn notify_connecting(&self, event: SendFilesToConnectingEvent); +} + +/// Progress information for a single file being sent. +pub struct SendFilesToSendingEvent { + pub id: String, + pub name: String, + pub sent: u64, + pub remaining: u64, +} + +/// Connection information about the receiver. +pub struct SendFilesToConnectingEvent { + pub receiver: SendFilesToReceiverProfile, +} + +/// Receiver identity preview available to the sender. +pub struct SendFilesToReceiverProfile { + pub id: String, + pub name: String, + pub avatar_b64: Option, +} + +/// Adapter bridging this crate's subscriber trait to the lower-level one. +struct SendFilesToSubscriberAdapter { + inner: Arc, +} +impl arkdropx_sender::send_files_to::SendFilesToSubscriber + for SendFilesToSubscriberAdapter +{ + fn get_id(&self) -> String { + self.inner.get_id() + } + + fn log(&self, message: String) { + #[cfg(debug_assertions)] + return self.inner.log(message.clone()); + } + + fn notify_sending( + &self, + event: arkdropx_sender::send_files_to::SendFilesToSendingEvent, + ) { + self.inner.notify_sending(SendFilesToSendingEvent { + id: event.id, + name: event.name, + sent: event.sent, + remaining: event.remaining, + }) + } + + fn notify_connecting( + &self, + event: arkdropx_sender::send_files_to::SendFilesToConnectingEvent, + ) { + self.inner + .notify_connecting(SendFilesToConnectingEvent { + receiver: SendFilesToReceiverProfile { + id: event.receiver.id, + name: event.receiver.name, + avatar_b64: event.receiver.avatar_b64, + }, + }) + } +} + +/// Start a new send-to session and return its bubble. +/// +/// Internally creates a dedicated Tokio runtime to drive async operations. +/// The caller owns the returned bubble and should retain it for the session +/// lifetime. Errors are mapped into `DropError`. +pub async fn send_files_to( + request: SendFilesToRequest, +) -> Result, DropError> { + let runtime = tokio::runtime::Runtime::new() + .map_err(|e| DropError::TODO(e.to_string()))?; + let bubble = runtime + .block_on(async { + let adapted_request = create_adapted_request(request); + arkdropx_sender::send_files_to::send_files_to(adapted_request).await + }) + .map_err(|e| DropError::TODO(e.to_string()))?; + Ok(Arc::new(SendFilesToBubble { + inner: bubble, + _runtime: runtime, + })) +} + +/// Convert the high-level request into the arkdropx_sender request format. +fn create_adapted_request( + request: SendFilesToRequest, +) -> arkdropx_sender::send_files_to::SendFilesToRequest { + let profile = arkdropx_sender::SenderProfile { + name: request.profile.name, + avatar_b64: request.profile.avatar_b64, + }; + let files = request + .files + .into_iter() + .map(|f| { + let data = SenderFileDataAdapter { inner: f.data }; + arkdropx_sender::SenderFile { + name: f.name, + data: Arc::new(data), + } + }) + .collect(); + let config = match request.config { + Some(config) => arkdropx_sender::SenderConfig { + chunk_size: config.chunk_size, + parallel_streams: config.parallel_streams, + }, + None => arkdropx_sender::SenderConfig::default(), + }; + arkdropx_sender::send_files_to::SendFilesToRequest { + ticket: request.ticket, + confirmation: request.confirmation, + profile, + files, + config, + } +} From aa23fa054f06b33756e145279b746d9ccaea322f Mon Sep 17 00:00:00 2001 From: Pushkar Mishra Date: Mon, 29 Dec 2025 21:12:18 +0530 Subject: [PATCH 15/17] Refactor imports and comments for clarity in TUI and Uniffi modules - Updated import statements in `home.rs`, `send_files_to.rs`, and `ready_to_receive.rs` for improved readability and organization. - Enhanced comments in `ready_to_receive_manager.rs` and `send_files_progress.rs` to clarify functionality and maintain consistency in code style. - Adjusted layout constraints in `send_files_to.rs` for better alignment and presentation. These changes contribute to a cleaner and more maintainable codebase, enhancing overall developer experience. Signed-off-by: Pushkar Mishra --- drop-core/tui/src/apps/home.rs | 6 ++--- drop-core/tui/src/apps/send_files_progress.rs | 3 ++- drop-core/tui/src/apps/send_files_to.rs | 11 +++++--- drop-core/tui/src/ready_to_receive_manager.rs | 3 ++- .../uniffi/src/receiver/ready_to_receive.rs | 26 ++++++++++++------- .../uniffi/src/receiver/receive_files.rs | 4 +-- drop-core/uniffi/src/sender/send_files.rs | 4 +-- drop-core/uniffi/src/sender/send_files_to.rs | 20 +++++++------- 8 files changed, 46 insertions(+), 31 deletions(-) diff --git a/drop-core/tui/src/apps/home.rs b/drop-core/tui/src/apps/home.rs index 5cf1f75f..50f930bd 100644 --- a/drop-core/tui/src/apps/home.rs +++ b/drop-core/tui/src/apps/home.rs @@ -253,9 +253,9 @@ impl HomeApp { } fn start_ready_to_receive(&self) { - use arkdropx_receiver::ReceiverProfile; - use arkdropx_receiver::ready_to_receive::{ - ReadyToReceiveConfig, ReadyToReceiveRequest, + use arkdropx_receiver::{ + ReceiverProfile, + ready_to_receive::{ReadyToReceiveConfig, ReadyToReceiveRequest}, }; let config = self.b.get_config(); diff --git a/drop-core/tui/src/apps/send_files_progress.rs b/drop-core/tui/src/apps/send_files_progress.rs index afbbb609..cc6e08d3 100644 --- a/drop-core/tui/src/apps/send_files_progress.rs +++ b/drop-core/tui/src/apps/send_files_progress.rs @@ -114,7 +114,8 @@ impl App for SendFilesProgressApp { KeyCode::Esc => { self.b.get_navigation().go_back(); } - // T/Y copy shortcuts - only in waiting mode (before transfer starts) + // T/Y copy shortcuts - only in waiting mode (before + // transfer starts) KeyCode::Char('t') | KeyCode::Char('T') if !self.has_transfer_started() => { diff --git a/drop-core/tui/src/apps/send_files_to.rs b/drop-core/tui/src/apps/send_files_to.rs index 2e0cf6fa..1c202f8b 100644 --- a/drop-core/tui/src/apps/send_files_to.rs +++ b/drop-core/tui/src/apps/send_files_to.rs @@ -3,8 +3,9 @@ use crate::{ BrowserMode, ControlCapture, OpenFileBrowserRequest, Page, SortMode, }; use arkdrop_common::FileData; -use arkdropx_sender::send_files_to::SendFilesToRequest; -use arkdropx_sender::{SenderConfig, SenderFile, SenderProfile}; +use arkdropx_sender::{ + SenderConfig, SenderFile, SenderProfile, send_files_to::SendFilesToRequest, +}; use ratatui::{ Frame, crossterm::event::{Event, KeyCode, KeyModifiers}, @@ -658,8 +659,10 @@ impl SendFilesToApp { .direction(Direction::Horizontal) .margin(1) .constraints([ - Constraint::Percentage(55), // Left side - connection + files input - Constraint::Percentage(45), // Right side - file list + send button + Constraint::Percentage(55), /* Left side - connection + + * files input */ + Constraint::Percentage(45), /* Right side - file list + send + * button */ ]) .split(area); diff --git a/drop-core/tui/src/ready_to_receive_manager.rs b/drop-core/tui/src/ready_to_receive_manager.rs index 3eef727d..60f49bc1 100644 --- a/drop-core/tui/src/ready_to_receive_manager.rs +++ b/drop-core/tui/src/ready_to_receive_manager.rs @@ -38,7 +38,8 @@ impl AppReadyToReceiveManager for MainAppReadyToReceiveManager { bub.subscribe(sub); } - // No explicit start needed - the bubble starts waiting immediately + // No explicit start needed - the bubble starts waiting + // immediately curr_bubble.write().unwrap().replace(bub); } Err(e) => { diff --git a/drop-core/uniffi/src/receiver/ready_to_receive.rs b/drop-core/uniffi/src/receiver/ready_to_receive.rs index ef472d35..13a8f31d 100644 --- a/drop-core/uniffi/src/receiver/ready_to_receive.rs +++ b/drop-core/uniffi/src/receiver/ready_to_receive.rs @@ -78,7 +78,8 @@ impl ReadyToReceiveBubble { pub fn unsubscribe(&self, subscriber: Arc) { let adapted_subscriber = ReadyToReceiveSubscriberAdapter { inner: subscriber }; - self.inner.unsubscribe(Arc::new(adapted_subscriber)) + self.inner + .unsubscribe(Arc::new(adapted_subscriber)) } } @@ -135,9 +136,9 @@ impl arkdropx_receiver::ready_to_receive::ReadyToReceiveSubscriber self.inner.get_id() } - fn log(&self, message: String) { + fn log(&self, _message: String) { #[cfg(debug_assertions)] - return self.inner.log(message.clone()); + return self.inner.log(_message.clone()); } fn notify_receiving( @@ -210,11 +211,18 @@ fn create_adapted_request( avatar_b64: request.profile.avatar_b64, }; let config = match request.config { - Some(config) => arkdropx_receiver::ready_to_receive::ReadyToReceiveConfig { - chunk_size: config.chunk_size, - parallel_streams: config.parallel_streams, - }, - None => arkdropx_receiver::ready_to_receive::ReadyToReceiveConfig::default(), + Some(config) => { + arkdropx_receiver::ready_to_receive::ReadyToReceiveConfig { + chunk_size: config.chunk_size, + parallel_streams: config.parallel_streams, + } + } + None => { + arkdropx_receiver::ready_to_receive::ReadyToReceiveConfig::default() + } }; - arkdropx_receiver::ready_to_receive::ReadyToReceiveRequest { profile, config } + arkdropx_receiver::ready_to_receive::ReadyToReceiveRequest { + profile, + config, + } } diff --git a/drop-core/uniffi/src/receiver/receive_files.rs b/drop-core/uniffi/src/receiver/receive_files.rs index 85eee420..70a8fe59 100644 --- a/drop-core/uniffi/src/receiver/receive_files.rs +++ b/drop-core/uniffi/src/receiver/receive_files.rs @@ -121,9 +121,9 @@ impl arkdropx_receiver::ReceiveFilesSubscriber self.inner.get_id() } - fn log(&self, message: String) { + fn log(&self, _message: String) { #[cfg(debug_assertions)] - return self.inner.log(message.clone()); + return self.inner.log(_message.clone()); } fn notify_receiving( diff --git a/drop-core/uniffi/src/sender/send_files.rs b/drop-core/uniffi/src/sender/send_files.rs index 5db39800..78c32cb5 100644 --- a/drop-core/uniffi/src/sender/send_files.rs +++ b/drop-core/uniffi/src/sender/send_files.rs @@ -123,9 +123,9 @@ impl arkdropx_sender::SendFilesSubscriber for SendFilesSubscriberAdapter { self.inner.get_id() } - fn log(&self, message: String) { + fn log(&self, _message: String) { #[cfg(debug_assertions)] - return self.inner.log(message.clone()); + return self.inner.log(_message.clone()); } fn notify_sending(&self, event: arkdropx_sender::SendFilesSendingEvent) { diff --git a/drop-core/uniffi/src/sender/send_files_to.rs b/drop-core/uniffi/src/sender/send_files_to.rs index 0beccc50..fe35ccee 100644 --- a/drop-core/uniffi/src/sender/send_files_to.rs +++ b/drop-core/uniffi/src/sender/send_files_to.rs @@ -68,7 +68,8 @@ impl SendFilesToBubble { pub fn unsubscribe(&self, subscriber: Arc) { let adapted_subscriber = SendFilesToSubscriberAdapter { inner: subscriber }; - self.inner.unsubscribe(Arc::new(adapted_subscriber)) + self.inner + .unsubscribe(Arc::new(adapted_subscriber)) } } @@ -117,21 +118,22 @@ impl arkdropx_sender::send_files_to::SendFilesToSubscriber self.inner.get_id() } - fn log(&self, message: String) { + fn log(&self, _message: String) { #[cfg(debug_assertions)] - return self.inner.log(message.clone()); + return self.inner.log(_message.clone()); } fn notify_sending( &self, event: arkdropx_sender::send_files_to::SendFilesToSendingEvent, ) { - self.inner.notify_sending(SendFilesToSendingEvent { - id: event.id, - name: event.name, - sent: event.sent, - remaining: event.remaining, - }) + self.inner + .notify_sending(SendFilesToSendingEvent { + id: event.id, + name: event.name, + sent: event.sent, + remaining: event.remaining, + }) } fn notify_connecting( From 5450614d81e0c9979d491c97aee4dffb1321984e Mon Sep 17 00:00:00 2001 From: Pushkar Mishra Date: Mon, 29 Dec 2025 22:51:51 +0530 Subject: [PATCH 16/17] Add disk space management step in Android bindings release workflow - Introduced a new step to free disk space in the GitHub Actions workflow for Android bindings, optimizing resource usage during the build process. - Configured options to manage tool cache, .NET, Haskell, large packages, Docker images, and swap storage. These changes enhance the efficiency of the build process by ensuring adequate disk space is available, contributing to smoother CI/CD operations. Signed-off-by: Pushkar Mishra --- .../workflows/arkdrop-android-bindings-release.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/arkdrop-android-bindings-release.yml b/.github/workflows/arkdrop-android-bindings-release.yml index 7e165b45..42de3eaa 100644 --- a/.github/workflows/arkdrop-android-bindings-release.yml +++ b/.github/workflows/arkdrop-android-bindings-release.yml @@ -21,6 +21,17 @@ jobs: working-directory: ./drop-core/uniffi/bindings/android steps: + - name: Free Disk Space + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + android: false + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true + - uses: actions/checkout@v4 - uses: Swatinem/rust-cache@v2 From bc66daf46ed5df9bceb3e2f91fec3515b4f97b5c Mon Sep 17 00:00:00 2001 From: Pushkar Mishra Date: Sat, 3 Jan 2026 13:33:16 +0530 Subject: [PATCH 17/17] Refactor SenderFileDataAdapter structure in uniffi module - Removed `pub(crate)` visibility from the `SenderFileDataAdapter` struct and its `inner` field to streamline access control. - This change simplifies the interface while maintaining encapsulation, contributing to a cleaner codebase. Signed-off-by: Pushkar Mishra --- drop-core/uniffi/src/sender.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/drop-core/uniffi/src/sender.rs b/drop-core/uniffi/src/sender.rs index a4e6140f..6d0810dd 100644 --- a/drop-core/uniffi/src/sender.rs +++ b/drop-core/uniffi/src/sender.rs @@ -47,8 +47,8 @@ pub trait SenderFileData: Send + Sync { /// Adapter that bridges this crate's `SenderFileData` trait to the /// `arkdropx_sender::SenderFileData` trait expected by the lower-level crate. -pub(crate) struct SenderFileDataAdapter { - pub(crate) inner: Arc, +struct SenderFileDataAdapter { + inner: Arc, } impl arkdropx_sender::SenderFileData for SenderFileDataAdapter { fn len(&self) -> u64 {