Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e2b4706d5 | ||
|
|
c3f26b77e8 | ||
|
|
9957c9b70f | ||
|
|
d4f52e3ec6 | ||
|
|
3ca52e2649 | ||
|
|
90fda425bd | ||
|
|
124d9c2dd4 | ||
|
|
e019ddccc9 | ||
|
|
61f2545588 | ||
|
|
b751d82748 |
Generated
+1
-1
@@ -6453,7 +6453,7 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerosend"
|
||||
version = "2.5.3-beta.1.1"
|
||||
version = "2.5.6"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"async-stream",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "zerosend"
|
||||
version = "2.5.3-beta.1.1"
|
||||
version = "2.5.6"
|
||||
edition = "2021"
|
||||
description = "Fast, peer-to-peer file transfer designed for ZeroTier, Tailscale, Radmin VPN and LAN"
|
||||
authors = ["RarDog"]
|
||||
|
||||
@@ -41,6 +41,10 @@ pub struct AppConfig {
|
||||
pub peer_aliases: HashMap<String, String>,
|
||||
pub window_width: f32,
|
||||
pub window_height: f32,
|
||||
#[serde(default)]
|
||||
pub window_x: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub window_y: Option<f32>,
|
||||
pub close_to_tray: bool,
|
||||
pub minimize_to_tray: bool,
|
||||
pub auto_zip_folders: bool,
|
||||
@@ -89,6 +93,8 @@ impl Default for AppConfig {
|
||||
peer_aliases: HashMap::new(),
|
||||
window_width: 940.0,
|
||||
window_height: 640.0,
|
||||
window_x: None,
|
||||
window_y: None,
|
||||
close_to_tray: true,
|
||||
minimize_to_tray: true,
|
||||
auto_zip_folders: true,
|
||||
@@ -212,3 +218,29 @@ fn get_default_download_dir() -> PathBuf {
|
||||
let _ = std::fs::create_dir_all(&fallback);
|
||||
fallback
|
||||
}
|
||||
|
||||
pub fn load_transfers_history() -> Vec<crate::transfer::TransferProgress> {
|
||||
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::<Vec<crate::transfer::TransferProgress>>(&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);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-5
@@ -304,16 +304,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
}
|
||||
|
||||
let start_minimized = (cli.tray || cli.minimized) && initial_files.is_empty();
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: eframe::egui::ViewportBuilder::default()
|
||||
let mut viewport = eframe::egui::ViewportBuilder::default()
|
||||
.with_inner_size([config.window_width, config.window_height])
|
||||
.with_min_inner_size([780.0, 500.0])
|
||||
.with_visible(!start_minimized)
|
||||
.with_title(format!("ZeroSend v{} - P2P Sharing for ZeroTier & VPN", env!("CARGO_PKG_VERSION"))),
|
||||
.with_title(format!("ZeroSend v{} - P2P Sharing for ZeroTier & VPN", env!("CARGO_PKG_VERSION")));
|
||||
|
||||
if let (Some(x), Some(y)) = (config.window_x, config.window_y) {
|
||||
viewport = viewport.with_position([x, y]);
|
||||
}
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
viewport,
|
||||
..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 +331,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
Box::new(move |cc| {
|
||||
Ok(Box::new(ui::gui::ZeroSendApp::new(
|
||||
cc,
|
||||
disc_ref,
|
||||
disc_clone,
|
||||
srv,
|
||||
config,
|
||||
initial_files,
|
||||
|
||||
+215
-33
@@ -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<Mutex>
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -876,37 +926,89 @@ impl TransferClient {
|
||||
.map_err(|e| format!("Failed to read file text: {}", e))
|
||||
}
|
||||
|
||||
/// Download a file or folder (zip) from a remote shared folder
|
||||
pub async fn download_shared_file(
|
||||
/// Download a file or folder (zip) from a remote shared folder with live progress tracking
|
||||
pub async fn download_shared_file_with_progress<F>(
|
||||
&self,
|
||||
peer_url: &str,
|
||||
folder_id: &str,
|
||||
subpath: &str,
|
||||
dest_path: &std::path::Path,
|
||||
pin: Option<&str>,
|
||||
) -> Result<u64, String> {
|
||||
peer_name: &str,
|
||||
mut on_progress: F,
|
||||
) -> Result<u64, String>
|
||||
where
|
||||
F: FnMut(TransferProgress) + Send + 'static,
|
||||
{
|
||||
let clean = subpath.trim_start_matches('/').trim_start_matches('\\');
|
||||
let mut url = format!("{}/api/shared/download/{}/{}", peer_url, folder_id, clean);
|
||||
if let Some(p) = pin.filter(|p| !p.trim().is_empty()) {
|
||||
url = format!("{}?pin={}", url, p);
|
||||
}
|
||||
|
||||
let fname = dest_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "downloaded".to_string());
|
||||
let session_id = format!("shared-dl-{}", uuid::Uuid::new_v4());
|
||||
|
||||
on_progress(TransferProgress {
|
||||
session_id: session_id.clone(),
|
||||
peer_name: peer_name.to_string(),
|
||||
is_incoming: true,
|
||||
current_file_index: 0,
|
||||
total_files: 1,
|
||||
current_file_name: fname.clone(),
|
||||
bytes_transferred: 0,
|
||||
total_bytes: 0,
|
||||
speed_bps: 0,
|
||||
state: TransferState::InProgress,
|
||||
checksum_verified: false,
|
||||
compressed_bytes: 0,
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
.map_err(|e| {
|
||||
let err_msg = format!("Network error: {}", e);
|
||||
on_progress(TransferProgress {
|
||||
session_id: session_id.clone(),
|
||||
peer_name: peer_name.to_string(),
|
||||
is_incoming: true,
|
||||
current_file_index: 0,
|
||||
total_files: 1,
|
||||
current_file_name: fname.clone(),
|
||||
bytes_transferred: 0,
|
||||
total_bytes: 0,
|
||||
speed_bps: 0,
|
||||
state: TransferState::Failed(err_msg.clone()),
|
||||
checksum_verified: false,
|
||||
compressed_bytes: 0,
|
||||
});
|
||||
err_msg
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Download failed: HTTP {}", resp.status()));
|
||||
let status = resp.status();
|
||||
let err_msg = format!("Download failed: HTTP {}", status);
|
||||
on_progress(TransferProgress {
|
||||
session_id: session_id.clone(),
|
||||
peer_name: peer_name.to_string(),
|
||||
is_incoming: true,
|
||||
current_file_index: 0,
|
||||
total_files: 1,
|
||||
current_file_name: fname.clone(),
|
||||
bytes_transferred: 0,
|
||||
total_bytes: 0,
|
||||
speed_bps: 0,
|
||||
state: TransferState::Failed(err_msg.clone()),
|
||||
checksum_verified: false,
|
||||
compressed_bytes: 0,
|
||||
});
|
||||
return Err(err_msg);
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read stream: {}", e))?;
|
||||
let len = bytes.len() as u64;
|
||||
let total_size = resp.content_length().unwrap_or(0);
|
||||
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
@@ -916,14 +1018,94 @@ impl TransferClient {
|
||||
.await
|
||||
.map_err(|e| format!("Could not create file: {}", e))?;
|
||||
|
||||
tokio::io::AsyncWriteExt::write_all(&mut file, &bytes)
|
||||
use futures_util::StreamExt;
|
||||
let mut stream = resp.bytes_stream();
|
||||
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;
|
||||
tokio::io::AsyncWriteExt::write_all(&mut file, &chunk)
|
||||
.await
|
||||
.map_err(|e| format!("Could not write file: {}", e))?;
|
||||
|
||||
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;
|
||||
on_progress(TransferProgress {
|
||||
session_id: session_id.clone(),
|
||||
peer_name: peer_name.to_string(),
|
||||
is_incoming: true,
|
||||
current_file_index: 0,
|
||||
total_files: 1,
|
||||
current_file_name: fname.clone(),
|
||||
bytes_transferred: bytes_received,
|
||||
total_bytes: if total_size > 0 { total_size } else { bytes_received },
|
||||
speed_bps: speed,
|
||||
state: TransferState::InProgress,
|
||||
checksum_verified: false,
|
||||
compressed_bytes: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("Stream error: {}", e);
|
||||
on_progress(TransferProgress {
|
||||
session_id: session_id.clone(),
|
||||
peer_name: peer_name.to_string(),
|
||||
is_incoming: true,
|
||||
current_file_index: 0,
|
||||
total_files: 1,
|
||||
current_file_name: fname.clone(),
|
||||
bytes_transferred: bytes_received,
|
||||
total_bytes: total_size,
|
||||
speed_bps: 0,
|
||||
state: TransferState::Failed(err_msg.clone()),
|
||||
checksum_verified: false,
|
||||
compressed_bytes: 0,
|
||||
});
|
||||
return Err(err_msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::io::AsyncWriteExt::flush(&mut file)
|
||||
.await
|
||||
.map_err(|e| format!("Could not flush file: {}", e))?;
|
||||
|
||||
Ok(len)
|
||||
on_progress(TransferProgress {
|
||||
session_id: session_id.clone(),
|
||||
peer_name: peer_name.to_string(),
|
||||
is_incoming: true,
|
||||
current_file_index: 1,
|
||||
total_files: 1,
|
||||
current_file_name: fname.clone(),
|
||||
bytes_transferred: bytes_received,
|
||||
total_bytes: if total_size > 0 { total_size } else { bytes_received },
|
||||
speed_bps: 0,
|
||||
state: TransferState::Completed,
|
||||
checksum_verified: true,
|
||||
compressed_bytes: 0,
|
||||
});
|
||||
|
||||
Ok(bytes_received)
|
||||
}
|
||||
|
||||
/// Download a file or folder (zip) from a remote shared folder
|
||||
pub async fn download_shared_file(
|
||||
&self,
|
||||
peer_url: &str,
|
||||
folder_id: &str,
|
||||
subpath: &str,
|
||||
dest_path: &std::path::Path,
|
||||
pin: Option<&str>,
|
||||
) -> Result<u64, String> {
|
||||
self.download_shared_file_with_progress(peer_url, folder_id, subpath, dest_path, pin, "Shared", |_| {}).await
|
||||
}
|
||||
|
||||
/// Upload a file directly to a friend's shared folder (if Read & Write allowed)
|
||||
|
||||
+299
-25
@@ -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<Arc<ServerState>>,
|
||||
Path(_session_id): Path<String>,
|
||||
Path(session_id): Path<String>,
|
||||
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,
|
||||
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 body: {}", 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)
|
||||
|
||||
+403
-115
@@ -37,6 +37,19 @@ pub enum Tab {
|
||||
Settings,
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
pub fn index(&self) -> usize {
|
||||
match self {
|
||||
Tab::Nearby => 0,
|
||||
Tab::Send => 1,
|
||||
Tab::Receive => 2,
|
||||
Tab::Shared => 3,
|
||||
Tab::Transfers => 4,
|
||||
Tab::Settings => 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||
pub enum SharedSubTab {
|
||||
NetworkShares,
|
||||
@@ -144,6 +157,7 @@ pub fn get_file_icon(name: &str) -> &'static str {
|
||||
|
||||
pub struct ZeroSendApp {
|
||||
// Services
|
||||
discovery: Arc<DiscoveryService>,
|
||||
discovery_peers: Arc<RwLock<HashMap<String, PeerInfo>>>,
|
||||
server: TransferServer,
|
||||
client: Arc<TransferClient>,
|
||||
@@ -270,6 +284,7 @@ pub struct ZeroSendApp {
|
||||
last_poll: Instant,
|
||||
last_ping_poll: Instant,
|
||||
last_window_save: Instant,
|
||||
last_tab_change: Instant,
|
||||
}
|
||||
|
||||
fn setup_custom_fonts(ctx: &egui::Context) {
|
||||
@@ -308,7 +323,7 @@ fn setup_custom_fonts(ctx: &egui::Context) {
|
||||
impl ZeroSendApp {
|
||||
pub fn new(
|
||||
cc: &eframe::CreationContext<'_>,
|
||||
discovery: &DiscoveryService,
|
||||
discovery: Arc<DiscoveryService>,
|
||||
server: TransferServer,
|
||||
config: AppConfig,
|
||||
initial_files: Vec<PathBuf>,
|
||||
@@ -382,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,
|
||||
@@ -396,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.1.1 ready".to_string()],
|
||||
transfers: initial_transfers,
|
||||
logs: vec!["⚡ ZeroSend v2.5.6 ready".to_string()],
|
||||
text_input: String::new(),
|
||||
analyzed_preview: analyze_text(""),
|
||||
recipient_pin: String::new(),
|
||||
@@ -476,6 +493,7 @@ impl ZeroSendApp {
|
||||
last_poll: Instant::now(),
|
||||
last_ping_poll: Instant::now(),
|
||||
last_window_save: Instant::now(),
|
||||
last_tab_change: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,7 +517,7 @@ impl ZeroSendApp {
|
||||
// 2. Poll incoming file transfer requests
|
||||
while let Ok(req) = self.incoming_request_rx.try_recv() {
|
||||
self.pending_request = Some(req.clone());
|
||||
self.current_tab = Tab::Transfers;
|
||||
self.switch_tab(Tab::Transfers);
|
||||
self.logs.push(format!("📥 Incoming transfer request from {}", req.sender_name));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
|
||||
@@ -524,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) {
|
||||
@@ -534,7 +553,7 @@ impl ZeroSendApp {
|
||||
}
|
||||
} else {
|
||||
self.transfers.insert(0, t);
|
||||
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);
|
||||
@@ -542,6 +561,7 @@ impl ZeroSendApp {
|
||||
crate::ui::sound::play_transfer_sound();
|
||||
}
|
||||
}
|
||||
transfers_changed = true;
|
||||
}
|
||||
|
||||
// 6. Direct sync with Server active transfers (Incoming downloads fallback)
|
||||
@@ -556,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);
|
||||
@@ -564,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;
|
||||
@@ -701,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<crate::network::SharedFolderSummary> = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -729,14 +769,63 @@ 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<crate::network::SharedFolderSummary> = 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<PeerInfo> = 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<crate::network::SharedFolderSummary> = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn switch_tab(&mut self, new_tab: Tab) {
|
||||
if self.current_tab != new_tab {
|
||||
self.current_tab = new_tab;
|
||||
self.last_tab_change = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
fn get_peer_display_name(&self, peer: &PeerInfo) -> (String, bool) {
|
||||
if let Some(alias) = self.config.peer_aliases.get(&peer.peer_id) {
|
||||
if !alias.trim().is_empty() {
|
||||
@@ -790,7 +879,7 @@ impl ZeroSendApp {
|
||||
}
|
||||
}
|
||||
if added_any {
|
||||
self.current_tab = Tab::Send;
|
||||
self.switch_tab(Tab::Send);
|
||||
self.send_mode = SendMode::Files;
|
||||
self.logs.push("📄 Pasted copied file(s) from clipboard to queue".to_string());
|
||||
return true;
|
||||
@@ -822,7 +911,7 @@ impl ZeroSendApp {
|
||||
};
|
||||
|
||||
self.queued_files.clear();
|
||||
self.current_tab = Tab::Transfers;
|
||||
self.switch_tab(Tab::Transfers);
|
||||
|
||||
for peer in targets {
|
||||
let peer_url = peer.transfer_url();
|
||||
@@ -921,7 +1010,13 @@ impl ZeroSendApp {
|
||||
(Tab::Settings, "⚙ Настройки и инфо", 0, false),
|
||||
];
|
||||
|
||||
for (tab, title, count, is_active_pulse) in tabs {
|
||||
let anim_target = self.current_tab.index() as f32;
|
||||
let animated_tab_idx = ui.ctx().animate_value_with_time(egui::Id::new("sidebar_active_tab"), anim_target, 0.20);
|
||||
if (animated_tab_idx - anim_target).abs() > 0.001 {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
|
||||
for (idx, (tab, title, count, is_active_pulse)) in tabs.into_iter().enumerate() {
|
||||
let is_selected = self.current_tab == tab;
|
||||
let mut btn_text = title.to_string();
|
||||
if count > 0 && tab != Tab::Receive && tab != Tab::Settings {
|
||||
@@ -932,6 +1027,9 @@ impl ZeroSendApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth proximity factor (1.0 = active, 0.0 = inactive)
|
||||
let proximity = (1.0 - (animated_tab_idx - (idx as f32)).abs()).clamp(0.0, 1.0);
|
||||
|
||||
let text_color = if is_selected {
|
||||
if theme.is_dark {
|
||||
Color32::from_rgb(56, 189, 248)
|
||||
@@ -944,14 +1042,23 @@ impl ZeroSendApp {
|
||||
theme.text_secondary
|
||||
};
|
||||
|
||||
let bg_color = if is_selected {
|
||||
theme.bg_card_active
|
||||
let bg_color = if proximity > 0.01 {
|
||||
let r = theme.bg_card_active.r();
|
||||
let g = theme.bg_card_active.g();
|
||||
let b = theme.bg_card_active.b();
|
||||
let a = (theme.bg_card_active.a() as f32 * proximity) as u8;
|
||||
Color32::from_rgba_premultiplied(r, g, b, a)
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
|
||||
let stroke = if is_selected {
|
||||
Stroke::new(1.0_f32, if theme.is_dark { Color32::from_rgb(56, 189, 248) } else { Color32::from_rgb(2, 132, 199) })
|
||||
let stroke = if proximity > 0.25 {
|
||||
let stroke_color = if theme.is_dark {
|
||||
Color32::from_rgba_premultiplied(56, 189, 248, (255.0 * proximity) as u8)
|
||||
} else {
|
||||
Color32::from_rgba_premultiplied(2, 132, 199, (255.0 * proximity) as u8)
|
||||
};
|
||||
Stroke::new(1.0_f32, stroke_color)
|
||||
} else {
|
||||
Stroke::NONE
|
||||
};
|
||||
@@ -963,7 +1070,7 @@ impl ZeroSendApp {
|
||||
.rounding(Rounding::same(6.0));
|
||||
|
||||
if ui.add(btn).clicked() {
|
||||
self.current_tab = tab;
|
||||
self.switch_tab(tab);
|
||||
}
|
||||
ui.add_space(3.0);
|
||||
}
|
||||
@@ -1259,11 +1366,11 @@ impl ZeroSendApp {
|
||||
.fill(theme.bg_card)
|
||||
.rounding(Rounding::same(8.0))
|
||||
.stroke(theme.stroke_border)
|
||||
.inner_margin(Margin::symmetric(12.0, 8.0))
|
||||
.inner_margin(Margin::symmetric(14.0, 10.0))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("📤").size(20.0));
|
||||
ui.label(RichText::new("Режим отправки:").strong().size(13.5).color(theme.text_primary));
|
||||
ui.label(RichText::new("Режим:").strong().size(13.5).color(theme.text_primary));
|
||||
|
||||
let files_active = self.send_mode == SendMode::Files;
|
||||
let text_active = self.send_mode == SendMode::QuickText;
|
||||
@@ -1296,8 +1403,8 @@ impl ZeroSendApp {
|
||||
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("🔒 PIN (опционально):").size(11.0).color(theme.text_muted));
|
||||
ui.add(egui::TextEdit::singleline(&mut self.recipient_pin).hint_text("PIN").desired_width(60.0));
|
||||
ui.label(RichText::new("🔒 PIN:").size(11.0).color(theme.text_muted));
|
||||
ui.add(egui::TextEdit::singleline(&mut self.recipient_pin).hint_text("PIN").desired_width(65.0));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1317,46 +1424,85 @@ impl ZeroSendApp {
|
||||
|
||||
if !self.peers.is_empty() {
|
||||
let all_selected = self.selected_peer_ids.len() == self.peers.len();
|
||||
if ui.small_button(if all_selected { "✕ Снять выбор со всех" } else { "✓ Выбрать всех" }).clicked() {
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if ui.button(RichText::new(if all_selected { "✕ Снять выбор" } else { "✓ Выбрать всех" }).color(theme.text_accent).size(11.5)).clicked() {
|
||||
if all_selected {
|
||||
self.selected_peer_ids.clear();
|
||||
} else {
|
||||
self.selected_peer_ids = self.peers.iter().map(|p| p.peer_id.clone()).collect();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(6.0);
|
||||
ui.add_space(8.0);
|
||||
|
||||
if self.peers.is_empty() {
|
||||
ui.label(RichText::new("В сети пока нет друзей онлайн. Подключитесь к ZeroTier или проверьте вкладку устройств.").color(theme.text_muted).size(11.5));
|
||||
Frame::none()
|
||||
.fill(theme.bg_card_secondary)
|
||||
.rounding(Rounding::same(6.0))
|
||||
.inner_margin(Margin::same(12.0))
|
||||
.show(ui, |ui| {
|
||||
ui.label(RichText::new("ℹ️ В сети пока нет обнаруженных друзей. Подключитесь к ZeroTier/LAN для автоматического поиска.").color(theme.text_muted).size(11.5));
|
||||
});
|
||||
} else {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for p in &self.peers {
|
||||
let is_selected = self.selected_peer_ids.contains(&p.peer_id);
|
||||
let (name_label, _) = self.get_peer_display_name(p);
|
||||
let chip_text = format!("{} ({})", name_label, p.ip);
|
||||
let os_icon = match p.os.to_lowercase().as_str() {
|
||||
s if s.contains("win") => "🪟",
|
||||
s if s.contains("linux") => "🐧",
|
||||
s if s.contains("mac") || s.contains("darwin") => "🍎",
|
||||
s if s.contains("android") || s.contains("ios") => "📱",
|
||||
_ => "💻",
|
||||
};
|
||||
|
||||
let chip_btn = Button::new(
|
||||
RichText::new(format!("{} {}", if is_selected { "✅" } else { "⬜" }, chip_text))
|
||||
.size(12.0)
|
||||
.strong()
|
||||
.color(if is_selected { Color32::WHITE } else { theme.text_secondary }),
|
||||
)
|
||||
.fill(if is_selected {
|
||||
Color32::from_rgb(37, 99, 235)
|
||||
} else {
|
||||
theme.bg_card_secondary
|
||||
})
|
||||
let (net_badge, net_color) = match p.net_type.to_lowercase().as_str() {
|
||||
s if s.contains("zerotier") => ("🪐 ZeroTier", Color32::from_rgb(251, 146, 60)),
|
||||
s if s.contains("tailscale") => ("🪢 Tailscale", Color32::from_rgb(56, 189, 248)),
|
||||
s if s.contains("lan") || s.contains("wi-fi") || s.contains("eth") => ("🏠 LAN", Color32::from_rgb(34, 197, 94)),
|
||||
s if s.contains("radmin") => ("🛡️ Radmin", Color32::from_rgb(168, 85, 247)),
|
||||
_ => ("🌐 Direct", Color32::from_rgb(100, 116, 139)),
|
||||
};
|
||||
|
||||
let card = Frame::none()
|
||||
.fill(if is_selected { theme.bg_card_active } else { theme.bg_card_secondary })
|
||||
.rounding(Rounding::same(8.0))
|
||||
.stroke(if is_selected {
|
||||
Stroke::new(1.0_f32, Color32::from_rgb(96, 165, 250))
|
||||
Stroke::new(1.5_f32, Color32::from_rgb(96, 165, 250))
|
||||
} else {
|
||||
Stroke::NONE
|
||||
theme.stroke_border
|
||||
})
|
||||
.rounding(Rounding::same(6.0));
|
||||
.inner_margin(Margin::symmetric(10.0, 8.0));
|
||||
|
||||
if ui.add(chip_btn).clicked() {
|
||||
let res = card.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(if is_selected { "✅" } else { "⬜" }).size(13.0));
|
||||
ui.label(RichText::new(os_icon).size(14.0));
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(&name_label).strong().size(12.0).color(if is_selected { Color32::WHITE } else { theme.text_primary }));
|
||||
if let Some(ping) = p.ping_ms {
|
||||
ui.label(RichText::new(format!("⚡ {}ms", ping)).size(10.0).color(Color32::from_rgb(34, 197, 94)));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(p.ip.to_string()).size(10.0).monospace().color(theme.text_muted));
|
||||
Frame::none()
|
||||
.fill(theme.bg_card)
|
||||
.rounding(Rounding::same(3.0))
|
||||
.inner_margin(Margin::symmetric(4.0, 1.0))
|
||||
.show(ui, |ui| {
|
||||
ui.label(RichText::new(net_badge).size(8.5).color(net_color).strong());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if res.response.interact(egui::Sense::click()).clicked() {
|
||||
if is_selected {
|
||||
self.selected_peer_ids.remove(&p.peer_id);
|
||||
} else {
|
||||
@@ -1557,7 +1703,7 @@ impl ZeroSendApp {
|
||||
} else {
|
||||
if theme.is_dark { Color32::from_rgb(51, 65, 85) } else { Color32::from_rgb(203, 213, 225) }
|
||||
})
|
||||
.min_size(Vec2::new(ui.available_width(), 42.0))
|
||||
.min_size(Vec2::new(ui.available_width(), 44.0))
|
||||
.rounding(Rounding::same(8.0));
|
||||
|
||||
if ui.add_enabled(can_send, send_btn).clicked() {
|
||||
@@ -2175,7 +2321,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());
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2629,7 +2777,7 @@ impl ZeroSendApp {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(RichText::new("🌐").size(40.0));
|
||||
ui.label(
|
||||
RichText::new("No shared folders discovered on your network.")
|
||||
RichText::new("В сети пока нет обнаруженных общих папок.")
|
||||
.size(16.0)
|
||||
.strong()
|
||||
.color(theme.text_primary),
|
||||
@@ -2637,7 +2785,7 @@ impl ZeroSendApp {
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"When your friends on ZeroTier / LAN add shared folders, they will automatically appear here with streaming and fast download support.",
|
||||
"Когда друзья в ZeroTier/LAN откроют доступ к своим папкам, они мгновенно появятся здесь со стримингом и скачиванием.",
|
||||
)
|
||||
.size(12.0)
|
||||
.color(theme.text_muted),
|
||||
@@ -2647,21 +2795,29 @@ impl ZeroSendApp {
|
||||
return;
|
||||
}
|
||||
|
||||
let total_shared_folders_count: usize = online_peers_with_shares.iter().map(|p| p.shared_folders.len()).sum();
|
||||
|
||||
ui.horizontal_top(|ui| {
|
||||
// Left Column: Peer & Folder Selector (250px)
|
||||
ui.allocate_ui(Vec2::new(250.0, ui.available_height()), |ui| {
|
||||
// ── Left Column: Modern Friends & Shares Sidebar (260px) ──
|
||||
ui.allocate_ui(Vec2::new(260.0, ui.available_height()), |ui| {
|
||||
Frame::none()
|
||||
.fill(theme.bg_card)
|
||||
.rounding(Rounding::same(8.0))
|
||||
.stroke(theme.stroke_border)
|
||||
.inner_margin(Margin::same(10.0))
|
||||
.inner_margin(Margin::symmetric(12.0, 10.0))
|
||||
.show(ui, |ui| {
|
||||
ui.label(
|
||||
RichText::new("Friends & Shares")
|
||||
.strong()
|
||||
.size(13.0)
|
||||
.color(theme.text_primary),
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("🌐 Общие папки").strong().size(13.5).color(theme.text_primary));
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
Frame::none()
|
||||
.fill(theme.bg_card_secondary)
|
||||
.rounding(Rounding::same(10.0))
|
||||
.inner_margin(Margin::symmetric(6.0, 2.0))
|
||||
.show(ui, |ui| {
|
||||
ui.label(RichText::new(format!("{}", total_shared_folders_count)).size(10.5).strong().color(Color32::from_rgb(56, 189, 248)));
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.separator();
|
||||
ui.add_space(6.0);
|
||||
@@ -2669,47 +2825,74 @@ impl ZeroSendApp {
|
||||
ScrollArea::vertical().show(ui, |ui| {
|
||||
for peer in &online_peers_with_shares {
|
||||
let (peer_display, _) = self.get_peer_display_name(peer);
|
||||
|
||||
Frame::none()
|
||||
.fill(theme.bg_card_secondary)
|
||||
.rounding(Rounding::same(8.0))
|
||||
.stroke(theme.stroke_border)
|
||||
.inner_margin(Margin::same(8.0))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("●").color(Color32::from_rgb(34, 197, 94)).size(11.0));
|
||||
ui.label(RichText::new(&peer_display).strong().color(theme.text_primary));
|
||||
ui.label(RichText::new("●").color(Color32::from_rgb(34, 197, 94)).size(10.0));
|
||||
ui.label(RichText::new(&peer_display).strong().size(12.0).color(theme.text_primary));
|
||||
if let Some(ping) = peer.ping_ms {
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
ui.label(RichText::new(format!("⚡ {}ms", ping)).size(9.5).color(Color32::from_rgb(34, 197, 94)));
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
|
||||
for folder in &peer.shared_folders {
|
||||
let is_selected = self.selected_peer_id_for_share.as_deref() == Some(&peer.peer_id)
|
||||
&& self.selected_folder_id_for_share.as_deref() == Some(&folder.id);
|
||||
|
||||
let badge = if folder.read_only { "RO" } else { "RW" };
|
||||
let btn_text = format!("📁 {} [{}]", folder.name, badge);
|
||||
let badge_color = if folder.read_only { Color32::from_rgb(59, 130, 246) } else { Color32::from_rgb(34, 197, 94) };
|
||||
let badge_text = if folder.read_only { "RO" } else { "RW" };
|
||||
|
||||
let btn = Button::new(RichText::new(btn_text).size(12.0).color(if is_selected {
|
||||
theme.text_accent
|
||||
} else {
|
||||
theme.text_secondary
|
||||
}))
|
||||
.fill(if is_selected {
|
||||
theme.bg_card_active
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
})
|
||||
let btn = Button::new(
|
||||
RichText::new(format!("📁 {}", folder.name))
|
||||
.size(11.5)
|
||||
.strong()
|
||||
.color(if is_selected { theme.text_accent } else { theme.text_primary }),
|
||||
)
|
||||
.fill(if is_selected { theme.bg_card_active } else { Color32::TRANSPARENT })
|
||||
.stroke(if is_selected { Stroke::new(1.0_f32, theme.text_accent) } else { Stroke::NONE })
|
||||
.min_size(Vec2::new(ui.available_width(), 26.0))
|
||||
.rounding(Rounding::same(4.0));
|
||||
.rounding(Rounding::same(6.0));
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add(btn).clicked() {
|
||||
self.selected_peer_id_for_share = Some(peer.peer_id.clone());
|
||||
self.selected_folder_id_for_share = Some(folder.id.clone());
|
||||
self.current_share_subpath = String::new();
|
||||
self.refresh_current_share_tree();
|
||||
}
|
||||
// Pill badge
|
||||
Frame::none()
|
||||
.fill(theme.bg_card)
|
||||
.rounding(Rounding::same(4.0))
|
||||
.stroke(Stroke::new(1.0_f32, badge_color))
|
||||
.inner_margin(Margin::symmetric(4.0, 1.0))
|
||||
.show(ui, |ui| {
|
||||
ui.label(RichText::new(badge_text).size(9.0).color(badge_color));
|
||||
});
|
||||
if folder.requires_pin {
|
||||
ui.label(RichText::new("🔒").size(10.0));
|
||||
}
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Right Column: Active Share Explorer
|
||||
// ── Right Column: Active Share Explorer ──
|
||||
ui.vertical(|ui| {
|
||||
if let (Some(peer_id), Some(folder_id)) = (
|
||||
self.selected_peer_id_for_share.clone(),
|
||||
@@ -2719,39 +2902,70 @@ impl ZeroSendApp {
|
||||
let peer = match peer_opt {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
ui.label("Selected peer is offline.");
|
||||
Frame::none()
|
||||
.fill(theme.bg_card)
|
||||
.rounding(Rounding::same(8.0))
|
||||
.stroke(theme.stroke_border)
|
||||
.inner_margin(Margin::same(20.0))
|
||||
.show(ui, |ui| {
|
||||
ui.label(RichText::new("⚠️ Выбранный узел не в сети.").color(Color32::from_rgb(239, 68, 68)));
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (peer_name, _) = self.get_peer_display_name(&peer);
|
||||
|
||||
// Top Explorer Header & Breadcrumbs
|
||||
// Top Explorer Header & Interactive Breadcrumbs Bar
|
||||
Frame::none()
|
||||
.fill(theme.bg_card)
|
||||
.rounding(Rounding::same(8.0))
|
||||
.stroke(theme.stroke_border)
|
||||
.inner_margin(Margin::same(10.0))
|
||||
.inner_margin(Margin::symmetric(10.0, 8.0))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("🏠 Root").clicked() {
|
||||
if ui.button(RichText::new("🏠 Корень").strong().color(theme.text_accent)).clicked() {
|
||||
self.current_share_subpath = String::new();
|
||||
self.refresh_current_share_tree();
|
||||
}
|
||||
|
||||
let mut navigate_subpath = None;
|
||||
if !self.current_share_subpath.is_empty() {
|
||||
ui.label("/");
|
||||
ui.label(
|
||||
RichText::new(&self.current_share_subpath)
|
||||
.monospace()
|
||||
.strong()
|
||||
.color(theme.text_primary),
|
||||
);
|
||||
let parts: Vec<String> = self.current_share_subpath.split('/').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect();
|
||||
let mut accumulated = String::new();
|
||||
for (idx, part) in parts.iter().enumerate() {
|
||||
ui.label(RichText::new("/").color(theme.text_muted));
|
||||
if !accumulated.is_empty() {
|
||||
accumulated.push('/');
|
||||
}
|
||||
accumulated.push_str(part);
|
||||
let is_last = idx == parts.len() - 1;
|
||||
if is_last {
|
||||
ui.label(RichText::new(part).strong().color(theme.text_primary));
|
||||
} else {
|
||||
let acc_clone = accumulated.clone();
|
||||
if ui.link(RichText::new(part).color(theme.text_accent)).clicked() {
|
||||
navigate_subpath = Some(acc_clone);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(target) = navigate_subpath {
|
||||
self.current_share_subpath = target;
|
||||
self.refresh_current_share_tree();
|
||||
}
|
||||
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
// Search filter box
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.share_search_query)
|
||||
.hint_text("🔍 Фильтр...")
|
||||
.desired_width(120.0),
|
||||
);
|
||||
|
||||
// Grep search button
|
||||
if ui.button(RichText::new("🔎 Поиск текста/кода").color(Color32::from_rgb(56, 189, 248))).clicked() {
|
||||
if ui.button(RichText::new("🔎 Поиск кода").color(Color32::from_rgb(56, 189, 248))).clicked() {
|
||||
self.show_grep_modal = true;
|
||||
self.grep_query.clear();
|
||||
self.grep_results.clear();
|
||||
@@ -2764,7 +2978,7 @@ impl ZeroSendApp {
|
||||
let peer_name_clone = peer_name.clone();
|
||||
let f_name = peer.shared_folders.iter().find(|f| f.id == folder_id_clone).map(|f| f.name.clone()).unwrap_or_else(|| "Shared".to_string());
|
||||
|
||||
if ui.button(RichText::new("🔄 Авто-Синхронизация").color(Color32::from_rgb(168, 85, 247))).clicked() {
|
||||
if ui.button(RichText::new("🔄 Синхронизация").color(Color32::from_rgb(168, 85, 247))).clicked() {
|
||||
self.new_sync_peer_id = peer_id_clone;
|
||||
self.new_sync_folder_id = folder_id_clone;
|
||||
self.new_sync_name = format!("Синхронизация {} ({})", f_name, peer_name_clone);
|
||||
@@ -2774,18 +2988,11 @@ impl ZeroSendApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Search filter box
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.share_search_query)
|
||||
.hint_text("🔍 Фильтр файлов...")
|
||||
.desired_width(130.0),
|
||||
);
|
||||
|
||||
// Upload button if folder is Read-Write
|
||||
if let Some(tree) = &self.current_share_tree {
|
||||
if !tree.read_only {
|
||||
let upload_btn = Button::new(
|
||||
RichText::new("⬆️ Upload File")
|
||||
RichText::new("⬆️ Загрузить")
|
||||
.strong()
|
||||
.color(Color32::WHITE),
|
||||
)
|
||||
@@ -2818,7 +3025,6 @@ impl ZeroSendApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Refresh tree after upload
|
||||
if let Ok(tree) = client.fetch_shared_tree(&peer_url, &f_id, Some(&sub), pin.as_deref()).await {
|
||||
let _ = tx.send(Some(tree));
|
||||
}
|
||||
@@ -2831,7 +3037,7 @@ impl ZeroSendApp {
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.add_space(6.0);
|
||||
|
||||
// Multi-select batch action toolbar
|
||||
if !self.selected_share_items.is_empty() {
|
||||
@@ -2842,15 +3048,15 @@ impl ZeroSendApp {
|
||||
.inner_margin(Margin::symmetric(12.0, 8.0))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(format!("Selected {} item(s)", self.selected_share_items.len())).strong().color(theme.text_primary));
|
||||
ui.label(RichText::new(format!("Выбрано: {} элементов", self.selected_share_items.len())).strong().color(theme.text_primary));
|
||||
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if ui.button("Clear Selection").clicked() {
|
||||
if ui.button("✕ Снять выбор").clicked() {
|
||||
self.selected_share_items.clear();
|
||||
}
|
||||
|
||||
let dl_btn = Button::new(
|
||||
RichText::new(format!("⬇️ Download Selected ({})", self.selected_share_items.len()))
|
||||
RichText::new(format!("⬇️ Скачать выбранные ({})", self.selected_share_items.len()))
|
||||
.strong()
|
||||
.color(Color32::WHITE),
|
||||
)
|
||||
@@ -2858,8 +3064,10 @@ impl ZeroSendApp {
|
||||
.rounding(Rounding::same(4.0));
|
||||
|
||||
if ui.add(dl_btn).clicked() {
|
||||
self.switch_tab(Tab::Transfers);
|
||||
let client = self.client.clone();
|
||||
let peer_url = peer.transfer_url();
|
||||
let peer_name_clone = peer_name.clone();
|
||||
let f_id = folder_id.clone();
|
||||
let items: Vec<String> = self.selected_share_items.iter().cloned().collect();
|
||||
let dl_dir = self.download_dir.clone();
|
||||
@@ -2869,6 +3077,7 @@ impl ZeroSendApp {
|
||||
Some(self.share_pin_input.trim().to_string())
|
||||
};
|
||||
let log_tx = self.log_tx.clone();
|
||||
let transfer_tx = self.transfer_tx.clone();
|
||||
let sound = self.sound_notifications;
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -2879,7 +3088,10 @@ impl ZeroSendApp {
|
||||
.unwrap_or_else(|| "downloaded".to_string());
|
||||
let dest = dl_dir.join(&fname);
|
||||
let _ = log_tx.send(format!("⬇️ Downloading {}...", fname));
|
||||
let _ = client.download_shared_file(&peer_url, &f_id, &item_rel, &dest, pin.as_deref()).await;
|
||||
let tx_clone = transfer_tx.clone();
|
||||
let _ = client.download_shared_file_with_progress(&peer_url, &f_id, &item_rel, &dest, pin.as_deref(), &peer_name_clone, move |p| {
|
||||
let _ = tx_clone.send(p);
|
||||
}).await;
|
||||
}
|
||||
if sound {
|
||||
crate::ui::sound::play_transfer_sound();
|
||||
@@ -2898,7 +3110,7 @@ impl ZeroSendApp {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(30.0);
|
||||
ui.spinner();
|
||||
ui.label("Loading shared contents...");
|
||||
ui.label("Загрузка содержимого папки...");
|
||||
});
|
||||
} else if let Some(tree) = self.current_share_tree.clone() {
|
||||
let is_writable_share = !tree.read_only;
|
||||
@@ -2913,7 +3125,7 @@ impl ZeroSendApp {
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("📁 ..");
|
||||
if ui.button("Parent Folder").clicked() {
|
||||
if ui.button("Вверх (Родительская папка)").clicked() {
|
||||
let p = std::path::Path::new(&self.current_share_subpath);
|
||||
let parent_str = p.parent().map(|p| p.to_string_lossy().replace('\\', "/")).unwrap_or_default();
|
||||
self.current_share_subpath = parent_str;
|
||||
@@ -2954,7 +3166,7 @@ impl ZeroSendApp {
|
||||
ui.label(RichText::new(icon).size(16.0));
|
||||
|
||||
if entry.is_dir {
|
||||
if ui.link(RichText::new(&entry.name).strong().color(theme.text_primary)).clicked() {
|
||||
if ui.link(RichText::new(&entry.name).strong().color(theme.text_accent)).clicked() {
|
||||
self.current_share_subpath = entry.relative_path.clone();
|
||||
self.refresh_current_share_tree();
|
||||
}
|
||||
@@ -2983,10 +3195,12 @@ impl ZeroSendApp {
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
// Action: Download
|
||||
let is_dir = entry.is_dir;
|
||||
let down_label = if is_dir { "⬇️ Zip" } else { "⬇️ Download" };
|
||||
let down_label = if is_dir { "⬇️ Zip" } else { "⬇️ Скачать" };
|
||||
if ui.button(down_label).clicked() {
|
||||
self.switch_tab(Tab::Transfers);
|
||||
let client = self.client.clone();
|
||||
let peer_url = peer.transfer_url();
|
||||
let peer_name_clone = peer_name.clone();
|
||||
let f_id = folder_id.clone();
|
||||
let sub = entry.relative_path.clone();
|
||||
let out_name = if is_dir { format!("{}.zip", entry.name) } else { entry.name.clone() };
|
||||
@@ -2997,11 +3211,15 @@ impl ZeroSendApp {
|
||||
Some(self.share_pin_input.trim().to_string())
|
||||
};
|
||||
let log_tx = self.log_tx.clone();
|
||||
let transfer_tx = self.transfer_tx.clone();
|
||||
let sound = self.sound_notifications;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = log_tx.send(format!("⬇️ Downloading {}...", out_name));
|
||||
match client.download_shared_file(&peer_url, &f_id, &sub, &dest, pin.as_deref()).await {
|
||||
let tx_clone = transfer_tx.clone();
|
||||
match client.download_shared_file_with_progress(&peer_url, &f_id, &sub, &dest, pin.as_deref(), &peer_name_clone, move |p| {
|
||||
let _ = tx_clone.send(p);
|
||||
}).await {
|
||||
Ok(bytes) => {
|
||||
let _ = log_tx.send(format!("✅ Downloaded {} ({})", out_name, human_bytes(bytes as f64)));
|
||||
if sound {
|
||||
@@ -3024,7 +3242,7 @@ impl ZeroSendApp {
|
||||
entry.relative_path.trim_start_matches('/')
|
||||
);
|
||||
|
||||
if ui.button(RichText::new("🎵 Play in App").color(Color32::from_rgb(236, 72, 153)).strong()).clicked() {
|
||||
if ui.button(RichText::new("🎵 Слушать").color(Color32::from_rgb(236, 72, 153)).strong()).clicked() {
|
||||
self.active_audio = Some(AudioTrackInfo {
|
||||
name: entry.name.clone(),
|
||||
url: stream_url.clone(),
|
||||
@@ -3060,7 +3278,7 @@ impl ZeroSendApp {
|
||||
let _ = std::process::Command::new("explorer").arg(&stream_url).spawn();
|
||||
}
|
||||
|
||||
if ui.button(RichText::new("▶️ Stream").color(Color32::from_rgb(56, 189, 248))).clicked() {
|
||||
if ui.button(RichText::new("▶️ Смотреть").color(Color32::from_rgb(56, 189, 248))).clicked() {
|
||||
let _ = std::process::Command::new("explorer").arg(&stream_url).spawn();
|
||||
}
|
||||
}
|
||||
@@ -3106,12 +3324,12 @@ impl ZeroSendApp {
|
||||
.inner_margin(Margin::same(20.0))
|
||||
.show(ui, |ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(RichText::new("🔒 Folder Locked or Empty").strong().size(14.0).color(theme.text_primary));
|
||||
ui.label(RichText::new("🔒 Папка защищена или пуста").strong().size(14.0).color(theme.text_primary));
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Enter PIN if required:");
|
||||
ui.label("Введите PIN если требуется:");
|
||||
ui.add(egui::TextEdit::singleline(&mut self.share_pin_input).password(true).desired_width(120.0));
|
||||
if ui.button("Unlock / Refresh").clicked() {
|
||||
if ui.button("Разблокировать / Обновить").clicked() {
|
||||
self.refresh_current_share_tree();
|
||||
}
|
||||
});
|
||||
@@ -3123,10 +3341,12 @@ impl ZeroSendApp {
|
||||
.fill(theme.bg_card)
|
||||
.rounding(Rounding::same(8.0))
|
||||
.stroke(theme.stroke_border)
|
||||
.inner_margin(Margin::same(40.0))
|
||||
.inner_margin(Margin::same(30.0))
|
||||
.show(ui, |ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(RichText::new("👈 Select a shared folder on the left to browse files, watch streams, and view code in Vim.").color(theme.text_muted));
|
||||
ui.label(RichText::new("👈 Выберите общую папку слева").size(15.0).strong().color(theme.text_primary));
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new("Выберите узел и папку в списке слева для просмотра и скачивания файлов.").size(12.0).color(theme.text_muted));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3172,6 +3392,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;
|
||||
@@ -3996,19 +4217,53 @@ impl ZeroSendApp {
|
||||
.stroke(theme.stroke_border)
|
||||
.inner_margin(Margin::same(14.0))
|
||||
.show(ui, |ui| {
|
||||
// v2.5.3-beta.1.1
|
||||
// v2.5.6
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("v2.5.3-beta.1.1").strong().color(Color32::from_rgb(34, 197, 94)));
|
||||
ui.label(RichText::new("v2.5.6").strong().color(Color32::from_rgb(34, 197, 94)));
|
||||
ui.label(RichText::new("(Текущая версия)").size(11.0).color(Color32::GRAY));
|
||||
});
|
||||
ui.label(" • 🪟 Сохранение положения и размера окна: Позиция окна (X, Y) и его размеры теперь непрерывно отслеживаются и восстанавливаются при каждом запуске приложения.");
|
||||
ui.label(" • 📤 Новый дизайн вкладки «Отправить файлы»: Мульти-выбор друзей карточками с ОС-иконками, бейджами сетей (ZeroTier/Tailscale/LAN), пингом и Drag & Drop.");
|
||||
ui.label(" • 📁 Обновлённый проводник «Сетевые общие папки»: Интерактивные Breadcrumbs, поиск Grep, стриминг и скачивание с мгновенным переходом в историю передач.");
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.add_space(8.0);
|
||||
|
||||
// v2.5.5
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("v2.5.5").strong().color(Color32::from_rgb(56, 189, 248)));
|
||||
});
|
||||
ui.label(" • 📥 Мгновенное перенаправление в передачу файлов: При начале скачивания из общей папки приложение сразу переключается во вкладку передач и отображает статус загрузки.");
|
||||
ui.label(" • ⚡ Отслеживание скорости скачивания общих файлов в реальном времени с выводом прогресса в TransferProgress.");
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.add_space(8.0);
|
||||
|
||||
// v2.5.4
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("v2.5.4").strong().color(Color32::from_rgb(56, 189, 248)));
|
||||
});
|
||||
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(56, 189, 248)));
|
||||
});
|
||||
ui.label(" • ✨ Плавные анимации переходов: Шелковистые кинетические анимации переключения вкладок (Cubic Ease-Out) и плавно скользящий индикатор выбора в меню.");
|
||||
ui.label(" • ⚡ TCP Socket Tuning: 4MB буферы сокетов, TCP_NODELAY и пул соединений через socket2 для максимальной скорости на VPN и LAN.");
|
||||
ui.label(" • 🌊 Zero-Drop Auto-Recovery: Авто-восстановление передач при сбоях Wi-Fi/VPN с экспоненциальным backoff до 8 попыток без обрыва.");
|
||||
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);
|
||||
@@ -4392,7 +4647,7 @@ impl ZeroSendApp {
|
||||
server_clone.accept_transfer(&session_id).await;
|
||||
});
|
||||
self.pending_request = None;
|
||||
self.current_tab = Tab::Transfers;
|
||||
self.switch_tab(Tab::Transfers);
|
||||
}
|
||||
|
||||
let reject_btn = Button::new(
|
||||
@@ -4581,20 +4836,41 @@ impl eframe::App for ZeroSendApp {
|
||||
self.poll_events(ctx);
|
||||
|
||||
if ctx.input(|i| i.viewport().close_requested()) {
|
||||
let _ = self.config.save();
|
||||
if self.close_to_tray {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
|
||||
}
|
||||
}
|
||||
|
||||
// Track & persist window position (X, Y)
|
||||
if let Some(pos) = ctx.input(|i| i.viewport().outer_rect.map(|r| r.min)) {
|
||||
// Filter out invalid / minimized off-screen coordinates (e.g. Windows minimized to -32000)
|
||||
if pos.x > -10000.0 && pos.x < 30000.0 && pos.y > -10000.0 && pos.y < 30000.0 {
|
||||
let pos_changed = match (self.config.window_x, self.config.window_y) {
|
||||
(Some(x), Some(y)) => (x - pos.x).abs() > 4.0 || (y - pos.y).abs() > 4.0,
|
||||
_ => true,
|
||||
};
|
||||
if pos_changed {
|
||||
self.config.window_x = Some(pos.x);
|
||||
self.config.window_y = Some(pos.y);
|
||||
if self.last_window_save.elapsed() > Duration::from_secs(1) {
|
||||
self.last_window_save = Instant::now();
|
||||
let _ = self.config.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track & persist window size (Width, Height)
|
||||
let cur_size = ctx.screen_rect().size();
|
||||
if cur_size.x >= 700.0 && cur_size.y >= 450.0 {
|
||||
if (self.config.window_width - cur_size.x).abs() > 10.0
|
||||
|| (self.config.window_height - cur_size.y).abs() > 10.0
|
||||
if (self.config.window_width - cur_size.x).abs() > 4.0
|
||||
|| (self.config.window_height - cur_size.y).abs() > 4.0
|
||||
{
|
||||
self.config.window_width = cur_size.x;
|
||||
self.config.window_height = cur_size.y;
|
||||
if self.last_window_save.elapsed() > Duration::from_secs(2) {
|
||||
if self.last_window_save.elapsed() > Duration::from_secs(1) {
|
||||
self.last_window_save = Instant::now();
|
||||
let _ = self.config.save();
|
||||
}
|
||||
@@ -4759,6 +5035,18 @@ impl eframe::App for ZeroSendApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Fluid Tab Transition Animation (Smooth Slide-in & Fade-in)
|
||||
let elapsed = self.last_tab_change.elapsed().as_secs_f32();
|
||||
let duration = 0.20; // 200 ms
|
||||
let progress = (elapsed / duration).min(1.0);
|
||||
if progress < 1.0 {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
// Cubic ease-out curve
|
||||
let ease = 1.0 - (1.0 - progress).powi(3);
|
||||
let slide_offset = (1.0 - ease) * 12.0;
|
||||
ui.add_space(slide_offset);
|
||||
|
||||
match self.current_tab {
|
||||
Tab::Nearby => self.render_nearby_tab(ui, theme),
|
||||
Tab::Send => self.render_send_tab(ui, theme),
|
||||
|
||||
Reference in New Issue
Block a user