feat: ZeroSend v2.4.1 - Shared Folders, Media Streaming, Vim Viewer, Portable Mode & Gitea Auto-Updater
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
/target
|
||||
/downloads
|
||||
config.json
|
||||
*.exe.old
|
||||
*.zerosend_part
|
||||
*.zerosend_test
|
||||
*.log
|
||||
Generated
+6573
File diff suppressed because it is too large
Load Diff
+63
@@ -0,0 +1,63 @@
|
||||
[package]
|
||||
name = "zerosend"
|
||||
version = "2.4.1"
|
||||
edition = "2021"
|
||||
description = "Fast, peer-to-peer file transfer designed for ZeroTier, Tailscale, Radmin VPN and LAN"
|
||||
authors = ["RarDog"]
|
||||
|
||||
[dependencies]
|
||||
# Async Runtime & Networking
|
||||
tokio = { version = "1.43", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["io", "codec"] }
|
||||
futures-util = "0.3"
|
||||
axum = { version = "0.8", features = ["multipart"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "fs"] }
|
||||
reqwest = { version = "0.12", features = ["stream", "json", "multipart"] }
|
||||
socket2 = { version = "0.5", features = ["all"] }
|
||||
network-interface = "2.0"
|
||||
ipnetwork = "0.20"
|
||||
|
||||
# GUI & Dialogs
|
||||
eframe = "0.29"
|
||||
rfd = "0.15"
|
||||
qrcode = "0.14"
|
||||
arboard = "3.4"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
tray-icon = "0.19"
|
||||
zip = { version = "2.2", default-features = false, features = ["deflate"] }
|
||||
|
||||
# CLI & TUI
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
ratatui = "0.29"
|
||||
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||
indicatif = "0.17"
|
||||
|
||||
# Serialization & Utils
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
uuid = { version = "1.11", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
walkdir = "2.5"
|
||||
human_bytes = "0.4"
|
||||
|
||||
# Windows Native Registry & Sound
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.52"
|
||||
windows-sys = { version = "0.59", features = ["Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[build-dependencies]
|
||||
winres = "0.1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
panic = "unwind"
|
||||
strip = true
|
||||
@@ -0,0 +1,74 @@
|
||||
# ⚡ ZeroSend v2.4.1 (Shared Folders, HTTP 206 Streaming, Vim Viewer & Portable Mode)
|
||||
|
||||
> **Ultra-fast, lightweight, peer-to-peer file & text sharing designed specifically for ZeroTier, Tailscale, Radmin VPN, and LAN networks.**
|
||||
|
||||
ZeroSend 2.4.1 is a modern, standalone, privacy-first alternative to LocalSend that is explicitly optimized for virtual mesh networks (ZeroTier, Tailscale, Radmin VPN). It features Network-wide Shared Folders with Read-Only / Read-Write permissions, instant HTTP 206 partial content streaming for video and music without waiting for downloads, an authentic embedded lightweight Vim code and text viewer, a self-contained single-folder portable storage mode (`config.json`), mobile-first Web dropzone with camera/voice-notes shortcuts, dual QR access links, 4-stream parallel chunk streaming, on-the-fly folder ZIP compression, and dynamic light/dark themes.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features Overview
|
||||
|
||||
### 📁 1. Network Shared Folders & Media Streaming (v2.4.1)
|
||||
* **My Shares:** Select any folder on your hard drive to share with friends over ZeroTier/LAN. Configure **Read-Only** (streaming & download) or **Read & Write** (friends can upload files directly to your folder), protected with an optional 4-digit PIN code.
|
||||
* **Network Shares Explorer:** Browse your friends' shared directories with interactive breadcrumb path navigation and instant search filtering.
|
||||
* **🎬 Instant HTTP 206 Media Streaming:** Watch shared movies, TV series, or listen to music on-the-fly with instant seeking without having to download the entire file first.
|
||||
* **⚡ Embedded Vim Code & Text Viewer:** Inspect `.rs`, `.py`, `.js`, `.ts`, `.html`, `.json`, `.toml`, `.cpp`, `.md`, and `.txt` files directly inside ZeroSend with genuine Vim keybindings (`j`/`k`, `Ctrl+d`/`Ctrl+u`, `gg`/`G`, `/` pattern search with live highlights, `:q` exit, `:<line>` line jump).
|
||||
* **📦 Single-Folder Portable Mode:** All configuration (`config.json`), downloads, and settings are created and saved directly inside the folder where `zerosend.exe` is located.
|
||||
* **🔄 Zero-Config Auto-Sync:** Shared folders are broadcast dynamically via discovery beacons (`DiscoveryBeacon`), automatically appearing when a peer joins the mesh network.
|
||||
|
||||
### 📱 2. Mobile-First Web Dropzone & PWA (v2.4.0)
|
||||
* **Mobile-First Touch Dashboard:** Optimized for iOS and Android with direct `📸 Photos/Videos`, `📄 Documents`, and `🎙️ Voice Note` recording action chips.
|
||||
* **Thumbnail Gallery Previews:** Real-time preview of selected photos and images before sending, with 1-tap delete chips.
|
||||
* **🎙️ Mobile Voice Notes:** Built-in Web Audio recorder with live sound wave animation and instant delivery to PC.
|
||||
* **📲 PWA Standalone App:** Add to Home Screen support on iOS and Android with custom app icons and manifest.
|
||||
* **Dual QR & Access Links:** Separate ZeroTier/VPN mesh link (`http://<zerotier_ip>:53318`) and Home Wi-Fi/LAN link (`http://<local_ip>:53318`) with 1-click copy, QR code, and browser launch.
|
||||
* **🛡️ SHA-256 Checksum on the Fly:** Cryptographic hash integrity verification computed in parallel during transmission with green badge status.
|
||||
|
||||
### ⚡ 3. High-Performance Multi-Stream Engine
|
||||
* **4-Stream Parallel Chunk Transfers:** Automatically splits single large files or batches into concurrent 4x HTTP/TCP streams, eliminating TCP window bottlenecks over VPN links.
|
||||
* **Chunk Auto-Retry & Fault Tolerance:** Automatic retry (up to 3 attempts) for failed chunks on Wi-Fi or VPN micro-disconnects without dropping the whole transfer.
|
||||
* **Dynamic Adaptive Chunk Sizing:** Scales chunk granularity from 512 KB (small files) up to 8 MB (huge 10GB+ archives) to maximize throughput and minimize overhead.
|
||||
* **Live Ping & Latency Indicator:** Real-time round-trip latency (`🟢 12 ms` / `🟡 65 ms` / `🔴 140 ms`) displayed right next to each peer in the Nearby list.
|
||||
* **Custom Bandwidth Speed Limiter:** Token-bucket upload rate limiter with interactive custom numeric input and 1-click presets (`Unlimited`, `5`, `10`, `25`, `50`, `100 MB/s`).
|
||||
* **Resumable Transfers (.zerosend_part Checkpointing):** Interrupted transfers seamlessly resume from the exact last chunk offset.
|
||||
* **Pinned Favorite Friends:** Star (`⭐`) frequently used friends in the **Nearby** tab to pin them to the top.
|
||||
|
||||
### 📦 4. On-The-Fly Folder ZIP Compression & Auto-Extract
|
||||
* **10x Faster Folder Sharing:** Packages directory trees into streaming Deflate ZIP archives on the fly.
|
||||
* **Automatic Extraction:** The recipient's ZeroSend automatically extracts the received ZIP folder right into their Downloads directory.
|
||||
* **Web & Mobile Friendly:** Mobile devices downloading via QR code get a clean single `.zip` file with 1 tap.
|
||||
|
||||
### 🎨 5. Dynamic Theme & Desktop Polish
|
||||
* **🌓 Auto Dark/Light Theme:** Automatically synchronizes with Windows 10/11 system theme or allows forcing Always Dark / Always Light with high-contrast text readability.
|
||||
* **System Tray Resident:** Minimizes cleanly to the Windows system tray with auto-popup on incoming file requests.
|
||||
* **Drag & Drop Anywhere:** Drag files or folders into the window or directly onto a peer's card in the Nearby list.
|
||||
* **Clipboard Screenshots (`Ctrl+V`):** Paste screenshots directly from clipboard to queue them for immediate transfer.
|
||||
|
||||
---
|
||||
|
||||
## 📜 Version History (Changelog)
|
||||
|
||||
### **v2.4.1 (Current Release)**
|
||||
* 📁 **Shared Folders Tab:** Dedicated Shared Folders management with Read-Only / Read-Write permissions and PIN security.
|
||||
* 🎬 **HTTP 206 Streaming:** Instant real-time video/audio streaming with seeking without downloading entire files.
|
||||
* ⚡ **Built-in Vim Code Viewer:** Terminal-style code viewer with full Vim keybindings, search (`/`), and line jump.
|
||||
* 📦 **Single-Folder Portable Mode:** All settings (`config.json`) and downloads are created & saved right in the exe folder.
|
||||
* 🔄 **Auto-Sync:** Real-time discovery beacon broadcasting of shared folders when connecting to ZeroTier/VPN.
|
||||
|
||||
### **v2.4.0**
|
||||
* 🛡️ **SHA-256 Checksum on the Fly:** File integrity calculation & verification during streaming with green verification badges.
|
||||
* 📸 **Mobile Media Previews:** Thumbnail gallery previews for photos with instant remove buttons before uploading.
|
||||
* 🎙️ **Mobile Voice Notes:** Built-in voice recorder with live audio wave pulse animation and one-tap delivery.
|
||||
* 📲 **PWA Support:** Web App Manifest & Add to Home Screen standalone install capabilities for mobile browsers.
|
||||
* 🎨 **Auto Theme:** Dark/Light theme mode with Windows Registry theme sync & contrast fixes.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Building from Source
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
The compiled standalone binary will be located at:
|
||||
`target/release/zerosend.exe`
|
||||
@@ -0,0 +1,45 @@
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
let mut res = winres::WindowsResource::new();
|
||||
res.set("FileDescription", "ZeroSend - Fast P2P Mesh File Sharing");
|
||||
res.set("ProductName", "ZeroSend");
|
||||
res.set("ProductVersion", "2.3.2.0");
|
||||
res.set("FileVersion", "2.3.2.0");
|
||||
res.set("CompanyName", "RarDog");
|
||||
res.set("LegalCopyright", "Copyright (C) 2026 RarDog");
|
||||
res.set("OriginalFilename", "zerosend.exe");
|
||||
res.set("InternalName", "zerosend");
|
||||
|
||||
// Standard application manifest
|
||||
res.set_manifest(r#"
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<assemblyIdentity
|
||||
version="2.3.2.0"
|
||||
processorArchitecture="*"
|
||||
name="ZeroSend"
|
||||
type="win32"
|
||||
/>
|
||||
<description>ZeroSend Fast P2P Mesh File Sharing</description>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
"#);
|
||||
|
||||
if let Err(e) = res.compile() {
|
||||
eprintln!("Warning: failed to compile winres: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn main() {}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ThemeMode {
|
||||
Auto, // Follow Windows / System theme
|
||||
Dark, // Always Dark Theme
|
||||
Light, // Always Light Theme
|
||||
}
|
||||
|
||||
impl Default for ThemeMode {
|
||||
fn default() -> Self {
|
||||
ThemeMode::Auto
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SharedFolderConfig {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
#[serde(default = "default_true")]
|
||||
pub read_only: bool,
|
||||
#[serde(default)]
|
||||
pub pin: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub peer_id: Option<String>,
|
||||
pub device_name: Option<String>,
|
||||
pub download_dir: PathBuf,
|
||||
pub auto_accept: bool,
|
||||
pub port: u16,
|
||||
pub preferred_interface: Option<String>,
|
||||
pub require_pin: bool,
|
||||
pub pin_code: String,
|
||||
pub peer_aliases: HashMap<String, String>,
|
||||
pub window_width: f32,
|
||||
pub window_height: f32,
|
||||
pub close_to_tray: bool,
|
||||
pub minimize_to_tray: bool,
|
||||
pub auto_zip_folders: bool,
|
||||
pub auto_extract_zip: bool,
|
||||
#[serde(default)]
|
||||
pub max_upload_speed_mbps: u32,
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_notifications: bool,
|
||||
#[serde(default)]
|
||||
pub favorite_peers: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub theme_mode: ThemeMode,
|
||||
#[serde(default)]
|
||||
pub shared_folders: Vec<SharedFolderConfig>,
|
||||
#[serde(default = "default_true")]
|
||||
pub check_updates_on_startup: bool,
|
||||
#[serde(default)]
|
||||
pub gitea_api_url: Option<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
let download_dir = get_default_download_dir();
|
||||
|
||||
Self {
|
||||
peer_id: Some(Uuid::new_v4().to_string()),
|
||||
device_name: None,
|
||||
download_dir,
|
||||
auto_accept: false,
|
||||
port: 53318,
|
||||
preferred_interface: None,
|
||||
require_pin: false,
|
||||
pin_code: "1234".to_string(),
|
||||
peer_aliases: HashMap::new(),
|
||||
window_width: 940.0,
|
||||
window_height: 640.0,
|
||||
close_to_tray: true,
|
||||
minimize_to_tray: true,
|
||||
auto_zip_folders: true,
|
||||
auto_extract_zip: true,
|
||||
max_upload_speed_mbps: 0,
|
||||
sound_notifications: true,
|
||||
favorite_peers: Vec::new(),
|
||||
theme_mode: ThemeMode::Auto,
|
||||
shared_folders: Vec::new(),
|
||||
check_updates_on_startup: true,
|
||||
gitea_api_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn get_app_dir() -> PathBuf {
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
if let Some(exe_dir) = exe_path.parent() {
|
||||
// Test if exe directory is writable
|
||||
let test_path = exe_dir.join(".zerosend_write_test");
|
||||
if std::fs::write(&test_path, b"test").is_ok() {
|
||||
let _ = std::fs::remove_file(&test_path);
|
||||
return exe_dir.to_path_buf();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to APPDATA if exe directory is read-only
|
||||
if let Some(mut app_data) = get_app_data_dir() {
|
||||
app_data.push("ZeroSend");
|
||||
let _ = std::fs::create_dir_all(&app_data);
|
||||
return app_data;
|
||||
}
|
||||
|
||||
PathBuf::from(".")
|
||||
}
|
||||
|
||||
pub fn config_path() -> PathBuf {
|
||||
Self::get_app_dir().join("config.json")
|
||||
}
|
||||
|
||||
pub fn load() -> Self {
|
||||
let path = Self::config_path();
|
||||
|
||||
// If local config doesn't exist yet, check if there was a legacy APPDATA config to migrate
|
||||
if !path.exists() {
|
||||
if let Some(legacy_dir) = get_app_data_dir() {
|
||||
let legacy_path = legacy_dir.join("ZeroSend").join("config.json");
|
||||
if legacy_path.exists() && legacy_path != path {
|
||||
if let Ok(content) = std::fs::read_to_string(&legacy_path) {
|
||||
if let Ok(legacy_cfg) = serde_json::from_str::<AppConfig>(&content) {
|
||||
info!("Migrated configuration from legacy {:?}", legacy_path);
|
||||
let _ = legacy_cfg.save();
|
||||
return legacy_cfg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(mut config) = serde_json::from_str::<AppConfig>(&content) {
|
||||
if config.peer_id.is_none() {
|
||||
config.peer_id = Some(Uuid::new_v4().to_string());
|
||||
let _ = config.save();
|
||||
}
|
||||
info!("Loaded configuration from {:?}", path);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
}
|
||||
let default = Self::default();
|
||||
let _ = default.save();
|
||||
default
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let path = Self::config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let json = serde_json::to_string_pretty(self)?;
|
||||
std::fs::write(&path, json)?;
|
||||
info!("Saved configuration to {:?}", path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn get_app_data_dir() -> Option<PathBuf> {
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
return Some(PathBuf::from(appdata));
|
||||
}
|
||||
if let Ok(home) = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")) {
|
||||
return Some(PathBuf::from(home).join(".config"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn get_default_download_dir() -> PathBuf {
|
||||
let app_dir = AppConfig::get_app_dir();
|
||||
let portable_downloads = app_dir.join("downloads");
|
||||
if std::fs::create_dir_all(&portable_downloads).is_ok() {
|
||||
return portable_downloads;
|
||||
}
|
||||
|
||||
if let Ok(userprofile) = std::env::var("USERPROFILE") {
|
||||
let downloads = PathBuf::from(userprofile).join("Downloads").join("ZeroSend");
|
||||
let _ = std::fs::create_dir_all(&downloads);
|
||||
return downloads;
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let downloads = PathBuf::from(home).join("Downloads").join("ZeroSend");
|
||||
let _ = std::fs::create_dir_all(&downloads);
|
||||
return downloads;
|
||||
}
|
||||
let fallback = PathBuf::from("./downloads");
|
||||
let _ = std::fs::create_dir_all(&fallback);
|
||||
fallback
|
||||
}
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod config;
|
||||
mod network;
|
||||
mod registry;
|
||||
mod transfer;
|
||||
mod ui;
|
||||
mod updater;
|
||||
|
||||
use clap::Parser;
|
||||
use config::AppConfig;
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use network::{get_available_interfaces, get_preferred_interface, DiscoveryService, InterfaceInfo, NetworkType};
|
||||
use std::net::Ipv4Addr;
|
||||
use transfer::{TransferClient, TransferServer, TransferState};
|
||||
use ui::{Cli, Commands};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn get_default_device_name() -> String {
|
||||
if let Ok(name) = std::env::var("COMPUTERNAME") {
|
||||
if !name.is_empty() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
if let Ok(name) = std::env::var("HOSTNAME") {
|
||||
if !name.is_empty() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
format!("Node-{}", &Uuid::new_v4().to_string()[..6])
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let config = AppConfig::load();
|
||||
let cli = Cli::parse();
|
||||
|
||||
// 1. Handle "interfaces" subcommand
|
||||
if let Some(Commands::Interfaces) = &cli.command {
|
||||
let interfaces = get_available_interfaces();
|
||||
println!("\n🔍 Detected Network Interfaces:\n");
|
||||
if interfaces.is_empty() {
|
||||
println!(" No active IPv4 network interfaces found.");
|
||||
} else {
|
||||
for (idx, iface) in interfaces.iter().enumerate() {
|
||||
let badge = match iface.net_type {
|
||||
NetworkType::ZeroTier => "🌟 [ZeroTier (Preferred)]",
|
||||
NetworkType::Radmin => "🛡️ [Radmin VPN]",
|
||||
NetworkType::Tailscale => "⚡ [Tailscale]",
|
||||
NetworkType::LAN => "🏠 [LAN / Wi-Fi]",
|
||||
NetworkType::Loopback => "🔄 [Loopback]",
|
||||
NetworkType::Unknown => "❓ [Other]",
|
||||
};
|
||||
|
||||
println!(
|
||||
" [{}] {:<25} -> IP: {:<15} Broadcast: {:<15} {}",
|
||||
idx + 1,
|
||||
iface.name,
|
||||
iface.ip,
|
||||
iface.broadcast,
|
||||
badge
|
||||
);
|
||||
}
|
||||
}
|
||||
println!("\nTip: ZeroSend automatically selects ZeroTier if active.\n");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 2. Determine Selected Interface
|
||||
let selected_iface = if let Some(custom_iface) = &cli.interface {
|
||||
let all = get_available_interfaces();
|
||||
all.into_iter()
|
||||
.find(|i| i.name.eq_ignore_ascii_case(custom_iface) || i.ip.to_string() == *custom_iface)
|
||||
.unwrap_or_else(|| {
|
||||
get_preferred_interface().unwrap_or(InterfaceInfo {
|
||||
name: "loopback".to_string(),
|
||||
ip: Ipv4Addr::new(127, 0, 0, 1),
|
||||
netmask: None,
|
||||
broadcast: Ipv4Addr::new(127, 0, 0, 1),
|
||||
net_type: NetworkType::Loopback,
|
||||
})
|
||||
})
|
||||
} else if let Some(pref_name) = &config.preferred_interface {
|
||||
let all = get_available_interfaces();
|
||||
all.into_iter()
|
||||
.find(|i| i.name == *pref_name)
|
||||
.unwrap_or_else(|| {
|
||||
get_preferred_interface().unwrap_or(InterfaceInfo {
|
||||
name: "loopback".to_string(),
|
||||
ip: Ipv4Addr::new(127, 0, 0, 1),
|
||||
netmask: None,
|
||||
broadcast: Ipv4Addr::new(127, 0, 0, 1),
|
||||
net_type: NetworkType::Loopback,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
get_preferred_interface().unwrap_or(InterfaceInfo {
|
||||
name: "loopback".to_string(),
|
||||
ip: Ipv4Addr::new(127, 0, 0, 1),
|
||||
netmask: None,
|
||||
broadcast: Ipv4Addr::new(127, 0, 0, 1),
|
||||
net_type: NetworkType::Loopback,
|
||||
})
|
||||
};
|
||||
|
||||
let device_name = cli
|
||||
.name
|
||||
.or_else(|| config.device_name.clone())
|
||||
.unwrap_or_else(get_default_device_name);
|
||||
|
||||
let download_dir = config.download_dir.clone();
|
||||
let port = cli.port;
|
||||
let peer_id = config.peer_id.clone().unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
|
||||
// 3. Handle "ping" subcommand
|
||||
if let Some(Commands::Ping { ip, port }) = &cli.command {
|
||||
println!("🔍 Pinging ZeroSend node at {}:{}...", ip, port);
|
||||
let client = TransferClient::new();
|
||||
match client.ping_peer(ip, *port).await {
|
||||
Ok(beacon) => {
|
||||
println!("\n✨ Node Online!");
|
||||
println!(" Name: {}", beacon.name);
|
||||
println!(" OS: {}", beacon.os);
|
||||
println!(" Network: {}", beacon.net_type);
|
||||
println!(" IP:Port: {}:{}", beacon.ip, beacon.transfer_port);
|
||||
println!(" Version: {}", beacon.version);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("❌ Ping failed: {}", e);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 4. Handle "send" subcommand (Headless CLI mode)
|
||||
if let Some(Commands::Send { target, files }) = &cli.command {
|
||||
let client = TransferClient::new();
|
||||
let target_url = if target.starts_with("http://") || target.starts_with("https://") {
|
||||
target.clone()
|
||||
} else {
|
||||
format!("http://{}:{}", target, port)
|
||||
};
|
||||
|
||||
let my_info = network::DiscoveryBeacon {
|
||||
peer_id,
|
||||
name: device_name,
|
||||
os: std::env::consts::OS.to_string(),
|
||||
ip: selected_iface.ip,
|
||||
transfer_port: port,
|
||||
net_type: selected_iface.net_type.as_str().to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
shared_folders: Vec::new(),
|
||||
};
|
||||
|
||||
println!("📤 Preparing to send {} item(s) to {}...", files.len(), target_url);
|
||||
|
||||
let pb = ProgressBar::new(100);
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta}) {msg}")
|
||||
.unwrap()
|
||||
.progress_chars("#>-"),
|
||||
);
|
||||
|
||||
let target_display = target.clone();
|
||||
let res = client
|
||||
.send_files(&target_url, &target_display, &my_info, files, config.auto_zip_folders, config.max_upload_speed_mbps, None, move |progress| {
|
||||
match progress.state {
|
||||
TransferState::PendingConfirmation => {
|
||||
pb.set_message("⏳ Waiting for recipient confirmation...");
|
||||
}
|
||||
TransferState::InProgress => {
|
||||
pb.set_length(progress.total_bytes);
|
||||
pb.set_position(progress.bytes_transferred);
|
||||
let speed_mb = (progress.speed_bps as f64) / 1_048_576.0;
|
||||
pb.set_message(format!("{:.1} MB/s | {}", speed_mb, progress.current_file_name));
|
||||
}
|
||||
TransferState::Completed => {
|
||||
pb.finish_with_message("✅ Transfer completed!");
|
||||
}
|
||||
TransferState::Failed(e) => {
|
||||
pb.abandon_with_message(format!("❌ Failed: {}", e));
|
||||
}
|
||||
TransferState::Rejected => {
|
||||
pb.abandon_with_message("⛔ Transfer rejected by recipient.");
|
||||
}
|
||||
TransferState::Canceled => {
|
||||
pb.abandon_with_message("🛑 Canceled.");
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
if let Err(e) = res {
|
||||
eprintln!("\nError sending files: {}", e);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 5. Handle "receive" subcommand (Headless Server / Daemon mode)
|
||||
if let Some(Commands::Receive { auto_accept }) = &cli.command {
|
||||
let is_auto_accept = *auto_accept || config.auto_accept;
|
||||
let mut discovery = DiscoveryService::new(
|
||||
peer_id.clone(),
|
||||
device_name.clone(),
|
||||
selected_iface.clone(),
|
||||
port,
|
||||
);
|
||||
let shared_summaries: Vec<network::SharedFolderSummary> = config.shared_folders.iter().map(|s| network::SharedFolderSummary {
|
||||
id: s.id.clone(),
|
||||
name: s.name.clone(),
|
||||
read_only: s.read_only,
|
||||
requires_pin: s.pin.is_some(),
|
||||
}).collect();
|
||||
discovery.update_shared_folders(shared_summaries).await;
|
||||
discovery.start().await?;
|
||||
|
||||
let (mut server, _) = TransferServer::new(
|
||||
discovery.my_info().clone(),
|
||||
download_dir.clone(),
|
||||
port,
|
||||
is_auto_accept,
|
||||
config.require_pin,
|
||||
config.pin_code.clone(),
|
||||
config.auto_extract_zip,
|
||||
config.shared_folders.clone(),
|
||||
);
|
||||
server.start().await?;
|
||||
|
||||
println!("\n⚡ ZeroSend Receiver Daemon Running");
|
||||
println!(" Device Name: {}", device_name);
|
||||
println!(" Interface: {} ({})", selected_iface.ip, selected_iface.net_type.as_str());
|
||||
println!(" HTTP Endpoint: http://{}:{}", selected_iface.ip, port);
|
||||
println!(" Web Dashboard: http://{}:{}", selected_iface.ip, port);
|
||||
println!(" Downloads Dir: {:?}", download_dir);
|
||||
println!(" Auto-Accept: {}", is_auto_accept);
|
||||
println!(" Auto-Extract: {}", config.auto_extract_zip);
|
||||
println!(" PIN Protected: {}\n", config.require_pin);
|
||||
println!("Press Ctrl+C to stop.\n");
|
||||
|
||||
tokio::signal::ctrl_c().await?;
|
||||
println!("\nShutting down ZeroSend...");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 6. Interactive Mode: GUI (Default, no console) or TUI (if --tui is passed)
|
||||
let is_auto_accept = config.auto_accept;
|
||||
let mut discovery = DiscoveryService::new(
|
||||
peer_id.clone(),
|
||||
device_name.clone(),
|
||||
selected_iface.clone(),
|
||||
port,
|
||||
);
|
||||
let shared_summaries: Vec<network::SharedFolderSummary> = config.shared_folders.iter().map(|s| network::SharedFolderSummary {
|
||||
id: s.id.clone(),
|
||||
name: s.name.clone(),
|
||||
read_only: s.read_only,
|
||||
requires_pin: s.pin.is_some(),
|
||||
}).collect();
|
||||
discovery.update_shared_folders(shared_summaries).await;
|
||||
discovery.start().await?;
|
||||
|
||||
let (mut server, channels) = TransferServer::new(
|
||||
discovery.my_info().clone(),
|
||||
download_dir,
|
||||
port,
|
||||
is_auto_accept,
|
||||
config.require_pin,
|
||||
config.pin_code.clone(),
|
||||
config.auto_extract_zip,
|
||||
config.shared_folders.clone(),
|
||||
);
|
||||
server.start().await?;
|
||||
|
||||
if cli.tui {
|
||||
ui::tui::run_tui(discovery, server).await?;
|
||||
} else {
|
||||
let mut initial_files = cli.files.clone();
|
||||
for arg in std::env::args().skip(1) {
|
||||
if !arg.starts_with('-') {
|
||||
let path = std::path::PathBuf::from(&arg);
|
||||
if path.exists() && !initial_files.contains(&path) {
|
||||
initial_files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let start_minimized = (cli.tray || cli.minimized) && initial_files.is_empty();
|
||||
let options = eframe::NativeOptions {
|
||||
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("ZeroSend v2.4.1 - P2P Sharing for ZeroTier & VPN"),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let disc_ref = &discovery;
|
||||
let srv = server;
|
||||
let in_req_rx = channels.incoming_request_rx;
|
||||
let in_txt_rx = channels.incoming_text_rx;
|
||||
|
||||
eframe::run_native(
|
||||
"ZeroSend",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
Ok(Box::new(ui::gui::ZeroSendApp::new(
|
||||
cc,
|
||||
disc_ref,
|
||||
srv,
|
||||
config,
|
||||
initial_files,
|
||||
in_req_rx,
|
||||
in_txt_rx,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
.map_err(|e| format!("GUI Error: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
use super::interfaces::InterfaceInfo;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use std::collections::HashMap;
|
||||
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
pub const DEFAULT_DISCOVERY_PORT: u16 = 53317;
|
||||
#[allow(dead_code)]
|
||||
pub const DEFAULT_TRANSFER_PORT: u16 = 53318;
|
||||
pub const BEACON_INTERVAL_SECS: u64 = 3;
|
||||
pub const PEER_TIMEOUT_SECS: i64 = 8;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SharedFolderSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub read_only: bool,
|
||||
pub requires_pin: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscoveryBeacon {
|
||||
pub peer_id: String,
|
||||
pub name: String,
|
||||
pub os: String,
|
||||
pub ip: Ipv4Addr,
|
||||
pub transfer_port: u16,
|
||||
pub net_type: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub shared_folders: Vec<SharedFolderSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PeerInfo {
|
||||
pub peer_id: String,
|
||||
pub name: String,
|
||||
pub os: String,
|
||||
pub ip: Ipv4Addr,
|
||||
pub transfer_port: u16,
|
||||
pub net_type: String,
|
||||
pub version: String,
|
||||
pub last_seen: DateTime<Utc>,
|
||||
pub is_online: bool,
|
||||
#[serde(default)]
|
||||
pub ping_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub shared_folders: Vec<SharedFolderSummary>,
|
||||
}
|
||||
|
||||
impl PeerInfo {
|
||||
pub fn transfer_url(&self) -> String {
|
||||
format!("http://{}:{}", self.ip, self.transfer_port)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DiscoveryService {
|
||||
my_info: DiscoveryBeacon,
|
||||
shared_folders: Arc<RwLock<Vec<SharedFolderSummary>>>,
|
||||
selected_iface: InterfaceInfo,
|
||||
peers: Arc<RwLock<HashMap<String, PeerInfo>>>,
|
||||
shutdown_tx: Option<tokio::sync::broadcast::Sender<()>>,
|
||||
}
|
||||
|
||||
impl DiscoveryService {
|
||||
pub fn new(
|
||||
peer_id: String,
|
||||
name: String,
|
||||
iface: InterfaceInfo,
|
||||
transfer_port: u16,
|
||||
) -> Self {
|
||||
let my_info = DiscoveryBeacon {
|
||||
peer_id,
|
||||
name,
|
||||
os: std::env::consts::OS.to_string(),
|
||||
ip: iface.ip,
|
||||
transfer_port,
|
||||
net_type: iface.net_type.as_str().to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
shared_folders: Vec::new(),
|
||||
};
|
||||
|
||||
Self {
|
||||
my_info,
|
||||
shared_folders: Arc::new(RwLock::new(Vec::new())),
|
||||
selected_iface: iface,
|
||||
peers: Arc::new(RwLock::new(HashMap::new())),
|
||||
shutdown_tx: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn peers(&self) -> Arc<RwLock<HashMap<String, PeerInfo>>> {
|
||||
self.peers.clone()
|
||||
}
|
||||
|
||||
pub fn my_info(&self) -> &DiscoveryBeacon {
|
||||
&self.my_info
|
||||
}
|
||||
|
||||
pub fn selected_interface(&self) -> &InterfaceInfo {
|
||||
&self.selected_iface
|
||||
}
|
||||
|
||||
pub async fn update_shared_folders(&self, shares: Vec<SharedFolderSummary>) {
|
||||
let mut sf = self.shared_folders.write().await;
|
||||
*sf = shares;
|
||||
}
|
||||
|
||||
/// Add or update a peer directly (useful for manual IP add / Tailscale / Ping)
|
||||
#[allow(dead_code)]
|
||||
pub async fn add_or_update_peer(&self, beacon: DiscoveryBeacon) {
|
||||
if beacon.peer_id == self.my_info.peer_id {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut peers = self.peers.write().await;
|
||||
peers.insert(
|
||||
beacon.peer_id.clone(),
|
||||
PeerInfo {
|
||||
peer_id: beacon.peer_id,
|
||||
name: beacon.name,
|
||||
os: beacon.os,
|
||||
ip: beacon.ip,
|
||||
transfer_port: beacon.transfer_port,
|
||||
net_type: beacon.net_type,
|
||||
version: beacon.version,
|
||||
last_seen: Utc::now(),
|
||||
is_online: true,
|
||||
ping_ms: None,
|
||||
shared_folders: beacon.shared_folders,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Start discovery beacon broadcaster and listener in background
|
||||
pub async fn start(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let (shutdown_tx, _) = tokio::sync::broadcast::channel::<()>(1);
|
||||
self.shutdown_tx = Some(shutdown_tx.clone());
|
||||
|
||||
// 1. Create UDP broadcast sending socket
|
||||
let broadcast_addr = SocketAddrV4::new(self.selected_iface.broadcast, DEFAULT_DISCOVERY_PORT);
|
||||
let my_info_static = self.my_info.clone();
|
||||
let shared_folders = self.shared_folders.clone();
|
||||
let mut shutdown_rx1 = shutdown_tx.subscribe();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let socket = match UdpSocket::bind("0.0.0.0:0").await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to bind UDP beacon sender: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = socket.set_broadcast(true) {
|
||||
warn!("Failed to set UDP broadcast flag: {}", e);
|
||||
}
|
||||
|
||||
info!(
|
||||
"Started discovery beacon on {} (broadcast: {})",
|
||||
my_info_static.ip, broadcast_addr
|
||||
);
|
||||
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(BEACON_INTERVAL_SECS));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
let mut current_beacon = my_info_static.clone();
|
||||
current_beacon.shared_folders = shared_folders.read().await.clone();
|
||||
if let Ok(data) = serde_json::to_vec(¤t_beacon) {
|
||||
let _ = socket.send_to(&data, broadcast_addr).await;
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx1.recv() => {
|
||||
debug!("Discovery broadcaster shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Create UDP listener socket
|
||||
let listener_socket = match create_udp_listener(DEFAULT_DISCOVERY_PORT) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Could not bind listener on port {}: {}. Will try random port.",
|
||||
DEFAULT_DISCOVERY_PORT, e
|
||||
);
|
||||
create_udp_listener(0)?
|
||||
}
|
||||
};
|
||||
|
||||
let my_peer_id = self.my_info.peer_id.clone();
|
||||
let peers_store = self.peers.clone();
|
||||
let mut shutdown_rx2 = shutdown_tx.subscribe();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 4096];
|
||||
info!("Discovery listener active");
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
res = listener_socket.recv_from(&mut buf) => {
|
||||
match res {
|
||||
Ok((size, _sender_addr)) => {
|
||||
if let Ok(beacon) = serde_json::from_slice::<DiscoveryBeacon>(&buf[..size]) {
|
||||
if beacon.peer_id != my_peer_id {
|
||||
let mut peers = peers_store.write().await;
|
||||
let old_ping = peers.get(&beacon.peer_id).and_then(|p| p.ping_ms);
|
||||
// Deduplicate: Remove any stale entry on the same IP and port with a different peer_id
|
||||
peers.retain(|id, p| {
|
||||
!(p.ip == beacon.ip && p.transfer_port == beacon.transfer_port && id != &beacon.peer_id)
|
||||
});
|
||||
|
||||
peers.insert(
|
||||
beacon.peer_id.clone(),
|
||||
PeerInfo {
|
||||
peer_id: beacon.peer_id,
|
||||
name: beacon.name,
|
||||
os: beacon.os,
|
||||
ip: beacon.ip,
|
||||
transfer_port: beacon.transfer_port,
|
||||
net_type: beacon.net_type,
|
||||
version: beacon.version,
|
||||
last_seen: Utc::now(),
|
||||
is_online: true,
|
||||
ping_ms: old_ping,
|
||||
shared_folders: beacon.shared_folders,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("UDP listener recv error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx2.recv() => {
|
||||
debug!("Discovery listener shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Housekeeping task to remove stale offline peers
|
||||
let peers_store_cleaner = self.peers.clone();
|
||||
let mut shutdown_rx3 = shutdown_tx.subscribe();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(3));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
let now = Utc::now();
|
||||
let mut peers = peers_store_cleaner.write().await;
|
||||
peers.retain(|_, peer| {
|
||||
let diff = now.signed_duration_since(peer.last_seen).num_seconds();
|
||||
diff <= PEER_TIMEOUT_SECS
|
||||
});
|
||||
}
|
||||
_ = shutdown_rx3.recv() => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn stop(&self) {
|
||||
if let Some(tx) = &self.shutdown_tx {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to create a reusable broadcast socket on Windows / Linux / macOS
|
||||
fn create_udp_listener(port: u16) -> std::io::Result<UdpSocket> {
|
||||
let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
|
||||
socket.set_reuse_address(true)?;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
socket.set_reuse_port(true)?;
|
||||
|
||||
socket.set_broadcast(true)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
|
||||
let bind_addr: SocketAddr = format!("0.0.0.0:{}", port).parse().unwrap();
|
||||
socket.bind(&bind_addr.into())?;
|
||||
|
||||
let std_socket: std::net::UdpSocket = socket.into();
|
||||
UdpSocket::from_std(std_socket)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use network_interface::{NetworkInterface, NetworkInterfaceConfig};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NetworkType {
|
||||
ZeroTier,
|
||||
Tailscale,
|
||||
Radmin,
|
||||
LAN,
|
||||
Loopback,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl NetworkType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
NetworkType::ZeroTier => "ZeroTier",
|
||||
NetworkType::Tailscale => "Tailscale",
|
||||
NetworkType::Radmin => "Radmin VPN",
|
||||
NetworkType::LAN => "LAN / Wi-Fi",
|
||||
NetworkType::Loopback => "Loopback",
|
||||
NetworkType::Unknown => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn badge(&self) -> &'static str {
|
||||
match self {
|
||||
NetworkType::ZeroTier => "[ZeroTier]",
|
||||
NetworkType::Tailscale => "[Tailscale]",
|
||||
NetworkType::Radmin => "[Radmin]",
|
||||
NetworkType::LAN => "[LAN]",
|
||||
NetworkType::Loopback => "[Local]",
|
||||
NetworkType::Unknown => "[Other]",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InterfaceInfo {
|
||||
pub name: String,
|
||||
pub ip: Ipv4Addr,
|
||||
#[allow(dead_code)]
|
||||
pub netmask: Option<Ipv4Addr>,
|
||||
pub broadcast: Ipv4Addr,
|
||||
pub net_type: NetworkType,
|
||||
}
|
||||
|
||||
impl InterfaceInfo {
|
||||
#[allow(dead_code)]
|
||||
pub fn is_virtual_vpn(&self) -> bool {
|
||||
matches!(
|
||||
self.net_type,
|
||||
NetworkType::ZeroTier | NetworkType::Tailscale | NetworkType::Radmin
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify network interface based on name and IP address ranges
|
||||
pub fn classify_interface(name: &str, ip: Ipv4Addr) -> NetworkType {
|
||||
let name_lower = name.to_lowercase();
|
||||
let octets = ip.octets();
|
||||
|
||||
// 1. Check by interface name
|
||||
if name_lower.contains("zerotier") || name_lower.contains("zt") {
|
||||
return NetworkType::ZeroTier;
|
||||
}
|
||||
if name_lower.contains("tailscale") {
|
||||
return NetworkType::Tailscale;
|
||||
}
|
||||
if name_lower.contains("radmin") || name_lower.contains("fametech") {
|
||||
return NetworkType::Radmin;
|
||||
}
|
||||
|
||||
// 2. Check by specific IP allocation ranges
|
||||
// Tailscale uses CGNAT range 100.64.0.0/10 (100.64.0.0 - 100.127.255.255)
|
||||
if octets[0] == 100 && (octets[1] >= 64 && octets[1] <= 127) {
|
||||
return NetworkType::Tailscale;
|
||||
}
|
||||
|
||||
// Radmin VPN typically assigns 26.x.x.x
|
||||
if octets[0] == 26 {
|
||||
return NetworkType::Radmin;
|
||||
}
|
||||
|
||||
// ZeroTier commonly assigns 10.147.x.x, 10.244.x.x, 172.22-31.x.x, or 192.168.19x.x
|
||||
if octets[0] == 10 && (octets[1] == 147 || octets[1] == 244) {
|
||||
return NetworkType::ZeroTier;
|
||||
}
|
||||
|
||||
// Loopback
|
||||
if ip.is_loopback() {
|
||||
return NetworkType::Loopback;
|
||||
}
|
||||
|
||||
// Standard private IPv4 ranges (LAN / Wi-Fi)
|
||||
if ip.is_private() {
|
||||
return NetworkType::LAN;
|
||||
}
|
||||
|
||||
NetworkType::Unknown
|
||||
}
|
||||
|
||||
/// Compute broadcast address from IP and netmask
|
||||
pub fn compute_broadcast(ip: Ipv4Addr, netmask: Option<Ipv4Addr>) -> Ipv4Addr {
|
||||
let mask = netmask.unwrap_or(Ipv4Addr::new(255, 255, 255, 0));
|
||||
let ip_u32 = u32::from(ip);
|
||||
let mask_u32 = u32::from(mask);
|
||||
let broadcast_u32 = ip_u32 | (!mask_u32);
|
||||
Ipv4Addr::from(broadcast_u32)
|
||||
}
|
||||
|
||||
/// Get all usable IPv4 interfaces sorted with ZeroTier and VPNs having highest priority
|
||||
pub fn get_available_interfaces() -> Vec<InterfaceInfo> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
if let Ok(interfaces) = NetworkInterface::show() {
|
||||
for iface in interfaces {
|
||||
for addr in iface.addr {
|
||||
if let IpAddr::V4(ipv4) = addr.ip() {
|
||||
// Skip 127.0.0.1 unless no other interface is found
|
||||
if ipv4.is_loopback() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let netmask_v4 = addr.netmask().and_then(|m| match m {
|
||||
IpAddr::V4(m_v4) => Some(m_v4),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let net_type = classify_interface(&iface.name, ipv4);
|
||||
let broadcast = compute_broadcast(ipv4, netmask_v4);
|
||||
|
||||
results.push(InterfaceInfo {
|
||||
name: iface.name.clone(),
|
||||
ip: ipv4,
|
||||
netmask: netmask_v4,
|
||||
broadcast,
|
||||
net_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority: ZeroTier (0) > Radmin (1) > Tailscale (2) > LAN (3) > Other (4)
|
||||
results.sort_by_key(|info| match info.net_type {
|
||||
NetworkType::ZeroTier => 0,
|
||||
NetworkType::Radmin => 1,
|
||||
NetworkType::Tailscale => 2,
|
||||
NetworkType::LAN => 3,
|
||||
NetworkType::Loopback => 4,
|
||||
NetworkType::Unknown => 5,
|
||||
});
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Pick best default interface (ZeroTier first, then others)
|
||||
pub fn get_preferred_interface() -> Option<InterfaceInfo> {
|
||||
let list = get_available_interfaces();
|
||||
list.into_iter().next()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod discovery;
|
||||
pub mod interfaces;
|
||||
|
||||
pub use discovery::*;
|
||||
pub use interfaces::*;
|
||||
@@ -0,0 +1,73 @@
|
||||
#[cfg(windows)]
|
||||
use winreg::enums::*;
|
||||
#[cfg(windows)]
|
||||
use winreg::RegKey;
|
||||
|
||||
/// Check if ZeroSend is registered in Windows Startup (HKCU\...\Run)
|
||||
pub fn is_autostart_registered() -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
if let Ok(key) = hkcu.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Run") {
|
||||
if let Ok(_val) = key.get_value::<String, _>("ZeroSend") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Register ZeroSend in Windows Startup (starts minimized to tray)
|
||||
pub fn register_autostart() -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let exe_path = std::env::current_exe()
|
||||
.map_err(|e| format!("Failed to get executable path: {}", e))?;
|
||||
let cmd_str = format!("\"{}\" --tray", exe_path.to_string_lossy());
|
||||
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
let (key, _) = hkcu
|
||||
.create_subkey(r"Software\Microsoft\Windows\CurrentVersion\Run")
|
||||
.map_err(|e| format!("Registry error: {}", e))?;
|
||||
key.set_value("ZeroSend", &cmd_str)
|
||||
.map_err(|e| format!("Registry error: {}", e))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
Err("Autostart is only supported on Windows".to_string())
|
||||
}
|
||||
|
||||
/// Unregister ZeroSend from Windows Startup
|
||||
pub fn unregister_autostart() -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
if let Ok(key) = hkcu.open_subkey_with_flags(
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Run",
|
||||
KEY_SET_VALUE,
|
||||
) {
|
||||
let _ = key.delete_value("ZeroSend");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
Err("Autostart is only supported on Windows".to_string())
|
||||
}
|
||||
|
||||
/// Check if Windows system theme is set to Dark Mode (AppsUseLightTheme == 0)
|
||||
pub fn is_system_dark_mode() -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
if let Ok(key) = hkcu.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") {
|
||||
if let Ok(val) = key.get_value::<u32, _>("AppsUseLightTheme") {
|
||||
return val == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
true // default to dark
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{error, info};
|
||||
use walkdir::WalkDir;
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::{CompressionMethod, ZipArchive, ZipWriter};
|
||||
|
||||
/// Compress a whole folder into a ZIP archive with fast Deflate compression
|
||||
pub fn compress_folder_to_zip(
|
||||
src_dir: &Path,
|
||||
zip_dest: &Path,
|
||||
) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
|
||||
info!("Compressing folder {:?} -> {:?}", src_dir, zip_dest);
|
||||
let file = File::create(zip_dest)?;
|
||||
let mut zip = ZipWriter::new(file);
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(CompressionMethod::Deflated)
|
||||
.compression_level(Some(3));
|
||||
|
||||
let parent = src_dir.parent().unwrap_or(src_dir);
|
||||
let mut buffer = Vec::with_capacity(64 * 1024);
|
||||
|
||||
for entry in WalkDir::new(src_dir).into_iter().filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
let rel_path = path.strip_prefix(parent).unwrap_or(path);
|
||||
let rel_path_str = rel_path.to_string_lossy().replace('\\', "/");
|
||||
|
||||
if rel_path_str.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.is_dir() {
|
||||
let dir_name = if rel_path_str.ends_with('/') {
|
||||
rel_path_str
|
||||
} else {
|
||||
format!("{}/", rel_path_str)
|
||||
};
|
||||
zip.add_directory(dir_name, options)?;
|
||||
} else if path.is_file() {
|
||||
zip.start_file(rel_path_str, options)?;
|
||||
let mut f = File::open(path)?;
|
||||
buffer.clear();
|
||||
f.read_to_end(&mut buffer)?;
|
||||
zip.write_all(&buffer)?;
|
||||
}
|
||||
}
|
||||
|
||||
zip.finish()?;
|
||||
let total_size = std::fs::metadata(zip_dest)?.len();
|
||||
info!("Compression completed. Zip size: {} bytes", total_size);
|
||||
Ok(total_size)
|
||||
}
|
||||
|
||||
/// Extract a received ZIP archive safely into the destination folder
|
||||
pub fn extract_zip_archive(
|
||||
zip_path: &Path,
|
||||
dest_dir: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
info!("Extracting ZIP archive {:?} into {:?}", zip_path, dest_dir);
|
||||
let file = File::open(zip_path)?;
|
||||
let mut archive = ZipArchive::new(file)?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
let raw_name = file.name().replace('\\', "/");
|
||||
|
||||
// Prevent Zip Slip / path traversal outside destination
|
||||
let safe_rel_path: PathBuf = raw_name
|
||||
.split('/')
|
||||
.filter(|part| !part.is_empty() && *part != ".." && *part != ".")
|
||||
.collect();
|
||||
|
||||
if safe_rel_path.as_os_str().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let outpath = dest_dir.join(&safe_rel_path);
|
||||
|
||||
if file.is_dir() {
|
||||
let _ = std::fs::create_dir_all(&outpath);
|
||||
} else {
|
||||
if let Some(p) = outpath.parent() {
|
||||
let _ = std::fs::create_dir_all(p);
|
||||
}
|
||||
match File::create(&outpath) {
|
||||
Ok(mut outfile) => {
|
||||
let _ = std::io::copy(&mut file, &mut outfile);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to extract file {:?}: {}", outpath, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Extraction completed successfully for {:?}", zip_path);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
use super::archive::compress_folder_to_zip;
|
||||
use super::protocol::{
|
||||
FileMetadata, PrepareTransferRequest, PrepareTransferResponse, SharedFolderInfo,
|
||||
SharedFolderTreeResponse, SharedUploadResponse, TextMessage, TransferProgress, TransferState,
|
||||
};
|
||||
use super::rate_limiter::RateLimiter;
|
||||
use super::text_analyzer::{analyze_text, AnalyzedText};
|
||||
use crate::network::DiscoveryBeacon;
|
||||
use reqwest::Client;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
pub fn compute_file_sha256(path: &std::path::Path) -> Result<String, std::io::Error> {
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let n = std::io::Read::read(&mut file, &mut buffer)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..n]);
|
||||
}
|
||||
Ok(hex::encode(hasher.finalize()))
|
||||
}
|
||||
|
||||
const NUM_PARALLEL_STREAMS: usize = 4; // 4 concurrent HTTP/TCP streams
|
||||
|
||||
fn calculate_adaptive_chunk_size(file_size: u64) -> usize {
|
||||
if file_size < 5 * 1024 * 1024 {
|
||||
512 * 1024 // 512 KB for small files < 5 MB
|
||||
} else if file_size < 50 * 1024 * 1024 {
|
||||
2 * 1024 * 1024 // 2 MB for medium files 5-50 MB
|
||||
} else if file_size < 500 * 1024 * 1024 {
|
||||
4 * 1024 * 1024 // 4 MB for large files 50-500 MB
|
||||
} else {
|
||||
8 * 1024 * 1024 // 8 MB for huge files >= 500 MB
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ChunkTask {
|
||||
file_path: PathBuf,
|
||||
rel_path: String,
|
||||
offset: u64,
|
||||
length: usize,
|
||||
retries: usize,
|
||||
}
|
||||
|
||||
pub struct TransferClient {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl TransferClient {
|
||||
pub fn new() -> Self {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(7200))
|
||||
.connect_timeout(Duration::from_secs(6))
|
||||
.tcp_nodelay(true)
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
|
||||
Self { client }
|
||||
}
|
||||
|
||||
/// Ping a remote peer to check health and get beacon info
|
||||
pub async fn ping_peer(&self, ip: &str, port: u16) -> Result<DiscoveryBeacon, String> {
|
||||
let url = format!("http://{}:{}/api/info", ip, port);
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to connect to {}: {}", url, e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Peer returned status: {}", resp.status()));
|
||||
}
|
||||
|
||||
let beacon = resp
|
||||
.json::<DiscoveryBeacon>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
Ok(beacon)
|
||||
}
|
||||
|
||||
/// Measure real-time round-trip latency in milliseconds
|
||||
pub async fn measure_ping(&self, ip: &str, port: u16) -> Option<u64> {
|
||||
let start = Instant::now();
|
||||
if self.ping_peer(ip, port).await.is_ok() {
|
||||
Some(start.elapsed().as_millis() as u64)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Send quick text / clipboard content to a peer
|
||||
pub async fn send_text(
|
||||
&self,
|
||||
peer_url: &str,
|
||||
my_info: &DiscoveryBeacon,
|
||||
text: &str,
|
||||
pin: Option<String>,
|
||||
) -> Result<AnalyzedText, String> {
|
||||
let analyzed = analyze_text(text);
|
||||
let msg = TextMessage {
|
||||
message_id: Uuid::new_v4().to_string(),
|
||||
sender_id: my_info.peer_id.clone(),
|
||||
sender_name: my_info.name.clone(),
|
||||
sender_os: my_info.os.clone(),
|
||||
content: analyzed.content.clone(),
|
||||
content_type: analyzed.content_type.clone(),
|
||||
pin,
|
||||
};
|
||||
|
||||
let url = format!("{}/api/transfer/text", peer_url);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&msg)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to contact {}: {}", url, e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
if resp.status() == reqwest::StatusCode::FORBIDDEN {
|
||||
return Err("Recipient rejected text (Incorrect or missing PIN code)".to_string());
|
||||
}
|
||||
return Err(format!("Server returned status: {}", resp.status()));
|
||||
}
|
||||
|
||||
Ok(analyzed)
|
||||
}
|
||||
|
||||
/// Send files/folders with 4-Stream Parallel Chunking, Adaptive Chunk Sizing, and Auto-Retry Fault Tolerance
|
||||
pub async fn send_files<F>(
|
||||
&self,
|
||||
peer_url: &str,
|
||||
peer_display_name: &str,
|
||||
my_info: &DiscoveryBeacon,
|
||||
paths: &[PathBuf],
|
||||
auto_zip_folders: bool,
|
||||
max_upload_speed_mbps: u32,
|
||||
pin: Option<String>,
|
||||
mut on_progress: F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: FnMut(TransferProgress) + Send + 'static,
|
||||
{
|
||||
let mut file_entries: Vec<(PathBuf, String, u64)> = Vec::new();
|
||||
let mut total_bytes = 0u64;
|
||||
let temp_dir = std::env::temp_dir().join("ZeroSend_Zip");
|
||||
let _ = std::fs::create_dir_all(&temp_dir);
|
||||
|
||||
for p in paths {
|
||||
if !p.exists() {
|
||||
return Err(format!("Path does not exist: {:?}", p));
|
||||
}
|
||||
|
||||
if p.is_dir() {
|
||||
if auto_zip_folders {
|
||||
let folder_name = p
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "folder".to_string());
|
||||
|
||||
let zip_filename = format!("{}.zip", folder_name);
|
||||
let temp_zip_path = temp_dir.join(&zip_filename);
|
||||
|
||||
// Notify UI that folder is being compressed into ZIP archive
|
||||
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!("📦 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 {
|
||||
let base_parent = p.parent().unwrap_or(p);
|
||||
for entry in WalkDir::new(p).into_iter().filter_map(|e| e.ok()) {
|
||||
let entry_path = entry.path();
|
||||
if entry_path.is_file() {
|
||||
let rel_path = entry_path
|
||||
.strip_prefix(base_parent)
|
||||
.unwrap_or(entry_path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
|
||||
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
total_bytes += size;
|
||||
file_entries.push((entry_path.to_path_buf(), rel_path, size));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let file_name = p
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "unnamed".to_string());
|
||||
|
||||
let size = tokio::fs::metadata(p)
|
||||
.await
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
total_bytes += size;
|
||||
file_entries.push((p.clone(), file_name, size));
|
||||
}
|
||||
}
|
||||
|
||||
if file_entries.is_empty() {
|
||||
return Err("No files selected for transfer".to_string());
|
||||
}
|
||||
|
||||
let file_metas: Vec<FileMetadata> = file_entries
|
||||
.iter()
|
||||
.map(|(path, rel_path, size)| {
|
||||
let sha256 = compute_file_sha256(path).ok();
|
||||
FileMetadata {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name: rel_path.clone(),
|
||||
relative_path: rel_path.clone(),
|
||||
size: *size,
|
||||
is_dir: false,
|
||||
sha256,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
|
||||
let prepare_req = PrepareTransferRequest {
|
||||
session_id: session_id.clone(),
|
||||
sender_id: my_info.peer_id.clone(),
|
||||
sender_name: my_info.name.clone(),
|
||||
sender_os: my_info.os.clone(),
|
||||
files: file_metas.clone(),
|
||||
total_size: total_bytes,
|
||||
pin,
|
||||
};
|
||||
|
||||
// Notify UI: Pending confirmation
|
||||
on_progress(TransferProgress {
|
||||
session_id: session_id.clone(),
|
||||
peer_name: peer_display_name.to_string(),
|
||||
is_incoming: false,
|
||||
current_file_index: 0,
|
||||
total_files: file_metas.len(),
|
||||
current_file_name: file_metas.first().map(|f| f.name.clone()).unwrap_or_default(),
|
||||
bytes_transferred: 0,
|
||||
total_bytes,
|
||||
speed_bps: 0,
|
||||
state: TransferState::PendingConfirmation,
|
||||
checksum_verified: false,
|
||||
});
|
||||
|
||||
let prepare_url = format!("{}/api/transfer/prepare", peer_url);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&prepare_url)
|
||||
.json(&prepare_req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to contact peer at {}: {}", prepare_url, e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let reason = if resp.status() == reqwest::StatusCode::FORBIDDEN {
|
||||
format!("Transfer rejected by {} (Check PIN code or permissions)", peer_display_name)
|
||||
} else {
|
||||
format!("Peer responded with status {}", resp.status())
|
||||
};
|
||||
|
||||
on_progress(TransferProgress {
|
||||
session_id: session_id.clone(),
|
||||
peer_name: peer_display_name.to_string(),
|
||||
is_incoming: false,
|
||||
current_file_index: 0,
|
||||
total_files: file_metas.len(),
|
||||
current_file_name: String::new(),
|
||||
bytes_transferred: 0,
|
||||
total_bytes,
|
||||
speed_bps: 0,
|
||||
state: TransferState::Rejected,
|
||||
checksum_verified: false,
|
||||
});
|
||||
|
||||
return Err(reason);
|
||||
}
|
||||
|
||||
let prep_resp: PrepareTransferResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid prepare response: {}", e))?;
|
||||
|
||||
let (session_id, received_offsets) = match prep_resp {
|
||||
PrepareTransferResponse::Accepted { session_id: accepted_id, received_offsets } => {
|
||||
info!(
|
||||
"Transfer session {} accepted by {} with {} resumable offset(s)",
|
||||
accepted_id, peer_display_name, received_offsets.len()
|
||||
);
|
||||
(accepted_id, received_offsets)
|
||||
}
|
||||
PrepareTransferResponse::Rejected { reason } => {
|
||||
return Err(format!("Rejected: {}", reason));
|
||||
}
|
||||
};
|
||||
|
||||
// Build queue of chunk tasks with dynamic adaptive chunk sizing
|
||||
let mut chunk_tasks = VecDeque::new();
|
||||
let mut already_transferred = 0u64;
|
||||
|
||||
for (file_path, rel_path, size) in &file_entries {
|
||||
let start_offset = received_offsets.get(rel_path).copied().unwrap_or(0);
|
||||
if start_offset > 0 && start_offset < *size {
|
||||
already_transferred += start_offset;
|
||||
info!("Resuming {} from byte {}", rel_path, start_offset);
|
||||
}
|
||||
|
||||
let mut curr_offset = start_offset;
|
||||
if curr_offset >= *size {
|
||||
continue;
|
||||
}
|
||||
|
||||
let chunk_size = calculate_adaptive_chunk_size(*size);
|
||||
|
||||
while curr_offset < *size {
|
||||
let chunk_len = ((*size - curr_offset) as usize).min(chunk_size);
|
||||
chunk_tasks.push_back(ChunkTask {
|
||||
file_path: file_path.clone(),
|
||||
rel_path: rel_path.clone(),
|
||||
offset: curr_offset,
|
||||
length: chunk_len,
|
||||
retries: 0,
|
||||
});
|
||||
curr_offset += chunk_len as u64;
|
||||
}
|
||||
}
|
||||
|
||||
let rate_limiter = RateLimiter::new((max_upload_speed_mbps as u64) * 1024 * 1024);
|
||||
let tasks_mutex = Arc::new(Mutex::new(chunk_tasks));
|
||||
let (chunk_tx, mut chunk_rx) = tokio::sync::mpsc::unbounded_channel::<(usize, String)>();
|
||||
let (progress_update_tx, mut progress_update_rx) = tokio::sync::mpsc::unbounded_channel::<TransferProgress>();
|
||||
|
||||
let s_id = session_id.clone();
|
||||
let p_name = peer_display_name.to_string();
|
||||
let t_files = file_metas.len();
|
||||
let p_tx = progress_update_tx.clone();
|
||||
|
||||
// Background progress tracker with smoothed EMA speed calculation
|
||||
tokio::spawn(async move {
|
||||
let mut total_sent = already_transferred;
|
||||
let mut last_bytes = total_sent;
|
||||
let mut last_time = Instant::now();
|
||||
let mut smoothed_speed = 0.0f64;
|
||||
|
||||
while let Some((len, fname)) = chunk_rx.recv().await {
|
||||
total_sent += len as u64;
|
||||
|
||||
if last_time.elapsed().as_millis() >= 350 {
|
||||
let elapsed_secs = last_time.elapsed().as_secs_f64();
|
||||
let delta = total_sent.saturating_sub(last_bytes);
|
||||
let instant_speed = if elapsed_secs > 0.0 {
|
||||
(delta as f64) / elapsed_secs
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
smoothed_speed = if smoothed_speed <= 0.0 {
|
||||
instant_speed
|
||||
} else {
|
||||
(smoothed_speed * 0.60) + (instant_speed * 0.40)
|
||||
};
|
||||
|
||||
let speed_bps = smoothed_speed as u64;
|
||||
|
||||
let _ = p_tx.send(TransferProgress {
|
||||
session_id: s_id.clone(),
|
||||
peer_name: p_name.clone(),
|
||||
is_incoming: false,
|
||||
current_file_index: 0,
|
||||
total_files: t_files,
|
||||
current_file_name: fname,
|
||||
bytes_transferred: total_sent,
|
||||
total_bytes,
|
||||
speed_bps,
|
||||
state: TransferState::InProgress,
|
||||
checksum_verified: false,
|
||||
});
|
||||
|
||||
last_time = Instant::now();
|
||||
last_bytes = total_sent;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
on_progress(TransferProgress {
|
||||
session_id: session_id.clone(),
|
||||
peer_name: peer_display_name.to_string(),
|
||||
is_incoming: false,
|
||||
current_file_index: 0,
|
||||
total_files: file_metas.len(),
|
||||
current_file_name: file_metas.first().map(|f| f.name.clone()).unwrap_or_default(),
|
||||
bytes_transferred: already_transferred,
|
||||
total_bytes,
|
||||
speed_bps: 0,
|
||||
state: TransferState::InProgress,
|
||||
checksum_verified: false,
|
||||
});
|
||||
|
||||
let start_time = Instant::now();
|
||||
let mut worker_set = tokio::task::JoinSet::new();
|
||||
|
||||
// Spawn parallel HTTP chunk streaming worker pool with auto-retry resilience
|
||||
for _ in 0..NUM_PARALLEL_STREAMS {
|
||||
let tasks_c = tasks_mutex.clone();
|
||||
let client_c = self.client.clone();
|
||||
let chunk_url = format!("{}/api/transfer/chunk/{}", peer_url, session_id);
|
||||
let limiter_c = rate_limiter.clone();
|
||||
let tx_c = chunk_tx.clone();
|
||||
|
||||
worker_set.spawn(async move {
|
||||
loop {
|
||||
let mut task = {
|
||||
let mut guard = tasks_c.lock().await;
|
||||
guard.pop_front()
|
||||
};
|
||||
|
||||
let Some(mut chunk_task) = task.take() else {
|
||||
break;
|
||||
};
|
||||
|
||||
let mut file = match File::open(&chunk_task.file_path).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => return Err(format!("Failed to open file {:?}: {}", chunk_task.file_path, e)),
|
||||
};
|
||||
|
||||
if let Err(e) = file.seek(std::io::SeekFrom::Start(chunk_task.offset)).await {
|
||||
return Err(format!("Failed to seek in {:?}: {}", chunk_task.file_path, e));
|
||||
}
|
||||
|
||||
let mut buf = vec![0u8; chunk_task.length];
|
||||
if let Err(e) = file.read_exact(&mut buf).await {
|
||||
return Err(format!("Failed to read chunk from {:?}: {}", chunk_task.file_path, e));
|
||||
}
|
||||
|
||||
limiter_c.throttle(buf.len()).await;
|
||||
|
||||
let resp = client_c
|
||||
.post(&chunk_url)
|
||||
.header("x-file-name", &chunk_task.rel_path)
|
||||
.header("x-chunk-offset", chunk_task.offset.to_string())
|
||||
.header("x-chunk-length", chunk_task.length.to_string())
|
||||
.body(buf)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let is_ok = match &resp {
|
||||
Ok(r) => r.status().is_success(),
|
||||
Err(_) => false,
|
||||
};
|
||||
|
||||
if !is_ok {
|
||||
if chunk_task.retries < 3 {
|
||||
chunk_task.retries += 1;
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
tasks_c.lock().await.push_back(chunk_task);
|
||||
continue;
|
||||
} else {
|
||||
let err_msg = match resp {
|
||||
Ok(r) => format!("Server returned error status {}", r.status()),
|
||||
Err(e) => format!("Network transfer error: {}", e),
|
||||
};
|
||||
return Err(err_msg);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tx_c.send((chunk_task.length, chunk_task.rel_path));
|
||||
}
|
||||
Ok::<(), String>(())
|
||||
});
|
||||
}
|
||||
drop(chunk_tx); // Drop main thread copy
|
||||
|
||||
// Wait for all workers while continuously forwarding progress updates to UI
|
||||
while !worker_set.is_empty() {
|
||||
tokio::select! {
|
||||
res = worker_set.join_next() => {
|
||||
if let Some(join_result) = res {
|
||||
match join_result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
on_progress(TransferProgress {
|
||||
session_id: session_id.clone(),
|
||||
peer_name: peer_display_name.to_string(),
|
||||
is_incoming: false,
|
||||
current_file_index: 0,
|
||||
total_files: file_metas.len(),
|
||||
current_file_name: String::new(),
|
||||
bytes_transferred: 0,
|
||||
total_bytes,
|
||||
speed_bps: 0,
|
||||
state: TransferState::Failed(e.clone()),
|
||||
checksum_verified: false,
|
||||
});
|
||||
return Err(e);
|
||||
}
|
||||
Err(join_err) => {
|
||||
return Err(format!("Worker thread panicked: {}", join_err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(progress) = progress_update_rx.recv() => {
|
||||
on_progress(progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drain remaining progress updates
|
||||
while let Ok(progress) = progress_update_rx.try_recv() {
|
||||
on_progress(progress);
|
||||
}
|
||||
|
||||
// Finalize transfer on receiver (atomic rename + zip extraction)
|
||||
let finish_url = format!("{}/api/transfer/finish/{}", peer_url, session_id);
|
||||
let finish_resp = self.client.post(&finish_url).send().await;
|
||||
if let Err(e) = finish_resp {
|
||||
info!("Warning: Finalize call response: {}", e);
|
||||
}
|
||||
|
||||
let elapsed = start_time.elapsed().as_secs_f64();
|
||||
let actual_uploaded = total_bytes.saturating_sub(already_transferred);
|
||||
let avg_speed = if elapsed > 0.0 {
|
||||
(actual_uploaded as f64 / elapsed) as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
on_progress(TransferProgress {
|
||||
session_id,
|
||||
peer_name: peer_display_name.to_string(),
|
||||
is_incoming: false,
|
||||
current_file_index: file_metas.len(),
|
||||
total_files: file_metas.len(),
|
||||
current_file_name: format!("{} file(s) delivered", file_metas.len()),
|
||||
bytes_transferred: total_bytes,
|
||||
total_bytes,
|
||||
speed_bps: avg_speed,
|
||||
state: TransferState::Completed,
|
||||
checksum_verified: true,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch list of shared folders from a remote peer
|
||||
#[allow(dead_code)]
|
||||
pub async fn fetch_shared_folders(
|
||||
&self,
|
||||
peer_url: &str,
|
||||
pin: Option<&str>,
|
||||
) -> Result<Vec<SharedFolderInfo>, String> {
|
||||
let mut url = format!("{}/api/shared/list", peer_url);
|
||||
if let Some(p) = pin.filter(|p| !p.trim().is_empty()) {
|
||||
url = format!("{}?pin={}", url, p);
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Server returned HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
resp.json::<Vec<SharedFolderInfo>>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse shared folders: {}", e))
|
||||
}
|
||||
|
||||
/// Fetch file/folder tree of a shared folder
|
||||
pub async fn fetch_shared_tree(
|
||||
&self,
|
||||
peer_url: &str,
|
||||
folder_id: &str,
|
||||
subpath: Option<&str>,
|
||||
pin: Option<&str>,
|
||||
) -> Result<SharedFolderTreeResponse, String> {
|
||||
let url = format!("{}/api/shared/tree/{}", peer_url, folder_id);
|
||||
let mut req = self.client.get(&url);
|
||||
if let Some(p) = subpath.filter(|s| !s.trim().is_empty()) {
|
||||
req = req.query(&[("path", p)]);
|
||||
}
|
||||
if let Some(p) = pin.filter(|p| !p.trim().is_empty()) {
|
||||
req = req.query(&[("pin", p)]);
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Server returned HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
resp.json::<SharedFolderTreeResponse>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse folder tree: {}", e))
|
||||
}
|
||||
|
||||
/// Fetch raw text of a file (used for Vim Code Viewer)
|
||||
pub async fn fetch_shared_file_text(
|
||||
&self,
|
||||
peer_url: &str,
|
||||
folder_id: &str,
|
||||
subpath: &str,
|
||||
pin: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let clean = subpath.trim_start_matches('/').trim_start_matches('\\');
|
||||
let mut url = format!("{}/api/shared/stream/{}/{}", peer_url, folder_id, clean);
|
||||
if let Some(p) = pin.filter(|p| !p.trim().is_empty()) {
|
||||
url = format!("{}?pin={}", url, p);
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Server returned HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
resp.text()
|
||||
.await
|
||||
.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(
|
||||
&self,
|
||||
peer_url: &str,
|
||||
folder_id: &str,
|
||||
subpath: &str,
|
||||
dest_path: &std::path::Path,
|
||||
pin: Option<&str>,
|
||||
) -> Result<u64, String> {
|
||||
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 resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Download failed: HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read stream: {}", e))?;
|
||||
let len = bytes.len() as u64;
|
||||
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
|
||||
let mut file = tokio::fs::File::create(dest_path)
|
||||
.await
|
||||
.map_err(|e| format!("Could not create file: {}", e))?;
|
||||
|
||||
tokio::io::AsyncWriteExt::write_all(&mut file, &bytes)
|
||||
.await
|
||||
.map_err(|e| format!("Could not write file: {}", e))?;
|
||||
tokio::io::AsyncWriteExt::flush(&mut file)
|
||||
.await
|
||||
.map_err(|e| format!("Could not flush file: {}", e))?;
|
||||
|
||||
Ok(len)
|
||||
}
|
||||
|
||||
/// Upload a file directly to a friend's shared folder (if Read & Write allowed)
|
||||
pub async fn upload_to_shared_folder(
|
||||
&self,
|
||||
peer_url: &str,
|
||||
folder_id: &str,
|
||||
subpath: &str,
|
||||
file_path: &std::path::Path,
|
||||
pin: Option<&str>,
|
||||
) -> Result<SharedUploadResponse, String> {
|
||||
let clean = subpath.trim_start_matches('/').trim_start_matches('\\');
|
||||
let mut url = format!("{}/api/shared/upload/{}/{}", peer_url, folder_id, clean);
|
||||
if let Some(p) = pin.filter(|p| !p.trim().is_empty()) {
|
||||
url = format!("{}?pin={}", url, p);
|
||||
}
|
||||
|
||||
let file_name = file_path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "file".to_string());
|
||||
|
||||
let file_bytes = tokio::fs::read(file_path)
|
||||
.await
|
||||
.map_err(|e| format!("Could not read local file: {}", e))?;
|
||||
|
||||
let part = reqwest::multipart::Part::bytes(file_bytes).file_name(file_name);
|
||||
let form = reqwest::multipart::Form::new().part("file", part);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Upload error: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Upload failed: HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
resp.json::<SharedUploadResponse>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse upload response: {}", e))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
pub mod archive;
|
||||
pub mod client;
|
||||
pub mod protocol;
|
||||
pub mod rate_limiter;
|
||||
pub mod server;
|
||||
pub mod text_analyzer;
|
||||
|
||||
pub use client::*;
|
||||
pub use protocol::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use rate_limiter::RateLimiter;
|
||||
pub use server::*;
|
||||
pub use text_analyzer::*;
|
||||
@@ -0,0 +1,189 @@
|
||||
use super::text_analyzer::ContentType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileMetadata {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub relative_path: String,
|
||||
pub size: u64,
|
||||
pub is_dir: bool,
|
||||
pub sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PrepareTransferRequest {
|
||||
pub session_id: String,
|
||||
pub sender_id: String,
|
||||
pub sender_name: String,
|
||||
pub sender_os: String,
|
||||
pub files: Vec<FileMetadata>,
|
||||
pub total_size: u64,
|
||||
pub pin: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TextMessage {
|
||||
pub message_id: String,
|
||||
pub sender_id: String,
|
||||
pub sender_name: String,
|
||||
pub sender_os: String,
|
||||
pub content: String,
|
||||
pub content_type: ContentType,
|
||||
pub pin: Option<String>,
|
||||
}
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum PrepareTransferResponse {
|
||||
Accepted {
|
||||
session_id: String,
|
||||
#[serde(default)]
|
||||
received_offsets: HashMap<String, u64>,
|
||||
},
|
||||
Rejected {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TransferState {
|
||||
PendingConfirmation,
|
||||
InProgress,
|
||||
Completed,
|
||||
Failed(String),
|
||||
Rejected,
|
||||
Canceled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TransferProgress {
|
||||
pub session_id: String,
|
||||
pub peer_name: String,
|
||||
pub is_incoming: bool,
|
||||
pub current_file_index: usize,
|
||||
pub total_files: usize,
|
||||
pub current_file_name: String,
|
||||
pub bytes_transferred: u64,
|
||||
pub total_bytes: u64,
|
||||
pub speed_bps: u64,
|
||||
pub state: TransferState,
|
||||
#[serde(default)]
|
||||
pub checksum_verified: bool,
|
||||
}
|
||||
|
||||
impl TransferProgress {
|
||||
pub fn percentage(&self) -> f64 {
|
||||
if self.total_bytes == 0 {
|
||||
100.0
|
||||
} else {
|
||||
(self.bytes_transferred as f64 / self.total_bytes as f64) * 100.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SharedFileEntry {
|
||||
pub name: String,
|
||||
pub relative_path: String,
|
||||
pub size: u64,
|
||||
pub is_dir: bool,
|
||||
pub modified_secs: u64,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SharedFolderInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub read_only: bool,
|
||||
pub requires_pin: bool,
|
||||
pub total_files: usize,
|
||||
pub total_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SharedFolderTreeResponse {
|
||||
pub folder_id: String,
|
||||
pub folder_name: String,
|
||||
pub current_path: String,
|
||||
pub read_only: bool,
|
||||
pub entries: Vec<SharedFileEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SharedUploadResponse {
|
||||
pub status: String,
|
||||
pub filename: String,
|
||||
pub bytes_written: u64,
|
||||
}
|
||||
|
||||
pub fn get_mime_type(filename: &str) -> &'static str {
|
||||
let lower = filename.to_lowercase();
|
||||
if lower.ends_with(".mp4") {
|
||||
"video/mp4"
|
||||
} else if lower.ends_with(".mkv") {
|
||||
"video/x-matroska"
|
||||
} else if lower.ends_with(".webm") {
|
||||
"video/webm"
|
||||
} else if lower.ends_with(".mov") {
|
||||
"video/quicktime"
|
||||
} else if lower.ends_with(".avi") {
|
||||
"video/x-msvideo"
|
||||
} else if lower.ends_with(".mp3") {
|
||||
"audio/mpeg"
|
||||
} else if lower.ends_with(".wav") {
|
||||
"audio/wav"
|
||||
} else if lower.ends_with(".flac") {
|
||||
"audio/flac"
|
||||
} else if lower.ends_with(".ogg") {
|
||||
"audio/ogg"
|
||||
} else if lower.ends_with(".m4a") || lower.ends_with(".aac") {
|
||||
"audio/mp4"
|
||||
} else if lower.ends_with(".png") {
|
||||
"image/png"
|
||||
} else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
|
||||
"image/jpeg"
|
||||
} else if lower.ends_with(".gif") {
|
||||
"image/gif"
|
||||
} else if lower.ends_with(".webp") {
|
||||
"image/webp"
|
||||
} else if lower.ends_with(".svg") {
|
||||
"image/svg+xml"
|
||||
} else if lower.ends_with(".pdf") {
|
||||
"application/pdf"
|
||||
} else if lower.ends_with(".zip") {
|
||||
"application/zip"
|
||||
} else if lower.ends_with(".rs")
|
||||
|| lower.ends_with(".py")
|
||||
|| lower.ends_with(".js")
|
||||
|| lower.ends_with(".ts")
|
||||
|| lower.ends_with(".jsx")
|
||||
|| lower.ends_with(".tsx")
|
||||
|| lower.ends_with(".html")
|
||||
|| lower.ends_with(".css")
|
||||
|| lower.ends_with(".json")
|
||||
|| lower.ends_with(".toml")
|
||||
|| lower.ends_with(".yaml")
|
||||
|| lower.ends_with(".yml")
|
||||
|| lower.ends_with(".c")
|
||||
|| lower.ends_with(".cpp")
|
||||
|| lower.ends_with(".h")
|
||||
|| lower.ends_with(".hpp")
|
||||
|| lower.ends_with(".sh")
|
||||
|| lower.ends_with(".bat")
|
||||
|| lower.ends_with(".cmd")
|
||||
|| lower.ends_with(".ps1")
|
||||
|| lower.ends_with(".txt")
|
||||
|| lower.ends_with(".md")
|
||||
|| lower.ends_with(".go")
|
||||
|| lower.ends_with(".java")
|
||||
|| lower.ends_with(".xml")
|
||||
|| lower.ends_with(".sql")
|
||||
{
|
||||
"text/plain; charset=utf-8"
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RateLimiter {
|
||||
max_bps: Arc<AtomicU64>,
|
||||
state: Arc<Mutex<RateLimiterState>>,
|
||||
}
|
||||
|
||||
struct RateLimiterState {
|
||||
tokens: f64,
|
||||
last_update: Instant,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new(max_bps: u64) -> Self {
|
||||
Self {
|
||||
max_bps: Arc::new(AtomicU64::new(max_bps)),
|
||||
state: Arc::new(Mutex::new(RateLimiterState {
|
||||
tokens: max_bps as f64,
|
||||
last_update: Instant::now(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn set_limit_bps(&self, max_bps: u64) {
|
||||
self.max_bps.store(max_bps, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub async fn throttle(&self, bytes: usize) {
|
||||
let max_bps = self.max_bps.load(Ordering::Relaxed);
|
||||
if max_bps == 0 || bytes == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut state = self.state.lock().await;
|
||||
let now = Instant::now();
|
||||
let elapsed = now.duration_since(state.last_update).as_secs_f64();
|
||||
state.last_update = now;
|
||||
|
||||
// Refill bucket
|
||||
state.tokens = (state.tokens + elapsed * (max_bps as f64)).min(max_bps as f64 * 2.0);
|
||||
|
||||
if state.tokens < bytes as f64 {
|
||||
let needed = bytes as f64 - state.tokens;
|
||||
let wait_secs = needed / (max_bps as f64);
|
||||
state.tokens = 0.0;
|
||||
drop(state);
|
||||
|
||||
tokio::time::sleep(Duration::from_secs_f64(wait_secs)).await;
|
||||
} else {
|
||||
state.tokens -= bytes as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{Ipv4Addr, SocketAddrV4};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ContentType {
|
||||
IpAddress,
|
||||
ServerAddress,
|
||||
WebUrl,
|
||||
Email,
|
||||
JsonData,
|
||||
CodeSnippet,
|
||||
PlainText,
|
||||
}
|
||||
|
||||
impl ContentType {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
ContentType::IpAddress => "📡 IP Address",
|
||||
ContentType::ServerAddress => "🔌 Server Address (IP:Port)",
|
||||
ContentType::WebUrl => "🌐 Web Link",
|
||||
ContentType::Email => "✉️ Email Address",
|
||||
ContentType::JsonData => "📦 JSON Data",
|
||||
ContentType::CodeSnippet => "💻 Code Snippet",
|
||||
ContentType::PlainText => "📝 Text Message",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn badge_color_rgb(&self) -> (u8, u8, u8) {
|
||||
match self {
|
||||
ContentType::IpAddress => (34, 197, 94), // Green
|
||||
ContentType::ServerAddress => (168, 85, 247), // Purple
|
||||
ContentType::WebUrl => (56, 189, 248), // Cyan
|
||||
ContentType::Email => (234, 179, 8), // Yellow
|
||||
ContentType::JsonData => (249, 115, 22), // Orange
|
||||
ContentType::CodeSnippet => (236, 72, 153), // Pink
|
||||
ContentType::PlainText => (148, 163, 184), // Gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnalyzedText {
|
||||
pub content: String,
|
||||
pub content_type: ContentType,
|
||||
}
|
||||
|
||||
/// Automatically detect the type of text content
|
||||
pub fn analyze_text(raw: &str) -> AnalyzedText {
|
||||
let trimmed = raw.trim();
|
||||
|
||||
// 1. Check if IP:Port
|
||||
if SocketAddrV4::from_str(trimmed).is_ok() {
|
||||
return AnalyzedText {
|
||||
content: trimmed.to_string(),
|
||||
content_type: ContentType::ServerAddress,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Check if IPv4 Address
|
||||
if Ipv4Addr::from_str(trimmed).is_ok() {
|
||||
return AnalyzedText {
|
||||
content: trimmed.to_string(),
|
||||
content_type: ContentType::IpAddress,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Check if URL
|
||||
if trimmed.starts_with("http://")
|
||||
|| trimmed.starts_with("https://")
|
||||
|| trimmed.starts_with("www.")
|
||||
|| (trimmed.contains('.') && (trimmed.ends_with(".com") || trimmed.ends_with(".ru") || trimmed.ends_with(".org") || trimmed.ends_with(".net") || trimmed.ends_with(".io") || trimmed.ends_with(".dev")))
|
||||
{
|
||||
return AnalyzedText {
|
||||
content: trimmed.to_string(),
|
||||
content_type: ContentType::WebUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Check if Email
|
||||
if trimmed.contains('@') && trimmed.contains('.') && !trimmed.contains(' ') && trimmed.len() > 5 {
|
||||
return AnalyzedText {
|
||||
content: trimmed.to_string(),
|
||||
content_type: ContentType::Email,
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Check if JSON
|
||||
if (trimmed.starts_with('{') && trimmed.ends_with('}'))
|
||||
|| (trimmed.starts_with('[') && trimmed.ends_with(']'))
|
||||
{
|
||||
if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
|
||||
return AnalyzedText {
|
||||
content: trimmed.to_string(),
|
||||
content_type: ContentType::JsonData,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Check if Code Snippet
|
||||
let code_indicators = [
|
||||
"fn ", "let ", "let mut ", "pub fn", "def ", "class ", "import ", "const ",
|
||||
"function ", "var ", "val ", "#include", "<html", "SELECT ", "INSERT INTO ",
|
||||
"return ", "if (", "while (", "for (", "=>",
|
||||
];
|
||||
if code_indicators.iter().any(|ind| trimmed.contains(ind)) || (trimmed.contains('{') && trimmed.contains('}') && trimmed.contains(';')) {
|
||||
return AnalyzedText {
|
||||
content: trimmed.to_string(),
|
||||
content_type: ContentType::CodeSnippet,
|
||||
};
|
||||
}
|
||||
|
||||
// Default
|
||||
AnalyzedText {
|
||||
content: trimmed.to_string(),
|
||||
content_type: ContentType::PlainText,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "zerosend", author, version, about = "⚡ Ultra-fast P2P file sharing over ZeroTier, Tailscale, Radmin VPN & LAN")]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Commands>,
|
||||
|
||||
/// Network interface name or IP to bind to (defaults to ZeroTier / best available)
|
||||
#[arg(short, long)]
|
||||
pub interface: Option<String>,
|
||||
|
||||
/// Custom device name to broadcast
|
||||
#[arg(short, long)]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// Transfer HTTP port (default: 53318)
|
||||
#[arg(short, long, default_value_t = 53318)]
|
||||
pub port: u16,
|
||||
|
||||
/// Run in Terminal (TUI) mode instead of GUI
|
||||
#[arg(long, default_value_t = false)]
|
||||
pub tui: bool,
|
||||
|
||||
/// Downloads directory (default: ./downloads)
|
||||
#[arg(short, long, default_value = "./downloads")]
|
||||
pub download_dir: PathBuf,
|
||||
|
||||
/// Start minimized to system tray (used for Windows autostart)
|
||||
#[arg(long, default_value_t = false)]
|
||||
pub tray: bool,
|
||||
|
||||
/// Start minimized to system tray (alias)
|
||||
#[arg(long, default_value_t = false)]
|
||||
pub minimized: bool,
|
||||
|
||||
/// Initial files or folders to send (e.g. from Windows Context Menu)
|
||||
#[arg(trailing_var_arg = true)]
|
||||
pub files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Commands {
|
||||
/// Send file(s) directly to a target peer IP or URL
|
||||
Send {
|
||||
/// Target peer IP address or URL (e.g. 10.147.20.55 or http://10.147.20.55:53318)
|
||||
target: String,
|
||||
|
||||
/// Path to file(s) or folder(s) to send
|
||||
#[arg(required = true)]
|
||||
files: Vec<PathBuf>,
|
||||
},
|
||||
|
||||
/// Run in headless receiver/daemon mode
|
||||
Receive {
|
||||
/// Automatically accept incoming file transfer requests
|
||||
#[arg(short, long, default_value_t = false)]
|
||||
auto_accept: bool,
|
||||
},
|
||||
|
||||
/// List all detected network interfaces (ZeroTier, Tailscale, Radmin, LAN)
|
||||
Interfaces,
|
||||
|
||||
/// Ping a ZeroSend node to check connectivity and fetch device info
|
||||
Ping {
|
||||
/// Target IP address
|
||||
ip: String,
|
||||
/// Target port (default 53318)
|
||||
#[arg(short, long, default_value_t = 53318)]
|
||||
port: u16,
|
||||
},
|
||||
}
|
||||
+3668
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
pub mod cli;
|
||||
pub mod gui;
|
||||
pub mod sound;
|
||||
pub mod tray;
|
||||
pub mod tui;
|
||||
pub mod vim_viewer;
|
||||
|
||||
pub use cli::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use sound::play_transfer_sound;
|
||||
@@ -0,0 +1,13 @@
|
||||
#[cfg(windows)]
|
||||
#[link(name = "user32")]
|
||||
extern "system" {
|
||||
fn MessageBeep(uType: u32) -> i32;
|
||||
}
|
||||
|
||||
/// Play a subtle native Windows notification sound (AirDrop chime style)
|
||||
pub fn play_transfer_sound() {
|
||||
#[cfg(windows)]
|
||||
unsafe {
|
||||
MessageBeep(0x00000040); // MB_ICONASTERISK
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
use eframe::egui::Context;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{channel, Receiver};
|
||||
use std::time::Duration;
|
||||
use tray_icon::{
|
||||
menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem},
|
||||
Icon, TrayIcon, TrayIconBuilder, TrayIconEvent,
|
||||
};
|
||||
|
||||
pub struct SystemTrayManager {
|
||||
_tray_icon: TrayIcon,
|
||||
action_rx: Receiver<TrayAction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TrayAction {
|
||||
ShowWindow,
|
||||
OpenDownloads,
|
||||
}
|
||||
|
||||
impl SystemTrayManager {
|
||||
pub fn new(
|
||||
ctx: Context,
|
||||
download_dir: PathBuf,
|
||||
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let menu = Menu::new();
|
||||
let show_item = MenuItem::new("⚡ Show ZeroSend", true, None);
|
||||
let downloads_item = MenuItem::new("📁 Open Downloads Folder", true, None);
|
||||
let quit_item = MenuItem::new("❌ Exit", true, None);
|
||||
|
||||
let show_id = show_item.id().clone();
|
||||
let downloads_id = downloads_item.id().clone();
|
||||
let quit_id = quit_item.id().clone();
|
||||
|
||||
menu.append_items(&[
|
||||
&show_item,
|
||||
&downloads_item,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&quit_item,
|
||||
])?;
|
||||
|
||||
let icon = create_default_tray_icon()?;
|
||||
|
||||
let tray_icon = TrayIconBuilder::new()
|
||||
.with_menu(Box::new(menu))
|
||||
.with_tooltip("ZeroSend - P2P Mesh Sharing")
|
||||
.with_icon(icon)
|
||||
.build()?;
|
||||
|
||||
let (action_tx, action_rx) = channel::<TrayAction>();
|
||||
|
||||
// Spawn a dedicated listener thread to handle tray clicks & menu actions
|
||||
let ctx_clone = ctx.clone();
|
||||
let dl_dir = download_dir.clone();
|
||||
let tx = action_tx.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
// 1. Check Tray Icon Left Clicks
|
||||
if let Ok(tray_event) = TrayIconEvent::receiver().try_recv() {
|
||||
match tray_event {
|
||||
TrayIconEvent::Click {
|
||||
button: tray_icon::MouseButton::Left,
|
||||
button_state: tray_icon::MouseButtonState::Up,
|
||||
..
|
||||
}
|
||||
| TrayIconEvent::DoubleClick {
|
||||
button: tray_icon::MouseButton::Left,
|
||||
..
|
||||
} => {
|
||||
let _ = tx.send(TrayAction::ShowWindow);
|
||||
ctx_clone.request_repaint();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check Tray Menu Items
|
||||
if let Ok(menu_event) = MenuEvent::receiver().try_recv() {
|
||||
if menu_event.id == show_id {
|
||||
let _ = tx.send(TrayAction::ShowWindow);
|
||||
ctx_clone.request_repaint();
|
||||
} else if menu_event.id == downloads_id {
|
||||
let _ = std::process::Command::new("explorer").arg(&dl_dir).spawn();
|
||||
let _ = tx.send(TrayAction::OpenDownloads);
|
||||
} else if menu_event.id == quit_id {
|
||||
// Exit immediately
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
_tray_icon: tray_icon,
|
||||
action_rx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Try receiving any pending tray actions
|
||||
pub fn try_recv_action(&self) -> Option<TrayAction> {
|
||||
self.action_rx.try_recv().ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn create_default_tray_icon() -> Result<Icon, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let width = 32u32;
|
||||
let height = 32u32;
|
||||
let mut rgba = Vec::with_capacity((width * height * 4) as usize);
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..height {
|
||||
let dx = x as f32 - 15.5;
|
||||
let dy = y as f32 - 15.5;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
|
||||
if dist <= 14.0 {
|
||||
if dist > 12.0 {
|
||||
// Cyan glowing ring
|
||||
rgba.extend_from_slice(&[56, 189, 248, 255]);
|
||||
} else if dist <= 7.0 {
|
||||
// White center dot
|
||||
rgba.extend_from_slice(&[255, 255, 255, 255]);
|
||||
} else {
|
||||
// Electric blue body
|
||||
rgba.extend_from_slice(&[37, 99, 235, 255]);
|
||||
}
|
||||
} else {
|
||||
rgba.extend_from_slice(&[0, 0, 0, 0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let icon = Icon::from_rgba(rgba, width, height)?;
|
||||
Ok(icon)
|
||||
}
|
||||
+672
@@ -0,0 +1,672 @@
|
||||
use crate::network::{DiscoveryService, InterfaceInfo, PeerInfo};
|
||||
use crate::transfer::{
|
||||
PrepareTransferRequest, TransferClient, TransferProgress, TransferServer, TransferState,
|
||||
};
|
||||
use crossterm::{
|
||||
event::{self, Event, KeyCode, KeyEventKind},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use human_bytes::human_bytes;
|
||||
use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{
|
||||
Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap,
|
||||
},
|
||||
Frame, Terminal,
|
||||
};
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub enum ModalState {
|
||||
None,
|
||||
ConfirmTransfer(PrepareTransferRequest),
|
||||
InputFilePath {
|
||||
peer_url: String,
|
||||
peer_name: String,
|
||||
input: String,
|
||||
},
|
||||
InputDirectIp {
|
||||
input: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub my_name: String,
|
||||
pub iface: InterfaceInfo,
|
||||
pub peers: Vec<PeerInfo>,
|
||||
pub peer_list_state: ListState,
|
||||
pub transfers: Vec<TransferProgress>,
|
||||
pub logs: Vec<String>,
|
||||
pub modal: ModalState,
|
||||
pub should_quit: bool,
|
||||
}
|
||||
|
||||
pub async fn run_tui(
|
||||
discovery: DiscoveryService,
|
||||
server: TransferServer,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// 1. Setup Terminal
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let server_state = server.state();
|
||||
let my_info = discovery.my_info().clone();
|
||||
let iface = discovery.selected_interface().clone();
|
||||
|
||||
let mut app = AppState {
|
||||
my_name: my_info.name.clone(),
|
||||
iface,
|
||||
peers: Vec::new(),
|
||||
peer_list_state: ListState::default(),
|
||||
transfers: Vec::new(),
|
||||
logs: vec![
|
||||
"⚡ ZeroSend initialized".to_string(),
|
||||
format!("📡 Listening on interface {} ({})", my_info.ip, my_info.net_type),
|
||||
"🔍 UDP Peer Discovery active".to_string(),
|
||||
],
|
||||
modal: ModalState::None,
|
||||
should_quit: false,
|
||||
};
|
||||
|
||||
let (log_tx, mut log_rx) = mpsc::unbounded_channel::<String>();
|
||||
let (transfer_update_tx, mut transfer_update_rx) = mpsc::unbounded_channel::<TransferProgress>();
|
||||
|
||||
// Listen for server incoming transfer requests
|
||||
let mut req_rx = server_state.incoming_request_events.subscribe();
|
||||
let mut trans_rx = server_state.transfer_events.subscribe();
|
||||
|
||||
let client = Arc::new(TransferClient::new());
|
||||
let peers_store = discovery.peers();
|
||||
|
||||
// Event Loop
|
||||
let mut last_tick = Instant::now();
|
||||
let tick_rate = Duration::from_millis(100);
|
||||
|
||||
while !app.should_quit {
|
||||
// Refresh peer list from discovery
|
||||
{
|
||||
let peers_map = peers_store.read().await;
|
||||
let mut list: Vec<PeerInfo> = peers_map.values().cloned().collect();
|
||||
list.sort_by(|a, b| b.is_online.cmp(&a.is_online).then_with(|| a.name.cmp(&b.name)));
|
||||
app.peers = list;
|
||||
|
||||
if !app.peers.is_empty() && app.peer_list_state.selected().is_none() {
|
||||
app.peer_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
// Receive logs
|
||||
while let Ok(msg) = log_rx.try_recv() {
|
||||
app.logs.push(msg);
|
||||
if app.logs.len() > 100 {
|
||||
app.logs.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Receive transfer updates from client or server
|
||||
while let Ok(t) = transfer_update_rx.try_recv() {
|
||||
if let Some(pos) = app.transfers.iter().position(|x| x.session_id == t.session_id) {
|
||||
app.transfers[pos] = t;
|
||||
} else {
|
||||
app.transfers.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
while let Ok(t) = trans_rx.try_recv() {
|
||||
if let Some(pos) = app.transfers.iter().position(|x| x.session_id == t.session_id) {
|
||||
app.transfers[pos] = t;
|
||||
} else {
|
||||
app.transfers.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for incoming confirmation requests
|
||||
while let Ok(req) = req_rx.try_recv() {
|
||||
app.logs.push(format!(
|
||||
"📥 Transfer request from {} ({} files)",
|
||||
req.sender_name,
|
||||
req.files.len()
|
||||
));
|
||||
app.modal = ModalState::ConfirmTransfer(req);
|
||||
}
|
||||
|
||||
// Draw UI
|
||||
terminal.draw(|f| draw_ui(f, &mut app))?;
|
||||
|
||||
// Handle Keys
|
||||
let timeout = tick_rate
|
||||
.checked_sub(last_tick.elapsed())
|
||||
.unwrap_or_else(|| Duration::from_secs(0));
|
||||
|
||||
if event::poll(timeout)? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind == KeyEventKind::Press {
|
||||
handle_key_event(
|
||||
key.code,
|
||||
&mut app,
|
||||
&server,
|
||||
&client,
|
||||
&my_info,
|
||||
&discovery,
|
||||
log_tx.clone(),
|
||||
transfer_update_tx.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if last_tick.elapsed() >= tick_rate {
|
||||
last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
// Restore terminal
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_key_event(
|
||||
code: KeyCode,
|
||||
app: &mut AppState,
|
||||
server: &TransferServer,
|
||||
client: &Arc<TransferClient>,
|
||||
my_info: &crate::network::DiscoveryBeacon,
|
||||
discovery: &DiscoveryService,
|
||||
log_tx: mpsc::UnboundedSender<String>,
|
||||
transfer_tx: mpsc::UnboundedSender<TransferProgress>,
|
||||
) {
|
||||
match &mut app.modal {
|
||||
ModalState::ConfirmTransfer(req) => {
|
||||
let session_id = req.session_id.clone();
|
||||
match code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('a') | KeyCode::Char('A') | KeyCode::Enter => {
|
||||
let _ = server.accept_transfer(&session_id).await;
|
||||
let _ = log_tx.send(format!("✅ Accepted transfer from {}", req.sender_name));
|
||||
app.modal = ModalState::None;
|
||||
}
|
||||
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Char('r') | KeyCode::Char('R') | KeyCode::Esc => {
|
||||
let _ = server.reject_transfer(&session_id).await;
|
||||
let _ = log_tx.send(format!("❌ Rejected transfer from {}", req.sender_name));
|
||||
app.modal = ModalState::None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ModalState::InputFilePath { peer_url, peer_name, input } => {
|
||||
match code {
|
||||
KeyCode::Enter => {
|
||||
let path_str = input.trim().to_string();
|
||||
if !path_str.is_empty() {
|
||||
let path = PathBuf::from(path_str.clone());
|
||||
let peer_url = peer_url.clone();
|
||||
let peer_name = peer_name.clone();
|
||||
let client_clone = client.clone();
|
||||
let my_info_clone = my_info.clone();
|
||||
let log_tx_clone = log_tx.clone();
|
||||
let transfer_tx_clone = transfer_tx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = log_tx_clone.send(format!("📤 Sending {:?} to {}...", path, peer_name));
|
||||
let res = client_clone
|
||||
.send_files(&peer_url, &peer_name, &my_info_clone, &[path], true, 0, None, move |progress| {
|
||||
let _ = transfer_tx_clone.send(progress);
|
||||
})
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(_) => {
|
||||
let _ = log_tx_clone.send(format!("🎉 Sent successfully to {}!", peer_name));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = log_tx_clone.send(format!("⚠️ Send failed to {}: {}", peer_name, e));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
app.modal = ModalState::None;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
app.modal = ModalState::None;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
input.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
input.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ModalState::InputDirectIp { input } => {
|
||||
match code {
|
||||
KeyCode::Enter => {
|
||||
let ip_str = input.trim().to_string();
|
||||
if !ip_str.is_empty() {
|
||||
let client_clone = client.clone();
|
||||
let log_tx_clone = log_tx.clone();
|
||||
let disc_peers = discovery.peers();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = log_tx_clone.send(format!("🔍 Pinging direct node at {}...", ip_str));
|
||||
match client_clone.ping_peer(&ip_str, 53318).await {
|
||||
Ok(beacon) => {
|
||||
let _ = log_tx_clone.send(format!("✨ Found peer: {} ({})", beacon.name, beacon.ip));
|
||||
let mut p_map = disc_peers.write().await;
|
||||
p_map.insert(
|
||||
beacon.peer_id.clone(),
|
||||
PeerInfo {
|
||||
peer_id: beacon.peer_id,
|
||||
name: beacon.name,
|
||||
os: beacon.os,
|
||||
ip: beacon.ip,
|
||||
transfer_port: beacon.transfer_port,
|
||||
net_type: beacon.net_type,
|
||||
version: beacon.version,
|
||||
last_seen: chrono::Utc::now(),
|
||||
is_online: true,
|
||||
ping_ms: None,
|
||||
shared_folders: beacon.shared_folders,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = log_tx_clone.send(format!("❌ Direct ping error: {}", e));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
app.modal = ModalState::None;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
app.modal = ModalState::None;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
input.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
input.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ModalState::None => {
|
||||
match code {
|
||||
KeyCode::Char('q') | KeyCode::Char('Q') => {
|
||||
app.should_quit = true;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if !app.peers.is_empty() {
|
||||
let i = match app.peer_list_state.selected() {
|
||||
Some(i) => {
|
||||
if i == 0 {
|
||||
app.peers.len() - 1
|
||||
} else {
|
||||
i - 1
|
||||
}
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
app.peer_list_state.select(Some(i));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if !app.peers.is_empty() {
|
||||
let i = match app.peer_list_state.selected() {
|
||||
Some(i) => {
|
||||
if i >= app.peers.len() - 1 {
|
||||
0
|
||||
} else {
|
||||
i + 1
|
||||
}
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
app.peer_list_state.select(Some(i));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('s') | KeyCode::Char('S') => {
|
||||
if let Some(i) = app.peer_list_state.selected() {
|
||||
if let Some(peer) = app.peers.get(i) {
|
||||
app.modal = ModalState::InputFilePath {
|
||||
peer_url: peer.transfer_url(),
|
||||
peer_name: peer.name.clone(),
|
||||
input: String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Char('D') => {
|
||||
app.modal = ModalState::InputDirectIp {
|
||||
input: String::new(),
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_ui(f: &mut Frame, app: &mut AppState) {
|
||||
let size = f.area();
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Header
|
||||
Constraint::Min(10), // Body
|
||||
Constraint::Length(3), // Footer / Shortcuts
|
||||
])
|
||||
.split(size);
|
||||
|
||||
// 1. Header
|
||||
let header_text = vec![
|
||||
Span::styled("⚡ ZeroSend ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
|
||||
Span::raw("| Node: "),
|
||||
Span::styled(&app.my_name, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||
Span::raw(" | Interface: "),
|
||||
Span::styled(
|
||||
format!("{} ({})", app.iface.ip, app.iface.net_type.as_str()),
|
||||
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" | Port: "),
|
||||
Span::styled("53318", Style::default().fg(Color::Magenta)),
|
||||
];
|
||||
let header = Paragraph::new(Line::from(header_text))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(Color::Cyan)),
|
||||
)
|
||||
.alignment(Alignment::Left);
|
||||
f.render_widget(header, chunks[0]);
|
||||
|
||||
// 2. Body (Left: Peers, Right: Transfers & Logs)
|
||||
let body_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
|
||||
.split(chunks[1]);
|
||||
|
||||
// Left Panel: Discovered Peers
|
||||
let items: Vec<ListItem> = app
|
||||
.peers
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let status_icon = if p.is_online { "🟢" } else { "⚪" };
|
||||
let lines = vec![
|
||||
Line::from(vec![
|
||||
Span::raw(format!("{} ", status_icon)),
|
||||
Span::styled(&p.name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(format!(" [{}]", p.net_type), Style::default().fg(Color::Blue)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" IP: "),
|
||||
Span::styled(format!("{}:{}", p.ip, p.transfer_port), Style::default().fg(Color::Gray)),
|
||||
Span::raw(" | OS: "),
|
||||
Span::styled(&p.os, Style::default().fg(Color::DarkGray)),
|
||||
]),
|
||||
];
|
||||
ListItem::new(lines)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let peers_block = Block::default()
|
||||
.title(" 📡 Discovered Peers (ZeroTier / VPN) ")
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(Color::Blue));
|
||||
|
||||
let peers_list = List::new(items)
|
||||
.block(peers_block)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.bg(Color::Rgb(30, 58, 138))
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("▶ ");
|
||||
|
||||
f.render_stateful_widget(peers_list, body_chunks[0], &mut app.peer_list_state);
|
||||
|
||||
// Right Panel: Transfers (Top) & Logs (Bottom)
|
||||
let right_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
|
||||
.split(body_chunks[1]);
|
||||
|
||||
// Transfers List
|
||||
let transfer_block = Block::default()
|
||||
.title(" 📦 Active & Recent Transfers ")
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(Color::Green));
|
||||
|
||||
if app.transfers.is_empty() {
|
||||
let no_transfers = Paragraph::new("No active transfers. Press [S] to send a file.")
|
||||
.style(Style::default().fg(Color::DarkGray))
|
||||
.block(transfer_block)
|
||||
.alignment(Alignment::Center);
|
||||
f.render_widget(no_transfers, right_chunks[0]);
|
||||
} else {
|
||||
let transfer_items: Vec<ListItem> = app
|
||||
.transfers
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let direction = if t.is_incoming { "📥 IN" } else { "📤 OUT" };
|
||||
let speed = if t.speed_bps > 0 {
|
||||
format!(" ({}/s)", human_bytes(t.speed_bps as f64))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let state_str = match &t.state {
|
||||
TransferState::PendingConfirmation => "⏳ Waiting confirmation...".to_string(),
|
||||
TransferState::InProgress => format!("{:.1}%{}", t.percentage(), speed),
|
||||
TransferState::Completed => "✅ Completed".to_string(),
|
||||
TransferState::Failed(e) => format!("❌ Failed: {}", e),
|
||||
TransferState::Rejected => "⛔ Rejected".to_string(),
|
||||
TransferState::Canceled => "🛑 Canceled".to_string(),
|
||||
};
|
||||
|
||||
let lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(format!("[{}] ", direction), Style::default().fg(Color::Magenta)),
|
||||
Span::styled(&t.current_file_name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
Span::raw(format!(" -> {}", t.peer_name)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("{}/{}", human_bytes(t.bytes_transferred as f64), human_bytes(t.total_bytes as f64)),
|
||||
Style::default().fg(Color::Cyan),
|
||||
),
|
||||
Span::raw(" | "),
|
||||
Span::styled(state_str, Style::default().fg(Color::Yellow)),
|
||||
]),
|
||||
];
|
||||
ListItem::new(lines)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let trans_list = List::new(transfer_items).block(transfer_block);
|
||||
f.render_widget(trans_list, right_chunks[0]);
|
||||
}
|
||||
|
||||
// Activity Logs
|
||||
let logs_block = Block::default()
|
||||
.title(" 📝 Activity Log ")
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(Color::Gray));
|
||||
|
||||
let log_lines: Vec<Line> = app
|
||||
.logs
|
||||
.iter()
|
||||
.rev()
|
||||
.take(10)
|
||||
.rev()
|
||||
.map(|l| Line::from(Span::styled(l, Style::default().fg(Color::White))))
|
||||
.collect();
|
||||
|
||||
let logs_widget = Paragraph::new(log_lines)
|
||||
.block(logs_block)
|
||||
.wrap(Wrap { trim: true });
|
||||
f.render_widget(logs_widget, right_chunks[1]);
|
||||
|
||||
// 3. Footer / Shortcuts
|
||||
let shortcuts = Line::from(vec![
|
||||
Span::styled("[S] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||
Span::raw("Send File "),
|
||||
Span::styled("[D] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||
Span::raw("Direct IP "),
|
||||
Span::styled("[↑/↓] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||
Span::raw("Select Peer "),
|
||||
Span::styled("[Q] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||
Span::raw("Quit"),
|
||||
]);
|
||||
let footer = Paragraph::new(shortcuts)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(Color::DarkGray)),
|
||||
)
|
||||
.alignment(Alignment::Center);
|
||||
f.render_widget(footer, chunks[2]);
|
||||
|
||||
// Modals
|
||||
match &app.modal {
|
||||
ModalState::ConfirmTransfer(req) => {
|
||||
let area = centered_rect(60, 30, size);
|
||||
f.render_widget(Clear, area);
|
||||
|
||||
let block = Block::default()
|
||||
.title(" 📥 Incoming File Transfer ")
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Double)
|
||||
.border_style(Style::default().fg(Color::Yellow));
|
||||
|
||||
let files_summary = req
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| format!(" - {} ({})", f.name, human_bytes(f.size as f64)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let text = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(&req.sender_name, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
|
||||
Span::raw(format!(" wants to send you {} file(s) ({})", req.files.len(), human_bytes(req.total_size as f64))),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(files_summary),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("[Y/A] Accept", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
|
||||
Span::raw(" | "),
|
||||
Span::styled("[N/R] Reject", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||
]),
|
||||
];
|
||||
|
||||
let modal = Paragraph::new(text)
|
||||
.block(block)
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: true });
|
||||
f.render_widget(modal, area);
|
||||
}
|
||||
ModalState::InputFilePath { peer_name, input, .. } => {
|
||||
let area = centered_rect(65, 25, size);
|
||||
f.render_widget(Clear, area);
|
||||
|
||||
let block = Block::default()
|
||||
.title(format!(" 📤 Send File to {} ", peer_name))
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(Color::Cyan));
|
||||
|
||||
let text = vec![
|
||||
Line::from("Enter absolute or relative path to file/folder:"),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("> ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(input, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
Span::styled("█", Style::default().fg(Color::Cyan)),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("[Enter] Send", Style::default().fg(Color::Green)),
|
||||
Span::raw(" "),
|
||||
Span::styled("[Esc] Cancel", Style::default().fg(Color::DarkGray)),
|
||||
]),
|
||||
];
|
||||
|
||||
let modal = Paragraph::new(text).block(block).wrap(Wrap { trim: true });
|
||||
f.render_widget(modal, area);
|
||||
}
|
||||
ModalState::InputDirectIp { input } => {
|
||||
let area = centered_rect(60, 25, size);
|
||||
f.render_widget(Clear, area);
|
||||
|
||||
let block = Block::default()
|
||||
.title(" 🔍 Connect Direct IP (Tailscale / Custom) ")
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(Color::Magenta));
|
||||
|
||||
let text = vec![
|
||||
Line::from("Enter peer IP address (e.g. 10.147.20.55 or 100.80.x.x):"),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("> ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(input, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
Span::styled("█", Style::default().fg(Color::Magenta)),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("[Enter] Connect", Style::default().fg(Color::Green)),
|
||||
Span::raw(" "),
|
||||
Span::styled("[Esc] Cancel", Style::default().fg(Color::DarkGray)),
|
||||
]),
|
||||
];
|
||||
|
||||
let modal = Paragraph::new(text).block(block).wrap(Wrap { trim: true });
|
||||
f.render_widget(modal, area);
|
||||
}
|
||||
ModalState::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to create centered modal rectangle
|
||||
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||
let popup_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
Constraint::Percentage(percent_y),
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
])
|
||||
.split(r);
|
||||
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
Constraint::Percentage(percent_x),
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
])
|
||||
.split(popup_layout[1])[1]
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
use crate::ui::gui::ThemeColors;
|
||||
use eframe::egui::{
|
||||
self, Color32, Frame, Key, Margin, RichText, Rounding, ScrollArea, Stroke, TextEdit, Vec2,
|
||||
};
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct VimViewerModal {
|
||||
pub is_open: bool,
|
||||
pub file_name: String,
|
||||
pub relative_path: String,
|
||||
pub source_peer: String,
|
||||
pub lines: Vec<String>,
|
||||
pub cursor_line: usize, // 1-indexed
|
||||
pub search_query: String,
|
||||
pub is_searching: bool,
|
||||
pub command_input: String,
|
||||
pub is_command_mode: bool,
|
||||
pub status_msg: Option<String>,
|
||||
last_g_press: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Default for VimViewerModal {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
is_open: false,
|
||||
file_name: String::new(),
|
||||
relative_path: String::new(),
|
||||
source_peer: String::new(),
|
||||
lines: Vec::new(),
|
||||
cursor_line: 1,
|
||||
search_query: String::new(),
|
||||
is_searching: false,
|
||||
command_input: String::new(),
|
||||
is_command_mode: false,
|
||||
status_msg: None,
|
||||
last_g_press: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VimViewerModal {
|
||||
pub fn open(&mut self, file_name: String, relative_path: String, source_peer: String, content: String) {
|
||||
self.is_open = true;
|
||||
self.file_name = file_name;
|
||||
self.relative_path = relative_path;
|
||||
self.source_peer = source_peer;
|
||||
self.lines = content.lines().map(|s| s.to_string()).collect();
|
||||
if self.lines.is_empty() {
|
||||
self.lines.push(String::new());
|
||||
}
|
||||
self.cursor_line = 1;
|
||||
self.search_query.clear();
|
||||
self.is_searching = false;
|
||||
self.command_input.clear();
|
||||
self.is_command_mode = false;
|
||||
self.status_msg = Some(format!("Loaded {} lines", self.lines.len()));
|
||||
self.last_g_press = None;
|
||||
}
|
||||
|
||||
pub fn close(&mut self) {
|
||||
self.is_open = false;
|
||||
self.lines.clear();
|
||||
}
|
||||
|
||||
pub fn handle_keys(&mut self, ctx: &egui::Context) {
|
||||
if !self.is_open {
|
||||
return;
|
||||
}
|
||||
|
||||
let input = ctx.input(|i| i.clone());
|
||||
|
||||
// Escape always cancels search or command mode, or exits if already normal
|
||||
if input.key_pressed(Key::Escape) {
|
||||
if self.is_searching {
|
||||
self.is_searching = false;
|
||||
} else if self.is_command_mode {
|
||||
self.is_command_mode = false;
|
||||
self.command_input.clear();
|
||||
} else {
|
||||
self.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if self.is_searching {
|
||||
if input.key_pressed(Key::Enter) {
|
||||
self.is_searching = false;
|
||||
self.find_next_match(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if self.is_command_mode {
|
||||
if input.key_pressed(Key::Enter) {
|
||||
let cmd = self.command_input.trim().to_string();
|
||||
self.is_command_mode = false;
|
||||
self.command_input.clear();
|
||||
self.execute_command(&cmd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// NORMAL Mode Keybindings
|
||||
let total = self.lines.len().max(1);
|
||||
|
||||
// j / Down (move down)
|
||||
if input.key_pressed(Key::J) || input.key_pressed(Key::ArrowDown) {
|
||||
self.cursor_line = (self.cursor_line + 1).min(total);
|
||||
}
|
||||
|
||||
// k / Up (move up)
|
||||
if input.key_pressed(Key::K) || input.key_pressed(Key::ArrowUp) {
|
||||
self.cursor_line = self.cursor_line.saturating_sub(1).max(1);
|
||||
}
|
||||
|
||||
// Ctrl+D / PageDown (half screen down)
|
||||
if (input.modifiers.ctrl && input.key_pressed(Key::D)) || input.key_pressed(Key::PageDown) {
|
||||
self.cursor_line = (self.cursor_line + 25).min(total);
|
||||
}
|
||||
|
||||
// Ctrl+U / PageUp (half screen up)
|
||||
if (input.modifiers.ctrl && input.key_pressed(Key::U)) || input.key_pressed(Key::PageUp) {
|
||||
self.cursor_line = self.cursor_line.saturating_sub(25).max(1);
|
||||
}
|
||||
|
||||
// gg (top of file)
|
||||
if input.key_pressed(Key::G) && !input.modifiers.shift {
|
||||
if let Some(prev) = self.last_g_press {
|
||||
if prev.elapsed().as_millis() < 450 {
|
||||
self.cursor_line = 1;
|
||||
self.status_msg = Some("Top of file (gg)".to_string());
|
||||
self.last_g_press = None;
|
||||
} else {
|
||||
self.last_g_press = Some(Instant::now());
|
||||
}
|
||||
} else {
|
||||
self.last_g_press = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
// G (Shift+G) -> end of file
|
||||
if input.modifiers.shift && input.key_pressed(Key::G) {
|
||||
self.cursor_line = total;
|
||||
self.status_msg = Some("Bottom of file (G)".to_string());
|
||||
}
|
||||
|
||||
// / (search mode)
|
||||
if input.key_pressed(Key::Slash) {
|
||||
self.is_searching = true;
|
||||
self.search_query.clear();
|
||||
}
|
||||
|
||||
// : (command mode)
|
||||
if input.modifiers.shift && input.key_pressed(Key::Colon) || input.key_pressed(Key::Colon) {
|
||||
self.is_command_mode = true;
|
||||
self.command_input.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_command(&mut self, cmd: &str) {
|
||||
if cmd == "q" || cmd == "q!" || cmd == "quit" {
|
||||
self.close();
|
||||
} else if let Ok(line_num) = cmd.parse::<usize>() {
|
||||
let total = self.lines.len().max(1);
|
||||
self.cursor_line = line_num.clamp(1, total);
|
||||
self.status_msg = Some(format!("Jumped to line {}", self.cursor_line));
|
||||
} else {
|
||||
self.status_msg = Some(format!("Unknown command: :{}", cmd));
|
||||
}
|
||||
}
|
||||
|
||||
fn find_next_match(&mut self, forward: bool) {
|
||||
if self.search_query.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let q = self.search_query.to_lowercase();
|
||||
let total = self.lines.len();
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let start = if forward { self.cursor_line } else { self.cursor_line.saturating_sub(2) };
|
||||
|
||||
for offset in 0..total {
|
||||
let idx = if forward {
|
||||
(start + offset) % total
|
||||
} else {
|
||||
(start + total - offset) % total
|
||||
};
|
||||
|
||||
if self.lines[idx].to_lowercase().contains(&q) {
|
||||
self.cursor_line = idx + 1;
|
||||
self.status_msg = Some(format!("Match found on line {}", self.cursor_line));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.status_msg = Some(format!("Pattern not found: '{}'", self.search_query));
|
||||
}
|
||||
|
||||
pub fn render(&mut self, ctx: &egui::Context, _theme: ThemeColors) {
|
||||
if !self.is_open {
|
||||
return;
|
||||
}
|
||||
|
||||
self.handle_keys(ctx);
|
||||
|
||||
let mut is_open = self.is_open;
|
||||
let screen_rect = ctx.screen_rect();
|
||||
let win_size = Vec2::new((screen_rect.width() * 0.92).min(1100.0), (screen_rect.height() * 0.88).min(750.0));
|
||||
|
||||
egui::Window::new(format!("⚡ Vim Viewer • {} ({})", self.file_name, self.source_peer))
|
||||
.open(&mut is_open)
|
||||
.collapsible(false)
|
||||
.resizable(true)
|
||||
.default_size(win_size)
|
||||
.anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO)
|
||||
.frame(
|
||||
Frame::window(&ctx.style())
|
||||
.fill(Color32::from_rgb(15, 18, 28))
|
||||
.stroke(Stroke::new(1.0_f32, Color32::from_rgb(51, 65, 85)))
|
||||
.rounding(Rounding::same(8.0))
|
||||
.inner_margin(Margin::same(0.0)),
|
||||
)
|
||||
.show(ctx, |ui| {
|
||||
// 1. Top Header Bar
|
||||
Frame::none()
|
||||
.fill(Color32::from_rgb(22, 27, 44))
|
||||
.inner_margin(Margin::symmetric(14.0, 10.0))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("📄").size(15.0));
|
||||
ui.label(
|
||||
RichText::new(&self.relative_path)
|
||||
.strong()
|
||||
.color(Color32::from_rgb(248, 250, 252))
|
||||
.size(13.0),
|
||||
);
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(RichText::new("✕ Close (:q)").color(Color32::from_rgb(203, 213, 225))).clicked() {
|
||||
self.is_open = false;
|
||||
}
|
||||
|
||||
ui.label(
|
||||
RichText::new(format!("{} lines", self.lines.len()))
|
||||
.size(11.0)
|
||||
.color(Color32::from_rgb(148, 163, 184)),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Code Area with Line Numbers Gutter
|
||||
let gutter_width = 54.0;
|
||||
let available_height = ui.available_height() - 32.0;
|
||||
|
||||
ScrollArea::vertical()
|
||||
.max_height(available_height)
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
// Gutter column (Line numbers)
|
||||
ui.allocate_ui(Vec2::new(gutter_width, 0.0), |ui| {
|
||||
ui.vertical(|ui| {
|
||||
for idx in 1..=self.lines.len() {
|
||||
let is_current = idx == self.cursor_line;
|
||||
let num_color = if is_current {
|
||||
Color32::from_rgb(245, 158, 11) // Amber current line
|
||||
} else {
|
||||
Color32::from_rgb(71, 85, 105)
|
||||
};
|
||||
|
||||
ui.label(
|
||||
RichText::new(format!("{:>4}", idx))
|
||||
.monospace()
|
||||
.size(12.0)
|
||||
.color(num_color),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Vertical divider line
|
||||
ui.add(egui::Separator::default().vertical());
|
||||
|
||||
// Code column
|
||||
ui.vertical(|ui| {
|
||||
let query_lower = self.search_query.to_lowercase();
|
||||
let has_query = !query_lower.is_empty();
|
||||
|
||||
for (idx, line) in self.lines.iter().enumerate() {
|
||||
let line_num = idx + 1;
|
||||
let is_current = line_num == self.cursor_line;
|
||||
|
||||
let line_display = if line.is_empty() { " " } else { line.as_str() };
|
||||
|
||||
let line_text = if is_current {
|
||||
RichText::new(line_display)
|
||||
.monospace()
|
||||
.size(12.0)
|
||||
.color(Color32::from_rgb(255, 255, 255))
|
||||
.strong()
|
||||
.background_color(Color32::from_rgb(30, 41, 59))
|
||||
} else if has_query && line_display.to_lowercase().contains(&query_lower) {
|
||||
RichText::new(line_display)
|
||||
.monospace()
|
||||
.size(12.0)
|
||||
.color(Color32::from_rgb(254, 240, 138))
|
||||
.background_color(Color32::from_rgb(113, 63, 18))
|
||||
} else {
|
||||
RichText::new(line_display)
|
||||
.monospace()
|
||||
.size(12.0)
|
||||
.color(Color32::from_rgb(226, 232, 240))
|
||||
};
|
||||
|
||||
ui.label(line_text);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Bottom Vim Status Bar
|
||||
Frame::none()
|
||||
.fill(Color32::from_rgb(11, 15, 25))
|
||||
.stroke(Stroke::new(1.0_f32, Color32::from_rgb(30, 41, 59)))
|
||||
.inner_margin(Margin::symmetric(10.0, 5.0))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
// Mode Pill
|
||||
let (mode_text, mode_bg, mode_fg) = if self.is_searching {
|
||||
("SEARCH", Color32::from_rgb(217, 119, 6), Color32::WHITE)
|
||||
} else if self.is_command_mode {
|
||||
("COMMAND", Color32::from_rgb(147, 51, 234), Color32::WHITE)
|
||||
} else {
|
||||
("NORMAL", Color32::from_rgb(37, 99, 235), Color32::WHITE)
|
||||
};
|
||||
|
||||
Frame::none()
|
||||
.fill(mode_bg)
|
||||
.rounding(Rounding::same(3.0))
|
||||
.inner_margin(Margin::symmetric(6.0, 2.0))
|
||||
.show(ui, |ui| {
|
||||
ui.label(RichText::new(mode_text).size(10.5).strong().color(mode_fg));
|
||||
});
|
||||
|
||||
if self.is_searching {
|
||||
ui.label(RichText::new("/").monospace().color(Color32::from_rgb(245, 158, 11)));
|
||||
let edit = TextEdit::singleline(&mut self.search_query)
|
||||
.hint_text("pattern (press Enter)...")
|
||||
.font(egui::TextStyle::Monospace)
|
||||
.desired_width(200.0);
|
||||
ui.add(edit).request_focus();
|
||||
} else if self.is_command_mode {
|
||||
ui.label(RichText::new(":").monospace().color(Color32::from_rgb(168, 85, 247)));
|
||||
let edit = TextEdit::singleline(&mut self.command_input)
|
||||
.hint_text("q / line number...")
|
||||
.font(egui::TextStyle::Monospace)
|
||||
.desired_width(200.0);
|
||||
ui.add(edit).request_focus();
|
||||
} else {
|
||||
if let Some(msg) = &self.status_msg {
|
||||
ui.label(RichText::new(msg).size(11.0).color(Color32::from_rgb(203, 213, 225)));
|
||||
}
|
||||
}
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(
|
||||
RichText::new("j/k: nav • gg/G: top/bot • /: find • :q: exit")
|
||||
.size(10.0)
|
||||
.color(Color32::from_rgb(100, 116, 139)),
|
||||
);
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.label(
|
||||
RichText::new(format!("Ln {}, Col 1", self.cursor_line))
|
||||
.size(11.0)
|
||||
.monospace()
|
||||
.color(Color32::from_rgb(148, 163, 184)),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
self.is_open = is_open;
|
||||
}
|
||||
}
|
||||
+359
@@ -0,0 +1,359 @@
|
||||
use futures_util::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::info;
|
||||
|
||||
pub const GITEA_DEFAULT_REPO_URL: &str = "https://gitea.rardogsynapse.online/RarDog/ZeroSend";
|
||||
pub const GITEA_DEFAULT_API_BASE: &str = "https://gitea.rardogsynapse.online/api/v1/repos/RarDog/ZeroSend";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GiteaAsset {
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
pub browser_download_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GiteaRelease {
|
||||
pub tag_name: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
#[serde(default)]
|
||||
pub draft: bool,
|
||||
#[serde(default)]
|
||||
pub prerelease: bool,
|
||||
#[serde(default)]
|
||||
pub published_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub assets: Vec<GiteaAsset>,
|
||||
#[serde(default)]
|
||||
pub html_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct UpdateInfo {
|
||||
pub current_version: String,
|
||||
pub latest_version: String,
|
||||
pub has_update: bool,
|
||||
pub release_name: String,
|
||||
pub release_notes: String,
|
||||
pub download_url: Option<String>,
|
||||
pub gitea_url: String,
|
||||
pub asset_size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum UpdateProgress {
|
||||
Downloading { percent: f32, downloaded: u64, total: u64 },
|
||||
Verifying,
|
||||
Installing,
|
||||
Completed,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Parse semantic version string (e.g. "v2.4.1", "2.4.2-beta") into (u32, u32, u32)
|
||||
pub fn parse_version(v: &str) -> (u32, u32, u32) {
|
||||
let clean = v.trim().trim_start_matches('v').trim_start_matches('V');
|
||||
let parts: Vec<&str> = clean.split('.').collect();
|
||||
let major = parts.first().and_then(|s| s.split('-').next()).and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
|
||||
let minor = parts.get(1).and_then(|s| s.split('-').next()).and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
|
||||
let patch = parts.get(2).and_then(|s| s.split('-').next()).and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
|
||||
(major, minor, patch)
|
||||
}
|
||||
|
||||
/// Returns true if latest version is strictly newer than current version
|
||||
pub fn is_newer_version(latest: &str, current: &str) -> bool {
|
||||
let (l_maj, l_min, l_pat) = parse_version(latest);
|
||||
let (c_maj, c_min, c_pat) = parse_version(current);
|
||||
(l_maj, l_min, l_pat) > (c_maj, c_min, c_pat)
|
||||
}
|
||||
|
||||
/// Check Gitea repository for available releases or newer version tags
|
||||
pub async fn check_gitea_update(api_base: Option<&str>) -> Result<UpdateInfo, String> {
|
||||
let base = api_base.unwrap_or(GITEA_DEFAULT_API_BASE).trim_end_matches('/');
|
||||
let current_version = env!("CARGO_PKG_VERSION").to_string();
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(6))
|
||||
.danger_accept_invalid_certs(true) // Helpful for self-hosted Gitea instances with internal certs
|
||||
.user_agent(format!("ZeroSend-Updater/{}", current_version))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
|
||||
|
||||
// 1. Try releases/latest API
|
||||
let latest_release_url = format!("{}/releases/latest", base);
|
||||
if let Ok(resp) = client.get(&latest_release_url).send().await {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(release) = resp.json::<GiteaRelease>().await {
|
||||
let latest_ver = release.tag_name.trim().trim_start_matches('v').to_string();
|
||||
let has_update = is_newer_version(&latest_ver, ¤t_version);
|
||||
|
||||
// Look for Windows executable asset (e.g. zerosend.exe, zerosend-vX.X.exe)
|
||||
let mut download_url = None;
|
||||
let mut asset_size = 0;
|
||||
|
||||
for asset in &release.assets {
|
||||
let name_lower = asset.name.to_lowercase();
|
||||
if name_lower.ends_with(".exe") || name_lower.ends_with(".zip") {
|
||||
download_url = Some(asset.browser_download_url.clone());
|
||||
asset_size = asset.size;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let gitea_url = release.html_url.unwrap_or_else(|| {
|
||||
format!("{}/releases/tag/{}", GITEA_DEFAULT_REPO_URL, release.tag_name)
|
||||
});
|
||||
|
||||
return Ok(UpdateInfo {
|
||||
current_version,
|
||||
latest_version: latest_ver,
|
||||
has_update,
|
||||
release_name: release.name,
|
||||
release_notes: release.body,
|
||||
download_url,
|
||||
gitea_url,
|
||||
asset_size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try releases list API (/releases)
|
||||
let releases_url = format!("{}/releases", base);
|
||||
if let Ok(resp) = client.get(&releases_url).send().await {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(releases) = resp.json::<Vec<GiteaRelease>>().await {
|
||||
if let Some(release) = releases.into_iter().find(|r| !r.draft) {
|
||||
let latest_ver = release.tag_name.trim().trim_start_matches('v').to_string();
|
||||
let has_update = is_newer_version(&latest_ver, ¤t_version);
|
||||
|
||||
let mut download_url = None;
|
||||
let mut asset_size = 0;
|
||||
|
||||
for asset in &release.assets {
|
||||
let name_lower = asset.name.to_lowercase();
|
||||
if name_lower.ends_with(".exe") || name_lower.ends_with(".zip") {
|
||||
download_url = Some(asset.browser_download_url.clone());
|
||||
asset_size = asset.size;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let gitea_url = release.html_url.unwrap_or_else(|| {
|
||||
format!("{}/releases/tag/{}", GITEA_DEFAULT_REPO_URL, release.tag_name)
|
||||
});
|
||||
|
||||
return Ok(UpdateInfo {
|
||||
current_version,
|
||||
latest_version: latest_ver,
|
||||
has_update,
|
||||
release_name: release.name,
|
||||
release_notes: release.body,
|
||||
download_url,
|
||||
gitea_url,
|
||||
asset_size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: Check raw Cargo.toml in master branch
|
||||
let raw_cargo_url = format!("{}/raw/branch/master/Cargo.toml", base);
|
||||
if let Ok(resp) = client.get(&raw_cargo_url).send().await {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(content) = resp.text().await {
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("version") {
|
||||
if let Some(ver_val) = line.split('=').nth(1) {
|
||||
let parsed = ver_val.trim().trim_matches('"').trim_matches('\'');
|
||||
let has_update = is_newer_version(parsed, ¤t_version);
|
||||
return Ok(UpdateInfo {
|
||||
current_version,
|
||||
latest_version: parsed.to_string(),
|
||||
has_update,
|
||||
release_name: format!("ZeroSend v{}", parsed),
|
||||
release_notes: "Latest master branch update available on Gitea.".to_string(),
|
||||
download_url: None,
|
||||
gitea_url: GITEA_DEFAULT_REPO_URL.to_string(),
|
||||
asset_size: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not reach Gitea server or no releases found.".to_string())
|
||||
}
|
||||
|
||||
/// Download and apply update on Windows with live progress reporting
|
||||
pub async fn perform_update_download(
|
||||
download_url: String,
|
||||
progress_tx: mpsc::UnboundedSender<UpdateProgress>,
|
||||
) {
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(120))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.user_agent("ZeroSend-AutoUpdater")
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = progress_tx.send(UpdateProgress::Error(format!("Client error: {}", e)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let response = match client.get(&download_url).send().await {
|
||||
Ok(r) => {
|
||||
if !r.status().is_success() {
|
||||
let _ = progress_tx.send(UpdateProgress::Error(format!(
|
||||
"Download failed with HTTP status {}",
|
||||
r.status()
|
||||
)));
|
||||
return;
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = progress_tx.send(UpdateProgress::Error(format!("Connection error: {}", e)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let total_size = response.content_length().unwrap_or(0);
|
||||
let current_exe = match std::env::current_exe() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let _ = progress_tx.send(UpdateProgress::Error(format!("Cannot locate exe: {}", e)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let exe_dir = current_exe.parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||
let temp_download_path = exe_dir.join("zerosend_update.exe");
|
||||
|
||||
// Download stream to temporary file
|
||||
let mut file = match tokio::fs::File::create(&temp_download_path).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let _ = progress_tx.send(UpdateProgress::Error(format!(
|
||||
"Failed to create update file {:?}: {}",
|
||||
temp_download_path, e
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut downloaded: u64 = 0;
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
while let Some(chunk_res) = stream.next().await {
|
||||
match chunk_res {
|
||||
Ok(chunk) => {
|
||||
if let Err(e) = file.write_all(&chunk).await {
|
||||
let _ = progress_tx.send(UpdateProgress::Error(format!("Disk write error: {}", e)));
|
||||
let _ = tokio::fs::remove_file(&temp_download_path).await;
|
||||
return;
|
||||
}
|
||||
downloaded += chunk.len() as u64;
|
||||
let percent = if total_size > 0 {
|
||||
(downloaded as f32 / total_size as f32).min(1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let _ = progress_tx.send(UpdateProgress::Downloading {
|
||||
percent,
|
||||
downloaded,
|
||||
total: total_size,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = progress_tx.send(UpdateProgress::Error(format!("Stream download error: {}", e)));
|
||||
let _ = tokio::fs::remove_file(&temp_download_path).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = file.flush().await {
|
||||
let _ = progress_tx.send(UpdateProgress::Error(format!("File flush error: {}", e)));
|
||||
return;
|
||||
}
|
||||
drop(file);
|
||||
|
||||
// Verify downloaded executable
|
||||
let _ = progress_tx.send(UpdateProgress::Verifying);
|
||||
match tokio::fs::read(&temp_download_path).await {
|
||||
Ok(bytes) => {
|
||||
if bytes.len() < 100_000 {
|
||||
let _ = progress_tx.send(UpdateProgress::Error(
|
||||
"Downloaded file is too small to be a valid executable".to_string(),
|
||||
));
|
||||
let _ = tokio::fs::remove_file(&temp_download_path).await;
|
||||
return;
|
||||
}
|
||||
// Check PE MZ magic header on Windows
|
||||
if bytes.len() >= 2 && (bytes[0] != b'M' || bytes[1] != b'Z') {
|
||||
let _ = progress_tx.send(UpdateProgress::Error(
|
||||
"Downloaded binary does not appear to be a valid Windows executable".to_string(),
|
||||
));
|
||||
let _ = tokio::fs::remove_file(&temp_download_path).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = progress_tx.send(UpdateProgress::Error(format!("Verification read error: {}", e)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Installing & Self-Replacing
|
||||
let _ = progress_tx.send(UpdateProgress::Installing);
|
||||
|
||||
// On Windows, rename current exe to .old, and move new exe into place
|
||||
let old_backup_path = exe_dir.join("zerosend.exe.old");
|
||||
let _ = std::fs::remove_file(&old_backup_path); // Remove prior old backup if any
|
||||
|
||||
let rename_result = std::fs::rename(¤t_exe, &old_backup_path);
|
||||
if rename_result.is_ok() {
|
||||
if let Err(e) = std::fs::rename(&temp_download_path, ¤t_exe) {
|
||||
// Restore old backup if move failed
|
||||
let _ = std::fs::rename(&old_backup_path, ¤t_exe);
|
||||
let _ = progress_tx.send(UpdateProgress::Error(format!("Failed to install update: {}", e)));
|
||||
return;
|
||||
}
|
||||
|
||||
info!("Update successfully installed to {:?}", current_exe);
|
||||
let _ = progress_tx.send(UpdateProgress::Completed);
|
||||
|
||||
// Spawn updated application and exit
|
||||
let _ = std::process::Command::new(¤t_exe).spawn();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
std::process::exit(0);
|
||||
} else {
|
||||
// Fallback: Use PowerShell deferred replacement
|
||||
info!("Direct rename locked; triggering background PowerShell replacement...");
|
||||
let script = format!(
|
||||
"Start-Sleep -Milliseconds 600; Move-Item -Force '{temp}' '{target}'; Start-Process '{target}'",
|
||||
temp = temp_download_path.to_string_lossy(),
|
||||
target = current_exe.to_string_lossy()
|
||||
);
|
||||
|
||||
let _ = std::process::Command::new("powershell")
|
||||
.args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", &script])
|
||||
.spawn();
|
||||
|
||||
let _ = progress_tx.send(UpdateProgress::Completed);
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user