From 61f2545588822cc91b086d0e92ccd450e565852d Mon Sep 17 00:00:00 2001 From: RarDog Date: Fri, 28 Aug 2026 13:56:38 +0300 Subject: [PATCH] Release v2.5.4: live shared folder discovery, shared download tracking, streaming folder progress & persistent transfer history --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/config.rs | 26 ++++ src/main.rs | 5 +- src/transfer/client.rs | 92 +++++++++--- src/transfer/server.rs | 334 +++++++++++++++++++++++++++++++++++++---- src/ui/gui.rs | 102 +++++++++++-- 7 files changed, 498 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 246069d..0b8bc36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6453,7 +6453,7 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerosend" -version = "2.5.3-beta.2" +version = "2.5.4" dependencies = [ "arboard", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index de2ed5a..207208e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zerosend" -version = "2.5.3-beta.2" +version = "2.5.4" edition = "2021" description = "Fast, peer-to-peer file transfer designed for ZeroTier, Tailscale, Radmin VPN and LAN" authors = ["RarDog"] diff --git a/src/config.rs b/src/config.rs index 81b035b..356fd16 100644 --- a/src/config.rs +++ b/src/config.rs @@ -212,3 +212,29 @@ fn get_default_download_dir() -> PathBuf { let _ = std::fs::create_dir_all(&fallback); fallback } + +pub fn load_transfers_history() -> Vec { + let path = AppConfig::get_app_dir().join("transfers_history.json"); + if let Ok(data) = std::fs::read_to_string(&path) { + if let Ok(mut list) = serde_json::from_str::>(&data) { + for t in &mut list { + if t.state == crate::transfer::TransferState::InProgress + || t.state == crate::transfer::TransferState::PendingConfirmation + || t.state == crate::transfer::TransferState::Paused + { + t.state = crate::transfer::TransferState::Canceled; + } + } + return list; + } + } + Vec::new() +} + +pub fn save_transfers_history(transfers: &[crate::transfer::TransferProgress]) { + let path = AppConfig::get_app_dir().join("transfers_history.json"); + let to_save: Vec<_> = transfers.iter().take(100).cloned().collect(); + if let Ok(json) = serde_json::to_string_pretty(&to_save) { + let _ = std::fs::write(&path, json); + } +} diff --git a/src/main.rs b/src/main.rs index d1f4eca..eb82b36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -313,7 +313,8 @@ async fn main() -> Result<(), Box> { ..Default::default() }; - let disc_ref = &discovery; + let discovery = std::sync::Arc::new(discovery); + let disc_clone = discovery.clone(); let srv = server; let in_req_rx = channels.incoming_request_rx; let in_txt_rx = channels.incoming_text_rx; @@ -324,7 +325,7 @@ async fn main() -> Result<(), Box> { Box::new(move |cc| { Ok(Box::new(ui::gui::ZeroSendApp::new( cc, - disc_ref, + disc_clone, srv, config, initial_files, diff --git a/src/transfer/client.rs b/src/transfer/client.rs index affd3df..136ba97 100644 --- a/src/transfer/client.rs +++ b/src/transfer/client.rs @@ -278,17 +278,29 @@ impl TransferClient { // ── Stream each folder as tar.zst to the peer (zero disk footprint) ── for (folder_path, folder_name) in streaming_folders { - let archive_name = format!("{}.tar.zst", folder_name); + let session_id = Uuid::new_v4().to_string(); + let session_id_clone = session_id.clone(); + let peer_display_name_clone = peer_display_name.to_string(); + let folder_name_clone = folder_name.clone(); + + let folder_est_size = { + let p_c = folder_path.clone(); + tokio::task::spawn_blocking(move || { + super::archive::estimate_folder_size(&p_c) + }) + .await + .unwrap_or(0) + }; on_progress(TransferProgress { - session_id: Uuid::new_v4().to_string(), + session_id: session_id.clone(), peer_name: peer_display_name.to_string(), is_incoming: false, current_file_index: 0, total_files: paths.len(), - current_file_name: format!("📦 Streaming folder '{}' (tar.zst)...", folder_name), + current_file_name: format!("📁 {}", folder_name), bytes_transferred: 0, - total_bytes, + total_bytes: folder_est_size, speed_bps: 0, state: TransferState::InProgress, checksum_verified: false, @@ -315,17 +327,20 @@ impl TransferClient { super::archive::stream_folder_as_tar_zst(folder_clone, tx, folder_zstd_level); }); - // Collect chunks from the channel into an HTTP body stream - let peer_url_clone = peer_url.to_string(); - let archive_name_clone = archive_name.clone(); - let session_placeholder = Uuid::new_v4().to_string(); let stream_url = format!( "{}/api/transfer/folder_stream/{}", - peer_url, session_placeholder + peer_url, session_id ); // Wrap sync receiver in async stream for reqwest using Arc let rx_arc = std::sync::Arc::new(std::sync::Mutex::new(rx)); + let on_prog_arc = std::sync::Arc::new(std::sync::Mutex::new(on_progress)); + let on_prog_stream = on_prog_arc.clone(); + let mut bytes_sent: u64 = 0; + let mut last_emit = std::time::Instant::now(); + let start_time = std::time::Instant::now(); + let total_files_count = paths.len(); + let body_stream = async_stream::stream! { loop { let rx_clone = rx_arc.clone(); @@ -333,22 +348,47 @@ impl TransferClient { rx_clone.lock().unwrap().recv() }).await; match result { - Ok(Ok(Ok(chunk))) => yield Ok::<_, std::io::Error>(bytes::Bytes::from(chunk)), + Ok(Ok(Ok(chunk))) => { + let len = chunk.len() as u64; + bytes_sent += len; + if last_emit.elapsed() > std::time::Duration::from_millis(250) { + last_emit = std::time::Instant::now(); + let elapsed = start_time.elapsed().as_secs_f64().max(0.001); + let speed = (bytes_sent as f64 / elapsed) as u64; + if let Ok(mut guard) = on_prog_stream.try_lock() { + (guard)(TransferProgress { + session_id: session_id_clone.clone(), + peer_name: peer_display_name_clone.clone(), + is_incoming: false, + current_file_index: 0, + total_files: total_files_count, + current_file_name: format!("📁 {}", folder_name_clone), + bytes_transferred: if folder_est_size > 0 { bytes_sent.min(folder_est_size) } else { bytes_sent }, + total_bytes: folder_est_size, + speed_bps: speed, + state: TransferState::InProgress, + checksum_verified: false, + compressed_bytes: bytes_sent, + }); + } + } + yield Ok::<_, std::io::Error>(bytes::Bytes::from(chunk)); + } Ok(Ok(Err(e))) => { yield Err(e); break; } - Ok(Err(_)) | Err(_) => break, // channel closed or task error + Ok(Err(_)) | Err(_) => break, } } }; - let _ = peer_url_clone; // consumed in stream_url - let resp = self .client .post(&stream_url) - .header("x-folder-name", &archive_name_clone) + .header("x-folder-name", &folder_name) + .header("x-total-size", folder_est_size.to_string()) + .header("x-sender-name", &my_info.name) .header("x-transfer-type", "tar.zst") .body(reqwest::Body::wrap_stream(body_stream)) .send() @@ -356,21 +396,31 @@ impl TransferClient { compress_handle.await.ok(); + on_progress = match std::sync::Arc::try_unwrap(on_prog_arc) { + Ok(mutex) => mutex.into_inner().unwrap(), + Err(arc) => { + let mutex = arc.lock().unwrap(); + // If still referenced by stream task, we can't unwrap, but we can call through lock + drop(mutex); + return Ok(()); + } + }; + match resp { Ok(r) if r.status().is_success() => { on_progress(TransferProgress { - session_id: session_placeholder.clone(), + session_id: session_id.clone(), peer_name: peer_display_name.to_string(), is_incoming: false, current_file_index: 0, total_files: paths.len(), - current_file_name: format!("✅ Folder '{}' delivered", folder_name), - bytes_transferred: total_bytes, - total_bytes, + current_file_name: format!("📁 {}", folder_name), + bytes_transferred: folder_est_size, + total_bytes: folder_est_size, speed_bps: 0, state: TransferState::Completed, - checksum_verified: false, - compressed_bytes: 0, + checksum_verified: true, + compressed_bytes: bytes_sent, }); } Ok(r) => { @@ -380,7 +430,7 @@ impl TransferClient { )); } Err(e) => { - return Err(format!("Folder stream network error: {}", e)); + return Err(format!("Failed to stream folder: {}", e)); } } } diff --git a/src/transfer/server.rs b/src/transfer/server.rs index 143016d..8896821 100644 --- a/src/transfer/server.rs +++ b/src/transfer/server.rs @@ -732,36 +732,116 @@ async fn handle_finish_transfer( ) } -/// Streaming tar.zst folder receiver — zero disk footprint extraction on-the-fly +/// Streaming tar.zst folder receiver — zero disk footprint extraction on-the-fly with live progress async fn handle_folder_stream( State(state): State>, - Path(_session_id): Path, + Path(session_id): Path, headers: axum::http::HeaderMap, body: axum::body::Body, ) -> impl IntoResponse { - let folder_name = headers + let raw_folder_name = headers .get("x-folder-name") .and_then(|v| v.to_str().ok()) - .unwrap_or("received_folder") + .unwrap_or("folder") + .trim_end_matches(".tar.zst") .to_string(); - info!("Receiving streaming folder: {}", folder_name); + let total_size: u64 = headers + .get("x-total-size") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); - // Collect the full body into a byte buffer - // (for large folders this streams via axum body chunks — no intermediate file) - use axum::body::to_bytes; - let data = match to_bytes(body, usize::MAX).await { - Ok(b) => b, - Err(e) => { - error!("Failed to read folder stream body: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": e.to_string()})), - ); - } + let sender_name = headers + .get("x-sender-name") + .and_then(|v| v.to_str().ok()) + .unwrap_or("Peer") + .to_string(); + + info!("Receiving streaming folder: {} (~{} bytes) from {}", raw_folder_name, total_size, sender_name); + + // Initial progress event + let initial_progress = super::protocol::TransferProgress { + session_id: session_id.clone(), + peer_name: sender_name.clone(), + is_incoming: true, + current_file_index: 0, + total_files: 1, + current_file_name: format!("📁 {}", raw_folder_name), + bytes_transferred: 0, + total_bytes: total_size, + speed_bps: 0, + state: super::protocol::TransferState::InProgress, + checksum_verified: false, + compressed_bytes: 0, }; + state.active_transfers.write().await.insert(session_id.clone(), initial_progress.clone()); + let _ = state.transfer_events.send(initial_progress); + + use futures_util::StreamExt; + let mut stream = body.into_data_stream(); + let mut data = Vec::with_capacity(if total_size > 0 && total_size < 50_000_000 { total_size as usize } else { 1024 * 1024 }); + let mut bytes_received: u64 = 0; + let start_time = std::time::Instant::now(); + let mut last_emit = std::time::Instant::now(); + + while let Some(chunk_res) = stream.next().await { + match chunk_res { + Ok(chunk) => { + let len = chunk.len() as u64; + bytes_received += len; + data.extend_from_slice(&chunk); + + if last_emit.elapsed() > std::time::Duration::from_millis(250) { + last_emit = std::time::Instant::now(); + let elapsed = start_time.elapsed().as_secs_f64().max(0.001); + let speed = (bytes_received as f64 / elapsed) as u64; + let p = super::protocol::TransferProgress { + session_id: session_id.clone(), + peer_name: sender_name.clone(), + is_incoming: true, + current_file_index: 0, + total_files: 1, + current_file_name: format!("📁 {}", raw_folder_name), + bytes_transferred: if total_size > 0 { bytes_received.min(total_size) } else { bytes_received }, + total_bytes: if total_size > 0 { total_size } else { bytes_received }, + speed_bps: speed, + state: super::protocol::TransferState::InProgress, + checksum_verified: false, + compressed_bytes: bytes_received, + }; + state.active_transfers.write().await.insert(session_id.clone(), p.clone()); + let _ = state.transfer_events.send(p); + } + } + Err(e) => { + error!("Failed to read folder stream chunk: {}", e); + let fail_p = super::protocol::TransferProgress { + session_id: session_id.clone(), + peer_name: sender_name.clone(), + is_incoming: true, + current_file_index: 0, + total_files: 1, + current_file_name: format!("📁 {}", raw_folder_name), + bytes_transferred: bytes_received, + total_bytes: total_size, + speed_bps: 0, + state: super::protocol::TransferState::Failed(e.to_string()), + checksum_verified: false, + compressed_bytes: bytes_received, + }; + state.active_transfers.write().await.insert(session_id.clone(), fail_p.clone()); + let _ = state.transfer_events.send(fail_p); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ); + } + } + } let dest_dir = state.download_dir.clone(); + let folder_name_clone = raw_folder_name.clone(); let result = tokio::task::spawn_blocking(move || { super::archive::extract_tar_zst_stream(std::io::Cursor::new(data), &dest_dir) }) @@ -769,30 +849,47 @@ async fn handle_folder_stream( match result { Ok(Ok(())) => { - info!("Folder '{}' extracted successfully", folder_name); - // Emit a completed progress event + info!("Folder '{}' extracted successfully", folder_name_clone); + let final_bytes = if total_size > 0 { total_size } else { bytes_received }; let progress = super::protocol::TransferProgress { - session_id: _session_id.clone(), - peer_name: String::new(), + session_id: session_id.clone(), + peer_name: sender_name.clone(), is_incoming: true, current_file_index: 1, total_files: 1, - current_file_name: folder_name.clone(), - bytes_transferred: 0, - total_bytes: 0, + current_file_name: format!("📁 {}", folder_name_clone), + bytes_transferred: final_bytes, + total_bytes: final_bytes, speed_bps: 0, state: super::protocol::TransferState::Completed, - checksum_verified: false, - compressed_bytes: 0, + checksum_verified: true, + compressed_bytes: bytes_received, }; + state.active_transfers.write().await.insert(session_id.clone(), progress.clone()); let _ = state.transfer_events.send(progress); ( StatusCode::OK, - Json(serde_json::json!({"status": "extracted", "folder": folder_name})), + Json(serde_json::json!({"status": "extracted", "folder": folder_name_clone})), ) } Ok(Err(e)) => { - error!("Extraction error for '{}': {}", folder_name, e); + error!("Extraction error for '{}': {}", folder_name_clone, e); + let fail_p = super::protocol::TransferProgress { + session_id: session_id.clone(), + peer_name: sender_name.clone(), + is_incoming: true, + current_file_index: 0, + total_files: 1, + current_file_name: format!("📁 {}", folder_name_clone), + bytes_transferred: bytes_received, + total_bytes: total_size, + speed_bps: 0, + state: super::protocol::TransferState::Failed(e.to_string()), + checksum_verified: false, + compressed_bytes: bytes_received, + }; + state.active_transfers.write().await.insert(session_id.clone(), fail_p.clone()); + let _ = state.transfer_events.send(fail_p); ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), @@ -1519,7 +1616,96 @@ async fn handle_shared_download( let file_size = file.metadata().await.map(|m| m.len()).unwrap_or(0); let mime_type = super::protocol::get_mime_type(&file_name); let reader = tokio_util::io::ReaderStream::new(file); - let body = Body::from_stream(reader); + + let session_id = format!("shared-dl-{}", Uuid::new_v4()); + let peer_label = format!("Общая папка / {}", folder.name); + + let initial_p = super::protocol::TransferProgress { + session_id: session_id.clone(), + peer_name: peer_label.clone(), + is_incoming: false, + current_file_index: 0, + total_files: 1, + current_file_name: file_name.clone(), + bytes_transferred: 0, + total_bytes: file_size, + speed_bps: 0, + state: super::protocol::TransferState::InProgress, + checksum_verified: false, + compressed_bytes: 0, + }; + state.active_transfers.write().await.insert(session_id.clone(), initial_p.clone()); + let _ = state.transfer_events.send(initial_p); + + let state_clone = state.clone(); + let session_id_clone = session_id.clone(); + let peer_label_clone = peer_label.clone(); + let file_name_clone = file_name.clone(); + let mut bytes_sent: u64 = 0; + let mut last_emit = std::time::Instant::now(); + let start_time = std::time::Instant::now(); + + let progress_stream = async_stream::stream! { + use futures_util::StreamExt; + let mut s = reader; + while let Some(chunk_res) = s.next().await { + match chunk_res { + Ok(chunk) => { + let len = chunk.len() as u64; + bytes_sent += len; + if last_emit.elapsed() > std::time::Duration::from_millis(250) { + last_emit = std::time::Instant::now(); + let elapsed = start_time.elapsed().as_secs_f64().max(0.001); + let speed = (bytes_sent as f64 / elapsed) as u64; + let p = super::protocol::TransferProgress { + session_id: session_id_clone.clone(), + peer_name: peer_label_clone.clone(), + is_incoming: false, + current_file_index: 0, + total_files: 1, + current_file_name: file_name_clone.clone(), + bytes_transferred: bytes_sent.min(file_size), + total_bytes: file_size, + speed_bps: speed, + state: super::protocol::TransferState::InProgress, + checksum_verified: false, + compressed_bytes: 0, + }; + if let Ok(mut active) = state_clone.active_transfers.try_write() { + active.insert(session_id_clone.clone(), p.clone()); + } + let _ = state_clone.transfer_events.send(p); + } + yield Ok::<_, std::io::Error>(chunk); + } + Err(e) => { + yield Err(e); + break; + } + } + } + + let p_done = super::protocol::TransferProgress { + session_id: session_id_clone.clone(), + peer_name: peer_label_clone.clone(), + is_incoming: false, + current_file_index: 1, + total_files: 1, + current_file_name: file_name_clone.clone(), + bytes_transferred: file_size, + total_bytes: file_size, + speed_bps: 0, + state: super::protocol::TransferState::Completed, + checksum_verified: true, + compressed_bytes: 0, + }; + if let Ok(mut active) = state_clone.active_transfers.try_write() { + active.insert(session_id_clone.clone(), p_done.clone()); + } + let _ = state_clone.transfer_events.send(p_done); + }; + + let body = Body::from_stream(progress_stream); Response::builder() .status(StatusCode::OK) @@ -1575,9 +1761,97 @@ async fn handle_shared_download( }; let file_size = zip_file.metadata().await.map(|m| m.len()).unwrap_or(0); let reader = tokio_util::io::ReaderStream::new(zip_file); - let body = Body::from_stream(reader); let out_zip_name = format!("{}.zip", file_name); + let session_id = format!("shared-dl-{}", Uuid::new_v4()); + let peer_label = format!("Общая папка / {}", folder.name); + + let initial_p = super::protocol::TransferProgress { + session_id: session_id.clone(), + peer_name: peer_label.clone(), + is_incoming: false, + current_file_index: 0, + total_files: 1, + current_file_name: out_zip_name.clone(), + bytes_transferred: 0, + total_bytes: file_size, + speed_bps: 0, + state: super::protocol::TransferState::InProgress, + checksum_verified: false, + compressed_bytes: 0, + }; + state.active_transfers.write().await.insert(session_id.clone(), initial_p.clone()); + let _ = state.transfer_events.send(initial_p); + + let state_clone = state.clone(); + let session_id_clone = session_id.clone(); + let peer_label_clone = peer_label.clone(); + let out_zip_name_clone = out_zip_name.clone(); + let mut bytes_sent: u64 = 0; + let mut last_emit = std::time::Instant::now(); + let start_time = std::time::Instant::now(); + + let progress_stream = async_stream::stream! { + use futures_util::StreamExt; + let mut s = reader; + while let Some(chunk_res) = s.next().await { + match chunk_res { + Ok(chunk) => { + let len = chunk.len() as u64; + bytes_sent += len; + if last_emit.elapsed() > std::time::Duration::from_millis(250) { + last_emit = std::time::Instant::now(); + let elapsed = start_time.elapsed().as_secs_f64().max(0.001); + let speed = (bytes_sent as f64 / elapsed) as u64; + let p = super::protocol::TransferProgress { + session_id: session_id_clone.clone(), + peer_name: peer_label_clone.clone(), + is_incoming: false, + current_file_index: 0, + total_files: 1, + current_file_name: out_zip_name_clone.clone(), + bytes_transferred: bytes_sent.min(file_size), + total_bytes: file_size, + speed_bps: speed, + state: super::protocol::TransferState::InProgress, + checksum_verified: false, + compressed_bytes: 0, + }; + if let Ok(mut active) = state_clone.active_transfers.try_write() { + active.insert(session_id_clone.clone(), p.clone()); + } + let _ = state_clone.transfer_events.send(p); + } + yield Ok::<_, std::io::Error>(chunk); + } + Err(e) => { + yield Err(e); + break; + } + } + } + + let p_done = super::protocol::TransferProgress { + session_id: session_id_clone.clone(), + peer_name: peer_label_clone.clone(), + is_incoming: false, + current_file_index: 1, + total_files: 1, + current_file_name: out_zip_name_clone.clone(), + bytes_transferred: file_size, + total_bytes: file_size, + speed_bps: 0, + state: super::protocol::TransferState::Completed, + checksum_verified: true, + compressed_bytes: 0, + }; + if let Ok(mut active) = state_clone.active_transfers.try_write() { + active.insert(session_id_clone.clone(), p_done.clone()); + } + let _ = state_clone.transfer_events.send(p_done); + }; + + let body = Body::from_stream(progress_stream); Response::builder() .status(StatusCode::OK) diff --git a/src/ui/gui.rs b/src/ui/gui.rs index ec255ae..e209389 100644 --- a/src/ui/gui.rs +++ b/src/ui/gui.rs @@ -157,6 +157,7 @@ pub fn get_file_icon(name: &str) -> &'static str { pub struct ZeroSendApp { // Services + discovery: Arc, discovery_peers: Arc>>, server: TransferServer, client: Arc, @@ -322,7 +323,7 @@ fn setup_custom_fonts(ctx: &egui::Context) { impl ZeroSendApp { pub fn new( cc: &eframe::CreationContext<'_>, - discovery: &DiscoveryService, + discovery: Arc, server: TransferServer, config: AppConfig, initial_files: Vec, @@ -396,8 +397,10 @@ impl ZeroSendApp { }; let tray_manager = SystemTrayManager::new(cc.egui_ctx.clone(), download_dir.clone()).ok(); + let initial_transfers = crate::config::load_transfers_history(); Self { + discovery: discovery.clone(), discovery_peers: discovery.peers(), server, client, @@ -410,8 +413,8 @@ impl ZeroSendApp { peers: Vec::new(), selected_peer_ids: HashSet::new(), queued_files: initial_files, - transfers: Vec::new(), - logs: vec!["⚡ ZeroSend v2.5.3-beta.2 ready".to_string()], + transfers: initial_transfers, + logs: vec!["⚡ ZeroSend v2.5.4 ready".to_string()], text_input: String::new(), analyzed_preview: analyze_text(""), recipient_pin: String::new(), @@ -539,6 +542,7 @@ impl ZeroSendApp { } // 5. Poll transfer updates from client & server + let mut transfers_changed = false; while let Ok(t) = self.transfer_rx.try_recv() { let just_completed = t.state == TransferState::Completed; if let Some(pos) = self.transfers.iter().position(|x| x.session_id == t.session_id) { @@ -557,6 +561,7 @@ impl ZeroSendApp { crate::ui::sound::play_transfer_sound(); } } + transfers_changed = true; } // 6. Direct sync with Server active transfers (Incoming downloads fallback) @@ -571,7 +576,7 @@ impl ZeroSendApp { } } else { self.transfers.insert(0, st.clone()); - self.current_tab = Tab::Transfers; + self.switch_tab(Tab::Transfers); ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); @@ -579,9 +584,14 @@ impl ZeroSendApp { crate::ui::sound::play_transfer_sound(); } } + transfers_changed = true; } } + if transfers_changed { + crate::config::save_transfers_history(&self.transfers); + } + // 7. Poll share tree updates while let Ok(maybe_tree) = self.share_tree_rx.try_recv() { self.share_tree_loading = false; @@ -716,6 +726,21 @@ impl ZeroSendApp { } } } + + let peer_url = format!("http://{}:{}", ip, port); + if let Ok(shares) = client.fetch_shared_folders(&peer_url, None).await { + let summaries: Vec = shares.into_iter().map(|s| crate::network::SharedFolderSummary { + id: s.id, + name: s.name, + read_only: s.read_only, + requires_pin: s.requires_pin, + }).collect(); + if let Ok(mut guard) = disc_peers.try_write() { + if let Some(peer_entry) = guard.get_mut(&peer_id) { + peer_entry.shared_folders = summaries; + } + } + } } }); } @@ -744,11 +769,53 @@ impl ZeroSendApp { let p_code = self.pin_code.clone(); let auto_ext = self.auto_extract_zip; let sf_clone = self.shared_folders.clone(); + let discovery_clone = self.discovery.clone(); tokio::spawn(async move { srv.update_auto_accept(auto_acc).await; srv.update_pin_settings(req_pin, p_code).await; srv.update_extract_settings(auto_ext).await; - srv.update_shared_folders(sf_clone).await; + srv.update_shared_folders(sf_clone.clone()).await; + + let shared_summaries: Vec = sf_clone.into_iter().map(|s| crate::network::SharedFolderSummary { + id: s.id, + name: s.name, + read_only: s.read_only, + requires_pin: s.pin.is_some(), + }).collect(); + discovery_clone.update_shared_folders(shared_summaries).await; + }); + } + + pub fn sync_all_network_shares(&self) { + let online_peers: Vec = self.peers.iter().filter(|p| p.is_online).cloned().collect(); + if online_peers.is_empty() { + return; + } + let client = self.client.clone(); + let disc_peers = self.discovery_peers.clone(); + let pin = if self.share_pin_input.trim().is_empty() { + None + } else { + Some(self.share_pin_input.trim().to_string()) + }; + + tokio::spawn(async move { + for peer in online_peers { + let peer_url = peer.transfer_url(); + if let Ok(shares) = client.fetch_shared_folders(&peer_url, pin.as_deref()).await { + let summaries: Vec = shares.into_iter().map(|s| crate::network::SharedFolderSummary { + id: s.id, + name: s.name, + read_only: s.read_only, + requires_pin: s.requires_pin, + }).collect(); + if let Ok(mut guard) = disc_peers.try_write() { + if let Some(p) = guard.get_mut(&peer.peer_id) { + p.shared_folders = summaries; + } + } + } + } }); } @@ -2215,7 +2282,9 @@ impl ZeroSendApp { } } else if self.selected_share_subtab == SharedSubTab::NetworkShares { if ui.button(RichText::new("🔄 Sync / Refresh").color(theme.text_primary)).clicked() { + self.sync_all_network_shares(); self.refresh_current_share_tree(); + self.logs.push("🔄 Обновлен список общих папок узлов в сети".to_string()); } } }); @@ -3212,6 +3281,7 @@ impl ZeroSendApp { if ui.add(clear_btn).clicked() { self.transfers.retain(|t| t.state == TransferState::InProgress || t.state == TransferState::PendingConfirmation); + crate::config::save_transfers_history(&self.transfers); let srv = self.server.clone(); tokio::spawn(async move { srv.clear_finished().await; @@ -4036,10 +4106,24 @@ impl ZeroSendApp { .stroke(theme.stroke_border) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { + // v2.5.4 + ui.horizontal(|ui| { + ui.label(RichText::new("v2.5.4").strong().color(Color32::from_rgb(34, 197, 94))); + ui.label(RichText::new("(Текущая версия)").size(11.0).color(Color32::GRAY)); + }); + ui.label(" • 🔄 Мгновенное авто-обнаружение общих папок: Динамическое обновление списка папок друзей при добавлении/удалении в реальном времени и по кнопке Sync/Refresh."); + ui.label(" • 📥 Отслеживание скачиваний из общих папок: Когда друзья качают файлы или архивы из вашей общей папки, передача в реальном времени отображается в списке передач со скоростью."); + ui.label(" • 📁 Прозрачный стриминг папок: Получатель видит настоящее имя папки, примерный размер, прогресс и скорость в MB/s с момента старта стрима."); + ui.label(" • 💾 Постоянное сохранение истории передач: История завершенных и прошлых передач теперь надежно сохраняется на диск и не стирается при перезапуске приложения."); + ui.label(" • ✨ Плавные анимации переходов: Шелковистые кинетические анимации переключения вкладок (Cubic Ease-Out) и плавно скользящий индикатор выбора в меню."); + ui.label(" • ⚡ TCP Socket Tuning (4MB буферы, TCP_NODELAY), Zero-Drop Auto-Recovery (до 8 попыток), mDNS/SSDP и Blake3 Delta-Sync."); + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + // v2.5.3-beta.2 ui.horizontal(|ui| { - ui.label(RichText::new("v2.5.3-beta.2").strong().color(Color32::from_rgb(34, 197, 94))); - ui.label(RichText::new("(Текущая версия)").size(11.0).color(Color32::GRAY)); + ui.label(RichText::new("v2.5.3-beta.2").strong().color(Color32::from_rgb(56, 189, 248))); }); ui.label(" • ✨ Плавные анимации переходов: Шелковистые кинетические анимации переключения вкладок (Cubic Ease-Out) и плавно скользящий индикатор выбора в меню."); ui.label(" • ⚡ TCP Socket Tuning: 4MB буферы сокетов, TCP_NODELAY и пул соединений через socket2 для максимальной скорости на VPN и LAN."); @@ -4047,9 +4131,7 @@ impl ZeroSendApp { ui.label(" • 📡 mDNS & Multicast Discovery: Мультикаст группы (239.255.255.250 и 224.0.0.251) для 100% обнаружения пиров на любых роутерах."); ui.label(" • 🗜️ Умная Zstd-компрессия: Авто-пропуск сжатия для медиа (.jpg/.mp4/.zip...) и потоковое Zstd Level 1 сжатие кода/текста."); ui.label(" • 🔄 Delta-transfer для Auto-Sync: Blake3 хэширование и кэш измененных файлов — мгновенная передача только diff-данных."); - ui.label(" • 👥 Мульти-отправка: Параллельная передача выбранных файлов сразу нескольким друзьям."); - ui.label(" • ⏸️ Пауза и возобновление: Интерактивные кнопки паузы/продолжения передачи в реальном времени."); - ui.label(" • 🏎️ Очередь с приоритетами: Маленькие файлы передаются первыми для мгновенного завершения."); + ui.label(" • 👥 Мульти-отправка, ⏸️ Пауза / ▶️ Возобновление и 🏎️ Очередь с приоритетами."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0);