3669 lines
179 KiB
Rust
3669 lines
179 KiB
Rust
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));
|
||
}
|
||
}
|
||
}
|