use crate::config::{AppConfig, SharedFolderConfig, ThemeMode}; use crate::network::{get_available_interfaces, DiscoveryService, InterfaceInfo, NetworkType, PeerInfo}; use crate::registry::{ is_autostart_registered, is_system_dark_mode, register_autostart, unregister_autostart, }; use crate::transfer::{ analyze_text, AnalyzedText, ContentType, PrepareTransferRequest, SharedFolderTreeResponse, TextMessage, TransferClient, TransferProgress, TransferServer, TransferState, }; use crate::ui::tray::{SystemTrayManager, TrayAction}; use crate::ui::vim_viewer::VimViewerModal; use crate::updater::{ check_gitea_update, perform_update_download, UpdateInfo, UpdateProgress, GITEA_DEFAULT_REPO_URL, }; use eframe::egui::{ self, Align, Button, CentralPanel, Color32, ColorImage, Frame, Layout, Margin, ProgressBar, RichText, Rounding, ScrollArea, SidePanel, Stroke, TextureHandle, TextureOptions, Ui, Vec2, }; use human_bytes::human_bytes; use qrcode::{Color as QrColor, QrCode}; use rfd::FileDialog; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{mpsc, RwLock}; #[derive(PartialEq, Eq, Clone, Copy)] pub enum Tab { Nearby, Send, Receive, Shared, Transfers, Settings, } #[derive(PartialEq, Eq, Clone, Copy)] pub enum SharedSubTab { NetworkShares, MyShares, } #[derive(PartialEq, Eq, Clone, Copy)] pub enum SendMode { Files, QuickText, } #[derive(Clone, Copy, Debug)] pub struct ThemeColors { pub is_dark: bool, pub bg_app: Color32, pub bg_sidebar: Color32, pub bg_card: Color32, pub bg_card_secondary: Color32, pub bg_card_active: Color32, pub bg_hover: Color32, pub stroke_border: Stroke, pub stroke_subtle: Stroke, pub text_primary: Color32, pub text_secondary: Color32, pub text_muted: Color32, pub text_accent: Color32, } impl ThemeColors { pub fn new(is_dark: bool) -> Self { if is_dark { Self { is_dark: true, bg_app: Color32::from_rgb(11, 15, 25), bg_sidebar: Color32::from_rgb(15, 23, 42), bg_card: Color32::from_rgb(30, 41, 59), bg_card_secondary: Color32::from_rgb(15, 23, 42), bg_card_active: Color32::from_rgb(30, 58, 138), bg_hover: Color32::from_rgb(51, 65, 85), stroke_border: Stroke::new(1.0_f32, Color32::from_rgb(51, 65, 85)), stroke_subtle: Stroke::new(1.0_f32, Color32::from_rgba_premultiplied(255, 255, 255, 15)), text_primary: Color32::from_rgb(248, 250, 252), text_secondary: Color32::from_rgb(203, 213, 225), text_muted: Color32::from_rgb(148, 163, 184), text_accent: Color32::from_rgb(56, 189, 248), } } else { Self { is_dark: false, bg_app: Color32::from_rgb(241, 245, 249), bg_sidebar: Color32::from_rgb(255, 255, 255), bg_card: Color32::from_rgb(255, 255, 255), bg_card_secondary: Color32::from_rgb(248, 250, 252), bg_card_active: Color32::from_rgb(219, 234, 254), bg_hover: Color32::from_rgb(226, 232, 240), stroke_border: Stroke::new(1.0_f32, Color32::from_rgb(203, 213, 225)), stroke_subtle: Stroke::new(1.0_f32, Color32::from_rgb(226, 232, 240)), text_primary: Color32::from_rgb(15, 23, 42), text_secondary: Color32::from_rgb(51, 65, 85), text_muted: Color32::from_rgb(100, 116, 139), text_accent: Color32::from_rgb(2, 132, 199), } } } } pub fn get_file_badge_info(name: &str, is_dir: bool) -> (&'static str, Color32, &'static str) { if is_dir { return ("📁", Color32::from_rgb(234, 179, 8), "FOLDER"); } let lower = name.to_lowercase(); if lower.ends_with(".zip") || lower.ends_with(".rar") || lower.ends_with(".7z") || lower.ends_with(".tar") || lower.ends_with(".gz") || lower.ends_with(".bz2") || lower.ends_with(".xz") { ("📦", Color32::from_rgb(245, 158, 11), "ARCHIVE") } else if lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") || lower.ends_with(".gif") || lower.ends_with(".webp") || lower.ends_with(".bmp") || lower.ends_with(".svg") || lower.ends_with(".ico") { ("🖼", Color32::from_rgb(56, 189, 248), "IMAGE") } else if lower.ends_with(".mp4") || lower.ends_with(".mkv") || lower.ends_with(".avi") || lower.ends_with(".mov") || lower.ends_with(".webm") || lower.ends_with(".flv") { ("🎬", Color32::from_rgb(168, 85, 247), "VIDEO") } else if lower.ends_with(".mp3") || lower.ends_with(".wav") || lower.ends_with(".flac") || lower.ends_with(".ogg") || lower.ends_with(".m4a") || lower.ends_with(".aac") { ("🎵", Color32::from_rgb(236, 72, 153), "AUDIO") } else if lower.ends_with(".exe") || lower.ends_with(".msi") || lower.ends_with(".iso") || lower.ends_with(".bin") || lower.ends_with(".dll") { ("💾", Color32::from_rgb(34, 197, 94), "BIN/ISO") } else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".pdf") || lower.ends_with(".docx") || lower.ends_with(".doc") || lower.ends_with(".xlsx") || lower.ends_with(".pptx") { ("📄", Color32::from_rgb(59, 130, 246), "DOC") } else if lower.ends_with(".rs") || lower.ends_with(".py") || lower.ends_with(".js") || lower.ends_with(".ts") || lower.ends_with(".html") || lower.ends_with(".css") || lower.ends_with(".json") || lower.ends_with(".toml") || lower.ends_with(".cpp") || lower.ends_with(".c") { ("💻", Color32::from_rgb(20, 184, 166), "CODE") } else { ("📄", Color32::from_rgb(148, 163, 184), "FILE") } } pub fn get_file_icon(name: &str) -> &'static str { get_file_badge_info(name, false).0 } pub struct ZeroSendApp { // Services discovery_peers: Arc>>, server: TransferServer, client: Arc, my_info: crate::network::DiscoveryBeacon, current_iface: InterfaceInfo, config: AppConfig, tray_manager: Option, // Navigation & State current_tab: Tab, send_mode: SendMode, peers: Vec, selected_peer_ids: HashSet, queued_files: Vec, transfers: Vec, logs: Vec, // Quick Text Input text_input: String, analyzed_preview: AnalyzedText, // Optional PIN for sender recipient_pin: String, // Channels from Server incoming_request_rx: mpsc::UnboundedReceiver, incoming_text_rx: mpsc::UnboundedReceiver, // Modals pending_request: Option, received_text_modal: Option, show_direct_ip_modal: bool, direct_ip_input: String, direct_ip_status: String, // Peer Rename Modal renaming_peer: Option<(String, String, String)>, // Settings custom_device_name: String, available_interfaces: Vec, auto_accept: bool, require_pin: bool, pin_code: String, download_dir: PathBuf, close_to_tray: bool, auto_zip_folders: bool, auto_extract_zip: bool, max_upload_speed_mbps: u32, sound_notifications: bool, autostart_enabled: bool, pub theme_mode: ThemeMode, // Shared Folders State shared_folders: Vec, selected_share_subtab: SharedSubTab, selected_peer_id_for_share: Option, selected_folder_id_for_share: Option, current_share_subpath: String, current_share_tree: Option, share_tree_loading: bool, share_search_query: String, share_pin_input: String, last_share_sync: Instant, pub vim_viewer: VimViewerModal, // Gitea Updater State pub update_info: Option, pub is_checking_update: bool, pub update_check_status_msg: Option, pub update_progress: Option, pub is_updating: bool, pub show_update_banner: bool, pub check_updates_on_startup: bool, // Internal UI Channels log_rx: mpsc::UnboundedReceiver, log_tx: mpsc::UnboundedSender, transfer_rx: mpsc::UnboundedReceiver, transfer_tx: mpsc::UnboundedSender, share_tree_rx: mpsc::UnboundedReceiver>, share_tree_tx: mpsc::UnboundedSender>, share_text_rx: mpsc::UnboundedReceiver<(String, String, String, String)>, share_text_tx: mpsc::UnboundedSender<(String, String, String, String)>, update_rx: mpsc::UnboundedReceiver>, update_tx: mpsc::UnboundedSender>, update_prog_rx: mpsc::UnboundedReceiver, update_prog_tx: mpsc::UnboundedSender, last_poll: Instant, last_ping_poll: Instant, last_window_save: Instant, } fn setup_custom_fonts(ctx: &egui::Context) { let mut fonts = egui::FontDefinitions::default(); #[cfg(target_os = "windows")] { let win_dir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".to_string()); let candidate_fonts = [ ("segoe_ui", format!("{}\\Fonts\\segoeui.ttf", win_dir)), ("segoe_ui_sym", format!("{}\\Fonts\\seguisym.ttf", win_dir)), ("segoe_ui_emoji", format!("{}\\Fonts\\seguiemj.ttf", win_dir)), ("arial", format!("{}\\Fonts\\arial.ttf", win_dir)), ]; for (name, path) in candidate_fonts { if let Ok(bytes) = std::fs::read(&path) { fonts.font_data.insert( name.to_string(), egui::FontData::from_owned(bytes), ); if let Some(family) = fonts.families.get_mut(&egui::FontFamily::Proportional) { family.push(name.to_string()); } if let Some(family) = fonts.families.get_mut(&egui::FontFamily::Monospace) { family.push(name.to_string()); } } } } ctx.set_fonts(fonts); } impl ZeroSendApp { pub fn new( cc: &eframe::CreationContext<'_>, discovery: &DiscoveryService, server: TransferServer, config: AppConfig, initial_files: Vec, incoming_request_rx: mpsc::UnboundedReceiver, incoming_text_rx: mpsc::UnboundedReceiver, ) -> Self { setup_custom_fonts(&cc.egui_ctx); let (log_tx, log_rx) = mpsc::unbounded_channel(); let (transfer_tx, transfer_rx) = mpsc::unbounded_channel(); let (share_tree_tx, share_tree_rx) = mpsc::unbounded_channel(); let (share_text_tx, share_text_rx) = mpsc::unbounded_channel(); let (update_tx, update_rx) = mpsc::unbounded_channel(); let (update_prog_tx, update_prog_rx) = mpsc::unbounded_channel(); // Subscribe to server transfer events (web uploads, incoming P2P transfers) let mut server_transfer_rx = server.state().transfer_events.subscribe(); let transfer_tx_clone = transfer_tx.clone(); let ctx_clone = cc.egui_ctx.clone(); tokio::spawn(async move { while let Ok(progress) = server_transfer_rx.recv().await { let _ = transfer_tx_clone.send(progress); ctx_clone.request_repaint(); } }); // Trigger startup update check from Gitea if enabled if config.check_updates_on_startup { let u_tx = update_tx.clone(); let api_url = config.gitea_api_url.clone(); tokio::spawn(async move { let res = check_gitea_update(api_url.as_deref()).await; let _ = u_tx.send(res); }); } let my_info = discovery.my_info().clone(); let iface = discovery.selected_interface().clone(); let download_dir = config.download_dir.clone(); let auto_accept = config.auto_accept; let require_pin = config.require_pin; let pin_code = config.pin_code.clone(); let close_to_tray = config.close_to_tray; let auto_zip_folders = config.auto_zip_folders; let auto_extract_zip = config.auto_extract_zip; let max_upload_speed_mbps = config.max_upload_speed_mbps; let sound_notifications = config.sound_notifications; let theme_mode = config.theme_mode; let check_updates_on_startup = config.check_updates_on_startup; let device_name = my_info.name.clone(); let shared_folders = config.shared_folders.clone(); let initial_tab = if !initial_files.is_empty() { Tab::Send } else { Tab::Nearby }; let tray_manager = SystemTrayManager::new(cc.egui_ctx.clone(), download_dir.clone()).ok(); Self { discovery_peers: discovery.peers(), server, client: Arc::new(TransferClient::new()), my_info: my_info.clone(), current_iface: iface, config, tray_manager, current_tab: initial_tab, send_mode: SendMode::Files, peers: Vec::new(), selected_peer_ids: HashSet::new(), queued_files: initial_files, transfers: Vec::new(), logs: vec!["⚡ ZeroSend v2.4.1 ready".to_string()], text_input: String::new(), analyzed_preview: analyze_text(""), recipient_pin: String::new(), incoming_request_rx, incoming_text_rx, pending_request: None, received_text_modal: None, show_direct_ip_modal: false, direct_ip_input: String::new(), direct_ip_status: String::new(), renaming_peer: None, custom_device_name: device_name, available_interfaces: get_available_interfaces(), auto_accept, require_pin, pin_code, download_dir, close_to_tray, auto_zip_folders, auto_extract_zip, max_upload_speed_mbps, sound_notifications, autostart_enabled: is_autostart_registered(), theme_mode, shared_folders, selected_share_subtab: SharedSubTab::NetworkShares, selected_peer_id_for_share: None, selected_folder_id_for_share: None, current_share_subpath: String::new(), current_share_tree: None, share_tree_loading: false, share_search_query: String::new(), share_pin_input: String::new(), last_share_sync: Instant::now(), vim_viewer: VimViewerModal::default(), update_info: None, is_checking_update: false, update_check_status_msg: None, update_progress: None, is_updating: false, show_update_banner: false, check_updates_on_startup, log_rx, log_tx, transfer_rx, transfer_tx, share_tree_rx, share_tree_tx, share_text_rx, share_text_tx, update_rx, update_tx, update_prog_rx, update_prog_tx, last_poll: Instant::now(), last_ping_poll: Instant::now(), last_window_save: Instant::now(), } } fn poll_events(&mut self, ctx: &egui::Context) { // 1. Poll Tray Actions if let Some(tray) = &self.tray_manager { if let Some(action) = tray.try_recv_action() { match action { TrayAction::ShowWindow => { ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } TrayAction::OpenDownloads => { let _ = std::process::Command::new("explorer").arg(&self.download_dir).spawn(); } } } } // 2. Poll incoming file transfer requests while let Ok(req) = self.incoming_request_rx.try_recv() { self.pending_request = Some(req.clone()); self.current_tab = Tab::Transfers; self.logs.push(format!("📥 Incoming transfer request from {}", req.sender_name)); ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } // 3. Poll incoming text messages while let Ok(msg) = self.incoming_text_rx.try_recv() { self.received_text_modal = Some(msg.clone()); self.logs.push(format!("📋 Received text from {}", msg.sender_name)); ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } // 4. Poll UI logs while let Ok(log) = self.log_rx.try_recv() { self.logs.push(log); if self.logs.len() > 100 { self.logs.remove(0); } } // 5. Poll transfer updates from client & server while let Ok(t) = self.transfer_rx.try_recv() { let just_completed = t.state == TransferState::Completed; if let Some(pos) = self.transfers.iter().position(|x| x.session_id == t.session_id) { let prev_state = self.transfers[pos].state.clone(); self.transfers[pos] = t; if just_completed && prev_state != TransferState::Completed && self.sound_notifications { crate::ui::sound::play_transfer_sound(); } } else { self.transfers.insert(0, t); self.current_tab = Tab::Transfers; ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); if just_completed && self.sound_notifications { crate::ui::sound::play_transfer_sound(); } } } // 6. Direct sync with Server active transfers (Incoming downloads fallback) if let Ok(server_transfers) = self.server.state().active_transfers.try_read() { for (id, st) in server_transfers.iter() { let just_completed = st.state == TransferState::Completed; if let Some(pos) = self.transfers.iter().position(|x| x.session_id == *id) { let prev_state = self.transfers[pos].state.clone(); self.transfers[pos] = st.clone(); if just_completed && prev_state != TransferState::Completed && self.sound_notifications { crate::ui::sound::play_transfer_sound(); } } else { self.transfers.insert(0, st.clone()); self.current_tab = Tab::Transfers; ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); if just_completed && self.sound_notifications { crate::ui::sound::play_transfer_sound(); } } } } // 7. Poll share tree updates while let Ok(maybe_tree) = self.share_tree_rx.try_recv() { self.share_tree_loading = false; self.current_share_tree = maybe_tree; } // 8. Poll vim text updates while let Ok((file_name, rel_path, peer_name, content)) = self.share_text_rx.try_recv() { self.vim_viewer.open(file_name, rel_path, peer_name, content); } // 9. Poll Gitea update check results while let Ok(res) = self.update_rx.try_recv() { self.is_checking_update = false; match res { Ok(info) => { if info.has_update { self.show_update_banner = true; self.logs.push(format!("✨ Gitea: New version v{} is available!", info.latest_version)); } else { self.update_check_status_msg = Some(format!("ZeroSend v{} is up to date!", info.current_version)); } self.update_info = Some(info); } Err(e) => { self.update_check_status_msg = Some(format!("Update check failed: {}", e)); } } } // 10. Poll Gitea update installation progress while let Ok(prog) = self.update_prog_rx.try_recv() { match &prog { UpdateProgress::Completed => { self.is_updating = false; self.logs.push("🎉 Update completed! Restarting ZeroSend...".to_string()); } UpdateProgress::Error(e) => { self.is_updating = false; self.logs.push(format!("❌ Update error: {}", e)); } _ => {} } self.update_progress = Some(prog); } // 9. Auto-sync network shared folder view (every 4 seconds) if self.last_share_sync.elapsed() > Duration::from_secs(4) { self.last_share_sync = Instant::now(); if self.current_tab == Tab::Shared && self.selected_share_subtab == SharedSubTab::NetworkShares { self.sync_active_share_tree_background(); } } // 10. Discovery & Server pending requests if self.last_poll.elapsed() > Duration::from_millis(300) { self.last_poll = Instant::now(); // Refresh discovered peer list with Favorite Pins prioritized if let Ok(peers_guard) = self.discovery_peers.try_read() { let mut list: Vec = peers_guard.values().cloned().collect(); let favs = &self.config.favorite_peers; list.sort_by(|a, b| { let a_fav = favs.contains(&a.peer_id); let b_fav = favs.contains(&b.peer_id); b_fav.cmp(&a_fav) .then_with(|| b.is_online.cmp(&a.is_online)) .then_with(|| a.name.cmp(&b.name)) }); self.peers = list; } // Double check if there's an active pending request in server state if self.pending_request.is_none() { if let Ok(pending_guard) = self.server.state().pending_requests.try_read() { if let Some((_, req)) = pending_guard.iter().next() { self.pending_request = Some(req.clone()); ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } } } } // 11. Periodic ping update for online peers (every 3 seconds) if self.last_ping_poll.elapsed() > Duration::from_secs(3) { self.last_ping_poll = Instant::now(); let peers_to_ping: Vec<(String, String, u16)> = self .peers .iter() .filter(|p| p.is_online) .map(|p| (p.peer_id.clone(), p.ip.to_string(), p.transfer_port)) .collect(); if !peers_to_ping.is_empty() { let disc_peers = self.discovery_peers.clone(); let client = self.client.clone(); tokio::spawn(async move { for (peer_id, ip, port) in peers_to_ping { if let Some(ms) = client.measure_ping(&ip, port).await { if let Ok(mut guard) = disc_peers.try_write() { if let Some(peer_entry) = guard.get_mut(&peer_id) { peer_entry.ping_ms = Some(ms); } } } } }); } } } fn persist_config(&mut self) { self.config.download_dir = self.download_dir.clone(); self.config.auto_accept = self.auto_accept; self.config.require_pin = self.require_pin; self.config.pin_code = self.pin_code.clone(); self.config.close_to_tray = self.close_to_tray; self.config.auto_zip_folders = self.auto_zip_folders; self.config.auto_extract_zip = self.auto_extract_zip; self.config.max_upload_speed_mbps = self.max_upload_speed_mbps; self.config.sound_notifications = self.sound_notifications; self.config.theme_mode = self.theme_mode; self.config.device_name = Some(self.custom_device_name.clone()); self.config.preferred_interface = Some(self.current_iface.name.clone()); self.config.shared_folders = self.shared_folders.clone(); let _ = self.config.save(); let srv = self.server.clone(); let auto_acc = self.auto_accept; let req_pin = self.require_pin; let p_code = self.pin_code.clone(); let auto_ext = self.auto_extract_zip; let sf_clone = self.shared_folders.clone(); tokio::spawn(async move { srv.update_auto_accept(auto_acc).await; srv.update_pin_settings(req_pin, p_code).await; srv.update_extract_settings(auto_ext).await; srv.update_shared_folders(sf_clone).await; }); } fn get_peer_display_name(&self, peer: &PeerInfo) -> (String, bool) { if let Some(alias) = self.config.peer_aliases.get(&peer.peer_id) { if !alias.trim().is_empty() { return (format!("{} ({})", alias, peer.name), true); } } (peer.name.clone(), false) } fn try_paste_clipboard_image(&mut self) -> bool { for _ in 0..5 { if let Ok(mut clipboard) = arboard::Clipboard::new() { // 1. Check for image in clipboard if let Ok(img_data) = clipboard.get_image() { let temp_dir = std::env::temp_dir().join("ZeroSend"); let _ = std::fs::create_dir_all(&temp_dir); let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); let file_path = temp_dir.join(format!("screenshot_{}.png", timestamp)); if let Some(img_buffer) = image::RgbaImage::from_raw( img_data.width as u32, img_data.height as u32, img_data.bytes.into_owned(), ) { if img_buffer.save(&file_path).is_ok() { if !self.queued_files.contains(&file_path) { self.queued_files.push(file_path.clone()); } self.current_tab = Tab::Send; self.send_mode = SendMode::Files; self.logs.push(format!( "📸 Pasted screenshot ({}x{}) to queue", img_data.width, img_data.height )); return true; } } } // 2. Check for copied file paths or text in clipboard if let Ok(text) = clipboard.get_text() { let mut added_any = false; for line in text.lines() { let trimmed = line.trim().trim_matches('"'); let path = PathBuf::from(trimmed); if path.exists() { if !self.queued_files.contains(&path) { self.queued_files.push(path.clone()); added_any = true; } } } if added_any { self.current_tab = Tab::Send; self.send_mode = SendMode::Files; self.logs.push("📄 Pasted copied file(s) from clipboard to queue".to_string()); return true; } } break; } std::thread::sleep(Duration::from_millis(25)); } false } fn start_sending_files(&mut self, targets: Vec, files: Vec) { if targets.is_empty() || files.is_empty() { return; } let client = self.client.clone(); let my_info = self.my_info.clone(); let transfer_tx = self.transfer_tx.clone(); let log_tx = self.log_tx.clone(); let auto_zip = self.auto_zip_folders; let speed_limit = self.max_upload_speed_mbps; let pin = if self.recipient_pin.trim().is_empty() { None } else { Some(self.recipient_pin.trim().to_string()) }; self.queued_files.clear(); self.current_tab = Tab::Transfers; for peer in targets { let peer_url = peer.transfer_url(); let peer_name = self.get_peer_display_name(&peer).0; let files_clone = files.clone(); let client_clone = client.clone(); let my_info_clone = my_info.clone(); let transfer_tx_clone = transfer_tx.clone(); let log_tx_clone = log_tx.clone(); let pin_clone = pin.clone(); tokio::spawn(async move { let _ = log_tx_clone.send(format!("📤 Sending {} items to {}...", files_clone.len(), peer_name)); let res = client_clone .send_files(&peer_url, &peer_name, &my_info_clone, &files_clone, auto_zip, speed_limit, pin_clone, move |progress| { let _ = transfer_tx_clone.send(progress); }) .await; match res { Ok(_) => { let _ = log_tx_clone.send(format!("🎉 All files delivered to {}!", peer_name)); } Err(e) => { let _ = log_tx_clone.send(format!("⚠️ Transfer failed to {}: {}", peer_name, e)); } } }); } } fn render_sidebar(&mut self, _ctx: &egui::Context, ui: &mut Ui, theme: ThemeColors) { ui.add_space(8.0); ui.horizontal(|ui| { ui.add_space(4.0); ui.label(RichText::new("⚡").size(24.0).color(theme.text_accent)); ui.vertical(|ui| { ui.label(RichText::new("ZeroSend").size(18.0).strong().color(theme.text_primary)); ui.label(RichText::new("ZeroTier & VPN Sharing").size(10.0).color(theme.text_muted)); }); }); ui.add_space(14.0); ui.separator(); ui.add_space(10.0); // Network Badge Card Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(10.0)) .show(ui, |ui| { ui.horizontal(|ui| { let dot_color = if self.current_iface.net_type == NetworkType::ZeroTier { Color32::from_rgb(34, 197, 94) } else { Color32::from_rgb(59, 130, 246) }; ui.label(RichText::new("●").color(dot_color).size(14.0)); ui.vertical(|ui| { ui.label( RichText::new(self.current_iface.net_type.as_str()) .strong() .size(12.0) .color(theme.text_primary), ); ui.label( RichText::new(format!("{}:{}", self.current_iface.ip, self.my_info.transfer_port)) .size(10.0) .color(theme.text_muted), ); }); }); }); ui.add_space(16.0); let active_count = self .transfers .iter() .filter(|t| t.state == TransferState::InProgress || t.state == TransferState::PendingConfirmation) .count(); let friend_shares_count: usize = self.peers.iter().map(|p| p.shared_folders.len()).sum(); let total_shares = self.shared_folders.len() + friend_shares_count; let tabs = [ (Tab::Nearby, "📡 Nearby Friends", self.peers.len(), false), (Tab::Send, "📤 Send (Files/Text)", self.queued_files.len(), false), (Tab::Receive, "📥 Receive / Web", 0, false), (Tab::Shared, "📁 Shared Folders", total_shares, false), (Tab::Transfers, "📜 Transfers", active_count, active_count > 0), (Tab::Settings, "⚙ Settings & About", 0, false), ]; for (tab, title, count, is_active_pulse) in tabs { let is_selected = self.current_tab == tab; let mut btn_text = title.to_string(); if count > 0 && tab != Tab::Receive && tab != Tab::Settings { if is_active_pulse { btn_text = format!("{} (⚡ {})", title, count); } else { btn_text = format!("{} ({})", title, count); } } let text_color = if is_selected { if theme.is_dark { Color32::from_rgb(56, 189, 248) } else { Color32::from_rgb(2, 132, 199) } } else if is_active_pulse { Color32::from_rgb(250, 204, 21) } else { theme.text_secondary }; let bg_color = if is_selected { theme.bg_card_active } else { Color32::TRANSPARENT }; let btn = Button::new(RichText::new(btn_text).size(13.0).color(text_color).strong()) .fill(bg_color) .min_size(Vec2::new(ui.available_width(), 36.0)) .rounding(Rounding::same(6.0)); if ui.add(btn).clicked() { self.current_tab = tab; } ui.add_space(4.0); } ui.with_layout(Layout::bottom_up(Align::Center), |ui| { ui.add_space(10.0); ui.label( RichText::new(format!("v{}", env!("CARGO_PKG_VERSION"))) .size(10.0) .color(theme.text_muted), ); }); } fn render_nearby_tab(&mut self, ui: &mut Ui, theme: ThemeColors) { ui.horizontal(|ui| { ui.heading(RichText::new("📡 Discovered Nodes (ZeroTier / VPN)").color(theme.text_primary)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button(RichText::new("🔍 Direct Connect (IP)").strong()).clicked() { self.show_direct_ip_modal = true; self.direct_ip_status.clear(); } }); }); ui.label( RichText::new("Devices in your ZeroTier network running ZeroSend appear here automatically.") .size(12.0) .color(theme.text_muted), ); ui.add_space(12.0); if self.peers.is_empty() { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(12.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(40.0)) .show(ui, |ui| { ui.vertical_centered(|ui| { ui.label(RichText::new("🔍").size(42.0)); ui.add_space(8.0); ui.label(RichText::new("Scanning ZeroTier network for friends...").size(16.0).strong().color(theme.text_primary)); ui.add_space(4.0); ui.label( RichText::new("Ensure your friends are connected to your ZeroTier network or use Direct Connect.") .size(12.0) .color(theme.text_muted), ); }); }); return; } let dropped_files = ui.ctx().input(|i| i.raw.dropped_files.clone()); let is_hovering_files = ui.ctx().input(|i| !i.raw.hovered_files.is_empty()); let pointer_pos = ui.ctx().input(|i| i.pointer.hover_pos()); if is_hovering_files { Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(8.0)) .stroke(Stroke::new(1.5_f32, theme.text_accent)) .inner_margin(Margin::same(10.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("🎯 Drop files directly onto any friend's card below to send instantly!").size(13.0).strong().color(theme.text_accent)); }); }); ui.add_space(8.0); } let mut toggle_fav: Option = None; let mut rename_target: Option<(String, String, String)> = None; let mut send_to_peer: Option = None; let mut quick_drop_target: Option<(PeerInfo, Vec)> = None; ScrollArea::vertical().show(ui, |ui| { for peer in &self.peers { let peer_id = peer.peer_id.clone(); let is_checked = self.selected_peer_ids.contains(&peer_id); let (display_name, has_alias) = self.get_peer_display_name(peer); let card_response = Frame::none() .fill(if is_checked { theme.bg_card_active } else { theme.bg_card }) .rounding(Rounding::same(10.0)) .stroke(if is_hovering_files { Stroke::new(1.5_f32, theme.text_accent) } else { theme.stroke_border }) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { ui.horizontal(|ui| { let os_icon = match peer.os.to_lowercase().as_str() { "windows" => "🪟", "linux" => "🐧", "macos" => "🍎", _ => "💻", }; ui.label(RichText::new(os_icon).size(28.0)); ui.add_space(8.0); ui.vertical(|ui| { ui.horizontal(|ui| { ui.label( RichText::new(&display_name) .size(15.0) .strong() .color(if has_alias { Color32::from_rgb(250, 204, 21) } else { theme.text_primary }), ); let is_fav = self.config.favorite_peers.contains(&peer.peer_id); let star_icon = if is_fav { "⭐" } else { "☆" }; if ui.small_button(star_icon).on_hover_text(if is_fav { "Unpin from Favorites" } else { "Pin to Favorites" }).clicked() { toggle_fav = Some(peer.peer_id.clone()); } if ui.small_button("✏").on_hover_text("Rename / Set Alias").clicked() { let curr = self.config.peer_aliases.get(&peer.peer_id).cloned().unwrap_or_default(); rename_target = Some((peer.peer_id.clone(), peer.name.clone(), curr)); } let badge_color = if peer.net_type.contains("ZeroTier") { Color32::from_rgb(34, 197, 94) } else { Color32::from_rgb(59, 130, 246) }; ui.label( RichText::new(format!("[{}]", peer.net_type)) .size(11.0) .color(badge_color), ); }); ui.horizontal(|ui| { ui.label( RichText::new(format!("{}:{} • {}", peer.ip, peer.transfer_port, peer.os)) .size(11.0) .color(theme.text_muted), ); if let Some(ms) = peer.ping_ms { let (ping_color, ping_icon) = if ms < 40 { (Color32::from_rgb(34, 197, 94), "🟢") } else if ms <= 100 { (Color32::from_rgb(250, 204, 21), "🟡") } else { (Color32::from_rgb(239, 68, 68), "🔴") }; ui.label( RichText::new(format!("• {} {} ms", ping_icon, ms)) .size(11.0) .color(ping_color), ); } }); }); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let send_btn = Button::new(RichText::new("📤 Send").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(37, 99, 235)) .rounding(Rounding::same(6.0)) .min_size(Vec2::new(90.0, 32.0)); if ui.add(send_btn).clicked() { send_to_peer = Some(peer_id); } }); }); }) .response; if !dropped_files.is_empty() { let is_target = if let Some(pos) = pointer_pos { card_response.rect.contains(pos) } else { card_response.hovered() }; if is_target { let paths: Vec = dropped_files.iter().filter_map(|f| f.path.clone()).collect(); if !paths.is_empty() { quick_drop_target = Some((peer.clone(), paths)); } } } ui.add_space(8.0); } }); if let Some((target_peer, files)) = quick_drop_target { self.logs.push(format!("🎯 Quick Drop: Sending {} item(s) to {}...", files.len(), self.get_peer_display_name(&target_peer).0)); self.start_sending_files(vec![target_peer], files); } if let Some(pid) = toggle_fav { if self.config.favorite_peers.contains(&pid) { self.config.favorite_peers.retain(|id| id != &pid); } else { self.config.favorite_peers.push(pid); } self.persist_config(); } if let Some(ren) = rename_target { self.renaming_peer = Some(ren); } if let Some(pid) = send_to_peer { self.selected_peer_ids.clear(); self.selected_peer_ids.insert(pid); self.current_tab = Tab::Send; } } fn render_send_tab(&mut self, ui: &mut Ui, theme: ThemeColors) { ui.horizontal(|ui| { ui.heading(RichText::new("📤 Send to Friends").color(theme.text_primary)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.selectable_value(&mut self.send_mode, SendMode::QuickText, "📋 Quick Text / Clipboard"); ui.selectable_value(&mut self.send_mode, SendMode::Files, "📁 Files & Folders"); }); }); ui.add_space(8.0); // Multi-Recipient Selector Card Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(12.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("Recipients:").strong().size(13.0).color(theme.text_primary)); if !self.peers.is_empty() { let all_selected = self.selected_peer_ids.len() == self.peers.len(); if ui.small_button(if all_selected { "Deselect All" } else { "Select All" }).clicked() { if all_selected { self.selected_peer_ids.clear(); } else { self.selected_peer_ids = self.peers.iter().map(|p| p.peer_id.clone()).collect(); } } } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.horizontal(|ui| { ui.label(RichText::new("PIN (optional):").size(11.0).color(theme.text_muted)); ui.add(egui::TextEdit::singleline(&mut self.recipient_pin).desired_width(60.0)); }); }); }); ui.add_space(6.0); if self.peers.is_empty() { ui.label(RichText::new("No online peers found yet. Discovering...").color(theme.text_muted)); } else { ui.horizontal_wrapped(|ui| { for p in &self.peers { let is_selected = self.selected_peer_ids.contains(&p.peer_id); let (name_label, _) = self.get_peer_display_name(p); let chip_text = format!("{} ({})", name_label, p.ip); let chip_btn = Button::new( RichText::new(format!("{} {}", if is_selected { "✅" } else { "⬜" }, chip_text)) .size(12.0) .color(if is_selected { Color32::WHITE } else { theme.text_secondary }), ) .fill(if is_selected { Color32::from_rgb(37, 99, 235) } else { theme.bg_card_secondary }) .rounding(Rounding::same(14.0)); if ui.add(chip_btn).clicked() { if is_selected { self.selected_peer_ids.remove(&p.peer_id); } else { self.selected_peer_ids.insert(p.peer_id.clone()); } } } }); } }); ui.add_space(14.0); match self.send_mode { SendMode::Files => self.render_send_files_subview(ui, theme), SendMode::QuickText => self.render_send_text_subview(ui, theme), } } fn render_send_files_subview(&mut self, ui: &mut Ui, theme: ThemeColors) { ui.horizontal(|ui| { if ui .add( Button::new(RichText::new("📄 Add Files").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(59, 130, 246)) .min_size(Vec2::new(120.0, 34.0)) .rounding(Rounding::same(6.0)), ) .clicked() { if let Some(files) = FileDialog::new().pick_files() { for f in files { if !self.queued_files.contains(&f) { self.queued_files.push(f); } } } } if ui .add( Button::new(RichText::new("📁 Add Folder").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(79, 70, 229)) .min_size(Vec2::new(120.0, 34.0)) .rounding(Rounding::same(6.0)), ) .clicked() { if let Some(folder) = FileDialog::new().pick_folder() { if !self.queued_files.contains(&folder) { self.queued_files.push(folder); } } } if ui .add( Button::new(RichText::new("📸 Paste Screenshot (Ctrl+V)").color(Color32::WHITE)) .fill(Color32::from_rgb(147, 51, 234)) .min_size(Vec2::new(180.0, 34.0)) .rounding(Rounding::same(6.0)), ) .clicked() { self.try_paste_clipboard_image(); } if !self.queued_files.is_empty() { if ui.button("Clear list").clicked() { self.queued_files.clear(); } } }); ui.add_space(10.0); let total_size: u64 = self .queued_files .iter() .filter_map(|p| std::fs::metadata(p).ok().map(|m| m.len())) .sum(); ui.horizontal(|ui| { ui.label( RichText::new(format!( "Selected Items: {} ({}) | Recipients: {}", self.queued_files.len(), human_bytes(total_size as f64), self.selected_peer_ids.len() )) .size(13.0) .strong() .color(theme.text_primary), ); if self.auto_zip_folders { ui.label(RichText::new("• (Folders auto-compressed to ZIP)").size(11.0).color(Color32::from_rgb(34, 197, 94))); } }); ui.add_space(6.0); Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(8.0)) .show(ui, |ui| { ScrollArea::vertical().max_height(200.0).show(ui, |ui| { if self.queued_files.is_empty() { ui.vertical_centered(|ui| { ui.add_space(24.0); ui.label(RichText::new("📂 Drag & Drop files or folders here").size(14.0).strong().color(theme.text_muted)); ui.add_space(4.0); ui.label(RichText::new("or paste screenshots directly with Ctrl+V").size(11.0).color(theme.text_muted)); ui.add_space(24.0); }); } else { let mut to_remove = None; for (idx, path) in self.queued_files.iter().enumerate() { let is_dir = path.is_dir(); let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let (icon, badge_color, category) = get_file_badge_info(&name, is_dir); let size_str = std::fs::metadata(path).ok().map(|m| human_bytes(m.len() as f64)).unwrap_or_default(); Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(6.0)) .stroke(theme.stroke_border) .inner_margin(Margin::symmetric(10.0, 6.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new(icon).size(16.0)); // Category Badge Pill Frame::none() .fill(badge_color.linear_multiply(0.2)) .rounding(Rounding::same(4.0)) .inner_margin(Margin::symmetric(6.0, 2.0)) .show(ui, |ui| { ui.label(RichText::new(category).size(9.0).color(badge_color).strong()); }); ui.label(RichText::new(&name).strong().color(theme.text_primary)); ui.label(RichText::new(format!("({})", size_str)).size(11.0).color(theme.text_muted)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.small_button("✕").on_hover_text("Remove from queue").clicked() { to_remove = Some(idx); } }); }); }); ui.add_space(4.0); } if let Some(i) = to_remove { self.queued_files.remove(i); } } }); }); ui.add_space(16.0); let num_recipients = self.selected_peer_ids.len(); let can_send = !self.queued_files.is_empty() && num_recipients > 0; let send_btn_text = if num_recipients == 1 { let target_name = self .peers .iter() .find(|p| self.selected_peer_ids.contains(&p.peer_id)) .map(|p| self.get_peer_display_name(p).0) .unwrap_or_else(|| "...".to_string()); format!("🚀 Send {} Item(s) to {}", self.queued_files.len(), target_name) } else { format!("🚀 Broadcast {} Item(s) to {} Recipients", self.queued_files.len(), num_recipients) }; let send_btn = Button::new(RichText::new(send_btn_text).size(15.0).strong().color(Color32::WHITE)) .fill(if can_send { Color32::from_rgb(16, 185, 129) } else { if theme.is_dark { Color32::from_rgb(51, 65, 85) } else { Color32::from_rgb(203, 213, 225) } }) .min_size(Vec2::new(ui.available_width(), 44.0)) .rounding(Rounding::same(8.0)); if ui.add_enabled(can_send, send_btn).clicked() { let targets: Vec = self .peers .iter() .filter(|p| self.selected_peer_ids.contains(&p.peer_id)) .cloned() .collect(); let files = self.queued_files.clone(); self.start_sending_files(targets, files); } } fn render_send_text_subview(&mut self, ui: &mut Ui, theme: ThemeColors) { ui.horizontal(|ui| { ui.label(RichText::new("Enter or paste text to send:").strong().color(theme.text_primary)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button("📋 Paste from Clipboard").clicked() { if let Ok(mut clipboard) = arboard::Clipboard::new() { if let Ok(text) = clipboard.get_text() { self.text_input = text; self.analyzed_preview = analyze_text(&self.text_input); } } } }); }); ui.add_space(6.0); let response = ui.add( egui::TextEdit::multiline(&mut self.text_input) .desired_rows(6) .desired_width(ui.available_width()) .font(egui::TextStyle::Monospace), ); if response.changed() { self.analyzed_preview = analyze_text(&self.text_input); } ui.add_space(8.0); if !self.text_input.trim().is_empty() { let (r, g, b) = self.analyzed_preview.content_type.badge_color_rgb(); Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(6.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(r, g, b))) .inner_margin(Margin::same(8.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("Auto-Detected:").size(11.0).color(theme.text_muted)); ui.label( RichText::new(self.analyzed_preview.content_type.label()) .size(12.0) .strong() .color(Color32::from_rgb(r, g, b)), ); }); }); ui.add_space(10.0); } let num_recipients = self.selected_peer_ids.len(); let can_send_text = !self.text_input.trim().is_empty() && num_recipients > 0; let btn_label = if num_recipients == 1 { let target_name = self .peers .iter() .find(|p| self.selected_peer_ids.contains(&p.peer_id)) .map(|p| self.get_peer_display_name(p).0) .unwrap_or_else(|| "...".to_string()); format!("🚀 Send Text to {}", target_name) } else { format!("🚀 Broadcast Text to {} Recipients", num_recipients) }; let send_btn = Button::new(RichText::new(btn_label).size(14.0).strong().color(Color32::WHITE)) .fill(if can_send_text { Color32::from_rgb(16, 185, 129) } else { if theme.is_dark { Color32::from_rgb(51, 65, 85) } else { Color32::from_rgb(203, 213, 225) } }) .min_size(Vec2::new(ui.available_width(), 40.0)) .rounding(Rounding::same(8.0)); if ui.add_enabled(can_send_text, send_btn).clicked() { let targets: Vec = self .peers .iter() .filter(|p| self.selected_peer_ids.contains(&p.peer_id)) .cloned() .collect(); let text = self.text_input.clone(); let client = self.client.clone(); let my_info = self.my_info.clone(); let log_tx = self.log_tx.clone(); let pin = if self.recipient_pin.trim().is_empty() { None } else { Some(self.recipient_pin.trim().to_string()) }; for peer in targets { let peer_url = peer.transfer_url(); let peer_name = self.get_peer_display_name(&peer).0; let text_clone = text.clone(); let client_clone = client.clone(); let my_info_clone = my_info.clone(); let log_tx_clone = log_tx.clone(); let pin_clone = pin.clone(); tokio::spawn(async move { match client_clone.send_text(&peer_url, &my_info_clone, &text_clone, pin_clone).await { Ok(analyzed) => { let _ = log_tx_clone.send(format!( "🎉 Sent text [{}] to {}!", analyzed.content_type.label(), peer_name )); } Err(e) => { let _ = log_tx_clone.send(format!("⚠️ Text send failed to {}: {}", peer_name, e)); } } }); } self.text_input.clear(); } } fn render_qr_code(ctx: &egui::Context, url: &str, tex_id: &str) -> Option { if let Ok(code) = QrCode::new(url.as_bytes()) { let width = code.width(); let mut pixels = Vec::with_capacity(width * width); for y in 0..width { for x in 0..width { let is_dark = code[(x, y)] == QrColor::Dark; pixels.push(if is_dark { Color32::BLACK } else { Color32::WHITE }); } } let image = ColorImage { size: [width, width], pixels, }; Some(ctx.load_texture(tex_id, image, TextureOptions::NEAREST)) } else { None } } fn render_receive_tab(&mut self, ui: &mut Ui, theme: ThemeColors) { ScrollArea::vertical().show(ui, |ui| { ui.horizontal(|ui| { ui.heading(RichText::new("📥 Receive & Web Access").color(theme.text_primary)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button("📂 Open Downloads Folder").clicked() { let _ = std::process::Command::new("explorer").arg(&self.download_dir).spawn(); } }); }); ui.add_space(8.0); // General Storage & Auto-Accept Settings Card Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("📁 Save Directory:").strong().color(theme.text_primary)); ui.label(RichText::new(format!("{:?}", self.download_dir)).color(theme.text_secondary)); }); ui.add_space(6.0); ui.horizontal(|ui| { if ui.checkbox(&mut self.auto_accept, "Auto-accept incoming transfers from friends").changed() { self.persist_config(); } if ui.checkbox(&mut self.auto_extract_zip, "Auto-extract received ZIP folders").changed() { self.persist_config(); } }); }); ui.add_space(14.0); ui.label(RichText::new("📱 Send from Phone / Browser (Web Dropzone)").strong().size(15.0).color(theme.text_primary)); ui.label(RichText::new("Scan the QR code with your phone camera or open the link in any mobile browser to send photos, videos, and files directly to this PC:").size(12.0).color(theme.text_muted)); ui.add_space(10.0); let port = self.my_info.transfer_port; // 1. ZeroTier / VPN Interface let vpn_iface = if self.current_iface.is_virtual_vpn() { Some(self.current_iface.clone()) } else { self.available_interfaces.iter().find(|i| i.is_virtual_vpn()).cloned() }; // 2. Local Wi-Fi / LAN Interface let lan_iface = if !self.current_iface.is_virtual_vpn() && self.current_iface.net_type != NetworkType::Loopback { Some(self.current_iface.clone()) } else { self.available_interfaces.iter().find(|i| !i.is_virtual_vpn() && i.net_type != NetworkType::Loopback).cloned() }; // Card 1: ZeroTier / VPN Mesh if let Some(iface) = &vpn_iface { let url = format!("http://{}:{}", iface.ip, port); let qr_tex = Self::render_qr_code(ui.ctx(), &url, "qr_vpn"); Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(168, 85, 247))) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.vertical(|ui| { ui.horizontal(|ui| { ui.label(RichText::new("🌐 ZeroTier / VPN Mesh Link").size(15.0).strong().color(theme.text_primary)); Frame::none() .fill(Color32::from_rgba_premultiplied(168, 85, 247, 40)) .rounding(Rounding::same(4.0)) .inner_margin(Margin::symmetric(6.0, 2.0)) .show(ui, |ui| { ui.label(RichText::new(iface.net_type.as_str()).size(10.0).color(Color32::from_rgb(192, 132, 252)).strong()); }); }); ui.add_space(3.0); ui.label(RichText::new("For phones or laptops connected via ZeroTier, Tailscale, or Radmin VPN mesh:").size(11.0).color(theme.text_muted)); ui.add_space(8.0); ui.horizontal(|ui| { ui.label(RichText::new(&url).size(14.0).strong().color(theme.text_primary)); if ui.button("📋 Copy").on_hover_text("Copy ZeroTier Web Link").clicked() { ui.output_mut(|o| o.copied_text = url.clone()); } if ui.button("🌐 Open").on_hover_text("Open in browser").clicked() { ui.ctx().open_url(egui::OpenUrl::same_tab(&url)); } }); }); if let Some(tex) = qr_tex { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { Frame::none() .fill(Color32::WHITE) .rounding(Rounding::same(6.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(4.0)) .show(ui, |ui| { ui.add(egui::Image::new(&tex).fit_to_exact_size(Vec2::new(90.0, 90.0))); }); }); } }); }); ui.add_space(10.0); } // Card 2: Local Wi-Fi / LAN Network if let Some(iface) = &lan_iface { let url = format!("http://{}:{}", iface.ip, port); let qr_tex = Self::render_qr_code(ui.ctx(), &url, "qr_lan"); Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(56, 189, 248))) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.vertical(|ui| { ui.horizontal(|ui| { ui.label(RichText::new("🏠 Home Wi-Fi / Local LAN Link").size(15.0).strong().color(theme.text_primary)); Frame::none() .fill(Color32::from_rgba_premultiplied(56, 189, 248, 40)) .rounding(Rounding::same(4.0)) .inner_margin(Margin::symmetric(6.0, 2.0)) .show(ui, |ui| { ui.label(RichText::new("Local Wi-Fi").size(10.0).color(Color32::from_rgb(56, 189, 248)).strong()); }); }); ui.add_space(3.0); ui.label(RichText::new("For phones, tablets, and devices connected to the same home/office Wi-Fi router:").size(11.0).color(theme.text_muted)); ui.add_space(8.0); ui.horizontal(|ui| { ui.label(RichText::new(&url).size(14.0).strong().color(theme.text_primary)); if ui.button("📋 Copy").on_hover_text("Copy Local Wi-Fi Link").clicked() { ui.output_mut(|o| o.copied_text = url.clone()); } if ui.button("🌐 Open").on_hover_text("Open in browser").clicked() { ui.ctx().open_url(egui::OpenUrl::same_tab(&url)); } }); }); if let Some(tex) = qr_tex { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { Frame::none() .fill(Color32::WHITE) .rounding(Rounding::same(6.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(4.0)) .show(ui, |ui| { ui.add(egui::Image::new(&tex).fit_to_exact_size(Vec2::new(90.0, 90.0))); }); }); } }); }); } else if vpn_iface.is_none() { // Fallback if neither was separately classified let url = format!("http://{}:{}", self.current_iface.ip, port); let qr_tex = Self::render_qr_code(ui.ctx(), &url, "qr_fallback"); Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(59, 130, 246))) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.vertical(|ui| { ui.label(RichText::new("🌐 Web Dropzone Link").size(15.0).strong().color(theme.text_primary)); ui.add_space(3.0); ui.label(RichText::new("Open this link or scan QR code on mobile:").size(11.0).color(theme.text_muted)); ui.add_space(8.0); ui.horizontal(|ui| { ui.label(RichText::new(&url).size(14.0).strong().color(theme.text_primary)); if ui.button("📋 Copy").clicked() { ui.output_mut(|o| o.copied_text = url.clone()); } if ui.button("🌐 Open").clicked() { ui.ctx().open_url(egui::OpenUrl::same_tab(&url)); } }); }); if let Some(tex) = qr_tex { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { Frame::none() .fill(Color32::WHITE) .rounding(Rounding::same(6.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(4.0)) .show(ui, |ui| { ui.add(egui::Image::new(&tex).fit_to_exact_size(Vec2::new(90.0, 90.0))); }); }); } }); }); } ui.add_space(14.0); }); } fn refresh_current_share_tree(&mut self) { if let (Some(peer_id), Some(folder_id)) = ( self.selected_peer_id_for_share.clone(), self.selected_folder_id_for_share.clone(), ) { if let Some(peer) = self.peers.iter().find(|p| p.peer_id == peer_id) { let peer_url = peer.transfer_url(); let subpath = self.current_share_subpath.clone(); let pin = if self.share_pin_input.trim().is_empty() { None } else { Some(self.share_pin_input.trim().to_string()) }; let client = self.client.clone(); let tx = self.share_tree_tx.clone(); let log_tx = self.log_tx.clone(); self.share_tree_loading = true; tokio::spawn(async move { match client .fetch_shared_tree(&peer_url, &folder_id, Some(&subpath), pin.as_deref()) .await { Ok(tree) => { let _ = tx.send(Some(tree)); } Err(e) => { let _ = tx.send(None); let _ = log_tx.send(format!("⚠️ Shared folder error: {}", e)); } } }); } } } fn sync_active_share_tree_background(&self) { if let (Some(peer_id), Some(folder_id)) = ( self.selected_peer_id_for_share.clone(), self.selected_folder_id_for_share.clone(), ) { if let Some(peer) = self.peers.iter().find(|p| p.peer_id == peer_id && p.is_online) { let peer_url = peer.transfer_url(); let subpath = self.current_share_subpath.clone(); let pin = if self.share_pin_input.trim().is_empty() { None } else { Some(self.share_pin_input.trim().to_string()) }; let client = self.client.clone(); let tx = self.share_tree_tx.clone(); tokio::spawn(async move { if let Ok(tree) = client .fetch_shared_tree(&peer_url, &folder_id, Some(&subpath), pin.as_deref()) .await { let _ = tx.send(Some(tree)); } }); } } } fn render_shared_tab(&mut self, ui: &mut Ui, theme: ThemeColors) { ui.horizontal(|ui| { let my_shares_count = self.shared_folders.len(); let network_shares_count: usize = self.peers.iter().map(|p| p.shared_folders.len()).sum(); let net_btn = Button::new( RichText::new(format!("🌐 Network Shares ({})", network_shares_count)) .strong() .color(if self.selected_share_subtab == SharedSubTab::NetworkShares { theme.text_primary } else { theme.text_secondary }), ) .fill(if self.selected_share_subtab == SharedSubTab::NetworkShares { theme.bg_card_active } else { theme.bg_card }) .min_size(Vec2::new(170.0, 32.0)) .rounding(Rounding::same(6.0)); if ui.add(net_btn).clicked() { self.selected_share_subtab = SharedSubTab::NetworkShares; } let my_btn = Button::new( RichText::new(format!("💻 My Shares ({})", my_shares_count)) .strong() .color(if self.selected_share_subtab == SharedSubTab::MyShares { theme.text_primary } else { theme.text_secondary }), ) .fill(if self.selected_share_subtab == SharedSubTab::MyShares { theme.bg_card_active } else { theme.bg_card }) .min_size(Vec2::new(150.0, 32.0)) .rounding(Rounding::same(6.0)); if ui.add(my_btn).clicked() { self.selected_share_subtab = SharedSubTab::MyShares; } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if self.selected_share_subtab == SharedSubTab::MyShares { let add_btn = Button::new(RichText::new("➕ Add Shared Folder").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(34, 197, 94)) .min_size(Vec2::new(160.0, 30.0)); if ui.add(add_btn).clicked() { if let Some(folder) = FileDialog::new().pick_folder() { let name = folder .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "Shared Folder".to_string()); let id = uuid::Uuid::new_v4().to_string(); self.shared_folders.push(SharedFolderConfig { id, name, path: folder, read_only: true, pin: None, }); self.persist_config(); } } } else { if ui.button(RichText::new("🔄 Sync / Refresh").color(theme.text_primary)).clicked() { self.refresh_current_share_tree(); } } }); }); ui.add_space(10.0); match self.selected_share_subtab { SharedSubTab::MyShares => self.render_my_shares(ui, theme), SharedSubTab::NetworkShares => self.render_network_shares(ui, theme), } } fn render_my_shares(&mut self, ui: &mut Ui, theme: ThemeColors) { if self.shared_folders.is_empty() { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(40.0)) .show(ui, |ui| { ui.vertical_centered(|ui| { ui.label(RichText::new("📁").size(40.0)); ui.label( RichText::new("You haven't shared any local folders yet.") .size(16.0) .strong() .color(theme.text_primary), ); ui.add_space(6.0); ui.label( RichText::new( "Share movies, music, or code folders so your friends on ZeroTier/LAN can stream and download them.", ) .size(12.0) .color(theme.text_muted), ); ui.add_space(16.0); let choose_btn = Button::new(RichText::new("➕ Choose Folder to Share").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(34, 197, 94)) .min_size(Vec2::new(200.0, 36.0)); if ui.add(choose_btn).clicked() { if let Some(folder) = FileDialog::new().pick_folder() { let name = folder .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "Shared Folder".to_string()); let id = uuid::Uuid::new_v4().to_string(); self.shared_folders.push(SharedFolderConfig { id, name, path: folder, read_only: true, pin: None, }); self.persist_config(); } } }); }); return; } let mut config_changed = false; let mut remove_index = None; ScrollArea::vertical().show(ui, |ui| { for (idx, share) in self.shared_folders.iter_mut().enumerate() { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(12.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("📁").size(24.0)); ui.vertical(|ui| { ui.horizontal(|ui| { ui.label(RichText::new(&share.name).strong().size(14.0).color(theme.text_primary)); let (ro_label, ro_color) = if share.read_only { ("Read-Only (Streaming/Download)", Color32::from_rgb(59, 130, 246)) } else { ("Read & Write (Uploads Allowed)", Color32::from_rgb(34, 197, 94)) }; Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(4.0)) .stroke(Stroke::new(1.0_f32, ro_color)) .inner_margin(Margin::symmetric(6.0, 2.0)) .show(ui, |ui| { ui.label(RichText::new(ro_label).size(10.5).color(ro_color)); }); if share.pin.is_some() { Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(4.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(245, 158, 11))) .inner_margin(Margin::symmetric(6.0, 2.0)) .show(ui, |ui| { ui.label( RichText::new("🔒 PIN Protected") .size(10.5) .color(Color32::from_rgb(245, 158, 11)), ); }); } }); ui.label( RichText::new(format!("{:?}", share.path)) .monospace() .size(11.0) .color(theme.text_muted), ); }); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button(RichText::new("🗑 Remove").color(Color32::from_rgb(239, 68, 68))).clicked() { remove_index = Some(idx); } if ui.button("📂 Open Folder").clicked() { let _ = std::process::Command::new("explorer").arg(&share.path).spawn(); } if ui.checkbox(&mut share.read_only, "Read-Only").changed() { config_changed = true; } }); }); }); ui.add_space(8.0); } }); if let Some(idx) = remove_index { self.shared_folders.remove(idx); config_changed = true; } if config_changed { self.persist_config(); } } fn render_network_shares(&mut self, ui: &mut Ui, theme: ThemeColors) { let online_peers_with_shares: Vec = self .peers .iter() .filter(|p| p.is_online && !p.shared_folders.is_empty()) .cloned() .collect(); if online_peers_with_shares.is_empty() { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(40.0)) .show(ui, |ui| { ui.vertical_centered(|ui| { ui.label(RichText::new("🌐").size(40.0)); ui.label( RichText::new("No shared folders discovered on your network.") .size(16.0) .strong() .color(theme.text_primary), ); ui.add_space(6.0); ui.label( RichText::new( "When your friends on ZeroTier / LAN add shared folders, they will automatically appear here with streaming and fast download support.", ) .size(12.0) .color(theme.text_muted), ); }); }); return; } ui.horizontal_top(|ui| { // Left Column: Peer & Folder Selector (250px) ui.allocate_ui(Vec2::new(250.0, ui.available_height()), |ui| { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(10.0)) .show(ui, |ui| { ui.label( RichText::new("Friends & Shares") .strong() .size(13.0) .color(theme.text_primary), ); ui.add_space(6.0); ui.separator(); ui.add_space(6.0); ScrollArea::vertical().show(ui, |ui| { for peer in &online_peers_with_shares { let (peer_display, _) = self.get_peer_display_name(peer); ui.horizontal(|ui| { ui.label(RichText::new("●").color(Color32::from_rgb(34, 197, 94)).size(11.0)); ui.label(RichText::new(&peer_display).strong().color(theme.text_primary)); }); for folder in &peer.shared_folders { let is_selected = self.selected_peer_id_for_share.as_deref() == Some(&peer.peer_id) && self.selected_folder_id_for_share.as_deref() == Some(&folder.id); let badge = if folder.read_only { "RO" } else { "RW" }; let btn_text = format!("📁 {} [{}]", folder.name, badge); let btn = Button::new(RichText::new(btn_text).size(12.0).color(if is_selected { theme.text_accent } else { theme.text_secondary })) .fill(if is_selected { theme.bg_card_active } else { Color32::TRANSPARENT }) .min_size(Vec2::new(ui.available_width(), 26.0)) .rounding(Rounding::same(4.0)); if ui.add(btn).clicked() { self.selected_peer_id_for_share = Some(peer.peer_id.clone()); self.selected_folder_id_for_share = Some(folder.id.clone()); self.current_share_subpath = String::new(); self.refresh_current_share_tree(); } } ui.add_space(6.0); } }); }); }); ui.add_space(10.0); // Right Column: Active Share Explorer ui.vertical(|ui| { if let (Some(peer_id), Some(folder_id)) = ( self.selected_peer_id_for_share.clone(), self.selected_folder_id_for_share.clone(), ) { let peer_opt = self.peers.iter().find(|p| p.peer_id == peer_id).cloned(); let peer = match peer_opt { Some(p) => p, None => { ui.label("Selected peer is offline."); return; } }; let (peer_name, _) = self.get_peer_display_name(&peer); // Top Explorer Header & Breadcrumbs Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(10.0)) .show(ui, |ui| { ui.horizontal(|ui| { if ui.button("🏠 Root").clicked() { self.current_share_subpath = String::new(); self.refresh_current_share_tree(); } if !self.current_share_subpath.is_empty() { ui.label("/"); ui.label( RichText::new(&self.current_share_subpath) .monospace() .strong() .color(theme.text_primary), ); } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { // Search filter box ui.add( egui::TextEdit::singleline(&mut self.share_search_query) .hint_text("🔍 Filter files...") .desired_width(160.0), ); // Upload button if folder is Read-Write if let Some(tree) = &self.current_share_tree { if !tree.read_only { let upload_btn = Button::new( RichText::new("⬆️ Upload File") .strong() .color(Color32::WHITE), ) .fill(Color32::from_rgb(34, 197, 94)) .rounding(Rounding::same(6.0)); if ui.add(upload_btn).clicked() { if let Some(files) = FileDialog::new().pick_files() { let client = self.client.clone(); let peer_url = peer.transfer_url(); let f_id = folder_id.clone(); let sub = self.current_share_subpath.clone(); let pin = if self.share_pin_input.trim().is_empty() { None } else { Some(self.share_pin_input.trim().to_string()) }; let log_tx = self.log_tx.clone(); let tx = self.share_tree_tx.clone(); tokio::spawn(async move { for f in files { let _ = log_tx.send(format!("⬆️ Uploading {:?} to share...", f.file_name())); match client.upload_to_shared_folder(&peer_url, &f_id, &sub, &f, pin.as_deref()).await { Ok(_) => { let _ = log_tx.send(format!("✅ Uploaded {:?}", f.file_name())); } Err(e) => { let _ = log_tx.send(format!("❌ Upload failed: {}", e)); } } } // Refresh tree after upload if let Ok(tree) = client.fetch_shared_tree(&peer_url, &f_id, Some(&sub), pin.as_deref()).await { let _ = tx.send(Some(tree)); } }); } } } } }); }); }); ui.add_space(8.0); // Explorer File Table if self.share_tree_loading { ui.vertical_centered(|ui| { ui.add_space(30.0); ui.spinner(); ui.label("Loading shared contents..."); }); } else if let Some(tree) = self.current_share_tree.clone() { ScrollArea::vertical().show(ui, |ui| { // Up to parent row if !self.current_share_subpath.is_empty() { Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(6.0)) .inner_margin(Margin::symmetric(10.0, 6.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label("📁 .."); if ui.button("Parent Folder").clicked() { let p = std::path::Path::new(&self.current_share_subpath); let parent_str = p.parent().map(|p| p.to_string_lossy().replace('\\', "/")).unwrap_or_default(); self.current_share_subpath = parent_str; self.refresh_current_share_tree(); } }); }); ui.add_space(4.0); } let q_lower = self.share_search_query.to_lowercase(); for entry in &tree.entries { if !q_lower.is_empty() && !entry.name.to_lowercase().contains(&q_lower) { continue; } let (icon, badge_color, badge_text) = get_file_badge_info(&entry.name, entry.is_dir); Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(6.0)) .stroke(theme.stroke_border) .inner_margin(Margin::symmetric(10.0, 6.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new(icon).size(16.0)); if entry.is_dir { if ui.link(RichText::new(&entry.name).strong().color(theme.text_primary)).clicked() { self.current_share_subpath = entry.relative_path.clone(); self.refresh_current_share_tree(); } } else { ui.label(RichText::new(&entry.name).strong().color(theme.text_primary)); } // Badge Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(3.0)) .stroke(Stroke::new(1.0_f32, badge_color)) .inner_margin(Margin::symmetric(5.0, 1.0)) .show(ui, |ui| { ui.label(RichText::new(badge_text).size(9.5).color(badge_color)); }); if !entry.is_dir { ui.label( RichText::new(format!("({})", human_bytes(entry.size as f64))) .size(11.0) .color(theme.text_muted), ); } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { // Action: Download let is_dir = entry.is_dir; let down_label = if is_dir { "⬇️ Zip" } else { "⬇️ Download" }; if ui.button(down_label).clicked() { let client = self.client.clone(); let peer_url = peer.transfer_url(); let f_id = folder_id.clone(); let sub = entry.relative_path.clone(); let out_name = if is_dir { format!("{}.zip", entry.name) } else { entry.name.clone() }; let dest = self.download_dir.join(&out_name); let pin = if self.share_pin_input.trim().is_empty() { None } else { Some(self.share_pin_input.trim().to_string()) }; let log_tx = self.log_tx.clone(); let sound = self.sound_notifications; tokio::spawn(async move { let _ = log_tx.send(format!("⬇️ Downloading {}...", out_name)); match client.download_shared_file(&peer_url, &f_id, &sub, &dest, pin.as_deref()).await { Ok(bytes) => { let _ = log_tx.send(format!("✅ Downloaded {} ({})", out_name, human_bytes(bytes as f64))); if sound { crate::ui::sound::play_transfer_sound(); } } Err(e) => { let _ = log_tx.send(format!("❌ Download failed: {}", e)); } } }); } // Action: Stream / Play (for video & audio) if badge_text == "VIDEO" || badge_text == "AUDIO" { if ui.button(RichText::new("▶️ Stream").color(Color32::from_rgb(236, 72, 153)).strong()).clicked() { let stream_url = format!( "{}/api/shared/stream/{}/{}", peer.transfer_url(), folder_id, entry.relative_path.trim_start_matches('/') ); let _ = std::process::Command::new("explorer").arg(&stream_url).spawn(); } } // Action: Vim View (for code & text) if badge_text == "CODE" || badge_text == "DOC" || entry.name.ends_with(".txt") || entry.name.ends_with(".log") { if ui.button(RichText::new("👁 Vim View").color(Color32::from_rgb(20, 184, 166)).strong()).clicked() { let client = self.client.clone(); let peer_url = peer.transfer_url(); let f_id = folder_id.clone(); let sub = entry.relative_path.clone(); let file_name = entry.name.clone(); let peer_display = peer_name.clone(); let text_tx = self.share_text_tx.clone(); let pin = if self.share_pin_input.trim().is_empty() { None } else { Some(self.share_pin_input.trim().to_string()) }; tokio::spawn(async move { if let Ok(text) = client.fetch_shared_file_text(&peer_url, &f_id, &sub, pin.as_deref()).await { let _ = text_tx.send((file_name, sub, peer_display, text)); } }); } } }); }); }); ui.add_space(3.0); } }); } else { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(20.0)) .show(ui, |ui| { ui.vertical_centered(|ui| { ui.label(RichText::new("🔒 Folder Locked or Empty").strong().size(14.0).color(theme.text_primary)); ui.add_space(6.0); ui.horizontal(|ui| { ui.label("Enter PIN if required:"); ui.add(egui::TextEdit::singleline(&mut self.share_pin_input).password(true).desired_width(120.0)); if ui.button("Unlock / Refresh").clicked() { self.refresh_current_share_tree(); } }); }); }); } } else { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(40.0)) .show(ui, |ui| { ui.vertical_centered(|ui| { ui.label(RichText::new("👈 Select a shared folder on the left to browse files, watch streams, and view code in Vim.").color(theme.text_muted)); }); }); } }); }); } fn render_transfers_tab(&mut self, ui: &mut Ui, theme: ThemeColors) { ui.horizontal(|ui| { ui.heading(RichText::new("📜 Active & Completed Transfers").color(theme.text_primary)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button("📂 Open Downloads").clicked() { let _ = std::process::Command::new("explorer").arg(&self.download_dir).spawn(); } if !self.transfers.is_empty() && ui.button("🗑️ Clear finished").clicked() { self.transfers.retain(|t| t.state == TransferState::InProgress || t.state == TransferState::PendingConfirmation); let srv = self.server.clone(); tokio::spawn(async move { srv.clear_finished().await; }); } }); }); ui.add_space(8.0); if self.transfers.is_empty() { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(40.0)) .show(ui, |ui| { ui.vertical_centered(|ui| { ui.label(RichText::new("📦").size(36.0)); ui.label(RichText::new("No active or past transfers.").size(15.0).color(theme.text_secondary)); ui.add_space(4.0); ui.label(RichText::new("Transfers will appear here with live speed and progress.").size(12.0).color(theme.text_muted)); }); }); return; } ScrollArea::vertical().show(ui, |ui| { for t in &self.transfers { let is_done = t.state == TransferState::Completed; let is_active = t.state == TransferState::InProgress; let icon = get_file_icon(&t.current_file_name); Frame::none() .fill(if is_active { if theme.is_dark { Color32::from_rgb(23, 37, 84) } else { Color32::from_rgb(219, 234, 254) } } else { theme.bg_card }) .rounding(Rounding::same(10.0)) .stroke(if is_active { Stroke::new(1.0_f32, Color32::from_rgb(59, 130, 246)) } else if is_done { Stroke::new(1.0_f32, Color32::from_rgb(34, 197, 94)) } else { theme.stroke_border }) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { // Top row: Direction, Peer, and Status Badge ui.horizontal(|ui| { let (dir_badge, dir_color) = if t.is_incoming { (format!("📥 Received from {}", t.peer_name), Color32::from_rgb(192, 132, 252)) } else { (format!("📤 Sent to {}", t.peer_name), Color32::from_rgb(96, 165, 250)) }; ui.label(RichText::new(dir_badge).size(13.0).strong().color(dir_color)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { match &t.state { TransferState::Completed => { ui.horizontal(|ui| { ui.label(RichText::new("✅ Completed").color(Color32::from_rgb(34, 197, 94)).strong().size(13.0)); if t.checksum_verified { Frame::none() .fill(Color32::from_rgba_premultiplied(34, 197, 94, 35)) .rounding(Rounding::same(4.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(34, 197, 94))) .inner_margin(Margin::symmetric(6.0, 2.0)) .show(ui, |ui| { ui.label(RichText::new("🛡️ SHA-256 Verified").size(10.5).color(Color32::from_rgb(74, 222, 128)).strong()) .on_hover_text("Cryptographic SHA-256 checksum verified on the fly. 100% integrity guaranteed."); }); } }); } TransferState::InProgress => { let speed_mb = (t.speed_bps as f64) / 1_048_576.0; ui.label( RichText::new(format!("⚡ {:.1} MB/s", speed_mb)) .color(if theme.is_dark { Color32::from_rgb(56, 189, 248) } else { Color32::from_rgb(2, 132, 199) }) .strong() .size(13.0), ); } TransferState::PendingConfirmation => { ui.label(RichText::new("⏳ Waiting for confirmation...").color(Color32::YELLOW).size(13.0)); } TransferState::Failed(e) => { ui.label(RichText::new(format!("❌ Failed: {}", e)).color(Color32::RED).size(12.0)); } TransferState::Rejected => { ui.label(RichText::new("⛔ Transfer rejected").color(Color32::RED).size(12.0)); } TransferState::Canceled => { ui.label(RichText::new("🛑 Canceled").color(theme.text_muted).size(12.0)); } } }); }); ui.add_space(8.0); // Middle row: File Icon, Detailed Filename, Size, and Buttons ui.horizontal(|ui| { ui.label(RichText::new(icon).size(24.0)); ui.add_space(4.0); ui.vertical(|ui| { ui.label( RichText::new(&t.current_file_name) .size(15.0) .strong() .color(theme.text_primary), ); let progress_pct = if t.total_bytes > 0 { ((t.bytes_transferred as f64 / t.total_bytes as f64) * 100.0).clamp(0.0, 100.0) as u32 } else { 100 }; let size_details = if t.state == TransferState::InProgress { let speed = t.speed_bps.max(1); let remaining_bytes = t.total_bytes.saturating_sub(t.bytes_transferred); let eta_secs = remaining_bytes / speed; let eta_str = if eta_secs < 60 { format!("~{}s left", eta_secs) } else if eta_secs < 3600 { format!("~{}m {}s left", eta_secs / 60, eta_secs % 60) } else { format!("~{}h {}m left", eta_secs / 3600, (eta_secs % 3600) / 60) }; let speed_mb = (t.speed_bps as f64) / 1_048_576.0; format!( "{} / {} ({}%) • ⚡ {:.1} MB/s • {}", human_bytes(t.bytes_transferred as f64), human_bytes(t.total_bytes as f64), progress_pct, speed_mb, eta_str ) } else if is_done { if t.checksum_verified { format!("Delivered • Total Size: {} • 🛡️ SHA-256 Verified", human_bytes(t.total_bytes as f64)) } else { format!("Delivered • Total Size: {}", human_bytes(t.total_bytes as f64)) } } else { format!("Total Size: {}", human_bytes(t.total_bytes as f64)) }; ui.label(RichText::new(size_details).size(11.0).color(theme.text_muted)); }); if is_done { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let target_file = self.download_dir.join(&t.current_file_name); let exists = target_file.exists(); if ui .add( Button::new(RichText::new("📂 Show in Folder").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(37, 99, 235)) .rounding(Rounding::same(6.0)), ) .clicked() { if exists { let _ = std::process::Command::new("explorer") .args(["/select,", &target_file.to_string_lossy()]) .spawn(); } else { let _ = std::process::Command::new("explorer").arg(&self.download_dir).spawn(); } } if exists && ui .add( Button::new(RichText::new("📄 Open").color(if theme.is_dark { Color32::WHITE } else { theme.text_primary })) .fill(if theme.is_dark { Color32::from_rgb(51, 65, 85) } else { Color32::from_rgb(226, 232, 240) }) .rounding(Rounding::same(6.0)), ) .clicked() { let _ = std::process::Command::new("explorer").arg(&target_file).spawn(); } }); } }); ui.add_space(8.0); // Progress Bar let progress_ratio = if t.total_bytes > 0 { (t.bytes_transferred as f32 / t.total_bytes as f32).clamp(0.0, 1.0) } else { 1.0 }; let pb = ProgressBar::new(progress_ratio) .show_percentage() .fill(if is_done { Color32::from_rgb(16, 185, 129) } else { Color32::from_rgb(56, 189, 248) }); ui.add(pb); }); ui.add_space(8.0); } }); } fn render_settings_tab(&mut self, ui: &mut Ui, theme: ThemeColors) { ScrollArea::vertical().show(ui, |ui| { ui.heading(RichText::new("⚙️ Settings & Preferences").color(theme.text_primary)); ui.add_space(8.0); Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(16.0)) .show(ui, |ui| { // Theme Mode Selector ui.label(RichText::new("🎨 Theme & Appearance").strong().color(theme.text_primary)); ui.add_space(4.0); ui.horizontal(|ui| { let themes = [ (ThemeMode::Auto, "🖥 Auto (System Theme)"), (ThemeMode::Dark, "🌙 Dark Theme"), (ThemeMode::Light, "☀️ Light Theme"), ]; for (mode, label) in themes { let is_active = self.theme_mode == mode; let btn = Button::new( RichText::new(label) .size(12.5) .color(if is_active { Color32::WHITE } else { theme.text_secondary }) .strong(), ) .fill(if is_active { Color32::from_rgb(37, 99, 235) } else { theme.bg_card_secondary }) .rounding(Rounding::same(6.0)) .min_size(Vec2::new(140.0, 32.0)); if ui.add(btn).clicked() { self.theme_mode = mode; self.config.theme_mode = mode; self.persist_config(); } } }); ui.add_space(4.0); let current_status = match self.theme_mode { ThemeMode::Auto => { if is_system_dark_mode() { "Currently automatically matching Windows Dark Theme" } else { "Currently automatically matching Windows Light Theme" } } ThemeMode::Dark => "Always Dark Theme", ThemeMode::Light => "Always Light Theme", }; ui.label(RichText::new(current_status).size(11.0).color(theme.text_muted)); ui.add_space(12.0); ui.separator(); ui.add_space(8.0); ui.label(RichText::new("Device Name:").strong().color(theme.text_primary)); if ui.text_edit_singleline(&mut self.custom_device_name).lost_focus() { self.persist_config(); } ui.add_space(10.0); ui.label(RichText::new("Network Interface:").strong().color(theme.text_primary)); let mut iface_changed = false; let ifaces = self.available_interfaces.clone(); egui::ComboBox::from_id_salt("iface_combo") .selected_text(format!("{} - {}", self.current_iface.net_type.as_str(), self.current_iface.ip)) .width(360.0) .show_ui(ui, |ui| { for iface in &ifaces { let label = format!("{} (IP: {})", iface.name, iface.ip); if ui.selectable_value(&mut self.current_iface, iface.clone(), label).changed() { iface_changed = true; } } }); if iface_changed { self.persist_config(); } ui.add_space(10.0); ui.label(RichText::new("Download Location:").strong().color(theme.text_primary)); ui.horizontal(|ui| { ui.label(RichText::new(format!("{:?}", self.download_dir)).color(theme.text_secondary)); if ui.button("Change Folder...").clicked() { if let Some(dir) = FileDialog::new().pick_folder() { self.download_dir = dir; self.persist_config(); } } }); ui.add_space(12.0); ui.separator(); ui.add_space(8.0); // Folder Compression Settings ui.label(RichText::new("📦 Ultra-Fast Folder ZIP Compression").strong().color(theme.text_primary)); ui.add_space(4.0); if ui .checkbox( &mut self.auto_zip_folders, "Compress folders into ZIP on the fly (10x faster for code, git, photo folders)", ) .changed() { self.persist_config(); } if ui .checkbox( &mut self.auto_extract_zip, "Automatically extract received ZIP folder archives into Downloads folder", ) .changed() { self.persist_config(); } ui.add_space(12.0); ui.separator(); ui.add_space(8.0); // Bandwidth Limiter Settings ui.label(RichText::new("🚀 Bandwidth Limiter (Upload Speed Cap)").strong().color(theme.text_primary)); ui.add_space(4.0); ui.horizontal(|ui| { ui.label(RichText::new("Speed limit:").color(theme.text_secondary)); if ui .add( egui::DragValue::new(&mut self.max_upload_speed_mbps) .range(0..=5000) .speed(1.0) .custom_formatter(|n, _| { if n == 0.0 { "Unlimited (0 MB/s)".to_string() } else { format!("{:.0} MB/s", n) } }), ) .changed() { self.persist_config(); } ui.add_space(8.0); ui.label( RichText::new("(Enter 0 for unlimited, or type any custom MB/s)") .size(11.0) .color(theme.text_muted), ); }); ui.add_space(4.0); ui.horizontal(|ui| { ui.label( RichText::new("Quick presets:") .size(12.0) .color(theme.text_muted), ); for preset in [0, 5, 10, 25, 50, 100] { let label = if preset == 0 { "Unlimited".to_string() } else { format!("{} MB/s", preset) }; let is_current = self.max_upload_speed_mbps == preset; let btn = egui::Button::new( RichText::new(label).size(11.0).color(if is_current { if theme.is_dark { Color32::from_rgb(56, 189, 248) } else { Color32::WHITE } } else { theme.text_secondary }), ) .fill(if is_current { Color32::from_rgb(37, 99, 235) } else { theme.bg_card_secondary }); if ui.add(btn).clicked() { self.max_upload_speed_mbps = preset; self.persist_config(); } } }); ui.add_space(12.0); ui.separator(); ui.add_space(8.0); // Audio & Sound Notification Settings ui.label(RichText::new("🔔 Audio & Notifications").strong().color(theme.text_primary)); ui.add_space(4.0); if ui .checkbox( &mut self.sound_notifications, "Play soft audio chime upon transfer completion", ) .changed() { self.persist_config(); } ui.add_space(12.0); ui.separator(); ui.add_space(8.0); // System Tray Options ui.label(RichText::new("🎛 Window & System Tray Behavior").strong().color(theme.text_primary)); ui.add_space(4.0); if ui .checkbox( &mut self.close_to_tray, "Close to System Tray (keeps ZeroSend running in background)", ) .changed() { self.persist_config(); } ui.add_space(12.0); ui.separator(); ui.add_space(8.0); // Windows Startup Autostart ui.label(RichText::new("🚀 Windows Startup Behavior").strong().color(theme.text_primary)); ui.add_space(4.0); ui.horizontal(|ui| { if self.autostart_enabled { ui.label(RichText::new("Status: Starts with Windows (Minimized to Tray)").color(Color32::from_rgb(34, 197, 94))); if ui.button("➖ Disable Autostart").clicked() { let _ = unregister_autostart(); self.autostart_enabled = is_autostart_registered(); } } else { ui.label(RichText::new("Status: Disabled").color(theme.text_muted)); if ui.button("➕ Start with Windows (Tray)").clicked() { let _ = register_autostart(); self.autostart_enabled = is_autostart_registered(); } } }); ui.add_space(12.0); ui.separator(); ui.add_space(8.0); // PIN Security Settings ui.label(RichText::new("🔒 Security / Anti-Spam PIN Protection").strong().color(theme.text_primary)); ui.add_space(4.0); if ui .checkbox( &mut self.require_pin, "Require PIN code for incoming files and text messages", ) .changed() { self.persist_config(); } if self.require_pin { ui.horizontal(|ui| { ui.label(RichText::new("4-Digit PIN Code:").color(theme.text_secondary)); if ui .add( egui::TextEdit::singleline(&mut self.pin_code) .password(true) .desired_width(80.0), ) .changed() { self.persist_config(); } }); } ui.add_space(12.0); ui.separator(); ui.add_space(8.0); // Gitea Updates & Sync Settings ui.label(RichText::new("🔄 Gitea Updates & Repository").strong().color(theme.text_primary)); ui.add_space(4.0); ui.horizontal(|ui| { ui.label(RichText::new("Gitea Repo:").color(theme.text_secondary)); if ui.link(RichText::new(GITEA_DEFAULT_REPO_URL).color(Color32::from_rgb(56, 189, 248))).clicked() { let _ = std::process::Command::new("explorer").arg(GITEA_DEFAULT_REPO_URL).spawn(); } }); ui.add_space(4.0); if ui .checkbox( &mut self.check_updates_on_startup, "Automatically check for updates on application startup", ) .changed() { self.config.check_updates_on_startup = self.check_updates_on_startup; self.persist_config(); } ui.add_space(6.0); let mut check_clicked = false; ui.horizontal(|ui| { let check_btn = Button::new( RichText::new(if self.is_checking_update { "⏳ Checking Gitea..." } else { "🔍 Check for Updates Now" }) .color(theme.text_primary) ) .fill(theme.bg_card_secondary) .rounding(Rounding::same(6.0)); if ui.add_enabled(!self.is_checking_update, check_btn).clicked() { check_clicked = true; } if let Some(msg) = &self.update_check_status_msg { ui.label(RichText::new(msg).size(12.0).color(theme.text_muted)); } }); if check_clicked { self.trigger_update_check(); } // Show active update info / download card let mut trigger_install_url = None; let mut open_gitea_url = None; if let Some(info) = &self.update_info { if info.has_update { ui.add_space(8.0); Frame::none() .fill(if theme.is_dark { Color32::from_rgb(17, 36, 26) } else { Color32::from_rgb(236, 253, 245) }) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(34, 197, 94))) .rounding(Rounding::same(8.0)) .inner_margin(Margin::same(12.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("✨ New Version Available:").strong().color(Color32::from_rgb(34, 197, 94))); ui.label(RichText::new(format!("v{}", info.latest_version)).strong().size(14.0).color(theme.text_primary)); ui.label(RichText::new(format!("(Current: v{})", info.current_version)).size(11.0).color(theme.text_muted)); }); if !info.release_notes.is_empty() { ui.add_space(4.0); ui.label(RichText::new(&info.release_notes).size(11.5).color(theme.text_secondary)); } ui.add_space(8.0); if self.is_updating { if let Some(prog) = &self.update_progress { match prog { UpdateProgress::Downloading { percent, downloaded, total } => { let pct_100 = (percent * 100.0) as u32; ui.label( RichText::new(format!("Downloading update: {} / {} ({}%)", human_bytes(*downloaded as f64), human_bytes(*total as f64), pct_100)) .size(11.5) .color(theme.text_primary) ); ui.add(ProgressBar::new(*percent).animate(true)); } UpdateProgress::Verifying => { ui.label(RichText::new("🛡️ Verifying downloaded binary...").color(Color32::from_rgb(56, 189, 248))); } UpdateProgress::Installing => { ui.label(RichText::new("⚡ Installing update and restarting ZeroSend...").color(Color32::from_rgb(34, 197, 94)).strong()); } UpdateProgress::Completed => { ui.label(RichText::new("🎉 Update completed! Relaunching...").color(Color32::from_rgb(34, 197, 94)).strong()); } UpdateProgress::Error(err) => { ui.label(RichText::new(format!("❌ Update failed: {}", err)).color(Color32::from_rgb(239, 68, 68))); } } } } else { ui.horizontal(|ui| { if let Some(dl_url) = &info.download_url { let install_btn = Button::new( RichText::new("🚀 Download & Install Update Now") .strong() .color(Color32::WHITE) ) .fill(Color32::from_rgb(34, 197, 94)) .rounding(Rounding::same(6.0)); if ui.add(install_btn).clicked() { trigger_install_url = Some(dl_url.clone()); } } let gitea_btn = Button::new( RichText::new("🌐 View on Gitea") .color(theme.text_primary) ) .fill(theme.bg_card_secondary) .rounding(Rounding::same(6.0)); if ui.add(gitea_btn).clicked() { open_gitea_url = Some(info.gitea_url.clone()); } }); } }); } } if let Some(dl) = trigger_install_url { self.trigger_install_update(dl); } if let Some(g_url) = open_gitea_url { let _ = std::process::Command::new("explorer").arg(&g_url).spawn(); } }); ui.add_space(14.0); // Collapsible Credits & About Card egui::CollapsingHeader::new(RichText::new("👥 About & Credits").strong().size(14.0).color(theme.text_primary)) .default_open(false) .show(ui, |ui| { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(16.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("⚡ ZeroSend").size(16.0).strong().color(theme.text_accent)); ui.label(RichText::new("v2.4.1").size(13.0).color(Color32::from_rgb(34, 197, 94)).strong()); }); ui.add_space(4.0); ui.label(RichText::new("Fast, peer-to-peer file & text sharing optimized for ZeroTier, Tailscale, Radmin VPN & LAN.").color(theme.text_secondary)); ui.add_space(8.0); ui.label(RichText::new("🛠 Developed by:").strong().color(theme.text_primary)); ui.label(RichText::new(" • RarDog (Idea, Requirements & Direction)").color(theme.text_secondary)); ui.label(RichText::new(" • Antigravity (Google DeepMind AI Coding Assistant)").color(theme.text_secondary)); ui.add_space(6.0); ui.label(RichText::new("⚙ Technology Stack:").strong().color(theme.text_primary)); ui.label(RichText::new(" • Rust 1.98, Tokio Async Runtime, Axum HTTP Streaming").color(theme.text_secondary)); ui.label(RichText::new(" • HTTP 206 Partial Content Media Streaming & Real-time Seeking").color(theme.text_secondary)); ui.label(RichText::new(" • Full Portable Exe-Folder Configuration & Data Storage").color(theme.text_secondary)); ui.label(RichText::new(" • Embedded Lightweight Vim Code & Text Viewer").color(theme.text_secondary)); ui.label(RichText::new(" • SHA-256 On-The-Fly Cryptographic File Integrity Verification").color(theme.text_secondary)); ui.label(RichText::new(" • Mobile Web PWA with Thumbnails & Voice Notes Recorder").color(theme.text_secondary)); ui.label(RichText::new(" • 4-Stream Parallel Chunk Worker Engine (Multi-stream Range transfers)").color(theme.text_secondary)); ui.label(RichText::new(" • Resumable .zerosend_part File Checkpointing Engine").color(theme.text_secondary)); ui.label(RichText::new(" • Token-Bucket Bandwidth Rate Limiter & Priority Discovery").color(theme.text_secondary)); ui.label(RichText::new(" • Egui & Eframe Desktop GUI Engine with Dynamic Dark/Light Themes").color(theme.text_secondary)); ui.label(RichText::new(" • Zip Fast Deflate Streaming Compression Engine").color(theme.text_secondary)); ui.label(RichText::new(" • UDP Broadcast ZeroTier Mesh Discovery Engine").color(theme.text_secondary)); }); }); ui.add_space(8.0); // Collapsible Version History / Changelog Card egui::CollapsingHeader::new(RichText::new("📜 Version History (Changelog)").strong().size(14.0).color(theme.text_primary)) .default_open(false) .show(ui, |ui| { Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { // v2.4.1 ui.horizontal(|ui| { ui.label(RichText::new("v2.4.1").strong().color(Color32::from_rgb(34, 197, 94))); ui.label(RichText::new("(Current Release)").size(11.0).color(Color32::GRAY)); }); ui.label(" • 📁 Shared Folders Tab: Dedicated Shared Folders management with Read-Only / Read-Write permissions and PIN security."); ui.label(" • 🎬 HTTP 206 Streaming: Instant real-time video/audio streaming with seeking without downloading entire files."); ui.label(" • ⚡ Built-in Vim Code Viewer: Terminal-style code viewer with full Vim keybindings, search (/), and line jump."); ui.label(" • 📦 Single-Folder Portable Mode: All settings (config.json) and downloads are created & saved right in the exe folder."); ui.label(" • 🔄 Auto-Sync: Real-time discovery beacon broadcasting of shared folders when connecting to ZeroTier/VPN."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v2.4.0 ui.horizontal(|ui| { ui.label(RichText::new("v2.4.0").strong().color(Color32::from_rgb(56, 189, 248))); }); ui.label(" • 🛡️ SHA-256 Checksum on the Fly: File integrity calculation & verification during streaming with green verification badges."); ui.label(" • 📸 Mobile Media Previews: Thumbnail gallery previews for photos with instant remove buttons before uploading."); ui.label(" • 🎙️ Mobile Voice Notes: Built-in voice recorder with live audio wave pulse animation and one-tap delivery."); ui.label(" • 📲 PWA Support: Web App Manifest & Add to Home Screen standalone install capabilities for mobile browsers."); ui.label(" • 🎨 Auto Theme: Dark/Light theme mode with Windows Registry theme sync & contrast fixes."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v2.3.2 ui.horizontal(|ui| { ui.label(RichText::new("v2.3.2").strong().color(Color32::from_rgb(56, 189, 248))); }); ui.label(" • 🚀 Start with Windows: Option to run ZeroSend on Windows startup minimized to tray."); ui.label(" • 🎯 Drag & Drop Quick Send: Drag files directly onto any friend's card in Nearby tab."); ui.label(" • Dual Web Links with dedicated QR codes (ZeroTier/VPN mesh + Local Wi-Fi router)."); ui.label(" • ZeroTier Web Confirmation popup on PC (Auto-accept on Local LAN)."); ui.label(" • Auto-switch to Transfers view on both PC and mobile when transfers begin."); ui.label(" • Exponential Moving Average (EMA) smoothed speed calculations (no spikes)."); ui.label(" • 4-Stream Parallel Chunk Transfers with Adaptive Chunk Sizing."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v2.3.1 ui.horizontal(|ui| { ui.label(RichText::new("v2.3.1").strong().color(Color32::from_rgb(34, 197, 94))); }); ui.label(" • Redesigned Mobile-First Web Dropzone (camera/photo quick shortcuts, mobile UX)."); ui.label(" • Dual Web Links with dedicated QR codes (ZeroTier/VPN mesh + Local Wi-Fi router)."); ui.label(" • ZeroTier Web Confirmation popup on PC (Auto-accept on Local LAN)."); ui.label(" • Auto-switch to Transfers view on both PC and mobile when transfers begin."); ui.label(" • Exponential Moving Average (EMA) smoothed speed calculations (no spikes)."); ui.label(" • Live folder compression status (shows 'Compressing to ZIP' in Transfers)."); ui.label(" • Smart Dropzone with colored category badges (Video, Audio, Docs, Binaries)."); ui.label(" • 4-Stream Parallel Chunk Transfers with Adaptive Chunk Sizing."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v2.0.0 ui.label(RichText::new("v2.0.0").strong().color(Color32::from_rgb(56, 189, 248))); ui.label(" • Redesigned modern Glassmorphism Web & Mobile dropzone dashboard."); ui.label(" • Web Quick Text sender & mobile-responsive drag-and-drop."); ui.label(" • Fixed global Ctrl+V screenshot pasting with retry mechanism."); ui.label(" • Real-time upload streaming progress & speed meters for sender."); ui.label(" • Permanent purge for 'Clear finished' transfers."); ui.label(" • Peer deduplication & persistent device UUIDs."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v1.2.1 ui.label(RichText::new("v1.2.1").strong().color(Color32::from_rgb(56, 189, 248))); ui.label(" • Real-time stream upload progress & live speed on sender side."); ui.label(" • Working 'Clear finished' button in Transfers tab."); ui.label(" • Persistent peer UUIDs & automatic peer deduplication (no duplicate ghosts)."); ui.label(" • Official Windows PE Resource & Manifest embedding (zero false positives)."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v1.2.0 ui.label(RichText::new("v1.2.0").strong().color(Color32::from_rgb(56, 189, 248))); ui.label(" • Crystal-clear transfer visualization, file icons & live speed meters."); ui.label(" • Fixed incoming transfer confirmation popup & robust message delivery."); ui.label(" • Fixed System Tray background listener and instant exit."); ui.label(" • On-the-fly fast ZIP compression for directories (10x faster)."); ui.label(" • Automatic ZIP extraction in receiver download directory."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v1.1.0 ui.label(RichText::new("v1.1.0").strong().color(Color32::from_rgb(56, 189, 248))); ui.label(" • System Tray integration with background daemon and tray menu."); ui.label(" • Window size & layout auto-save (memory)."); ui.label(" • Collapsible About, Credits & Version History section."); ui.label(" • Polish and performance improvements."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v1.0.0 ui.label(RichText::new("v1.0.0").strong().color(Color32::GRAY)); ui.label(" • Native Desktop GUI (no terminal console window)."); ui.label(" • Multi-recipient parallel file & text broadcasting."); ui.label(" • Smart Clipboard with content auto-detection (IP, URL, Code, JSON)."); ui.label(" • QR Code generator for mobile file dropzone."); ui.label(" • 4-digit PIN security protection for incoming transfers."); ui.label(" • Global Drag-and-Drop files directly into the window."); ui.label(" • Ctrl+V screenshot/clipboard image pasting."); ui.label(" • 'Open file' and 'Show in folder' buttons in Transfers."); ui.label(" • Custom peer nicknames (aliases) saved in config."); ui.label(" • Windows Explorer context menu ('Send with ZeroSend')."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v0.1.0 ui.label(RichText::new("v0.1.0").strong().color(Color32::GRAY)); ui.label(" • Initial ZeroTier & VPN network interface scanner."); ui.label(" • UDP broadcast peer discovery engine."); ui.label(" • High-speed HTTP streaming transfer engine."); ui.label(" • Terminal UI (TUI) and headless CLI daemon."); }); }); ui.add_space(14.0); }); } fn render_modals(&mut self, ctx: &egui::Context, theme: ThemeColors) { // Direct IP Modal if self.show_direct_ip_modal { egui::Window::new("🔍 Direct Connect (IP)") .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) .show(ctx, |ui| { ui.label(RichText::new("Enter ZeroTier / Tailscale / Radmin IP of peer:").color(theme.text_primary)); ui.add_space(4.0); ui.text_edit_singleline(&mut self.direct_ip_input); if !self.direct_ip_status.is_empty() { ui.add_space(4.0); ui.label(RichText::new(&self.direct_ip_status).size(11.0).color(Color32::YELLOW)); } ui.add_space(12.0); ui.horizontal(|ui| { if ui.add(Button::new(RichText::new("Connect & Ping").strong().color(Color32::WHITE)).fill(Color32::from_rgb(37, 99, 235))).clicked() { let ip = self.direct_ip_input.trim().to_string(); if !ip.is_empty() { let client = self.client.clone(); let peers_store = self.discovery_peers.clone(); let log_tx = self.log_tx.clone(); self.direct_ip_status = "Connecting...".to_string(); tokio::spawn(async move { match client.ping_peer(&ip, 53318).await { Ok(beacon) => { let _ = log_tx.send(format!("✨ Added direct node: {} ({})", beacon.name, beacon.ip)); let mut peers = peers_store.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: chrono::Utc::now(), is_online: true, ping_ms: None, shared_folders: beacon.shared_folders, }, ); } Err(e) => { let _ = log_tx.send(format!("❌ Direct connect error: {}", e)); } } }); self.show_direct_ip_modal = false; } } if ui.button("Cancel").clicked() { self.show_direct_ip_modal = false; } }); }); } // Rename Peer Modal if let Some((peer_id, peer_name, mut current_alias)) = self.renaming_peer.clone() { egui::Window::new("✏️ Rename Friend / Set Alias") .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) .show(ctx, |ui| { ui.label(RichText::new(format!("Set custom nickname for {}", peer_name)).color(theme.text_primary)); ui.add_space(6.0); ui.text_edit_singleline(&mut current_alias); ui.add_space(10.0); ui.horizontal(|ui| { if ui.add(Button::new(RichText::new("Save Alias").strong().color(Color32::WHITE)).fill(Color32::from_rgb(37, 99, 235))).clicked() { if current_alias.trim().is_empty() { self.config.peer_aliases.remove(&peer_id); } else { self.config.peer_aliases.insert(peer_id.clone(), current_alias.trim().to_string()); } self.persist_config(); self.renaming_peer = None; } if ui.button("Cancel").clicked() { self.renaming_peer = None; } }); }); } // Received Quick Text Modal if let Some(msg) = self.received_text_modal.clone() { let (r, g, b) = msg.content_type.badge_color_rgb(); egui::Window::new("📋 Received Quick Text") .collapsible(false) .resizable(true) .min_width(400.0) .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) .show(ctx, |ui| { ui.horizontal(|ui| { ui.label(RichText::new(format!("From: {}", msg.sender_name)).strong().size(14.0).color(theme.text_primary)); ui.label( RichText::new(format!("[{}]", msg.content_type.label())) .strong() .color(Color32::from_rgb(r, g, b)), ); }); ui.add_space(8.0); Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(6.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(12.0)) .show(ui, |ui| { ScrollArea::vertical().max_height(200.0).show(ui, |ui| { ui.label( RichText::new(&msg.content) .monospace() .color(theme.text_primary), ); }); }); ui.add_space(12.0); ui.horizontal(|ui| { let content_copy = msg.content.clone(); if ui.add(Button::new(RichText::new("📋 Copy to Clipboard").strong().color(Color32::WHITE)).fill(Color32::from_rgb(37, 99, 235))).clicked() { ui.output_mut(|o| o.copied_text = content_copy.clone()); } if msg.content_type == ContentType::WebUrl { if ui.button("🌐 Open Link").clicked() { ui.ctx().open_url(egui::OpenUrl::same_tab(&content_copy)); } } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button("Close").clicked() { self.received_text_modal = None; } }); }); }); } // PROMINENT INCOMING FILE TRANSFER CONFIRMATION MODAL if let Some(req) = self.pending_request.clone() { egui::Window::new("📥 INCOMING FILE TRANSFER REQUEST") .collapsible(false) .resizable(false) .min_width(450.0) .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) .show(ctx, |ui| { ui.add_space(4.0); ui.horizontal(|ui| { ui.label(RichText::new("📥").size(32.0)); ui.vertical(|ui| { ui.label( RichText::new(format!("Incoming files from: {}", req.sender_name)) .size(17.0) .strong() .color(theme.text_accent), ); ui.label( RichText::new(format!( "Total: {} file(s) • Total Size: {}", req.files.len(), human_bytes(req.total_size as f64) )) .size(12.0) .color(theme.text_secondary), ); }); }); ui.add_space(10.0); ui.label(RichText::new("Items to be received:").strong().size(13.0).color(theme.text_primary)); ui.add_space(4.0); Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(10.0)) .show(ui, |ui| { ScrollArea::vertical().max_height(140.0).show(ui, |ui| { for f in req.files.iter().take(8) { let item_icon = get_file_icon(&f.name); ui.horizontal(|ui| { ui.label(item_icon); ui.label(RichText::new(&f.name).color(theme.text_primary).strong()); ui.label(RichText::new(format!("({})", human_bytes(f.size as f64))).size(11.0).color(theme.text_muted)); }); ui.separator(); } if req.files.len() > 8 { ui.label(RichText::new(format!(" ... and {} more files", req.files.len() - 8)).color(theme.text_muted)); } }); }); ui.add_space(8.0); ui.horizontal(|ui| { ui.label(RichText::new("Save destination:").size(11.0).color(theme.text_muted)); ui.label(RichText::new(format!("{:?}", self.download_dir)).size(11.0).color(theme.text_secondary)); }); ui.add_space(16.0); ui.horizontal(|ui| { let accept_btn = Button::new( RichText::new("✅ ACCEPT & DOWNLOAD") .size(14.0) .strong() .color(Color32::WHITE), ) .fill(Color32::from_rgb(34, 197, 94)) .min_size(Vec2::new(190.0, 42.0)) .rounding(Rounding::same(8.0)); if ui.add(accept_btn).clicked() { let server_clone = self.server.clone(); let session_id = req.session_id.clone(); let initial_progress = TransferProgress { session_id: session_id.clone(), peer_name: req.sender_name.clone(), is_incoming: true, current_file_index: 0, total_files: req.files.len(), current_file_name: req.files.first().map(|f| f.name.clone()).unwrap_or_default(), bytes_transferred: 0, total_bytes: req.total_size, speed_bps: 0, state: TransferState::InProgress, checksum_verified: false, }; if let Some(pos) = self.transfers.iter().position(|x| x.session_id == session_id) { self.transfers[pos] = initial_progress; } else { self.transfers.insert(0, initial_progress); } tokio::spawn(async move { server_clone.accept_transfer(&session_id).await; }); self.pending_request = None; self.current_tab = Tab::Transfers; } let reject_btn = Button::new( RichText::new("❌ DECLINE") .size(14.0) .strong() .color(Color32::WHITE), ) .fill(Color32::from_rgb(239, 68, 68)) .min_size(Vec2::new(130.0, 42.0)) .rounding(Rounding::same(8.0)); if ui.add(reject_btn).clicked() { let server_clone = self.server.clone(); let session_id = req.session_id.clone(); tokio::spawn(async move { server_clone.reject_transfer(&session_id).await; }); self.pending_request = None; } }); ui.add_space(4.0); }); } self.vim_viewer.render(ctx, theme); } pub fn trigger_update_check(&mut self) { self.is_checking_update = true; self.update_check_status_msg = Some("Connecting to Gitea and checking for updates...".to_string()); let tx = self.update_tx.clone(); let api_url = self.config.gitea_api_url.clone(); tokio::spawn(async move { let res = check_gitea_update(api_url.as_deref()).await; let _ = tx.send(res); }); } pub fn trigger_install_update(&mut self, download_url: String) { self.is_updating = true; self.update_progress = Some(UpdateProgress::Downloading { percent: 0.0, downloaded: 0, total: 0 }); let prog_tx = self.update_prog_tx.clone(); tokio::spawn(async move { perform_update_download(download_url, prog_tx).await; }); } } impl eframe::App for ZeroSendApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.poll_events(ctx); if ctx.input(|i| i.viewport().close_requested()) { if self.close_to_tray { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); } } let cur_size = ctx.screen_rect().size(); if cur_size.x >= 700.0 && cur_size.y >= 450.0 { if (self.config.window_width - cur_size.x).abs() > 10.0 || (self.config.window_height - cur_size.y).abs() > 10.0 { self.config.window_width = cur_size.x; self.config.window_height = cur_size.y; if self.last_window_save.elapsed() > Duration::from_secs(2) { self.last_window_save = Instant::now(); let _ = self.config.save(); } } } let dropped_files = ctx.input(|i| i.raw.dropped_files.clone()); if !dropped_files.is_empty() { let mut added_count = 0; for file in dropped_files { if let Some(path) = file.path { if !self.queued_files.contains(&path) { self.queued_files.push(path); added_count += 1; } } } if added_count > 0 { self.current_tab = Tab::Send; self.send_mode = SendMode::Files; self.logs.push(format!("📥 Added {} dropped item(s) to send queue", added_count)); } } let is_paste = ctx.input(|i| (i.modifiers.ctrl || i.modifiers.command) && i.key_pressed(egui::Key::V)); if is_paste && !ctx.wants_keyboard_input() { self.try_paste_clipboard_image(); } let is_dark = match self.theme_mode { ThemeMode::Auto => is_system_dark_mode(), ThemeMode::Dark => true, ThemeMode::Light => false, }; let theme = ThemeColors::new(is_dark); let mut style = (*ctx.style()).clone(); style.visuals.dark_mode = is_dark; style.visuals.panel_fill = theme.bg_app; style.visuals.window_fill = theme.bg_card; style.visuals.override_text_color = Some(theme.text_primary); style.visuals.widgets.noninteractive.bg_fill = theme.bg_card; style.visuals.widgets.inactive.bg_fill = theme.bg_card_secondary; style.visuals.widgets.inactive.rounding = Rounding::same(6.0); style.visuals.widgets.hovered.bg_fill = theme.bg_hover; style.visuals.widgets.hovered.rounding = Rounding::same(6.0); style.visuals.widgets.active.bg_fill = Color32::from_rgb(37, 99, 235); style.visuals.widgets.active.rounding = Rounding::same(6.0); ctx.set_style(style); SidePanel::left("sidebar_panel") .resizable(false) .exact_width(195.0) .frame( Frame::none() .fill(theme.bg_sidebar) .stroke(theme.stroke_subtle) .inner_margin(Margin::same(12.0)), ) .show(ctx, |ui| { self.render_sidebar(ctx, ui, theme); }); CentralPanel::default() .frame( Frame::none() .fill(theme.bg_app) .inner_margin(Margin::same(20.0)), ) .show(ctx, |ui| { // Top Update Banner if update available on Gitea if self.show_update_banner { if let Some(info) = &self.update_info { if info.has_update { let mut dismiss = false; let mut trigger_install = None; let mut open_gitea = false; let gitea_url = info.gitea_url.clone(); let latest_ver = info.latest_version.clone(); let download_url = info.download_url.clone(); Frame::none() .fill(if theme.is_dark { Color32::from_rgb(17, 43, 31) } else { Color32::from_rgb(220, 252, 231) }) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(34, 197, 94))) .rounding(Rounding::same(8.0)) .inner_margin(Margin::symmetric(14.0, 10.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("✨").size(16.0)); ui.label( RichText::new(format!("Update Available: ZeroSend v{} is out on Gitea!", latest_ver)) .strong() .color(if theme.is_dark { Color32::WHITE } else { Color32::from_rgb(20, 83, 45) }) ); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button(RichText::new("✕").size(12.0).color(theme.text_muted)).clicked() { dismiss = true; } if ui.button(RichText::new("🌐 View on Gitea").size(11.5)).clicked() { open_gitea = true; } if let Some(dl) = download_url { if !self.is_updating { let update_btn = Button::new( RichText::new("🚀 Update Now").strong().size(12.0).color(Color32::WHITE) ) .fill(Color32::from_rgb(34, 197, 94)) .rounding(Rounding::same(6.0)); if ui.add(update_btn).clicked() { trigger_install = Some(dl); } } else { ui.label(RichText::new("⏳ Updating...").color(Color32::from_rgb(34, 197, 94)).strong()); } } }); }); }); if dismiss { self.show_update_banner = false; } if open_gitea { let _ = std::process::Command::new("explorer").arg(&gitea_url).spawn(); } if let Some(dl) = trigger_install { self.trigger_install_update(dl); } ui.add_space(10.0); } } } match self.current_tab { Tab::Nearby => self.render_nearby_tab(ui, theme), Tab::Send => self.render_send_tab(ui, theme), Tab::Receive => self.render_receive_tab(ui, theme), Tab::Shared => self.render_shared_tab(ui, theme), Tab::Transfers => self.render_transfers_tab(ui, theme), Tab::Settings => self.render_settings_tab(ui, theme), } }); self.render_modals(ctx, theme); if self.transfers.iter().any(|t| t.state == TransferState::InProgress) || self.is_updating { ctx.request_repaint_after(Duration::from_millis(50)); } else { ctx.request_repaint_after(Duration::from_millis(200)); } } }