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