Skip to main content

lanflare_core/client/
transfer.rs

1//! Client transfer orchestrator.
2//!
3//! Coordinates file upload sessions to remote LocalSend / LanFlare peers, including:
4//! - Pre-flight calculation of file sizes, MIME types, and optional SHA-256 hashes.
5//! - Negotiation with the receiver via `POST /api/localsend/v2/prepare-upload`.
6//! - Streaming parallel uploads of accepted files with configurable concurrency bounds.
7//! - Progress reporting over Tokio channels (bytes transferred, speed, ETA, status).
8//! - Graceful cancellation via `POST /api/localsend/v2/cancel` and cancellation tokens.
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use futures_util::stream::Stream;
17use reqwest::Body;
18use sha2::{Digest, Sha256};
19use tokio::fs::File;
20use tokio::io::AsyncReadExt;
21use tokio::sync::{mpsc, Semaphore};
22use tokio_util::io::ReaderStream;
23use tokio_util::sync::CancellationToken;
24use uuid::Uuid;
25
26use lanflare_proto::discovery::RegisterDtoV2;
27use lanflare_proto::transfer::{FileDto, FileMetadataDto, PrepareUploadRequestDtoV2};
28
29use crate::client::{ClientError, LsClient};
30
31/// Default chunk size used when streaming files from disk to the network (128 KB).
32pub const DEFAULT_STREAM_CHUNK_SIZE: usize = 128 * 1024;
33
34/// Specification for a file to be sent to a remote peer.
35#[derive(Clone, Debug)]
36pub struct OutgoingFile {
37    /// Unique client-side ID for this file in the session.
38    pub id: String,
39    /// Absolute path to the file on local disk.
40    pub path: PathBuf,
41    /// Name to advertise to the remote receiver (defaults to file basename).
42    pub file_name: String,
43    /// Size of the file in bytes.
44    pub size: u64,
45    /// MIME type string (e.g. `application/octet-stream`).
46    pub file_type: String,
47    /// Precomputed or lazily computed SHA-256 hash (hex string).
48    pub sha256: Option<String>,
49}
50
51impl OutgoingFile {
52    /// Creates an `OutgoingFile` from a local file path.
53    ///
54    /// Reads file metadata to obtain size. Basename is derived automatically.
55    pub async fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, ClientError> {
56        let p = path.as_ref().to_path_buf();
57        let meta = tokio::fs::metadata(&p).await?;
58
59        let file_name = p
60            .file_name()
61            .and_then(|n| n.to_str())
62            .unwrap_or("file.bin")
63            .to_string();
64
65        let file_type = mime_guess_simple(&file_name);
66
67        Ok(Self {
68            id: Uuid::new_v4().to_string(),
69            path: p,
70            file_name,
71            size: meta.len(),
72            file_type,
73            sha256: None,
74        })
75    }
76
77    /// Creates an `OutgoingFile` from a local file path with custom relative advertised file_name.
78    pub async fn from_path_with_name<P: AsRef<Path>>(
79        path: P,
80        file_name: String,
81    ) -> Result<Self, ClientError> {
82        let p = path.as_ref().to_path_buf();
83        let meta = tokio::fs::metadata(&p).await?;
84        let file_type = mime_guess_simple(&file_name);
85
86        Ok(Self {
87            id: Uuid::new_v4().to_string(),
88            path: p,
89            file_name,
90            size: meta.len(),
91            file_type,
92            sha256: None,
93        })
94    }
95
96    /// Computes and populates the SHA-256 hash of the local file.
97    pub async fn with_computed_sha256(mut self) -> Result<Self, ClientError> {
98        let mut file = File::open(&self.path).await?;
99        let mut hasher = Sha256::new();
100        let mut buffer = vec![0u8; DEFAULT_STREAM_CHUNK_SIZE];
101
102        loop {
103            let n = file.read(&mut buffer).await?;
104            if n == 0 {
105                break;
106            }
107            hasher.update(&buffer[..n]);
108        }
109
110        let hash_bytes = hasher.finalize();
111        self.sha256 = Some(format!("{:x}", hash_bytes));
112        Ok(self)
113    }
114
115    /// Converts this file into a protocol [`FileDto`].
116    pub fn to_dto(&self) -> FileDto {
117        FileDto {
118            id: self.id.clone(),
119            file_name: self.file_name.clone(),
120            size: self.size,
121            file_type: self.file_type.clone(),
122            sha256: self.sha256.clone(),
123            preview: None,
124            metadata: Some(FileMetadataDto {
125                modified: None,
126                accessed: None,
127            }),
128        }
129    }
130}
131
132/// Simple MIME classifier without external dependencies.
133pub fn mime_guess_simple(file_name: &str) -> String {
134    let lower = file_name.to_ascii_lowercase();
135    if lower.ends_with(".png") {
136        "image/png".to_string()
137    } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
138        "image/jpeg".to_string()
139    } else if lower.ends_with(".gif") {
140        "image/gif".to_string()
141    } else if lower.ends_with(".webp") {
142        "image/webp".to_string()
143    } else if lower.ends_with(".mp4") {
144        "video/mp4".to_string()
145    } else if lower.ends_with(".mkv") {
146        "video/x-matroska".to_string()
147    } else if lower.ends_with(".mp3") {
148        "audio/mpeg".to_string()
149    } else if lower.ends_with(".pdf") {
150        "application/pdf".to_string()
151    } else if lower.ends_with(".zip") {
152        "application/zip".to_string()
153    } else if lower.ends_with(".tar") || lower.ends_with(".gz") {
154        "application/gzip".to_string()
155    } else if lower.ends_with(".txt") {
156        "text/plain".to_string()
157    } else if lower.ends_with(".json") {
158        "application/json".to_string()
159    } else {
160        "application/octet-stream".to_string()
161    }
162}
163
164/// Fine-grained transfer progress updates sent during session execution.
165#[derive(Clone, Debug)]
166pub enum TransferProgress {
167    /// Negotiation succeeded and transfer started.
168    Started {
169        session_id: String,
170        accepted_files: Vec<String>,
171        total_bytes: u64,
172    },
173    /// Progress update during upload.
174    FileProgress {
175        session_id: String,
176        file_id: String,
177        file_name: String,
178        file_bytes_sent: u64,
179        file_total_bytes: u64,
180        session_bytes_sent: u64,
181        session_total_bytes: u64,
182        /// Transfer speed in bytes per second.
183        bytes_per_second: u64,
184        /// Estimated remaining duration until completion.
185        eta: Option<Duration>,
186    },
187    /// A single file finished uploading and was accepted by the receiver.
188    FileFinished {
189        session_id: String,
190        file_id: String,
191        file_name: String,
192    },
193    /// A single file upload encountered an error.
194    FileFailed {
195        session_id: String,
196        file_id: String,
197        file_name: String,
198        error: String,
199    },
200    /// All accepted files transferred successfully.
201    SessionCompleted {
202        session_id: String,
203        total_files: usize,
204        total_bytes: u64,
205        duration: Duration,
206    },
207    /// The transfer session was cancelled or aborted.
208    SessionCancelled {
209        session_id: String,
210        reason: String,
211    },
212}
213
214/// Options configuring the transfer orchestrator.
215#[derive(Clone, Debug)]
216pub struct TransferOptions {
217    /// Maximum number of files to upload in parallel (default: 4).
218    pub max_concurrency: usize,
219    /// Chunk size in bytes when streaming from disk to network (default: 128 KB).
220    pub chunk_size: usize,
221    /// Optional PIN code for recipient authentication.
222    pub pin: Option<String>,
223    /// Optional cancellation token.
224    pub cancel_token: Option<CancellationToken>,
225    /// Interval between progress emissions per file (default: 100ms).
226    pub progress_interval: Duration,
227}
228
229impl Default for TransferOptions {
230    fn default() -> Self {
231        Self {
232            max_concurrency: 4,
233            chunk_size: DEFAULT_STREAM_CHUNK_SIZE,
234            pin: None,
235            cancel_token: None,
236            progress_interval: Duration::from_millis(100),
237        }
238    }
239}
240
241/// Orchestrates an end-to-end file transfer session to a remote peer.
242pub struct TransferOrchestrator {
243    client: LsClient,
244    sender_info: RegisterDtoV2,
245}
246
247impl TransferOrchestrator {
248    /// Creates a new transfer orchestrator.
249    pub fn new(client: LsClient, sender_info: RegisterDtoV2) -> Self {
250        Self {
251            client,
252            sender_info,
253        }
254    }
255
256    /// Executes the transfer session to `base_url` for the provided files.
257    ///
258    /// Emits progress events over `progress_tx` if provided.
259    pub async fn transfer(
260        &self,
261        base_url: &str,
262        files: Vec<OutgoingFile>,
263        options: TransferOptions,
264        progress_tx: Option<mpsc::Sender<TransferProgress>>,
265    ) -> Result<(), ClientError> {
266        if files.is_empty() {
267            return Ok(());
268        }
269
270        let cancel_token = options
271            .cancel_token
272            .unwrap_or_default();
273
274        // 1. Prepare request DTO
275        let mut dler_files = HashMap::new();
276        let mut files_by_id = HashMap::new();
277
278        for file in files {
279            dler_files.insert(file.id.clone(), file.to_dto());
280            files_by_id.insert(file.id.clone(), file);
281        }
282
283        let prep_req = PrepareUploadRequestDtoV2 {
284            info: self.sender_info.clone(),
285            files: dler_files,
286        };
287
288        // 2. Negotiate prepare-upload
289        tokio::select! {
290            _ = cancel_token.cancelled() => {
291                Err(ClientError::SessionRejected)
292            }
293            res = self.client.prepare_upload(base_url, &prep_req, options.pin.as_deref()) => {
294                let prep_resp = res?;
295                self.run_upload_loop(
296                    base_url,
297                    prep_resp.session_id,
298                    prep_resp.files,
299                    files_by_id,
300                    options.max_concurrency,
301                    options.progress_interval,
302                    options.chunk_size,
303                    cancel_token,
304                    progress_tx,
305                ).await
306            }
307        }
308    }
309
310    #[allow(clippy::too_many_arguments)]
311    async fn run_upload_loop(
312        &self,
313        base_url: &str,
314        session_id: String,
315        accepted_tokens: HashMap<String, String>,
316        mut files_by_id: HashMap<String, OutgoingFile>,
317        concurrency: usize,
318        progress_interval: Duration,
319        chunk_size: usize,
320        cancel_token: CancellationToken,
321        progress_tx: Option<mpsc::Sender<TransferProgress>>,
322    ) -> Result<(), ClientError> {
323        let start_time = Instant::now();
324
325        // Calculate total accepted bytes
326        let accepted_file_ids: Vec<String> = accepted_tokens.keys().cloned().collect();
327        let mut session_total_bytes: u64 = 0;
328        for file_id in &accepted_file_ids {
329            if let Some(f) = files_by_id.get(file_id) {
330                session_total_bytes += f.size;
331            }
332        }
333
334        if let Some(ref tx) = progress_tx {
335            let _ = tx
336                .send(TransferProgress::Started {
337                    session_id: session_id.clone(),
338                    accepted_files: accepted_file_ids.clone(),
339                    total_bytes: session_total_bytes,
340                })
341                .await;
342        }
343
344        let session_bytes_counter = Arc::new(AtomicU64::new(0));
345        let semaphore = Arc::new(Semaphore::new(concurrency.max(1)));
346        let mut join_set = tokio::task::JoinSet::new();
347
348        for (file_id, token) in accepted_tokens {
349            let Some(file) = files_by_id.remove(&file_id) else {
350                continue;
351            };
352
353            let client = self.client.clone();
354            let base_url = base_url.to_string();
355            let session_id = session_id.clone();
356            let sem = Arc::clone(&semaphore);
357            let cancel = cancel_token.clone();
358            let tx = progress_tx.clone();
359            let session_bytes = Arc::clone(&session_bytes_counter);
360
361            join_set.spawn(async move {
362                let _permit = sem.acquire().await.unwrap();
363
364                if cancel.is_cancelled() {
365                    return Err(ClientError::SessionRejected);
366                }
367
368                upload_single_file(
369                    client,
370                    base_url,
371                    session_id,
372                    file,
373                    token,
374                    session_bytes,
375                    session_total_bytes,
376                    start_time,
377                    progress_interval,
378                    chunk_size,
379                    cancel,
380                    tx,
381                )
382                .await
383            });
384        }
385
386        let mut first_error: Option<ClientError> = None;
387
388        while let Some(res) = join_set.join_next().await {
389            match res {
390                Ok(Ok(())) => {}
391                Ok(Err(err)) => {
392                    if first_error.is_none() {
393                        first_error = Some(err);
394                        // Cancel other ongoing uploads on first error
395                        cancel_token.cancel();
396                    }
397                }
398                Err(join_err) => {
399                    if first_error.is_none() {
400                        first_error = Some(ClientError::Tls(format!("Task join error: {join_err}")));
401                        cancel_token.cancel();
402                    }
403                }
404            }
405        }
406
407        if let Some(err) = first_error {
408            // Notify peer of cancellation
409            let _ = self.client.cancel_session(base_url, &session_id).await;
410
411            if let Some(ref tx) = progress_tx {
412                let _ = tx
413                    .send(TransferProgress::SessionCancelled {
414                        session_id: session_id.clone(),
415                        reason: err.to_string(),
416                    })
417                    .await;
418            }
419            return Err(err);
420        }
421
422        if cancel_token.is_cancelled() {
423            let _ = self.client.cancel_session(base_url, &session_id).await;
424            if let Some(ref tx) = progress_tx {
425                let _ = tx
426                    .send(TransferProgress::SessionCancelled {
427                        session_id: session_id.clone(),
428                        reason: "Cancelled by user".to_string(),
429                    })
430                    .await;
431            }
432            return Err(ClientError::SessionRejected);
433        }
434
435        let duration = start_time.elapsed();
436        if let Some(ref tx) = progress_tx {
437            let _ = tx
438                .send(TransferProgress::SessionCompleted {
439                    session_id,
440                    total_files: accepted_file_ids.len(),
441                    total_bytes: session_total_bytes,
442                    duration,
443                })
444                .await;
445        }
446
447        Ok(())
448    }
449}
450
451/// Streams a single file to the receiver, calculating speed and dispatching progress events.
452#[allow(clippy::too_many_arguments)]
453async fn upload_single_file(
454    client: LsClient,
455    base_url: String,
456    session_id: String,
457    file: OutgoingFile,
458    token: String,
459    session_bytes_counter: Arc<AtomicU64>,
460    session_total_bytes: u64,
461    session_start_time: Instant,
462    progress_interval: Duration,
463    chunk_size: usize,
464    cancel_token: CancellationToken,
465    progress_tx: Option<mpsc::Sender<TransferProgress>>,
466) -> Result<(), ClientError> {
467    let file_id = file.id.clone();
468    let file_name = file.file_name.clone();
469    let file_size = file.size;
470
471    let fs_file = File::open(&file.path).await.map_err(ClientError::Io)?;
472    let reader_stream = ReaderStream::with_capacity(fs_file, chunk_size);
473
474    let progress_stream = ProgressStream::new(
475        reader_stream,
476        session_id.clone(),
477        file_id.clone(),
478        file_name.clone(),
479        file_size,
480        session_bytes_counter,
481        session_total_bytes,
482        session_start_time,
483        progress_interval,
484        cancel_token.clone(),
485        progress_tx.clone(),
486    );
487
488    let body = Body::wrap_stream(progress_stream);
489
490    let upload_res = client
491        .upload_file(
492            &base_url,
493            &session_id,
494            &file_id,
495            &token,
496            body,
497            file_size,
498        )
499        .await;
500
501    match upload_res {
502        Ok(()) => {
503            if let Some(ref tx) = progress_tx {
504                let _ = tx
505                    .send(TransferProgress::FileFinished {
506                        session_id,
507                        file_id,
508                        file_name,
509                    })
510                    .await;
511            }
512            Ok(())
513        }
514        Err(err) => {
515            if let Some(ref tx) = progress_tx {
516                let _ = tx
517                    .send(TransferProgress::FileFailed {
518                        session_id,
519                        file_id,
520                        file_name,
521                        error: err.to_string(),
522                    })
523                    .await;
524            }
525            Err(err)
526        }
527    }
528}
529
530/// A wrapper stream that counts uploaded bytes and emits progress reports at fixed intervals.
531struct ProgressStream<S> {
532    inner: S,
533    session_id: String,
534    file_id: String,
535    file_name: String,
536    file_total: u64,
537    file_sent: u64,
538    session_bytes: Arc<AtomicU64>,
539    session_total: u64,
540    session_start: Instant,
541    last_progress_emit: Instant,
542    progress_interval: Duration,
543    cancel: CancellationToken,
544    progress_tx: Option<mpsc::Sender<TransferProgress>>,
545}
546
547impl<S> ProgressStream<S> {
548    #[allow(clippy::too_many_arguments)]
549    fn new(
550        inner: S,
551        session_id: String,
552        file_id: String,
553        file_name: String,
554        file_total: u64,
555        session_bytes: Arc<AtomicU64>,
556        session_total: u64,
557        session_start: Instant,
558        progress_interval: Duration,
559        cancel: CancellationToken,
560        progress_tx: Option<mpsc::Sender<TransferProgress>>,
561    ) -> Self {
562        Self {
563            inner,
564            session_id,
565            file_id,
566            file_name,
567            file_total,
568            file_sent: 0,
569            session_bytes,
570            session_total,
571            session_start,
572            last_progress_emit: Instant::now(),
573            progress_interval,
574            cancel,
575            progress_tx,
576        }
577    }
578}
579
580impl<S, B, E> Stream for ProgressStream<S>
581where
582    S: Stream<Item = Result<B, E>> + Unpin,
583    B: bytes::Buf,
584    E: Into<Box<dyn std::error::Error + Send + Sync>>,
585{
586    type Item = Result<B, E>;
587
588    fn poll_next(
589        mut self: std::pin::Pin<&mut Self>,
590        cx: &mut std::task::Context<'_>,
591    ) -> std::task::Poll<Option<Self::Item>> {
592        if self.cancel.is_cancelled() {
593            return std::task::Poll::Ready(None);
594        }
595
596        let this = &mut *self;
597        match std::pin::Pin::new(&mut this.inner).poll_next(cx) {
598            std::task::Poll::Ready(Some(Ok(chunk))) => {
599                let chunk_len = chunk.remaining() as u64;
600                this.file_sent += chunk_len;
601                let cur_session_sent =
602                    this.session_bytes.fetch_add(chunk_len, Ordering::Relaxed) + chunk_len;
603
604                let now = Instant::now();
605                if now.duration_since(this.last_progress_emit) >= this.progress_interval {
606                    this.last_progress_emit = now;
607
608                    let elapsed = now.duration_since(this.session_start).as_secs_f64();
609                    let bytes_per_second = if elapsed > 0.0 {
610                        (cur_session_sent as f64 / elapsed) as u64
611                    } else {
612                        0
613                    };
614
615                    let eta = if bytes_per_second > 0 && cur_session_sent < this.session_total {
616                        let remaining_bytes = this.session_total - cur_session_sent;
617                        Some(Duration::from_secs_f64(
618                            remaining_bytes as f64 / bytes_per_second as f64,
619                        ))
620                    } else {
621                        None
622                    };
623
624                    if let Some(ref tx) = this.progress_tx {
625                        let _ = tx.try_send(TransferProgress::FileProgress {
626                            session_id: this.session_id.clone(),
627                            file_id: this.file_id.clone(),
628                            file_name: this.file_name.clone(),
629                            file_bytes_sent: this.file_sent,
630                            file_total_bytes: this.file_total,
631                            session_bytes_sent: cur_session_sent,
632                            session_total_bytes: this.session_total,
633                            bytes_per_second,
634                            eta,
635                        });
636                    }
637                }
638
639                std::task::Poll::Ready(Some(Ok(chunk)))
640            }
641            other => other,
642        }
643    }
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    #[test]
651    fn test_mime_guess_simple() {
652        assert_eq!(mime_guess_simple("photo.jpg"), "image/jpeg");
653        assert_eq!(mime_guess_simple("photo.JPEG"), "image/jpeg");
654        assert_eq!(mime_guess_simple("graphic.PNG"), "image/png");
655        assert_eq!(mime_guess_simple("doc.pdf"), "application/pdf");
656        assert_eq!(mime_guess_simple("notes.txt"), "text/plain");
657        assert_eq!(mime_guess_simple("binary.bin"), "application/octet-stream");
658    }
659
660    #[tokio::test]
661    async fn test_outgoing_file_from_path_and_sha256() {
662        let temp_dir = std::env::temp_dir().join(format!("lanflare_outgoing_test_{}", Uuid::new_v4()));
663        tokio::fs::create_dir_all(&temp_dir).await.unwrap();
664        let file_path = temp_dir.join("sample.txt");
665        tokio::fs::write(&file_path, b"Hello LanFlare!").await.unwrap();
666
667        let outgoing = OutgoingFile::from_path(&file_path)
668            .await
669            .expect("Should construct from path");
670        assert_eq!(outgoing.file_name, "sample.txt");
671        assert_eq!(outgoing.size, 15);
672        assert_eq!(outgoing.file_type, "text/plain");
673        assert!(outgoing.sha256.is_none());
674
675        let outgoing_with_hash = outgoing
676            .with_computed_sha256()
677            .await
678            .expect("Should compute hash");
679        assert!(outgoing_with_hash.sha256.is_some());
680        let dto = outgoing_with_hash.to_dto();
681        assert_eq!(dto.file_name, "sample.txt");
682        assert_eq!(dto.size, 15);
683        assert_eq!(dto.sha256, outgoing_with_hash.sha256);
684
685        let _ = tokio::fs::remove_dir_all(&temp_dir).await;
686    }
687}
688