4 Commits
9 changed files with 1667 additions and 849 deletions
Generated
+87 -1
View File
@@ -440,6 +440,28 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "async-stream"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
dependencies = [
"async-stream-impl",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-stream-impl"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "async-task" name = "async-task"
version = "4.7.1" version = "4.7.1"
@@ -1544,6 +1566,16 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.11" version = "0.1.11"
@@ -4651,6 +4683,17 @@ dependencies = [
"version-compare", "version-compare",
] ]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.12.16" version = "0.12.16"
@@ -6090,6 +6133,16 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix 1.1.4",
]
[[package]] [[package]]
name = "xcursor" name = "xcursor"
version = "0.3.11" version = "0.3.11"
@@ -6372,10 +6425,12 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]] [[package]]
name = "zerosend" name = "zerosend"
version = "2.5.0" version = "2.5.3-beta"
dependencies = [ dependencies = [
"arboard", "arboard",
"async-stream",
"axum", "axum",
"bytes",
"chrono", "chrono",
"clap", "clap",
"crossterm", "crossterm",
@@ -6395,6 +6450,7 @@ dependencies = [
"serde_json", "serde_json",
"sha2", "sha2",
"socket2 0.5.10", "socket2 0.5.10",
"tar",
"tokio", "tokio",
"tokio-util", "tokio-util",
"tower", "tower",
@@ -6409,6 +6465,7 @@ dependencies = [
"winreg", "winreg",
"winres", "winres",
"zip", "zip",
"zstd",
] ]
[[package]] [[package]]
@@ -6459,6 +6516,7 @@ dependencies = [
"memchr", "memchr",
"thiserror 2.0.20", "thiserror 2.0.20",
"zopfli", "zopfli",
"zstd",
] ]
[[package]] [[package]]
@@ -6479,6 +6537,34 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]
[[package]] [[package]]
name = "zune-core" name = "zune-core"
version = "0.5.3" version = "0.5.3"
+8 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "zerosend" name = "zerosend"
version = "2.5.0" version = "2.5.3-beta"
edition = "2021" edition = "2021"
description = "Fast, peer-to-peer file transfer designed for ZeroTier, Tailscale, Radmin VPN and LAN" description = "Fast, peer-to-peer file transfer designed for ZeroTier, Tailscale, Radmin VPN and LAN"
authors = ["RarDog"] authors = ["RarDog"]
@@ -10,6 +10,8 @@ authors = ["RarDog"]
tokio = { version = "1.43", features = ["full"] } tokio = { version = "1.43", features = ["full"] }
tokio-util = { version = "0.7", features = ["io", "codec"] } tokio-util = { version = "0.7", features = ["io", "codec"] }
futures-util = "0.3" futures-util = "0.3"
async-stream = "0.3"
bytes = "1.9"
axum = { version = "0.8", features = ["multipart"] } axum = { version = "0.8", features = ["multipart"] }
tower = "0.5" tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "fs"] } tower-http = { version = "0.6", features = ["cors", "trace", "fs"] }
@@ -19,6 +21,11 @@ socket2 = { version = "0.5", features = ["all"] }
network-interface = "2.0" network-interface = "2.0"
ipnetwork = "0.20" ipnetwork = "0.20"
# Compression & Archiving
zstd = "0.13"
tar = "0.4"
zip = { version = "2.2", default-features = false, features = ["deflate", "zstd"] }
# GUI & Dialogs # GUI & Dialogs
eframe = "0.29" eframe = "0.29"
rfd = "0.15" rfd = "0.15"
@@ -26,7 +33,6 @@ qrcode = "0.14"
arboard = "3.4" arboard = "3.4"
image = { version = "0.25", default-features = false, features = ["png"] } image = { version = "0.25", default-features = false, features = ["png"] }
tray-icon = "0.19" tray-icon = "0.19"
zip = { version = "2.2", default-features = false, features = ["deflate"] }
# CLI & TUI # CLI & TUI
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
+9 -1
View File
@@ -32,6 +32,14 @@ fn get_default_device_name() -> String {
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Clean up any old binaries or temp files from previous self-updates
if let Ok(current_exe) = std::env::current_exe() {
if let Some(exe_dir) = current_exe.parent() {
let _ = std::fs::remove_file(exe_dir.join("zerosend.exe.old"));
let _ = std::fs::remove_file(exe_dir.join("zerosend_update.exe"));
}
}
let config = AppConfig::load(); let config = AppConfig::load();
let cli = Cli::parse(); let cli = Cli::parse();
@@ -298,7 +306,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
.with_inner_size([config.window_width, config.window_height]) .with_inner_size([config.window_width, config.window_height])
.with_min_inner_size([780.0, 500.0]) .with_min_inner_size([780.0, 500.0])
.with_visible(!start_minimized) .with_visible(!start_minimized)
.with_title("ZeroSend v2.5.0 - P2P Sharing for ZeroTier & VPN"), .with_title(format!("ZeroSend v{} - P2P Sharing for ZeroTier & VPN", env!("CARGO_PKG_VERSION"))),
..Default::default() ..Default::default()
}; };
+201
View File
@@ -6,6 +6,10 @@ use walkdir::WalkDir;
use zip::write::SimpleFileOptions; use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipArchive, ZipWriter}; use zip::{CompressionMethod, ZipArchive, ZipWriter};
// ─────────────────────────────────────────────────────────────────
// Legacy Zip helpers (kept for backward-compat & single-file zips)
// ─────────────────────────────────────────────────────────────────
/// Compress a whole folder into a ZIP archive with fast Deflate compression /// Compress a whole folder into a ZIP archive with fast Deflate compression
pub fn compress_folder_to_zip( pub fn compress_folder_to_zip(
src_dir: &Path, src_dir: &Path,
@@ -97,3 +101,200 @@ pub fn extract_zip_archive(
info!("Extraction completed successfully for {:?}", zip_path); info!("Extraction completed successfully for {:?}", zip_path);
Ok(()) Ok(())
} }
// ─────────────────────────────────────────────────────────────────
// Streaming Tar + Zstd (zero disk footprint for folder transfers)
// ─────────────────────────────────────────────────────────────────
/// Returns an estimated uncompressed byte count for the folder
/// (used only to display progress; actual compressed size differs).
pub fn estimate_folder_size(dir: &Path) -> u64 {
WalkDir::new(dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_file())
.map(|e| e.metadata().map(|m| m.len()).unwrap_or(0))
.sum()
}
/// Stream an entire directory as a Zstandard-compressed TAR archive
/// into a `std::sync::mpsc::SyncSender<Vec<u8>>` channel.
///
/// Run this inside `tokio::task::spawn_blocking` so it does not stall
/// the async executor. The caller receives raw compressed bytes and
/// can forward them directly over the network without any temp file.
pub fn stream_folder_as_tar_zst(
src_dir: PathBuf,
tx: std::sync::mpsc::SyncSender<std::io::Result<Vec<u8>>>,
zstd_level: i32, // 1 = ultra-fast, 3 = balanced
) {
info!("Streaming folder {:?} as tar.zst (level={})", src_dir, zstd_level);
let writer = ChannelWriter::new(tx.clone(), 256 * 1024);
let enc = match zstd::stream::write::Encoder::new(writer, zstd_level) {
Ok(e) => e,
Err(e) => {
let _ = tx.send(Err(std::io::Error::other(e.to_string())));
return;
}
};
let mut tar = tar::Builder::new(enc);
let parent = src_dir.parent().unwrap_or(&src_dir);
for entry in WalkDir::new(&src_dir).into_iter() {
let entry = match entry {
Ok(e) => e,
Err(e) => {
error!("WalkDir error: {}", e);
continue;
}
};
let path = entry.path();
let rel_path = match path.strip_prefix(parent) {
Ok(r) => r,
Err(_) => path,
};
if rel_path.as_os_str().is_empty() || rel_path == Path::new(".") {
continue;
}
if path.is_dir() {
let _ = tar.append_dir(rel_path, path);
} else if path.is_file() {
match File::open(path) {
Ok(mut f) => {
let _ = tar.append_file(rel_path, &mut f);
}
Err(e) => {
error!("Cannot open {:?}: {}", path, e);
}
}
}
}
let enc = match tar.into_inner() {
Ok(e) => e,
Err(e) => {
let _ = tx.send(Err(std::io::Error::other(e.to_string())));
return;
}
};
if let Err(e) = enc.finish() {
let _ = tx.send(Err(std::io::Error::other(e.to_string())));
return;
}
info!("Folder streaming completed for {:?}", src_dir);
}
/// Extract a `tar.zst` byte stream directly into `dest_dir`,
/// with path-traversal protection.
///
/// Call this inside `spawn_blocking`.
pub fn extract_tar_zst_stream<R: Read>(
reader: R,
dest_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Extracting tar.zst stream into {:?}", dest_dir);
let decoder = zstd::stream::read::Decoder::new(reader)?;
let mut archive = tar::Archive::new(decoder);
for entry in archive.entries()? {
let mut entry = entry?;
let entry_path = entry.path()?.into_owned();
let safe_rel: PathBuf = entry_path
.components()
.filter(|c| matches!(c, std::path::Component::Normal(_)))
.collect();
if safe_rel.as_os_str().is_empty() {
continue;
}
let outpath = dest_dir.join(&safe_rel);
match entry.header().entry_type() {
tar::EntryType::Directory => {
let _ = std::fs::create_dir_all(&outpath);
}
tar::EntryType::Regular => {
if let Some(p) = outpath.parent() {
let _ = std::fs::create_dir_all(p);
}
if let Ok(mut out) = File::create(&outpath) {
if let Err(e) = std::io::copy(&mut entry, &mut out) {
error!("Write error for {:?}: {}", outpath, e);
}
}
}
_ => {}
}
}
info!("Extraction completed into {:?}", dest_dir);
Ok(())
}
// ─────────────────────────────────────────────────────────────────
// Internal helpers
// ─────────────────────────────────────────────────────────────────
/// A `Write` adapter that accumulates bytes and flushes chunks through
/// a `SyncSender<Result<Vec<u8>>>` when the buffer is full.
struct ChannelWriter {
tx: std::sync::mpsc::SyncSender<std::io::Result<Vec<u8>>>,
buf: Vec<u8>,
chunk_size: usize,
}
impl ChannelWriter {
fn new(
tx: std::sync::mpsc::SyncSender<std::io::Result<Vec<u8>>>,
chunk_size: usize,
) -> Self {
Self {
tx,
buf: Vec::with_capacity(chunk_size),
chunk_size,
}
}
fn flush_buf(&mut self) -> std::io::Result<()> {
if self.buf.is_empty() {
return Ok(());
}
let chunk = std::mem::replace(&mut self.buf, Vec::with_capacity(self.chunk_size));
self.tx
.send(Ok(chunk))
.map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "receiver dropped"))
}
}
impl Write for ChannelWriter {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
self.buf.extend_from_slice(data);
if self.buf.len() >= self.chunk_size {
self.flush_buf()?;
}
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
self.flush_buf()
}
}
impl Drop for ChannelWriter {
fn drop(&mut self) {
let _ = self.flush_buf();
}
}
+117 -31
View File
@@ -1,4 +1,5 @@
use super::archive::compress_folder_to_zip; #[allow(unused_imports)]
use super::archive;
use super::protocol::{ use super::protocol::{
FileMetadata, GrepResponse, PrepareTransferRequest, PrepareTransferResponse, FileMetadata, GrepResponse, PrepareTransferRequest, PrepareTransferResponse,
SharedFileSaveRequest, SharedFileSaveResponse, SharedFolderInfo, SharedFolderTreeResponse, SharedFileSaveRequest, SharedFileSaveResponse, SharedFolderInfo, SharedFolderTreeResponse,
@@ -160,8 +161,8 @@ impl TransferClient {
{ {
let mut file_entries: Vec<(PathBuf, String, u64)> = Vec::new(); let mut file_entries: Vec<(PathBuf, String, u64)> = Vec::new();
let mut total_bytes = 0u64; let mut total_bytes = 0u64;
let temp_dir = std::env::temp_dir().join("ZeroSend_Zip"); // Folders that need streaming tar.zst transfer (no disk zip)
let _ = std::fs::create_dir_all(&temp_dir); let mut streaming_folders: Vec<(PathBuf, String)> = Vec::new();
for p in paths { for p in paths {
if !p.exists() { if !p.exists() {
@@ -175,35 +176,18 @@ impl TransferClient {
.map(|n| n.to_string_lossy().into_owned()) .map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "folder".to_string()); .unwrap_or_else(|| "folder".to_string());
let zip_filename = format!("{}.zip", folder_name); // Estimate uncompressed size for progress display
let temp_zip_path = temp_dir.join(&zip_filename); let estimated_size = {
let p_clone = p.clone();
tokio::task::spawn_blocking(move || {
super::archive::estimate_folder_size(&p_clone)
})
.await
.unwrap_or(0)
};
// Notify UI that folder is being compressed into ZIP archive total_bytes += estimated_size;
on_progress(TransferProgress { streaming_folders.push((p.clone(), folder_name));
session_id: Uuid::new_v4().to_string(),
peer_name: peer_display_name.to_string(),
is_incoming: false,
current_file_index: 0,
total_files: paths.len(),
current_file_name: format!("📦 Compressing folder '{}' into ZIP...", folder_name),
bytes_transferred: 0,
total_bytes: 0,
speed_bps: 0,
state: TransferState::InProgress,
checksum_verified: false,
});
let p_clone = p.clone();
let temp_zip_clone = temp_zip_path.clone();
let zip_size = tokio::task::spawn_blocking(move || {
compress_folder_to_zip(&p_clone, &temp_zip_clone)
})
.await
.map_err(|e| format!("Compression task error: {}", e))?
.map_err(|e| format!("Failed to compress folder {:?}: {}", p, e))?;
total_bytes += zip_size;
file_entries.push((temp_zip_path, zip_filename, zip_size));
} else { } else {
let base_parent = p.parent().unwrap_or(p); let base_parent = p.parent().unwrap_or(p);
for entry in WalkDir::new(p).into_iter().filter_map(|e| e.ok()) { for entry in WalkDir::new(p).into_iter().filter_map(|e| e.ok()) {
@@ -237,10 +221,112 @@ 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);
on_progress(TransferProgress {
session_id: Uuid::new_v4().to_string(),
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),
bytes_transferred: 0,
total_bytes,
speed_bps: 0,
state: TransferState::InProgress,
checksum_verified: false,
});
// Build a bounded channel: blocking producer → async consumer → HTTP body
let (tx, rx) = std::sync::mpsc::sync_channel::<std::io::Result<Vec<u8>>>(32);
let folder_clone = folder_path.clone();
// Spawn blocking tar+zstd compressor thread
let compress_handle = tokio::task::spawn_blocking(move || {
super::archive::stream_folder_as_tar_zst(folder_clone, tx, 1);
});
// 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
);
// Wrap sync receiver in async stream for reqwest using Arc<Mutex>
let rx_arc = std::sync::Arc::new(std::sync::Mutex::new(rx));
let body_stream = async_stream::stream! {
loop {
let rx_clone = rx_arc.clone();
let result = tokio::task::spawn_blocking(move || {
rx_clone.lock().unwrap().recv()
}).await;
match result {
Ok(Ok(Ok(chunk))) => 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
}
}
};
let _ = peer_url_clone; // consumed in stream_url
let resp = self
.client
.post(&stream_url)
.header("x-folder-name", &archive_name_clone)
.header("x-transfer-type", "tar.zst")
.body(reqwest::Body::wrap_stream(body_stream))
.send()
.await;
compress_handle.await.ok();
match resp {
Ok(r) if r.status().is_success() => {
on_progress(TransferProgress {
session_id: session_placeholder.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,
speed_bps: 0,
state: TransferState::Completed,
checksum_verified: false,
});
}
Ok(r) => {
return Err(format!(
"Folder stream rejected by peer: HTTP {}",
r.status()
));
}
Err(e) => {
return Err(format!("Folder stream network error: {}", e));
}
}
}
if file_entries.is_empty() && paths.iter().all(|p| p.is_dir() && auto_zip_folders) {
// All items were streaming folders — done
return Ok(());
}
if file_entries.is_empty() { if file_entries.is_empty() {
return Err("No files selected for transfer".to_string()); return Err("No files selected for transfer".to_string());
} }
let file_metas: Vec<FileMetadata> = file_entries let file_metas: Vec<FileMetadata> = file_entries
.iter() .iter()
.map(|(path, rel_path, size)| { .map(|(path, rel_path, size)| {
+83 -1
View File
@@ -1,3 +1,5 @@
#[allow(unused_imports)]
use super::archive;
use super::archive::extract_zip_archive; use super::archive::extract_zip_archive;
use super::protocol::{ use super::protocol::{
FileMetadata, GrepMatch, GrepResponse, PrepareTransferRequest, PrepareTransferResponse, FileMetadata, GrepMatch, GrepResponse, PrepareTransferRequest, PrepareTransferResponse,
@@ -186,6 +188,10 @@ impl TransferServer {
"/api/transfer/finish/{session_id}", "/api/transfer/finish/{session_id}",
post(handle_finish_transfer), post(handle_finish_transfer),
) )
.route(
"/api/transfer/folder_stream/{session_id}",
post(handle_folder_stream).layer(DefaultBodyLimit::disable()),
)
.route( .route(
"/api/transfer/upload/{session_id}", "/api/transfer/upload/{session_id}",
post(handle_upload).layer(DefaultBodyLimit::disable()), post(handle_upload).layer(DefaultBodyLimit::disable()),
@@ -658,6 +664,81 @@ async fn handle_finish_transfer(
) )
} }
/// Streaming tar.zst folder receiver — zero disk footprint extraction on-the-fly
async fn handle_folder_stream(
State(state): State<Arc<ServerState>>,
Path(_session_id): Path<String>,
headers: axum::http::HeaderMap,
body: axum::body::Body,
) -> impl IntoResponse {
let folder_name = headers
.get("x-folder-name")
.and_then(|v| v.to_str().ok())
.unwrap_or("received_folder")
.to_string();
info!("Receiving streaming folder: {}", folder_name);
// 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 dest_dir = state.download_dir.clone();
let result = tokio::task::spawn_blocking(move || {
super::archive::extract_tar_zst_stream(std::io::Cursor::new(data), &dest_dir)
})
.await;
match result {
Ok(Ok(())) => {
info!("Folder '{}' extracted successfully", folder_name);
// Emit a completed progress event
let progress = super::protocol::TransferProgress {
session_id: _session_id.clone(),
peer_name: String::new(),
is_incoming: true,
current_file_index: 1,
total_files: 1,
current_file_name: folder_name.clone(),
bytes_transferred: 0,
total_bytes: 0,
speed_bps: 0,
state: super::protocol::TransferState::Completed,
checksum_verified: false,
};
let _ = state.transfer_events.send(progress);
(
StatusCode::OK,
Json(serde_json::json!({"status": "extracted", "folder": folder_name})),
)
}
Ok(Err(e)) => {
error!("Extraction error for '{}': {}", folder_name, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
}
Err(e) => {
error!("Spawn blocking error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
}
}
}
async fn handle_upload( async fn handle_upload(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
Path(session_id): Path<String>, Path(session_id): Path<String>,
@@ -2330,7 +2411,7 @@ async fn handle_web_ui(State(state): State<Arc<ServerState>>) -> Html<String> {
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<div class="footer"> <div class="footer">
ZeroSend v2.4.1 • Ultra-fast P2P File & Text Sharing • 🛡️ SHA-256 Verified ZeroSend v{version} • Ultra-fast P2P File & Text Sharing • 🛡️ SHA-256 Verified
</div> </div>
</div> </div>
@@ -2766,6 +2847,7 @@ async fn handle_web_ui(State(state): State<Arc<ServerState>>) -> Html<String> {
</html>"###, </html>"###,
device_name = state.my_info.name, device_name = state.my_info.name,
net_type = state.my_info.net_type, net_type = state.my_info.net_type,
version = env!("CARGO_PKG_VERSION"),
); );
Html(html) Html(html)
+1126 -787
View File
File diff suppressed because it is too large Load Diff
+21 -21
View File
@@ -100,9 +100,9 @@ impl VimViewerModal {
self.is_command_mode = false; self.is_command_mode = false;
self.is_dirty = false; self.is_dirty = false;
self.status_msg = Some(format!( self.status_msg = Some(format!(
"Loaded {} lines • {}", "Загружено {} строк • {}",
self.lines.len(), self.lines.len(),
if self.is_writable { "Writable [i: edit, :w: save]" } else { "Read-Only" } if self.is_writable { "Доступна запись [i: правка, :w: сохранить]" } else { "Только чтение" }
)); ));
self.last_g_press = None; self.last_g_press = None;
} }
@@ -117,7 +117,7 @@ impl VimViewerModal {
pub fn trigger_save(&mut self) { pub fn trigger_save(&mut self) {
if !self.is_writable { if !self.is_writable {
self.status_msg = Some("❌ Cannot save: folder is Read-Only".to_string()); self.status_msg = Some("❌ Невозможно сохранить: папка только для чтения".to_string());
return; return;
} }
@@ -129,10 +129,10 @@ impl VimViewerModal {
self.buffer_content.clone(), self.buffer_content.clone(),
self.pin.clone(), self.pin.clone(),
)); ));
self.status_msg = Some("⏳ Saving changes to remote peer...".to_string()); self.status_msg = Some("⏳ Сохранение изменений на удаленном узле...".to_string());
self.is_dirty = false; self.is_dirty = false;
} else { } else {
self.status_msg = Some("❌ No remote endpoint associated with this file".to_string()); self.status_msg = Some("❌ Нет удаленного подключения для этого файла".to_string());
} }
} }
@@ -361,14 +361,14 @@ impl VimViewerModal {
} }
if self.is_writable { if self.is_writable {
ui.label(RichText::new("• [Writable]").color(Color32::from_rgb(34, 197, 94)).size(11.0)); ui.label(RichText::new("• [Доступна запись]").color(Color32::from_rgb(34, 197, 94)).size(11.0));
} else { } else {
ui.label(RichText::new("• [Read-Only]").color(Color32::from_rgb(148, 163, 184)).size(11.0)); ui.label(RichText::new("• [Только чтение]").color(Color32::from_rgb(148, 163, 184)).size(11.0));
} }
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui if ui
.button(RichText::new("✕ Close (:q)").color(Color32::from_rgb(203, 213, 225))) .button(RichText::new("✕ Закрыть (:q)").color(Color32::from_rgb(203, 213, 225)))
.clicked() .clicked()
{ {
self.close(); self.close();
@@ -376,7 +376,7 @@ impl VimViewerModal {
if self.is_writable { if self.is_writable {
let save_btn = egui::Button::new( let save_btn = egui::Button::new(
RichText::new("💾 Save (:w)") RichText::new("💾 Сохранить (:w)")
.strong() .strong()
.color(Color32::WHITE), .color(Color32::WHITE),
) )
@@ -387,7 +387,7 @@ impl VimViewerModal {
self.trigger_save(); self.trigger_save();
} }
let edit_label = if self.is_insert_mode { "👁 View Mode (Esc)" } else { "✏️ Edit (i)" }; let edit_label = if self.is_insert_mode { "👁 Просмотр (Esc)" } else { "✏️ Редактировать (i)" };
if ui.button(RichText::new(edit_label).color(Color32::from_rgb(226, 232, 240))).clicked() { if ui.button(RichText::new(edit_label).color(Color32::from_rgb(226, 232, 240))).clicked() {
self.is_insert_mode = !self.is_insert_mode; self.is_insert_mode = !self.is_insert_mode;
if !self.is_insert_mode { if !self.is_insert_mode {
@@ -398,7 +398,7 @@ impl VimViewerModal {
if self.file_name.to_lowercase().ends_with(".md") { if self.file_name.to_lowercase().ends_with(".md") {
if ui if ui
.selectable_label(self.is_markdown_view, "👁 Markdown Preview") .selectable_label(self.is_markdown_view, "👁 Предпросмотр Markdown")
.clicked() .clicked()
{ {
self.is_markdown_view = !self.is_markdown_view; self.is_markdown_view = !self.is_markdown_view;
@@ -406,7 +406,7 @@ impl VimViewerModal {
} }
ui.label( ui.label(
RichText::new(format!("{} lines", self.lines.len())) RichText::new(format!("{} строк", self.lines.len()))
.size(11.0) .size(11.0)
.color(Color32::from_rgb(148, 163, 184)), .color(Color32::from_rgb(148, 163, 184)),
); );
@@ -539,13 +539,13 @@ impl VimViewerModal {
ui.horizontal(|ui| { ui.horizontal(|ui| {
// Mode Pill // Mode Pill
let (mode_text, mode_bg, mode_fg) = if self.is_insert_mode { let (mode_text, mode_bg, mode_fg) = if self.is_insert_mode {
("INSERT", Color32::from_rgb(34, 197, 94), Color32::BLACK) ("ВСТАВКА", Color32::from_rgb(34, 197, 94), Color32::BLACK)
} else if self.is_searching { } else if self.is_searching {
("SEARCH", Color32::from_rgb(217, 119, 6), Color32::WHITE) ("ПОИСК", Color32::from_rgb(217, 119, 6), Color32::WHITE)
} else if self.is_command_mode { } else if self.is_command_mode {
("COMMAND", Color32::from_rgb(147, 51, 234), Color32::WHITE) ("КОМАНДА", Color32::from_rgb(147, 51, 234), Color32::WHITE)
} else { } else {
("NORMAL", Color32::from_rgb(37, 99, 235), Color32::WHITE) ("ОБЫЧНЫЙ", Color32::from_rgb(37, 99, 235), Color32::WHITE)
}; };
Frame::none() Frame::none()
@@ -559,16 +559,16 @@ impl VimViewerModal {
if self.is_searching { if self.is_searching {
ui.label(RichText::new("/").monospace().color(Color32::from_rgb(245, 158, 11))); ui.label(RichText::new("/").monospace().color(Color32::from_rgb(245, 158, 11)));
let edit = TextEdit::singleline(&mut self.search_query) let edit = TextEdit::singleline(&mut self.search_query)
.hint_text("pattern (press Enter)...") .hint_text("поиск (нажмите Enter)...")
.font(egui::TextStyle::Monospace) .font(egui::TextStyle::Monospace)
.desired_width(200.0); .desired_width(200.0);
ui.add(edit).request_focus(); ui.add(edit).request_focus();
} else if self.is_command_mode { } else if self.is_command_mode {
ui.label(RichText::new(":").monospace().color(Color32::from_rgb(168, 85, 247))); ui.label(RichText::new(":").monospace().color(Color32::from_rgb(168, 85, 247)));
let edit = TextEdit::singleline(&mut self.command_input) let edit = TextEdit::singleline(&mut self.command_input)
.hint_text("w (save) / q (quit) / line...") .hint_text("w (сохранить) / q (выход) / номер...")
.font(egui::TextStyle::Monospace) .font(egui::TextStyle::Monospace)
.desired_width(200.0); .desired_width(220.0);
ui.add(edit).request_focus(); ui.add(edit).request_focus();
} else { } else {
if let Some(msg) = &self.status_msg { if let Some(msg) = &self.status_msg {
@@ -578,7 +578,7 @@ impl VimViewerModal {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label( ui.label(
RichText::new("i: edit • Esc: normal • :w: save • :q: exit") RichText::new("i: правка • Esc: обычный • :w: сохранить • :q: выход")
.size(10.0) .size(10.0)
.color(Color32::from_rgb(100, 116, 139)), .color(Color32::from_rgb(100, 116, 139)),
); );
@@ -586,7 +586,7 @@ impl VimViewerModal {
ui.separator(); ui.separator();
ui.label( ui.label(
RichText::new(format!("Ln {}, Col 1", self.cursor_line)) RichText::new(format!("Стр {}, Кол 1", self.cursor_line))
.size(11.0) .size(11.0)
.monospace() .monospace()
.color(Color32::from_rgb(148, 163, 184)), .color(Color32::from_rgb(148, 163, 184)),
+15 -5
View File
@@ -335,17 +335,27 @@ pub async fn perform_update_download(
info!("Update successfully installed to {:?}", current_exe); info!("Update successfully installed to {:?}", current_exe);
let _ = progress_tx.send(UpdateProgress::Completed); let _ = progress_tx.send(UpdateProgress::Completed);
// Spawn background PowerShell cleanup script to delete old binary once this process terminates
let cleanup_script = format!(
"Start-Sleep -Seconds 1; Remove-Item -Force -ErrorAction SilentlyContinue '{}'",
old_backup_path.to_string_lossy()
);
let _ = std::process::Command::new("powershell")
.args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", &cleanup_script])
.spawn();
// Spawn updated application and exit // Spawn updated application and exit
let _ = std::process::Command::new(&current_exe).spawn(); let _ = std::process::Command::new(&current_exe).spawn();
std::thread::sleep(Duration::from_millis(500)); std::thread::sleep(Duration::from_millis(400));
std::process::exit(0); std::process::exit(0);
} else { } else {
// Fallback: Use PowerShell deferred replacement // Fallback: Use PowerShell deferred replacement and cleanup
info!("Direct rename locked; triggering background PowerShell replacement..."); info!("Direct rename locked; triggering background PowerShell replacement...");
let script = format!( let script = format!(
"Start-Sleep -Milliseconds 600; Move-Item -Force '{temp}' '{target}'; Start-Process '{target}'", "Start-Sleep -Milliseconds 600; Move-Item -Force '{temp}' '{target}'; Start-Sleep -Seconds 1; Remove-Item -Force -ErrorAction SilentlyContinue '{old}'; Start-Process '{target}'",
temp = temp_download_path.to_string_lossy(), temp = temp_download_path.to_string_lossy(),
target = current_exe.to_string_lossy() target = current_exe.to_string_lossy(),
old = old_backup_path.to_string_lossy()
); );
let _ = std::process::Command::new("powershell") let _ = std::process::Command::new("powershell")
@@ -353,7 +363,7 @@ pub async fn perform_update_download(
.spawn(); .spawn();
let _ = progress_tx.send(UpdateProgress::Completed); let _ = progress_tx.send(UpdateProgress::Completed);
std::thread::sleep(Duration::from_millis(500)); std::thread::sleep(Duration::from_millis(400));
std::process::exit(0); std::process::exit(0);
} }
} }