Files
ZeroSend/src/ui/gui.rs
T

3669 lines
179 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<RwLock<HashMap<String, PeerInfo>>>,
server: TransferServer,
client: Arc<TransferClient>,
my_info: crate::network::DiscoveryBeacon,
current_iface: InterfaceInfo,
config: AppConfig,
tray_manager: Option<SystemTrayManager>,
// Navigation & State
current_tab: Tab,
send_mode: SendMode,
peers: Vec<PeerInfo>,
selected_peer_ids: HashSet<String>,
queued_files: Vec<PathBuf>,
transfers: Vec<TransferProgress>,
logs: Vec<String>,
// Quick Text Input
text_input: String,
analyzed_preview: AnalyzedText,
// Optional PIN for sender
recipient_pin: String,
// Channels from Server
incoming_request_rx: mpsc::UnboundedReceiver<PrepareTransferRequest>,
incoming_text_rx: mpsc::UnboundedReceiver<TextMessage>,
// Modals
pending_request: Option<PrepareTransferRequest>,
received_text_modal: Option<TextMessage>,
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<InterfaceInfo>,
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<SharedFolderConfig>,
selected_share_subtab: SharedSubTab,
selected_peer_id_for_share: Option<String>,
selected_folder_id_for_share: Option<String>,
current_share_subpath: String,
current_share_tree: Option<SharedFolderTreeResponse>,
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<UpdateInfo>,
pub is_checking_update: bool,
pub update_check_status_msg: Option<String>,
pub update_progress: Option<UpdateProgress>,
pub is_updating: bool,
pub show_update_banner: bool,
pub check_updates_on_startup: bool,
// Internal UI Channels
log_rx: mpsc::UnboundedReceiver<String>,
log_tx: mpsc::UnboundedSender<String>,
transfer_rx: mpsc::UnboundedReceiver<TransferProgress>,
transfer_tx: mpsc::UnboundedSender<TransferProgress>,
share_tree_rx: mpsc::UnboundedReceiver<Option<SharedFolderTreeResponse>>,
share_tree_tx: mpsc::UnboundedSender<Option<SharedFolderTreeResponse>>,
share_text_rx: mpsc::UnboundedReceiver<(String, String, String, String)>,
share_text_tx: mpsc::UnboundedSender<(String, String, String, String)>,
update_rx: mpsc::UnboundedReceiver<Result<UpdateInfo, String>>,
update_tx: mpsc::UnboundedSender<Result<UpdateInfo, String>>,
update_prog_rx: mpsc::UnboundedReceiver<UpdateProgress>,
update_prog_tx: mpsc::UnboundedSender<UpdateProgress>,
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<PathBuf>,
incoming_request_rx: mpsc::UnboundedReceiver<PrepareTransferRequest>,
incoming_text_rx: mpsc::UnboundedReceiver<TextMessage>,
) -> 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<PeerInfo> = 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<PeerInfo>, files: Vec<PathBuf>) {
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<String> = None;
let mut rename_target: Option<(String, String, String)> = None;
let mut send_to_peer: Option<String> = None;
let mut quick_drop_target: Option<(PeerInfo, Vec<PathBuf>)> = 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<PathBuf> = 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<PeerInfo> = 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<PeerInfo> = 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<TextureHandle> {
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<PeerInfo> = 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));
}
}
}