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 diff --git a/drop-core/cli/Cargo.toml b/drop-core/cli/Cargo.toml index c6ad674e..fcb4c347 100644 --- a/drop-core/cli/Cargo.toml +++ b/drop-core/cli/Cargo.toml @@ -20,9 +20,9 @@ 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" diff --git a/drop-core/cli/src/lib.rs b/drop-core/cli/src/lib.rs index 042294e7..3f198591 100644 --- a/drop-core/cli/src/lib.rs +++ b/drop-core/cli/src/lib.rs @@ -72,12 +72,22 @@ use arkdrop_common::{ use arkdropx_receiver::{ ReceiveFilesConnectingEvent, ReceiveFilesFile, ReceiveFilesReceivingEvent, ReceiveFilesRequest, ReceiveFilesSubscriber, ReceiverProfile, + ready_to_receive::{ + ReadyToReceiveBubble, ReadyToReceiveConfig, + ReadyToReceiveConnectingEvent, ReadyToReceiveFile, + ReadyToReceiveReceivingEvent, ReadyToReceiveRequest, + ReadyToReceiveSubscriber, ready_to_receive, + }, receive_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, + }, }; use clap::{Arg, ArgMatches, Command}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; @@ -149,16 +159,16 @@ impl FileSender { tokio::select! { _ = tokio::signal::ctrl_c() => { - println!("🚫 Cancelling file transfer..."); + println!("Cancelling file transfer..."); let _ = bubble.cancel().await; - println!("✅ Transfer cancelled"); + println!("Transfer cancelled"); + Ok(()) } _ = wait_for_send_completion(&bubble) => { - println!("✅ All files sent successfully!"); + println!("All files sent successfully!"); + Ok(()) } } - - Ok(()) } fn create_sender_files( @@ -208,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() { @@ -291,27 +320,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"); + Ok(()) } _ = wait_for_receive_completion(&bubble) => { - println!("✅ All files received successfully!"); + println!("All files received successfully!"); + Ok(()) } } - - Ok(()) } /// Returns a ReceiverProfile derived from this FileReceiver's Profile. @@ -355,7 +384,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("#>-") } } @@ -367,13 +396,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] 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 { @@ -386,7 +421,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 @@ -403,20 +438,20 @@ 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)); } } fn notify_connecting(&self, event: SendFilesConnectingEvent) { - let _ = self.mp.println("🔗 Connected to receiver:"); + let _ = self.mp.println("Connected to receiver:"); 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)); } } @@ -428,6 +463,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 { @@ -439,6 +476,7 @@ impl FileReceiveSubscriber { mp: MultiProgress::new(), bars: RwLock::new(HashMap::new()), received: RwLock::new(HashMap::new()), + file_handles: RwLock::new(HashMap::new()), } } @@ -446,7 +484,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("#>-") } } @@ -457,7 +495,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)); } } @@ -466,47 +504,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; @@ -515,7 +558,7 @@ impl ReceiveFilesSubscriber for FileReceiveSubscriber { pb.set_position(*entry); if *entry >= len { pb.finish_with_message(format!( - "✅ Received {}", + "[DONE] Received {}", file.name )); } @@ -526,40 +569,74 @@ 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; + // Get or create cached file handle + let mut file_handles = match self.file_handles.write() { + Ok(handles) => handles, + Err(e) => { + eprintln!("[ERROR] Error accessing file handles: {}", e); + return; + } + }; + 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; + } + } } - if let Err(e) = file_stream.flush() { - eprintln!("❌ Error flushing file {}: {}", file.name, e); + match fs::File::options() + .create(true) + .append(true) + .open(&file_path) + { + Ok(f) => entry.insert(f), + Err(e) => { + eprintln!( + "[ERROR] Failed to open file {}: {}", + file_path.display(), + e + ); + return; + } } } - Err(e) => { - eprintln!("❌ Error opening file {}: {}", file.name, 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:"); + let _ = self.mp.println("Connected to sender:"); 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 @@ -567,7 +644,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()); @@ -576,7 +662,7 @@ impl ReceiveFilesSubscriber for FileReceiveSubscriber { } } Err(e) => { - eprintln!("❌ Error updating files list: {e}"); + eprintln!("[ERROR] Error updating files list: {}", e); } } } @@ -597,6 +683,8 @@ pub 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, } @@ -615,6 +703,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), }) @@ -644,14 +733,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 ); @@ -663,7 +776,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) { @@ -679,7 +804,7 @@ impl SenderFileData for FileData { } Err(e) => { eprintln!( - "❌ Error reading from file {}: {}", + "[ERROR] Error reading from file {}: {}", self.path.display(), e ); @@ -724,12 +849,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 ); @@ -738,10 +863,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 @@ -763,7 +910,7 @@ impl SenderFileData for FileData { } Err(e) => { eprintln!( - "❌ Error reading chunk from file {}: {}", + "[ERROR] Error reading chunk from file {}: {}", self.path.display(), e ); @@ -918,6 +1065,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); @@ -1043,6 +1196,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<()> { @@ -1078,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"); @@ -1162,6 +1399,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::*; @@ -1181,3 +1477,514 @@ mod tests { assert_eq!(profile.avatar_b64, Some("dGVzdA==".to_string())); } } + +// 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>, + // Cache file handles to avoid reopening on every chunk + file_handles: 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()), + file_handles: 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_or_else(|_| ProgressStyle::default_bar()) + .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!("[DEBUG] {}", message)); + } + } + + fn notify_receiving(&self, event: ReadyToReceiveReceivingEvent) { + let files = match self.files.read() { + Ok(files) => files, + Err(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!("[ERROR] File not found with ID: {}", event.id); + return; + } + }; + + 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(|| { + 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 + }); + + { + 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; + + // 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); + + // Get or create cached file handle + let mut file_handles = match self.file_handles.write() { + Ok(handles) => handles, + Err(e) => { + eprintln!("[ERROR] Error accessing file handles: {}", e); + return; + } + }; + 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) + { + 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) { + 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:"); + 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 = 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()); + pb.set_message(format!("Receiving {}", f.name)); + bars.insert(f.id.clone(), pb); + } + } + Err(e) => { + eprintln!("[ERROR] 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_or_else(|_| ProgressStyle::default_bar()) + .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!("[DEBUG] {}", message)); + } + } + + fn notify_sending(&self, event: SendFilesToSendingEvent) { + 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.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_or_else(|_| ProgressStyle::default_spinner()), + ); + 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!("[DONE] Sent {}", event.name)); + } else { + pb.set_message(format!("Sending {}", event.name)); + } + } + + fn notify_connecting(&self, event: SendFilesToConnectingEvent) { + let _ = self.mp.println("Connected to receiver:"); + 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 { + set_default_out_dir(path.clone())?; + println!("💾 Saved '{}' as default receive directory", dir); + } + path + } + None => get_default_out_dir(), + }; + + // 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(); + + // Display QR code and session info + 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); + 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..."); + let _ = bubble.cancel().await; + println!("✅ Transfer cancelled"); + Ok(()) + } + _ = wait_for_send_files_to_completion(&bubble) => { + println!("✅ All files sent successfully!"); + Ok(()) + } + } +} 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/entities/src/data.rs b/drop-core/entities/src/data.rs index 1cbf169f..c0d512e9 100644 --- a/drop-core/entities/src/data.rs +++ b/drop-core/entities/src/data.rs @@ -35,9 +35,7 @@ pub trait Data: Send + Sync { /// the known total length at creation time. fn len(&self) -> u64; - /// Checks if the data is empty (length is 0). - /// - /// Default implementation returns `true` if `len() == 0`. + /// Returns true if the data has zero length. fn is_empty(&self) -> bool { self.len() == 0 } diff --git a/drop-core/exchanges/receiver/Cargo.toml b/drop-core/exchanges/receiver/Cargo.toml index 393813b1..2f9d29d5 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 7e8fe860..ed433304 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,9 +17,23 @@ //! 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::{ + io::{BufReader, Bytes, Read}, + sync::{RwLock, atomic::AtomicBool}, +}; + pub use receive_files::*; /// Identity and presentation for the receiving peer. @@ -87,3 +103,100 @@ impl ReceiverConfig { } } } + +/// Metadata and data carrier representing a file being received. +/// +/// Note: Depending on your flow, you may receive file data via events +/// (see `receive_files` module) rather than pulling bytes directly from this +/// structure. +#[derive(Debug)] +pub struct ReceiverFile { + /// Unique, sender-provided file identifier. + pub id: String, + /// Human-readable file name (as provided by sender). + pub name: String, + /// Backing data accessor for byte-wise reads. + pub data: ReceiverFileData, +} + +/// Backing data abstraction for a locally stored file used by the receiver. +/// +/// This type supports: +/// - Lazy initialization of a byte iterator over the file. +/// - Byte-wise `read()` until EOF, returning `None` when complete. +/// - A simple `is_finished` flag to short-circuit further reads after EOF. +/// +/// Caveats: +/// - `len()` currently counts bytes by iterating the file; this is O(n) and +/// re-reads the file. Prefer using file metadata for length if available. +/// - `read()` is not optimized for high-throughput stream reads; it is intended +/// for simple scenarios and examples. Use buffered I/O where performance +/// matters. +#[derive(Debug)] +pub struct ReceiverFileData { + is_finished: AtomicBool, + path: std::path::PathBuf, + reader: RwLock>>>, +} +impl ReceiverFileData { + /// Create a new `ReceiverFileData` from a filesystem path. + pub fn new(path: std::path::PathBuf) -> Self { + Self { + is_finished: AtomicBool::new(false), + path, + reader: RwLock::new(None), + } + } + + /// Return the file length in bytes using file metadata (O(1)). + pub fn len(&self) -> u64 { + std::fs::metadata(&self.path) + .map(|m| m.len()) + .unwrap_or(0) + } + + /// 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 + /// the stream has been marked finished. + /// + /// 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) + { + return None; + } + if self.reader.read().unwrap().is_none() { + let file = std::fs::File::open(&self.path).unwrap(); + self.reader + .write() + .unwrap() + .replace(BufReader::new(file).bytes()); + } + let next = self + .reader + .write() + .unwrap() + .as_mut() + .unwrap() + .next(); + if let Some(read_result) = next + && let Ok(byte) = read_result + { + return Some(byte); + } + *self.reader.write().unwrap() = None; + self.is_finished + .store(true, std::sync::atomic::Ordering::Relaxed); + None + } +} 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..38e1945a --- /dev/null +++ b/drop-core/exchanges/receiver/src/ready_to_receive/handler.rs @@ -0,0 +1,548 @@ +//! 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 arkdrop_entities::Profile; +use arkdropx_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 let Err(e) = carrier.greet().await { + carrier.log(format!("accept: Handshake failed: {:?}", e)); + return Err(iroh::protocol::AcceptError::NotAllowed {}); + } + + if let Err(e) = carrier.receive_files().await { + carrier.log(format!("accept: File reception failed: {:?}", e)); + 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..79e61b66 --- /dev/null +++ b/drop-core/exchanges/receiver/src/ready_to_receive/mod.rs @@ -0,0 +1,336 @@ +//! 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 arkdrop_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 { + if let Err(e) = router.shutdown().await { + eprintln!("[ERROR] Failed to shutdown router: {}", e); + } + }); + } + + 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 3c994be6..68e6da6c 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 arkdrop_entities::Data; use std::sync::Arc; @@ -58,14 +68,16 @@ 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. fn len(&self) -> u64; - /// Checks if the data is empty (length is 0). - fn is_empty(&self) -> bool; + /// 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; @@ -74,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, } @@ -86,10 +98,6 @@ impl Data for SenderFileDataAdapter { self.inner.len() } - fn is_empty(&self) -> bool { - self.len() == 0 - } - fn read(&self) -> Option { self.inner.read() } diff --git a/drop-core/exchanges/sender/src/send_files/handler.rs b/drop-core/exchanges/sender/src/send_files/handler.rs index 5045df12..906540b2 100644 --- a/drop-core/exchanges/sender/src/send_files/handler.rs +++ b/drop-core/exchanges/sender/src/send_files/handler.rs @@ -397,13 +397,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 }); // Limit concurrent streams to negotiated number @@ -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 new file mode 100644 index 00000000..7b076626 --- /dev/null +++ b/drop-core/exchanges/sender/src/send_files_to.rs @@ -0,0 +1,581 @@ +//! 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 arkdrop_entities::{File, Profile}; +use arkdropx_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()); + + // Use compare_exchange to atomically check and set is_running + if self + .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: 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 + } + + /// 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(); + 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 { + 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, + )) +} 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/home.rs b/drop-core/tui/src/apps/home.rs index 24c0e55b..50f930bd 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, + 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..6862a514 --- /dev/null +++ b/drop-core/tui/src/apps/ready_to_receive_progress.rs @@ -0,0 +1,952 @@ +use std::{ + collections::HashMap, + fs, + io::Write, + sync::{Arc, RwLock, atomic::AtomicU32}, + time::Instant, +}; + +use crate::{ + App, AppBackend, ControlCapture, + utilities::{clipboard::copy_to_clipboard, 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, + + // Copy feedback for T/Y clipboard shortcuts + copy_feedback: 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(); + } + // 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, + } + } + + 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), + copy_feedback: RwLock::new(None), + } + } + + 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; + *self.copy_feedback.write().unwrap() = None; + } + + 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() + && 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)) + } + + // ── 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) { + let blocks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(3), // Title + Constraint::Min(15), // QR Code + Constraint::Length(6), // 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 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() + { + let mut lines = 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(), + ), + 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(), 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...", + 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/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..cc6e08d3 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 { @@ -78,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); @@ -109,6 +114,18 @@ 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 +242,8 @@ impl SendFilesProgressApp { files: RwLock::new(Vec::new()), total_transfer_speed: RwLock::new(0.0), + + copy_feedback: RwLock::new(None), } } @@ -267,11 +286,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 +301,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 +581,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 +771,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 +858,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 +868,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 new file mode 100644 index 00000000..1c202f8b --- /dev/null +++ b/drop-core/tui/src/apps/send_files_to.rs @@ -0,0 +1,1167 @@ +use crate::{ + App, AppBackend, AppFileBrowserSaveEvent, AppFileBrowserSubscriber, + BrowserMode, ControlCapture, OpenFileBrowserRequest, Page, SortMode, +}; +use arkdrop_common::FileData; +use arkdropx_sender::{ + SenderConfig, SenderFile, SenderProfile, send_files_to::SendFilesToRequest, +}; +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 { + self.handle_text_input_controls(ev) + } else { + 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) + .is_some_and(|c| c.is_whitespace()) + { + new_pos += 1; + } + + while new_pos < last_pos + && chars + .get(new_pos) + .is_some_and(|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() + && 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..7c8680d9 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,8 +42,26 @@ 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()) + AppConfig::load().unwrap_or_default() } fn get_navigation(&self) -> Arc { @@ -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..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"), @@ -360,6 +359,25 @@ 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("T", "Copy Ticket"), + HelperFooterControl::new("Y", "Copy Code"), + 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..7a6b9850 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)] @@ -82,7 +98,7 @@ pub struct ControlCapture { impl ControlCapture { pub fn new(ev: &Event) -> Self { - return Self { ev: ev.clone() }; + Self { ev: ev.clone() } } } @@ -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..60f49bc1 --- /dev/null +++ b/drop-core/tui/src/ready_to_receive_manager.rs @@ -0,0 +1,81 @@ +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/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/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/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 f9623e82..39c0fa65 100644 --- a/drop-core/tui/src/utilities/mod.rs +++ b/drop-core/tui/src/utilities/mod.rs @@ -1 +1,3 @@ +pub mod clipboard; 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); + } + } + } +} 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..13a8f31d --- /dev/null +++ b/drop-core/uniffi/src/receiver/ready_to_receive.rs @@ -0,0 +1,228 @@ +//! 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/receiver/receive_files.rs b/drop-core/uniffi/src/receiver/receive_files.rs index e78e2810..70a8fe59 100644 --- a/drop-core/uniffi/src/receiver/receive_files.rs +++ b/drop-core/uniffi/src/receiver/receive_files.rs @@ -30,34 +30,31 @@ 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 - .runtime - .block_on(async { - return self.inner.start(); - }) - .map_err(|e| DropError::TODO(e.to_string())); + self.runtime + .block_on(async { self.inner.start() }) + .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 +63,8 @@ impl ReceiveFilesBubble { pub fn unsubscribe(&self, subscriber: Arc) { let adapted_subscriber = ReceiveFilesSubscriberAdapter { inner: subscriber }; - return self - .inner - .unsubscribe(Arc::new(adapted_subscriber)); + self.inner + .unsubscribe(Arc::new(adapted_subscriber)) } } @@ -122,32 +118,30 @@ impl arkdropx_receiver::ReceiveFilesSubscriber for ReceiveFilesSubscriberAdapter { fn get_id(&self) -> String { - return self.inner.get_id(); + 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( &self, event: arkdropx_receiver::ReceiveFilesReceivingEvent, ) { - return self - .inner + self.inner .notify_receiving(ReceiveFilesReceivingEvent { id: event.id, data: event.data, - }); + }) } fn notify_connecting( &self, event: arkdropx_receiver::ReceiveFilesConnectingEvent, ) { - return self - .inner + self.inner .notify_connecting(ReceiveFilesConnectingEvent { sender: ReceiveFilesProfile { id: event.sender.id, @@ -163,7 +157,7 @@ impl arkdropx_receiver::ReceiveFilesSubscriber len: f.len, }) .collect(), - }); + }) } } @@ -180,13 +174,13 @@ 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()))?; - return Ok(Arc::new(ReceiveFilesBubble { + Ok(Arc::new(ReceiveFilesBubble { inner: bubble, runtime, - })); + })) } /// Convert the high-level request into the arkdropx_receiver request format. @@ -206,10 +200,10 @@ 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, config, - }; + } } diff --git a/drop-core/uniffi/src/sender.rs b/drop-core/uniffi/src/sender.rs index 2ce49f0c..6d0810dd 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 { @@ -50,19 +52,19 @@ struct SenderFileDataAdapter { } impl arkdropx_sender::SenderFileData for SenderFileDataAdapter { fn len(&self) -> u64 { - return self.inner.len(); + self.inner.len() } fn is_empty(&self) -> bool { - return self.inner.is_empty(); + self.inner.is_empty() } 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 c5cc14e3..78c32cb5 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,8 @@ impl SendFilesBubble { pub fn unsubscribe(&self, subscriber: Arc) { let adapted_subscriber = SendFilesSubscriberAdapter { inner: subscriber }; - return self - .inner - .unsubscribe(Arc::new(adapted_subscriber)); + self.inner + .unsubscribe(Arc::new(adapted_subscriber)) } } @@ -121,36 +120,35 @@ struct SendFilesSubscriberAdapter { } impl arkdropx_sender::SendFilesSubscriber for SendFilesSubscriberAdapter { fn get_id(&self) -> String { - return self.inner.get_id(); + 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) { - return self.inner.notify_sending(SendFilesSendingEvent { + self.inner.notify_sending(SendFilesSendingEvent { id: event.id, name: event.name, sent: event.sent, remaining: event.remaining, - }); + }) } fn notify_connecting( &self, event: arkdropx_sender::SendFilesConnectingEvent, ) { - return self - .inner + self.inner .notify_connecting(SendFilesConnectingEvent { receiver: SendFilesProfile { id: event.receiver.id, name: event.receiver.name, avatar_b64: event.receiver.avatar_b64, }, - }); + }) } } @@ -167,13 +165,13 @@ 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()))?; - return Ok(Arc::new(SendFilesBubble { + Ok(Arc::new(SendFilesBubble { inner: bubble, _runtime: runtime, - })); + })) } /// Convert the high-level request into the arkdropx_sender request format. @@ -193,10 +191,10 @@ 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), - }; + } }) .collect(); let config = match request.config { @@ -206,9 +204,9 @@ fn create_adapted_request( }, None => arkdropx_sender::SenderConfig::default(), }; - return arkdropx_sender::SendFilesRequest { + arkdropx_sender::SendFilesRequest { profile, files, config, - }; + } } 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..fe35ccee --- /dev/null +++ b/drop-core/uniffi/src/sender/send_files_to.rs @@ -0,0 +1,209 @@ +//! 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, + } +}