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