Files
ZeroSend/src/ui/gui.rs
T

5134 lines
274 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::config::{AppConfig, SharedFolderConfig, ThemeMode};
use crate::network::{get_available_interfaces, DiscoveryService, InterfaceInfo, NetworkType, PeerInfo};
use crate::registry::{
is_autostart_registered, is_system_dark_mode,
register_autostart, unregister_autostart,
};
use crate::transfer::{
analyze_text, AnalyzedText, ContentType, PrepareTransferRequest, SharedFolderTreeResponse,
TextMessage, TransferClient, TransferProgress, TransferServer, TransferState,
};
use crate::ui::tray::{SystemTrayManager, TrayAction};
use crate::ui::vim_viewer::VimViewerModal;
use crate::updater::{
check_gitea_update, perform_update_download, UpdateInfo, UpdateProgress,
GITEA_DEFAULT_REPO_URL,
};
use eframe::egui::{
self, Align, Button, CentralPanel, Color32, ColorImage, Frame, Layout, Margin, ProgressBar,
RichText, Rounding, ScrollArea, SidePanel, Stroke, TextureHandle, TextureOptions, Ui, Vec2,
};
use human_bytes::human_bytes;
use qrcode::{Color as QrColor, QrCode};
use rfd::FileDialog;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, RwLock};
#[derive(PartialEq, Eq, Clone, Copy)]
pub enum Tab {
Nearby,
Send,
Receive,
Shared,
Transfers,
Settings,
}
impl Tab {
pub fn index(&self) -> usize {
match self {
Tab::Nearby => 0,
Tab::Send => 1,
Tab::Receive => 2,
Tab::Shared => 3,
Tab::Transfers => 4,
Tab::Settings => 5,
}
}
}
#[derive(PartialEq, Eq, Clone, Copy)]
pub enum SharedSubTab {
NetworkShares,
MyShares,
AutoSync,
}
#[derive(Clone, Debug)]
pub struct AudioTrackInfo {
pub name: String,
pub url: String,
pub peer_name: String,
#[allow(dead_code)]
pub is_playing: bool,
}
#[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: Arc<DiscoveryService>,
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,
pub selected_share_items: HashSet<String>,
// Media & Audio & Watch Party
pub active_audio: Option<AudioTrackInfo>,
#[allow(dead_code)]
pub audio_volume: f32,
pub watch_party_session: Option<crate::transfer::WatchPartySyncEvent>,
pub is_in_watch_party: bool,
// Remote Grep State
pub show_grep_modal: bool,
pub grep_query: String,
pub grep_results: Vec<crate::transfer::GrepMatch>,
pub is_grepping: bool,
pub grep_status_msg: Option<String>,
// Live Folder Sync State
pub folder_sync_pairs: Vec<crate::transfer::FolderSyncPairConfig>,
pub sync_log_messages: Vec<String>,
pub new_sync_local_path: String,
pub new_sync_name: String,
pub new_sync_peer_id: String,
pub new_sync_folder_id: String,
// WebDAV Windows Drive Mount State
#[allow(dead_code)]
pub webdav_mounted: bool,
#[allow(dead_code)]
pub webdav_drive_letter: char,
#[allow(dead_code)]
pub webdav_status_msg: Option<String>,
// 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, Option<String>, Option<String>, Option<String>, bool)>,
share_text_tx: mpsc::UnboundedSender<(String, String, String, String, Option<String>, Option<String>, Option<String>, bool)>,
sync_status_rx: mpsc::UnboundedReceiver<crate::transfer::SyncStatusUpdate>,
#[allow(dead_code)]
sync_status_tx: mpsc::UnboundedSender<crate::transfer::SyncStatusUpdate>,
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,
last_tab_change: 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: Arc<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 (sync_status_tx, sync_status_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 client = Arc::new(TransferClient::new());
// Initialize and spawn Folder Auto-Sync background worker
let sync_engine = Arc::new(crate::transfer::FolderSyncEngine::new(
client.clone(),
config.folder_sync_pairs.clone(),
sync_status_tx.clone(),
));
let peers_map_ref = discovery.peers();
tokio::spawn(async move {
sync_engine.start_background_sync(peers_map_ref).await;
});
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 folder_sync_pairs = config.folder_sync_pairs.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();
let initial_transfers = crate::config::load_transfers_history();
Self {
discovery: discovery.clone(),
discovery_peers: discovery.peers(),
server,
client,
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: initial_transfers,
logs: vec!["⚡ ZeroSend v2.5.5 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(),
selected_share_items: HashSet::new(),
active_audio: None,
audio_volume: 1.0,
watch_party_session: None,
is_in_watch_party: false,
show_grep_modal: false,
grep_query: String::new(),
grep_results: Vec::new(),
is_grepping: false,
grep_status_msg: None,
folder_sync_pairs,
sync_log_messages: Vec::new(),
new_sync_local_path: String::new(),
new_sync_name: String::new(),
new_sync_peer_id: String::new(),
new_sync_folder_id: String::new(),
webdav_mounted: false,
webdav_drive_letter: 'Z',
webdav_status_msg: None,
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,
sync_status_rx,
sync_status_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(),
last_tab_change: 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.switch_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
let mut transfers_changed = false;
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.switch_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();
}
}
transfers_changed = true;
}
// 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.switch_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();
}
}
transfers_changed = true;
}
}
if transfers_changed {
crate::config::save_transfers_history(&self.transfers);
}
// 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, peer_url, folder_id, pin, is_writable)) = self.share_text_rx.try_recv() {
self.vim_viewer.open_with_meta(file_name, rel_path, peer_name, content, peer_url, folder_id, pin, is_writable);
}
// 8b. Check if Vim Editor requested saving back to remote peer
if let Some((peer_url, folder_id, rel_path, content, pin)) = self.vim_viewer.pending_save_request.take() {
let client = self.client.clone();
let log_tx = self.log_tx.clone();
tokio::spawn(async move {
match client.save_shared_file(&peer_url, &folder_id, &rel_path, &content, pin.as_deref()).await {
Ok(resp) => {
let _ = log_tx.send(format!("💾 Saved '{}' to remote share ({} bytes)", rel_path, resp.bytes_written));
}
Err(e) => {
let _ = log_tx.send(format!("❌ Failed to save '{}': {}", rel_path, e));
}
}
});
}
// 8c. Poll folder sync status messages
while let Ok(update) = self.sync_status_rx.try_recv() {
let msg = format!("[{}] {}", update.timestamp, update.message);
self.sync_log_messages.push(msg.clone());
if self.sync_log_messages.len() > 100 {
self.sync_log_messages.remove(0);
}
if !update.is_error {
self.logs.push(format!("🔄 AutoSync: {}", update.message));
}
}
// 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);
}
}
}
let peer_url = format!("http://{}:{}", ip, port);
if let Ok(shares) = client.fetch_shared_folders(&peer_url, None).await {
let summaries: Vec<crate::network::SharedFolderSummary> = shares.into_iter().map(|s| crate::network::SharedFolderSummary {
id: s.id,
name: s.name,
read_only: s.read_only,
requires_pin: s.requires_pin,
}).collect();
if let Ok(mut guard) = disc_peers.try_write() {
if let Some(peer_entry) = guard.get_mut(&peer_id) {
peer_entry.shared_folders = summaries;
}
}
}
}
});
}
}
}
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();
let discovery_clone = self.discovery.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.clone()).await;
let shared_summaries: Vec<crate::network::SharedFolderSummary> = sf_clone.into_iter().map(|s| crate::network::SharedFolderSummary {
id: s.id,
name: s.name,
read_only: s.read_only,
requires_pin: s.pin.is_some(),
}).collect();
discovery_clone.update_shared_folders(shared_summaries).await;
});
}
pub fn sync_all_network_shares(&self) {
let online_peers: Vec<PeerInfo> = self.peers.iter().filter(|p| p.is_online).cloned().collect();
if online_peers.is_empty() {
return;
}
let client = self.client.clone();
let disc_peers = self.discovery_peers.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 {
for peer in online_peers {
let peer_url = peer.transfer_url();
if let Ok(shares) = client.fetch_shared_folders(&peer_url, pin.as_deref()).await {
let summaries: Vec<crate::network::SharedFolderSummary> = shares.into_iter().map(|s| crate::network::SharedFolderSummary {
id: s.id,
name: s.name,
read_only: s.read_only,
requires_pin: s.requires_pin,
}).collect();
if let Ok(mut guard) = disc_peers.try_write() {
if let Some(p) = guard.get_mut(&peer.peer_id) {
p.shared_folders = summaries;
}
}
}
}
});
}
pub fn switch_tab(&mut self, new_tab: Tab) {
if self.current_tab != new_tab {
self.current_tab = new_tab;
self.last_tab_change = Instant::now();
}
}
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.switch_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.switch_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(26.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("P2P Mesh & LAN Sharing").size(10.5).color(theme.text_muted));
});
});
ui.add_space(12.0);
ui.separator();
ui.add_space(8.0);
// Network Status Badge Card (ZeroTier / Tailscale / LAN / VPN)
let (net_icon, net_badge_title, net_accent_color) = match self.current_iface.net_type {
NetworkType::ZeroTier => ("🪐", "ZeroTier Network", Color32::from_rgb(251, 146, 60)),
NetworkType::Tailscale => ("🪢", "Tailscale VPN", Color32::from_rgb(56, 189, 248)),
NetworkType::LAN => ("🏠", "Локальная сеть (LAN)", Color32::from_rgb(34, 197, 94)),
NetworkType::Radmin => ("🛡️", "Radmin VPN", Color32::from_rgb(168, 85, 247)),
_ => ("🌐", self.current_iface.net_type.as_str(), Color32::from_rgb(59, 130, 246)),
};
Frame::none()
.fill(if theme.is_dark { Color32::from_rgb(15, 23, 42) } else { Color32::from_rgb(241, 245, 249) })
.rounding(Rounding::same(8.0))
.stroke(Stroke::new(1.0_f32, net_accent_color.linear_multiply(0.5)))
.inner_margin(Margin::symmetric(10.0, 8.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(net_icon).size(20.0));
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new(net_badge_title)
.strong()
.size(11.5)
.color(theme.text_primary),
);
ui.label(RichText::new("●").color(Color32::from_rgb(34, 197, 94)).size(9.0));
});
ui.horizontal(|ui| {
let ip_str = format!("{}:{}", self.current_iface.ip, self.my_info.transfer_port);
ui.label(
RichText::new(&ip_str)
.monospace()
.size(10.0)
.color(theme.text_muted),
);
if ui.small_button("📋").on_hover_text("Скопировать IP:Port в буфер обмена").clicked() {
if let Ok(mut clipboard) = arboard::Clipboard::new() {
let _ = clipboard.set_text(&ip_str);
self.logs.push(format!("📋 IP скопирован: {}", ip_str));
}
}
});
});
});
});
ui.add_space(14.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, "📡 Устройства в сети", self.peers.len(), false),
(Tab::Send, "📤 Отправить файлы", self.queued_files.len(), false),
(Tab::Receive, "📥 Прием / Web-ссылка", 0, false),
(Tab::Shared, "📁 Общие папки", total_shares, false),
(Tab::Transfers, "📜 Передачи и файлы", active_count, active_count > 0),
(Tab::Settings, "⚙ Настройки и инфо", 0, false),
];
let anim_target = self.current_tab.index() as f32;
let animated_tab_idx = ui.ctx().animate_value_with_time(egui::Id::new("sidebar_active_tab"), anim_target, 0.20);
if (animated_tab_idx - anim_target).abs() > 0.001 {
ui.ctx().request_repaint();
}
for (idx, (tab, title, count, is_active_pulse)) in tabs.into_iter().enumerate() {
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);
}
}
// Smooth proximity factor (1.0 = active, 0.0 = inactive)
let proximity = (1.0 - (animated_tab_idx - (idx as f32)).abs()).clamp(0.0, 1.0);
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 proximity > 0.01 {
let r = theme.bg_card_active.r();
let g = theme.bg_card_active.g();
let b = theme.bg_card_active.b();
let a = (theme.bg_card_active.a() as f32 * proximity) as u8;
Color32::from_rgba_premultiplied(r, g, b, a)
} else {
Color32::TRANSPARENT
};
let stroke = if proximity > 0.25 {
let stroke_color = if theme.is_dark {
Color32::from_rgba_premultiplied(56, 189, 248, (255.0 * proximity) as u8)
} else {
Color32::from_rgba_premultiplied(2, 132, 199, (255.0 * proximity) as u8)
};
Stroke::new(1.0_f32, stroke_color)
} else {
Stroke::NONE
};
let btn = Button::new(RichText::new(btn_text).size(12.5).color(text_color).strong())
.fill(bg_color)
.stroke(stroke)
.min_size(Vec2::new(ui.available_width(), 34.0))
.rounding(Rounding::same(6.0));
if ui.add(btn).clicked() {
self.switch_tab(tab);
}
ui.add_space(3.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")))
.monospace()
.size(10.5)
.color(theme.text_muted),
);
});
}
fn render_nearby_tab(&mut self, ui: &mut Ui, theme: ThemeColors) {
// Hero Top Banner
Frame::none()
.fill(if theme.is_dark { Color32::from_rgb(15, 23, 42) } else { Color32::from_rgb(238, 242, 255) })
.rounding(Rounding::same(8.0))
.stroke(Stroke::new(1.0_f32, Color32::from_rgb(56, 189, 248)))
.inner_margin(Margin::symmetric(14.0, 10.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("📡").size(24.0));
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new("Устройства и друзья в сети")
.strong()
.size(14.0)
.color(theme.text_primary),
);
Frame::none()
.fill(Color32::from_rgba_premultiplied(34, 197, 94, 30))
.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("● Онлайн в сети").size(10.5).color(Color32::from_rgb(34, 197, 94)).strong());
});
});
ui.label(
RichText::new(format!("Ваше имя: {} • IP: {}:{}", self.custom_device_name, self.current_iface.ip, self.my_info.transfer_port))
.size(11.0)
.color(theme.text_muted),
);
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let direct_btn = Button::new(RichText::new("🔍 Прямое подключение (IP)").strong().color(Color32::WHITE))
.fill(Color32::from_rgb(37, 99, 235))
.rounding(Rounding::same(6.0));
if ui.add(direct_btn).clicked() {
self.show_direct_ip_modal = true;
self.direct_ip_status.clear();
}
});
});
});
ui.add_space(10.0);
if self.peers.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(42.0));
ui.add_space(8.0);
ui.label(RichText::new("Поиск устройств в сети ZeroTier / LAN...").size(16.0).strong().color(theme.text_primary));
ui.add_space(4.0);
ui.label(
RichText::new("Убедитесь, что друзья подключены к вашей сети ZeroTier/VPN или воспользуйтесь кнопкой «Прямое подключение (IP)» выше.")
.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, Color32::from_rgb(34, 197, 94)))
.inner_margin(Margin::same(10.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("🎯 Перетащите файлы на карточку любого друга для мгновенной отправки!").size(13.0).strong().color(Color32::from_rgb(34, 197, 94)));
});
});
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 open_shares_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(12.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(30.0));
ui.add_space(6.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 { "Убрать из избранного" } else { "Закрепить в избранном" }).clicked() {
toggle_fav = Some(peer.peer_id.clone());
}
if ui.small_button("✏").on_hover_text("Переименовать / задать псевдоним").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 (net_badge_icon, net_badge_color) = if peer.net_type.contains("ZeroTier") {
("🪐 ZeroTier", Color32::from_rgb(251, 146, 60))
} else if peer.net_type.contains("Tailscale") {
("🪢 Tailscale", Color32::from_rgb(56, 189, 248))
} else if peer.net_type.contains("Radmin") {
("🛡️ Radmin", Color32::from_rgb(168, 85, 247))
} else {
("🏠 LAN", Color32::from_rgb(34, 197, 94))
};
Frame::none()
.fill(theme.bg_card_secondary)
.rounding(Rounding::same(4.0))
.stroke(Stroke::new(1.0_f32, net_badge_color.linear_multiply(0.6)))
.inner_margin(Margin::symmetric(5.0, 1.5))
.show(ui, |ui| {
ui.label(
RichText::new(net_badge_icon)
.size(9.5)
.color(net_badge_color)
.strong(),
);
});
if !peer.shared_folders.is_empty() {
Frame::none()
.fill(Color32::from_rgba_premultiplied(168, 85, 247, 30))
.rounding(Rounding::same(4.0))
.stroke(Stroke::new(1.0_f32, Color32::from_rgb(168, 85, 247)))
.inner_margin(Margin::symmetric(5.0, 1.5))
.show(ui, |ui| {
ui.label(
RichText::new(format!("📁 {} папок", peer.shared_folders.len()))
.size(10.0)
.color(Color32::from_rgb(168, 85, 247))
.strong(),
);
});
}
});
ui.horizontal(|ui| {
ui.label(
RichText::new(format!("{}:{} • {}", peer.ip, peer.transfer_port, peer.os))
.monospace()
.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("📤 Отправить").strong().color(Color32::WHITE))
.fill(Color32::from_rgb(37, 99, 235))
.rounding(Rounding::same(6.0))
.min_size(Vec2::new(95.0, 32.0));
if ui.add(send_btn).clicked() {
send_to_peer = Some(peer_id.clone());
}
if !peer.shared_folders.is_empty() {
let shares_btn = Button::new(RichText::new("📁 Общие папки").color(Color32::from_rgb(168, 85, 247)))
.fill(theme.bg_card_secondary)
.rounding(Rounding::same(6.0))
.min_size(Vec2::new(105.0, 32.0));
if ui.add(shares_btn).clicked() {
open_shares_peer = Some(peer_id.clone());
}
}
});
});
})
.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!("🎯 Быстрая отправка: {} файлов для {}...", 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;
}
if let Some(pid) = open_shares_peer {
self.selected_peer_id_for_share = Some(pid);
self.selected_share_subtab = SharedSubTab::NetworkShares;
self.current_tab = Tab::Shared;
}
}
fn render_send_tab(&mut self, ui: &mut Ui, theme: ThemeColors) {
// ── Top Mode Switcher Bar ──
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(10.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::symmetric(14.0, 9.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("📤").size(20.0));
ui.label(RichText::new("Режим отправки:").strong().size(13.5).color(theme.text_primary));
let files_active = self.send_mode == SendMode::Files;
let text_active = self.send_mode == SendMode::QuickText;
let files_btn = Button::new(
RichText::new("📁 Файлы и папки")
.size(12.5)
.strong()
.color(if files_active { Color32::WHITE } else { theme.text_secondary }),
)
.fill(if files_active { Color32::from_rgb(37, 99, 235) } else { theme.bg_card_secondary })
.min_size(Vec2::new(140.0, 30.0))
.rounding(Rounding::same(6.0));
if ui.add(files_btn).clicked() {
self.send_mode = SendMode::Files;
}
let text_btn = Button::new(
RichText::new("💬 Быстрый текст / Ссылка")
.size(12.5)
.strong()
.color(if text_active { Color32::WHITE } else { theme.text_secondary }),
)
.fill(if text_active { Color32::from_rgb(37, 99, 235) } else { theme.bg_card_secondary })
.min_size(Vec2::new(180.0, 30.0))
.rounding(Rounding::same(6.0));
if ui.add(text_btn).clicked() {
self.send_mode = SendMode::QuickText;
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("🔒 PIN-защита:").size(11.5).color(theme.text_muted));
ui.add(egui::TextEdit::singleline(&mut self.recipient_pin).hint_text("PIN").password(true).desired_width(70.0));
});
});
});
});
ui.add_space(8.0);
// ── Step 1: Multi-Recipient Modern Cards ──
let total_peers = self.peers.len();
let selected_peers_count = self.selected_peer_ids.len();
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(10.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(12.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new(format!("Шаг 1: Выберите получателей ({}/{} выбрано)", selected_peers_count, total_peers))
.strong()
.size(13.0)
.color(theme.text_primary),
);
if !self.peers.is_empty() {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let all_selected = selected_peers_count == total_peers;
if ui.button(RichText::new(if all_selected { "✕ Снять выбор со всех" } else { "✓ Выбрать всех друзей" }).size(11.5).color(theme.text_accent)).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.add_space(8.0);
if self.peers.is_empty() {
Frame::none()
.fill(theme.bg_card_secondary)
.rounding(Rounding::same(8.0))
.inner_margin(Margin::same(12.0))
.show(ui, |ui| {
ui.label(RichText::new("В сети пока нет друзей онлайн. Подключитесь к ZeroTier / VPN или проверьте вкладку устройств.").color(theme.text_muted).size(12.0));
});
} else {
ui.horizontal_wrapped(|ui| {
for p in &self.peers {
let is_selected = self.selected_peer_ids.contains(&p.peer_id);
let (name_label, has_alias) = self.get_peer_display_name(p);
let os_icon = match p.os.to_lowercase().as_str() {
"windows" => "🪟",
"linux" => "🐧",
"macos" => "🍎",
_ => "💻",
};
let (net_badge_icon, net_badge_color) = if p.net_type.contains("ZeroTier") {
("🪐 ZeroTier", Color32::from_rgb(251, 146, 60))
} else if p.net_type.contains("Tailscale") {
("🪢 Tailscale", Color32::from_rgb(56, 189, 248))
} else if p.net_type.contains("Radmin") {
("🛡️ Radmin", Color32::from_rgb(168, 85, 247))
} else {
("🏠 LAN", Color32::from_rgb(34, 197, 94))
};
let stroke = if is_selected {
Stroke::new(1.5_f32, Color32::from_rgb(56, 189, 248))
} else {
theme.stroke_border
};
let card = Frame::none()
.fill(if is_selected {
if theme.is_dark { Color32::from_rgb(23, 37, 84) } else { Color32::from_rgb(219, 234, 254) }
} else {
theme.bg_card_secondary
})
.rounding(Rounding::same(8.0))
.stroke(stroke)
.inner_margin(Margin::symmetric(10.0, 6.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(if is_selected { "✅" } else { "⬜" }).size(13.0));
ui.label(RichText::new(os_icon).size(14.0));
ui.label(
RichText::new(&name_label)
.size(12.5)
.strong()
.color(if has_alias { Color32::from_rgb(250, 204, 21) } else { theme.text_primary }),
);
// Network Pill Badge
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(4.0))
.stroke(Stroke::new(1.0_f32, net_badge_color.linear_multiply(0.6)))
.inner_margin(Margin::symmetric(5.0, 1.0))
.show(ui, |ui| {
ui.label(RichText::new(net_badge_icon).size(9.5).color(net_badge_color).strong());
});
ui.label(RichText::new(p.ip.to_string()).monospace().size(10.0).color(theme.text_muted));
if let Some(ping) = p.ping_ms {
ui.label(RichText::new(format!("⚡ {}ms", ping)).size(9.5).color(Color32::from_rgb(34, 197, 94)));
}
});
});
if ui.interact(card.response.rect, card.response.id, egui::Sense::click()).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(8.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) {
// ── Step 2: Choose Files & Folders ──
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(10.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(12.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("Шаг 2: Выберите файлы для отправки").strong().size(13.0).color(theme.text_primary));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if !self.queued_files.is_empty() && ui.button(RichText::new("🗑 Очистить очередь").color(Color32::from_rgb(239, 68, 68))).clicked() {
self.queued_files.clear();
}
});
});
ui.add_space(8.0);
ui.horizontal(|ui| {
let add_files_btn = Button::new(RichText::new("📄 Добавить файлы...").strong().color(Color32::WHITE))
.fill(Color32::from_rgb(37, 99, 235))
.min_size(Vec2::new(145.0, 32.0))
.rounding(Rounding::same(6.0));
if ui.add(add_files_btn).clicked() {
if let Some(files) = FileDialog::new().pick_files() {
for f in files {
if !self.queued_files.contains(&f) {
self.queued_files.push(f);
}
}
}
}
let add_folder_btn = Button::new(RichText::new("📁 Добавить папку...").strong().color(Color32::WHITE))
.fill(Color32::from_rgb(79, 70, 229))
.min_size(Vec2::new(145.0, 32.0))
.rounding(Rounding::same(6.0));
if ui.add(add_folder_btn).clicked() {
if let Some(folder) = FileDialog::new().pick_folder() {
if !self.queued_files.contains(&folder) {
self.queued_files.push(folder);
}
}
}
let paste_btn = Button::new(RichText::new("📸 Вставить скриншот (Ctrl+V)").strong().color(Color32::WHITE))
.fill(Color32::from_rgb(147, 51, 234))
.min_size(Vec2::new(195.0, 32.0))
.rounding(Rounding::same(6.0));
if ui.add(paste_btn).clicked() {
self.try_paste_clipboard_image();
}
});
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!(
"Выбрано: {} элементов ({}) • Получателей: {}",
self.queued_files.len(),
human_bytes(total_size as f64),
self.selected_peer_ids.len()
))
.size(12.5)
.strong()
.color(theme.text_primary),
);
if self.auto_zip_folders {
Frame::none()
.fill(Color32::from_rgba_premultiplied(34, 197, 94, 25))
.rounding(Rounding::same(4.0))
.inner_margin(Margin::symmetric(6.0, 2.0))
.show(ui, |ui| {
ui.label(RichText::new("⚡ Zero-Disk Streaming (tar.zst)").size(10.5).color(Color32::from_rgb(34, 197, 94)).strong());
});
}
});
ui.add_space(6.0);
// ── Files Dropzone Box ──
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("📂").size(32.0));
ui.add_space(4.0);
ui.label(RichText::new("Перетащите файлы или папки сюда (Drag & Drop)").size(13.5).strong().color(theme.text_primary));
ui.add_space(2.0);
ui.label(RichText::new("или нажмите Ctrl+V для быстрой вставки файлов и скриншотов из буфера обмена").size(11.5).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(18.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.5).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(RichText::new("✕").color(Color32::from_rgb(239, 68, 68))).on_hover_text("Удалить из очереди").clicked() {
to_remove = Some(idx);
}
});
});
});
ui.add_space(4.0);
}
if let Some(i) = to_remove {
self.queued_files.remove(i);
}
}
});
});
});
ui.add_space(10.0);
// ── Step 3: Big Send Button ──
let num_recipients = self.selected_peer_ids.len();
let total_size: u64 = self
.queued_files
.iter()
.filter_map(|p| std::fs::metadata(p).ok().map(|m| m.len()))
.sum();
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!("🚀 Отправить {} файл(ов) для {} ({})", self.queued_files.len(), target_name, human_bytes(total_size as f64))
} else if num_recipients > 1 {
format!("🚀 Отправить {} файл(ов) для {} получателей ({})", self.queued_files.len(), num_recipients, human_bytes(total_size as f64))
} else {
"🚀 Выберите получателя и добавьте файлы для отправки".to_string()
};
let send_btn = Button::new(RichText::new(send_btn_text).size(14.5).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) }
})
.stroke(if can_send { Stroke::new(1.0_f32, Color32::from_rgb(52, 211, 153)) } else { Stroke::NONE })
.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) {
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("Шаг 2: Введите текст или ссылку для отправки:").strong().size(13.0).color(theme.text_primary));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.button("📋 Вставить из буфера обмена").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(8.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("Тип содержимого:").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!("🚀 Отправить текст для {}", target_name)
} else {
format!("🚀 Отправить текст для {} получателей", 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!(
"🎉 Текст [{}] доставлен для {}!",
analyzed.content_type.label(),
peer_name
));
}
Err(e) => {
let _ = log_tx_clone.send(format!("⚠️ Ошибка отправки текста для {}: {}", 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| {
// Hero Top Banner
Frame::none()
.fill(if theme.is_dark { Color32::from_rgb(15, 23, 42) } else { Color32::from_rgb(238, 242, 255) })
.rounding(Rounding::same(8.0))
.stroke(Stroke::new(1.0_f32, Color32::from_rgb(99, 102, 241)))
.inner_margin(Margin::symmetric(14.0, 10.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("📥").size(24.0));
ui.vertical(|ui| {
ui.label(
RichText::new("Прием файлов и Web Dropzone")
.strong()
.size(14.0)
.color(theme.text_primary),
);
ui.label(
RichText::new("Вы можете отправлять фото, видео и файлы с телефонов и ноутбуков прямо через браузер без установки приложения!")
.size(11.5)
.color(theme.text_secondary),
);
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.button(RichText::new("📂 Открыть папку загрузок").strong()).clicked() {
let _ = std::process::Command::new("explorer").arg(&self.download_dir).spawn();
}
});
});
});
ui.add_space(10.0);
// Storage & Auto-Accept Settings 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("📁 Папка для сохранения:").strong().color(theme.text_primary));
ui.label(RichText::new(format!("{:?}", self.download_dir)).monospace().size(11.5).color(theme.text_secondary));
});
ui.add_space(6.0);
ui.horizontal(|ui| {
if ui.checkbox(&mut self.auto_accept, "Автоматически принимать входящие файлы от друзей").changed() {
self.persist_config();
}
if ui.checkbox(&mut self.auto_extract_zip, "Автораспаковка ZIP архивов").changed() {
self.persist_config();
}
});
});
ui.add_space(12.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").size(14.5).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("Для телефонов или ноутбуков, подключенных через сеть ZeroTier, Tailscale или Radmin VPN:").size(11.0).color(theme.text_muted));
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(RichText::new(&url).monospace().size(13.5).strong().color(theme.text_accent));
if ui.button("📋 Скопировать").on_hover_text("Скопировать ссылку").clicked() {
ui.output_mut(|o| o.copied_text = url.clone());
}
if ui.button("🌐 Открыть").on_hover_text("Открыть в браузере").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("🏠 Ссылка для домашней сети Wi-Fi / LAN").size(14.5).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("Локальный Wi-Fi").size(10.0).color(Color32::from_rgb(56, 189, 248)).strong());
});
});
ui.add_space(3.0);
ui.label(RichText::new("Для смартфонов и планшетов, подключенных к одному домашнему Wi-Fi роутеру:").size(11.0).color(theme.text_muted));
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(RichText::new(&url).monospace().size(13.5).strong().color(theme.text_accent));
if ui.button("📋 Скопировать").on_hover_text("Скопировать ссылку").clicked() {
ui.output_mut(|o| o.copied_text = url.clone());
}
if ui.button("🌐 Открыть").on_hover_text("Открыть в браузере").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);
} 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 Ссылка").size(14.5).strong().color(theme.text_primary));
ui.add_space(3.0);
ui.label(RichText::new("Откройте эту ссылку в браузере или отсканируйте QR-код на телефоне:").size(11.0).color(theme.text_muted));
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(RichText::new(&url).monospace().size(13.5).strong().color(theme.text_accent));
if ui.button("📋 Скопировать").clicked() {
ui.output_mut(|o| o.copied_text = url.clone());
}
if ui.button("🌐 Открыть").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) {
// 1. Audio Dock (if track is active)
let mut stop_audio = false;
if let Some(track) = &self.active_audio {
Frame::none()
.fill(if theme.is_dark { Color32::from_rgb(26, 17, 36) } else { Color32::from_rgb(253, 242, 248) })
.stroke(Stroke::new(1.0_f32, Color32::from_rgb(236, 72, 153)))
.rounding(Rounding::same(8.0))
.inner_margin(Margin::symmetric(14.0, 8.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("🎵").size(18.0));
ui.label(RichText::new(&track.name).strong().color(theme.text_primary));
ui.label(RichText::new(format!("• from {}", track.peer_name)).size(11.0).color(theme.text_muted));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.button(RichText::new("✕ Stop").color(theme.text_muted)).clicked() {
stop_audio = true;
}
if ui.button("🌐 Open in System Player").clicked() {
let _ = std::process::Command::new("explorer").arg(&track.url).spawn();
}
});
});
});
ui.add_space(8.0);
}
if stop_audio {
self.active_audio = None;
}
// 2. Watch Party Session Dock (if active)
let mut leave_party = false;
if let Some(session) = &self.watch_party_session {
Frame::none()
.fill(if theme.is_dark { Color32::from_rgb(30, 20, 45) } else { Color32::from_rgb(245, 243, 255) })
.stroke(Stroke::new(1.0_f32, Color32::from_rgb(168, 85, 247)))
.rounding(Rounding::same(8.0))
.inner_margin(Margin::symmetric(14.0, 8.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("🎬").size(18.0));
ui.label(RichText::new("Active Watch Party:").strong().color(Color32::from_rgb(168, 85, 247)));
ui.label(RichText::new(&session.relative_path).strong().color(theme.text_primary));
ui.label(RichText::new(format!("• Host: {}", session.host_peer_name)).size(11.0).color(theme.text_muted));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.button(RichText::new("✕ Leave Room").color(Color32::from_rgb(239, 68, 68))).clicked() {
leave_party = true;
}
if ui.button("📋 Copy Watch Party Link").clicked() {
let link = format!("zerosend://watch-party?room={}&file={}", session.room_id, session.relative_path);
ui.output_mut(|o| o.copied_text = link);
}
});
});
});
ui.add_space(8.0);
}
if leave_party {
self.watch_party_session = None;
self.is_in_watch_party = false;
}
// Subtabs: Network Shares, My Shares, Live Folder Sync
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 sync_count = self.folder_sync_pairs.len();
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(160.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(140.0, 32.0))
.rounding(Rounding::same(6.0));
if ui.add(my_btn).clicked() {
self.selected_share_subtab = SharedSubTab::MyShares;
}
let sync_btn = Button::new(
RichText::new(format!("🔄 Auto-Sync ({})", sync_count))
.strong()
.color(if self.selected_share_subtab == SharedSubTab::AutoSync {
theme.text_primary
} else {
theme.text_secondary
}),
)
.fill(if self.selected_share_subtab == SharedSubTab::AutoSync {
theme.bg_card_active
} else {
theme.bg_card
})
.min_size(Vec2::new(140.0, 32.0))
.rounding(Rounding::same(6.0));
if ui.add(sync_btn).clicked() {
self.selected_share_subtab = SharedSubTab::AutoSync;
}
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 self.selected_share_subtab == SharedSubTab::NetworkShares {
if ui.button(RichText::new("🔄 Sync / Refresh").color(theme.text_primary)).clicked() {
self.sync_all_network_shares();
self.refresh_current_share_tree();
self.logs.push("🔄 Обновлен список общих папок узлов в сети".to_string());
}
}
});
});
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),
SharedSubTab::AutoSync => self.render_auto_sync_subtab(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(30.0))
.show(ui, |ui| {
ui.vertical_centered(|ui| {
ui.label(RichText::new("📁").size(36.0));
ui.label(
RichText::new("У вас пока нет локальных общих папок.")
.size(15.0)
.strong()
.color(theme.text_primary),
);
ui.add_space(4.0);
ui.label(
RichText::new(
"Откройте доступ к папкам с фильмами, музыкой или кодом, чтобы ваши друзья в ZeroTier/LAN могли смотреть и скачивать их.",
)
.size(12.0)
.color(theme.text_muted),
);
ui.add_space(12.0);
let choose_btn = Button::new(RichText::new("➕ Выбрать папку для общего доступа").strong().color(Color32::WHITE))
.fill(Color32::from_rgb(34, 197, 94))
.min_size(Vec2::new(220.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 {
("Только чтение (Стриминг/Скачивание)", Color32::from_rgb(59, 130, 246))
} else {
("Чтение и запись (Загрузка разрешена)", 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-защита")
.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("🗑 Удалить").color(Color32::from_rgb(239, 68, 68))).clicked() {
remove_index = Some(idx);
}
if ui.button("📂 Открыть папку").clicked() {
let _ = std::process::Command::new("explorer").arg(&share.path).spawn();
}
if ui.checkbox(&mut share.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_auto_sync_subtab(&mut self, ui: &mut Ui, theme: ThemeColors) {
// Explanatory Banner
Frame::none()
.fill(if theme.is_dark { Color32::from_rgb(15, 23, 42) } else { Color32::from_rgb(238, 242, 255) })
.rounding(Rounding::same(8.0))
.stroke(Stroke::new(1.0_f32, Color32::from_rgb(99, 102, 241)))
.inner_margin(Margin::symmetric(14.0, 10.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("🔄").size(22.0));
ui.vertical(|ui| {
ui.label(
RichText::new("Двусторонняя авто-синхронизация (Two-Way Live Sync)")
.strong()
.size(14.0)
.color(theme.text_primary),
);
ui.label(
RichText::new(
"Автоматически зеркалирует локальную папку на вашем ПК с общей папкой друга в фоновом режиме каждые 8 секунд без лишних действий.",
)
.size(11.5)
.color(theme.text_secondary),
);
});
});
});
ui.add_space(10.0);
// Collect all online friends with shared folders
let mut online_shares: Vec<(String, String, String, String)> = Vec::new();
for peer in &self.peers {
if peer.is_online && !peer.shared_folders.is_empty() {
let (pname, _) = self.get_peer_display_name(peer);
for folder in &peer.shared_folders {
online_shares.push((peer.peer_id.clone(), pname.clone(), folder.id.clone(), folder.name.clone()));
}
}
}
// STEP-BY-STEP PAIRING WIZARD
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(14.0))
.show(ui, |ui| {
ui.label(RichText::new("➕ Настройка новой авто-синхронизации (3 простых шага)").strong().size(13.5).color(theme.text_accent));
ui.add_space(8.0);
ui.separator();
ui.add_space(8.0);
// --- ШАГ 1: Выбор папки друга ---
ui.label(RichText::new("Шаг 1: Выберите общую папку друга из сети").strong().color(theme.text_primary));
ui.add_space(4.0);
if online_shares.is_empty() {
Frame::none()
.fill(theme.bg_card_secondary)
.rounding(Rounding::same(6.0))
.inner_margin(Margin::same(8.0))
.show(ui, |ui| {
ui.label(
RichText::new("ℹ️ В сети пока не обнаружено общих папок у друзей. Подключитесь к ZeroTier/LAN, чтобы друзья появились онлайн.")
.size(11.5)
.color(theme.text_muted),
);
});
} else {
ui.horizontal_wrapped(|ui| {
for (pid, pname, fid, fname) in &online_shares {
let is_chosen = self.new_sync_peer_id == *pid && self.new_sync_folder_id == *fid;
let btn_text = format!("📁 {} (от {})", fname, pname);
let share_btn = Button::new(
RichText::new(btn_text)
.size(12.0)
.strong()
.color(if is_chosen { Color32::WHITE } else { theme.text_primary }),
)
.fill(if is_chosen {
Color32::from_rgb(37, 99, 235)
} else {
theme.bg_card_secondary
})
.stroke(if is_chosen {
Stroke::new(1.5_f32, Color32::from_rgb(96, 165, 250))
} else {
Stroke::NONE
})
.rounding(Rounding::same(6.0))
.min_size(Vec2::new(180.0, 32.0));
if ui.add(share_btn).clicked() {
self.new_sync_peer_id = pid.clone();
self.new_sync_folder_id = fid.clone();
if self.new_sync_name.trim().is_empty() {
self.new_sync_name = format!("Синхронизация {} ({})", fname, pname);
}
}
}
});
}
ui.add_space(10.0);
// --- ШАГ 2: Выбор локальной папки на диске ---
ui.label(RichText::new("Шаг 2: Выберите локальную папку на вашем компьютере").strong().color(theme.text_primary));
ui.add_space(4.0);
ui.horizontal(|ui| {
let browse_btn = Button::new(RichText::new("📁 Выбрать папку на диске...").strong().color(Color32::WHITE))
.fill(Color32::from_rgb(59, 130, 246))
.rounding(Rounding::same(4.0));
if ui.add(browse_btn).clicked() {
if let Some(folder) = FileDialog::new().pick_folder() {
self.new_sync_local_path = folder.to_string_lossy().into_owned();
}
}
if self.new_sync_local_path.is_empty() {
ui.label(RichText::new("Папка не выбрана").color(Color32::from_rgb(239, 68, 68)).size(11.5));
} else {
ui.label(RichText::new(&self.new_sync_local_path).strong().color(Color32::from_rgb(34, 197, 94)).monospace().size(11.5));
}
});
ui.add_space(10.0);
// --- ШАГ 3: Запуск синхронизации ---
ui.label(RichText::new("Шаг 3: Назовите и запустите синхронизацию").strong().color(theme.text_primary));
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("Имя пары:");
ui.add(egui::TextEdit::singleline(&mut self.new_sync_name).hint_text("Мой проект").desired_width(180.0));
let is_ready = !self.new_sync_local_path.trim().is_empty()
&& !self.new_sync_peer_id.trim().is_empty()
&& !self.new_sync_folder_id.trim().is_empty();
let start_btn = Button::new(RichText::new("🚀 Запустить авто-синхронизацию").strong().color(Color32::WHITE))
.fill(if is_ready { Color32::from_rgb(34, 197, 94) } else { Color32::from_rgb(100, 116, 139) })
.rounding(Rounding::same(4.0))
.min_size(Vec2::new(200.0, 30.0));
if ui.add_enabled(is_ready, start_btn).clicked() {
let name = if self.new_sync_name.trim().is_empty() {
"Folder Sync".to_string()
} else {
self.new_sync_name.trim().to_string()
};
let peer_name = self.peers.iter()
.find(|p| p.peer_id == self.new_sync_peer_id)
.map(|p| self.get_peer_display_name(p).0)
.unwrap_or_else(|| "Friend".to_string());
let pair = crate::transfer::FolderSyncPairConfig {
id: uuid::Uuid::new_v4().to_string(),
name,
local_path: PathBuf::from(self.new_sync_local_path.trim()),
remote_peer_id: self.new_sync_peer_id.trim().to_string(),
remote_peer_name: peer_name,
remote_folder_id: self.new_sync_folder_id.trim().to_string(),
is_active: true,
last_sync: None,
};
self.folder_sync_pairs.push(pair);
self.config.folder_sync_pairs = self.folder_sync_pairs.clone();
self.persist_config();
self.logs.push(format!("🔄 Создана новая авто-синхронизация: {}", self.new_sync_name));
self.new_sync_local_path.clear();
self.new_sync_name.clear();
self.new_sync_peer_id.clear();
self.new_sync_folder_id.clear();
}
});
});
ui.add_space(10.0);
// List of Active Sync Pairs
ui.label(RichText::new(format!("Активные пары синхронизации ({})", self.folder_sync_pairs.len())).strong().size(13.0).color(theme.text_primary));
ui.add_space(4.0);
if self.folder_sync_pairs.is_empty() {
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(24.0))
.show(ui, |ui| {
ui.vertical_centered(|ui| {
ui.label(RichText::new("🔄 Нет активных настроенных пар синхронизации.").color(theme.text_secondary));
ui.label(RichText::new("Выберите общую папку друга в мастере выше и укажите локальную папку для старта.").size(11.5).color(theme.text_muted));
});
});
} else {
let mut remove_sync_idx = None;
let mut sync_changed = false;
for (idx, pair) in self.folder_sync_pairs.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(22.0));
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(&pair.name).strong().size(13.5).color(theme.text_primary));
if pair.is_active {
ui.label(RichText::new("● Авто-синхронизация активна (каждые 8с)").color(Color32::from_rgb(34, 197, 94)).size(11.0).strong());
} else {
ui.label(RichText::new("⏸ На паузе").color(theme.text_muted).size(11.0));
}
});
ui.horizontal(|ui| {
ui.label(RichText::new("💻 Локально:").strong().size(11.0).color(theme.text_secondary));
ui.label(RichText::new(format!("{:?}", pair.local_path)).monospace().size(11.0).color(theme.text_muted));
ui.label(RichText::new("➔ 🌐 Удаленно (Друг):").strong().size(11.0).color(theme.text_secondary));
ui.label(RichText::new(&pair.remote_peer_name).size(11.0).color(theme.text_accent));
});
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.button(RichText::new("🗑 Удалить").color(Color32::from_rgb(239, 68, 68))).clicked() {
remove_sync_idx = Some(idx);
}
if ui.checkbox(&mut pair.is_active, "Включено").changed() {
sync_changed = true;
}
if ui.button("📂 Открыть локально").clicked() {
let _ = std::process::Command::new("explorer").arg(&pair.local_path).spawn();
}
});
});
});
ui.add_space(6.0);
}
if let Some(idx) = remove_sync_idx {
self.folder_sync_pairs.remove(idx);
sync_changed = true;
}
if sync_changed {
self.config.folder_sync_pairs = self.folder_sync_pairs.clone();
self.persist_config();
}
}
ui.add_space(10.0);
// Sync Live Console Log
Frame::none()
.fill(Color32::from_rgb(10, 13, 20))
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(10.0))
.show(ui, |ui| {
ui.label(RichText::new("📜 Журнал работы авто-синхронизации (Live Activity Log)").strong().size(12.0).color(Color32::from_rgb(148, 163, 184)));
ui.add_space(4.0);
ScrollArea::vertical().max_height(140.0).show(ui, |ui| {
if self.sync_log_messages.is_empty() {
ui.label(RichText::new("Синхронизация ожидает изменений. Новые и измененные файлы будут синхронизироваться автоматически.").size(11.0).color(Color32::from_rgb(100, 116, 139)));
} else {
for log in self.sync_log_messages.iter().rev() {
ui.label(RichText::new(log).monospace().size(11.0).color(Color32::from_rgb(203, 213, 225)));
}
}
});
});
}
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(12.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(48.0))
.show(ui, |ui| {
ui.vertical_centered(|ui| {
ui.label(RichText::new("🌐").size(48.0));
ui.add_space(8.0);
ui.label(
RichText::new("В сети пока нет обнаруженных общих папок")
.size(17.0)
.strong()
.color(theme.text_primary),
);
ui.add_space(6.0);
ui.label(
RichText::new(
"Когда ваши друзья в ZeroTier, Tailscale, Radmin VPN или LAN откроют доступ к папкам, они мгновенно появятся здесь с поддержкой стриминга и быстрой загрузки.",
)
.size(12.5)
.color(theme.text_secondary),
);
ui.add_space(14.0);
let refresh_btn = Button::new(RichText::new("🔄 Обновить список (Sync / Refresh)").strong().color(Color32::WHITE))
.fill(Color32::from_rgb(37, 99, 235))
.min_size(Vec2::new(200.0, 32.0));
if ui.add(refresh_btn).clicked() {
self.sync_all_network_shares();
self.refresh_current_share_tree();
}
});
});
return;
}
let total_shared_folders_count: usize = online_peers_with_shares.iter().map(|p| p.shared_folders.len()).sum();
ui.horizontal_top(|ui| {
// ── Left Column: Modern Friends & Shares Sidebar (270px) ──
ui.allocate_ui(Vec2::new(270.0, ui.available_height()), |ui| {
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(10.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::symmetric(12.0, 10.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("🌐 Общие папки").strong().size(13.5).color(theme.text_primary));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
Frame::none()
.fill(theme.bg_card_secondary)
.rounding(Rounding::same(10.0))
.inner_margin(Margin::symmetric(6.0, 2.0))
.show(ui, |ui| {
ui.label(RichText::new(format!("{}", total_shared_folders_count)).size(10.5).strong().color(Color32::from_rgb(56, 189, 248)));
});
});
});
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);
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| {
ui.horizontal(|ui| {
ui.label(RichText::new("●").color(Color32::from_rgb(34, 197, 94)).size(10.0));
ui.label(RichText::new(&peer_display).strong().size(12.0).color(theme.text_primary));
if let Some(ping) = peer.ping_ms {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(RichText::new(format!("⚡ {}ms", ping)).size(9.5).color(Color32::from_rgb(34, 197, 94)));
});
}
});
ui.add_space(4.0);
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_color = if folder.read_only { Color32::from_rgb(59, 130, 246) } else { Color32::from_rgb(34, 197, 94) };
let badge_text = if folder.read_only { "RO" } else { "RW" };
let btn = Button::new(
RichText::new(format!("📁 {}", folder.name))
.size(11.5)
.strong()
.color(if is_selected { theme.text_accent } else { theme.text_primary }),
)
.fill(if is_selected { theme.bg_card_active } else { Color32::TRANSPARENT })
.stroke(if is_selected { Stroke::new(1.0_f32, theme.text_accent) } else { Stroke::NONE })
.min_size(Vec2::new(ui.available_width(), 26.0))
.rounding(Rounding::same(6.0));
ui.horizontal(|ui| {
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();
}
// Pill badge
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(4.0))
.stroke(Stroke::new(1.0_f32, badge_color))
.inner_margin(Margin::symmetric(4.0, 1.0))
.show(ui, |ui| {
ui.label(RichText::new(badge_text).size(9.0).color(badge_color));
});
if folder.requires_pin {
ui.label(RichText::new("🔒").size(10.0));
}
});
ui.add_space(2.0);
}
});
ui.add_space(6.0);
}
});
});
});
ui.add_space(8.0);
// ── Right Column: Active Share Modern 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 => {
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.label(RichText::new("⚠️ Выбранный узел временно не в сети.").color(Color32::from_rgb(239, 68, 68)));
});
return;
}
};
let (peer_name, _) = self.get_peer_display_name(&peer);
// ── Top Explorer Header & Interactive Breadcrumbs Bar ──
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::symmetric(10.0, 8.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
if ui.button(RichText::new("🏠 Корень").strong().color(theme.text_accent)).clicked() {
self.current_share_subpath = String::new();
self.refresh_current_share_tree();
}
let mut navigate_subpath = None;
if !self.current_share_subpath.is_empty() {
let parts: Vec<String> = self.current_share_subpath.split('/').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect();
let mut accumulated = String::new();
for (idx, part) in parts.iter().enumerate() {
ui.label(RichText::new("/").color(theme.text_muted));
if !accumulated.is_empty() {
accumulated.push('/');
}
accumulated.push_str(part);
let is_last = idx == parts.len() - 1;
if is_last {
ui.label(RichText::new(part).strong().color(theme.text_primary));
} else {
let acc_clone = accumulated.clone();
if ui.link(RichText::new(part).color(theme.text_accent)).clicked() {
navigate_subpath = Some(acc_clone);
}
}
}
}
if let Some(sub) = navigate_subpath {
self.current_share_subpath = sub;
self.refresh_current_share_tree();
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
// Grep search button
if ui.button(RichText::new("🔎 Grep по коду").color(Color32::from_rgb(56, 189, 248))).clicked() {
self.show_grep_modal = true;
self.grep_query.clear();
self.grep_results.clear();
self.grep_status_msg = None;
}
// Quick Auto-Sync pairing shortcut
let folder_id_clone = folder_id.clone();
let peer_id_clone = peer.peer_id.clone();
let peer_name_clone = peer_name.clone();
let f_name = peer.shared_folders.iter().find(|f| f.id == folder_id_clone).map(|f| f.name.clone()).unwrap_or_else(|| "Shared".to_string());
if ui.button(RichText::new("🔄 Синхронизация").color(Color32::from_rgb(168, 85, 247))).clicked() {
self.new_sync_peer_id = peer_id_clone;
self.new_sync_folder_id = folder_id_clone;
self.new_sync_name = format!("Синхронизация {} ({})", f_name, peer_name_clone);
self.selected_share_subtab = SharedSubTab::AutoSync;
if let Some(folder) = FileDialog::new().pick_folder() {
self.new_sync_local_path = folder.to_string_lossy().into_owned();
}
}
// Search filter box
ui.add(
egui::TextEdit::singleline(&mut self.share_search_query)
.hint_text("🔍 Фильтр файлов...")
.desired_width(130.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("⬆️ Загрузить файл")
.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!("⬆️ Загрузка {:?} в общую папку...", 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!("✅ Загружено {:?}", f.file_name()));
}
Err(e) => {
let _ = log_tx.send(format!("❌ Ошибка загрузки: {}", e));
}
}
}
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(6.0);
// ── Multi-select Batch Action Toolbar ──
if !self.selected_share_items.is_empty() {
Frame::none()
.fill(if theme.is_dark { Color32::from_rgb(23, 37, 84) } else { Color32::from_rgb(219, 234, 254) })
.rounding(Rounding::same(6.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::symmetric(12.0, 8.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(format!("Выбрано файлов: {}", self.selected_share_items.len())).strong().color(theme.text_primary));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.button("✖ Снять выбор").clicked() {
self.selected_share_items.clear();
}
let dl_btn = Button::new(
RichText::new(format!("⬇️ Скачать выбранное ({})", self.selected_share_items.len()))
.strong()
.color(Color32::WHITE),
)
.fill(Color32::from_rgb(37, 99, 235))
.rounding(Rounding::same(4.0));
if ui.add(dl_btn).clicked() {
let client = self.client.clone();
let peer_url = peer.transfer_url();
let f_id = folder_id.clone();
let items: Vec<String> = self.selected_share_items.iter().cloned().collect();
let dl_dir = self.download_dir.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 transfer_tx = self.transfer_tx.clone();
let sound = self.sound_notifications;
let peer_display_name = peer_name.clone();
// Switch tab to Transfers!
self.switch_tab(Tab::Transfers);
tokio::spawn(async move {
for item_rel in items {
let fname = std::path::Path::new(&item_rel)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "downloaded".to_string());
let dest = dl_dir.join(&fname);
let _ = log_tx.send(format!("⬇️ Загрузка {} от {}...", fname, peer_display_name));
let t_tx = transfer_tx.clone();
let _ = client.download_shared_file_with_progress(
&peer_url,
&f_id,
&item_rel,
&dest,
pin.as_deref(),
&peer_display_name,
move |prog| {
let _ = t_tx.send(prog);
},
).await;
}
if sound {
crate::ui::sound::play_transfer_sound();
}
});
self.selected_share_items.clear();
}
});
});
});
ui.add_space(6.0);
}
// ── Explorer File Table & Cards ──
if self.share_tree_loading {
ui.vertical_centered(|ui| {
ui.add_space(30.0);
ui.spinner();
ui.label("Загрузка содержимого папки...");
});
} else if let Some(tree) = self.current_share_tree.clone() {
let is_writable_share = !tree.read_only;
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(RichText::new("На уровень выше").strong().color(theme.text_accent)).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, 7.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
// Checkbox for multi-select
if !entry.is_dir {
let mut is_sel = self.selected_share_items.contains(&entry.relative_path);
if ui.checkbox(&mut is_sel, "").changed() {
if is_sel {
self.selected_share_items.insert(entry.relative_path.clone());
} else {
self.selected_share_items.remove(&entry.relative_path);
}
}
}
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));
}
// Category 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 { "⬇️ Скачать" };
let dl_btn = Button::new(RichText::new(down_label).strong().color(Color32::WHITE))
.fill(Color32::from_rgb(37, 99, 235))
.rounding(Rounding::same(4.0));
if ui.add(dl_btn).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 transfer_tx = self.transfer_tx.clone();
let sound = self.sound_notifications;
let peer_display_name = peer_name.clone();
// Switch tab to Transfers immediately!
self.switch_tab(Tab::Transfers);
tokio::spawn(async move {
let _ = log_tx.send(format!("⬇️ Загрузка {} от {}...", out_name, peer_display_name));
let t_tx = transfer_tx.clone();
let res = client.download_shared_file_with_progress(
&peer_url,
&f_id,
&sub,
&dest,
pin.as_deref(),
&peer_display_name,
move |prog| {
let _ = t_tx.send(prog);
},
).await;
match res {
Ok(bytes) => {
let _ = log_tx.send(format!("✅ Загружено {} ({})", out_name, human_bytes(bytes as f64)));
if sound {
crate::ui::sound::play_transfer_sound();
}
}
Err(e) => {
let _ = log_tx.send(format!("❌ Ошибка загрузки {}: {}", out_name, e));
}
}
});
}
// ── Action: Audio In-App Player ──
if badge_text == "AUDIO" {
let stream_url = format!(
"{}/api/shared/stream/{}/{}",
peer.transfer_url(),
folder_id,
entry.relative_path.trim_start_matches('/')
);
if ui.button(RichText::new("🎵 Играть").color(Color32::from_rgb(236, 72, 153)).strong()).clicked() {
self.active_audio = Some(AudioTrackInfo {
name: entry.name.clone(),
url: stream_url.clone(),
peer_name: peer_name.clone(),
is_playing: true,
});
let _ = std::process::Command::new("explorer").arg(&stream_url).spawn();
}
}
// ── Action: Video Watch Party & Stream ──
if badge_text == "VIDEO" {
let stream_url = format!(
"{}/api/shared/stream/{}/{}",
peer.transfer_url(),
folder_id,
entry.relative_path.trim_start_matches('/')
);
if ui.button(RichText::new("🎬 Watch Party").color(Color32::from_rgb(168, 85, 247)).strong()).clicked() {
self.watch_party_session = Some(crate::transfer::WatchPartySyncEvent {
room_id: uuid::Uuid::new_v4().to_string(),
host_peer_id: self.my_info.peer_id.clone(),
host_peer_name: self.my_info.name.clone(),
folder_id: folder_id.clone(),
relative_path: entry.relative_path.clone(),
is_playing: true,
position_secs: 0.0,
timestamp_ms: chrono::Local::now().timestamp_millis() as u64,
});
self.is_in_watch_party = true;
self.logs.push(format!("🎬 Watch Party создана для '{}'", entry.name));
let _ = std::process::Command::new("explorer").arg(&stream_url).spawn();
}
if ui.button(RichText::new("▶️ Стрим").color(Color32::from_rgb(56, 189, 248))).clicked() {
let _ = std::process::Command::new("explorer").arg(&stream_url).spawn();
}
}
// ── Action: Vim View / Edit (for code & text) ──
if badge_text == "CODE" || badge_text == "DOC" || entry.name.ends_with(".txt") || entry.name.ends_with(".log") || entry.name.ends_with(".md") {
let edit_label = if is_writable_share { "✏️ Vim Редактор" } else { "👁 Vim Просмотр" };
let btn_color = if is_writable_share { Color32::from_rgb(34, 197, 94) } else { Color32::from_rgb(20, 184, 166) };
if ui.button(RichText::new(edit_label).color(btn_color).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 is_w = is_writable_share;
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, Some(peer_url), Some(f_id), pin, is_w));
}
});
}
}
});
});
});
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(24.0))
.show(ui, |ui| {
ui.vertical_centered(|ui| {
ui.label(RichText::new("🔒 Папка защищена PIN-кодом или пуста").strong().size(14.5).color(theme.text_primary));
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label("Введите PIN-код:");
ui.add(egui::TextEdit::singleline(&mut self.share_pin_input).password(true).desired_width(120.0));
let unlock_btn = Button::new(RichText::new("🔓 Разблокировать / Обновить").strong().color(Color32::WHITE))
.fill(Color32::from_rgb(37, 99, 235));
if ui.add(unlock_btn).clicked() {
self.refresh_current_share_tree();
}
});
});
});
}
} else {
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(10.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(48.0))
.show(ui, |ui| {
ui.vertical_centered(|ui| {
ui.label(RichText::new("📂").size(40.0));
ui.add_space(6.0);
ui.label(RichText::new("Выберите общую папку друга на панели слева").strong().size(15.0).color(theme.text_primary));
ui.add_space(4.0);
ui.label(RichText::new("Вы сможете просматривать структуру файлов, стримить музыку и видео, открывать код во встроенном редакторе Vim и скачивать нужные файлы.").size(12.0).color(theme.text_muted));
});
});
}
});
});
}
fn render_transfers_tab(&mut self, ui: &mut Ui, theme: ThemeColors) {
let total_count = self.transfers.len();
let active_count = self.transfers.iter().filter(|t| t.state == TransferState::InProgress || t.state == TransferState::PendingConfirmation).count();
let completed_count = self.transfers.iter().filter(|t| t.state == TransferState::Completed).count();
let total_speed_bps: u64 = self.transfers.iter().filter(|t| t.state == TransferState::InProgress).map(|t| t.speed_bps).sum();
let total_speed_mb = (total_speed_bps as f64) / 1_048_576.0;
// Top Metrics Summary Dashboard
Frame::none()
.fill(if theme.is_dark { Color32::from_rgb(15, 23, 42) } else { Color32::from_rgb(238, 242, 255) })
.rounding(Rounding::same(8.0))
.stroke(Stroke::new(1.0_f32, Color32::from_rgb(56, 189, 248)))
.inner_margin(Margin::symmetric(14.0, 10.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("📜").size(24.0));
ui.vertical(|ui| {
ui.label(
RichText::new("История и мониторинг передач")
.strong()
.size(14.0)
.color(theme.text_primary),
);
ui.label(
RichText::new("Отслеживание скорости в реальном времени, криптографическая проверка SHA-256 и управление файлами.")
.size(11.5)
.color(theme.text_secondary),
);
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if !self.transfers.is_empty() {
let clear_btn = Button::new(RichText::new("🗑 Очистить завершенные").color(Color32::from_rgb(239, 68, 68)))
.fill(theme.bg_card_secondary)
.rounding(Rounding::same(6.0));
if ui.add(clear_btn).clicked() {
self.transfers.retain(|t| t.state == TransferState::InProgress || t.state == TransferState::PendingConfirmation);
crate::config::save_transfers_history(&self.transfers);
let srv = self.server.clone();
tokio::spawn(async move {
srv.clear_finished().await;
});
}
}
let open_btn = Button::new(RichText::new("📂 Папка загрузок").strong().color(Color32::WHITE))
.fill(Color32::from_rgb(37, 99, 235))
.rounding(Rounding::same(6.0));
if ui.add(open_btn).clicked() {
let _ = std::process::Command::new("explorer").arg(&self.download_dir).spawn();
}
});
});
ui.add_space(8.0);
ui.separator();
ui.add_space(6.0);
// 4 Metric Pill Cards with equal columns and perfect rounding
ui.columns(4, |cols| {
// Metric 1: Total
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(6.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::symmetric(10.0, 6.0))
.show(&mut cols[0], |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| {
ui.label(RichText::new("Всего передач").size(10.5).color(theme.text_muted));
ui.label(RichText::new(format!("{}", total_count)).strong().size(15.0).color(theme.text_primary));
});
});
// Metric 2: Active
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(6.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::symmetric(10.0, 6.0))
.show(&mut cols[1], |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| {
ui.label(RichText::new("В процессе").size(10.5).color(theme.text_muted));
ui.label(RichText::new(format!("{}", active_count)).strong().size(15.0).color(Color32::from_rgb(56, 189, 248)));
});
});
// Metric 3: Completed
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(6.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::symmetric(10.0, 6.0))
.show(&mut cols[2], |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| {
ui.label(RichText::new("Завершено").size(10.5).color(theme.text_muted));
ui.label(RichText::new(format!("{}", completed_count)).strong().size(15.0).color(Color32::from_rgb(34, 197, 94)));
});
});
// Metric 4: Speed
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(6.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::symmetric(10.0, 6.0))
.show(&mut cols[3], |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| {
ui.label(RichText::new("Общая скорость").size(10.5).color(theme.text_muted));
ui.label(RichText::new(format!("{:.1} MB/s", total_speed_mb)).strong().size(15.0).color(if total_speed_mb > 0.0 { Color32::from_rgb(250, 204, 21) } else { theme.text_secondary }));
});
});
});
});
ui.add_space(10.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.add_space(6.0);
ui.label(RichText::new("История передач пока пуста.").size(15.0).strong().color(theme.text_secondary));
ui.add_space(4.0);
ui.label(RichText::new("Отправьте файлы друзьям или примите передачу — здесь появится прогресс, скорость и SHA-256 отчет.").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(12.0))
.show(ui, |ui| {
// Top row: Direction, Peer, and Status Badge
ui.horizontal(|ui| {
let (dir_badge, dir_color) = if t.is_incoming {
(format!("📥 Получено от {}", t.peer_name), Color32::from_rgb(192, 132, 252))
} else {
(format!("📤 Отправлено для {}", 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("✅ Завершено").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 Проверено").size(10.5).color(Color32::from_rgb(74, 222, 128)).strong())
.on_hover_text("Криптографическая контрольная сумма SHA-256 проверена на лету. 100% гарантия целостности.");
});
}
});
}
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("⏳ Ожидание подтверждения...").color(Color32::YELLOW).size(13.0));
}
TransferState::Failed(e) => {
ui.label(RichText::new(format!("❌ Ошибка: {}", e)).color(Color32::RED).size(12.0));
}
TransferState::Rejected => {
ui.label(RichText::new("⛔ Передача отклонена").color(Color32::RED).size(12.0));
}
TransferState::Paused => {
ui.label(RichText::new("⏸ На паузе").color(Color32::from_rgb(234, 179, 8)).strong().size(13.0));
}
TransferState::Canceled => {
ui.label(RichText::new("🛑 Отменено").color(theme.text_muted).size(12.0));
}
}
});
});
ui.add_space(6.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(14.5)
.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 comp_info = if t.compressed_bytes > 0 && t.compressed_bytes < t.bytes_transferred {
let saved_pct = ((t.bytes_transferred.saturating_sub(t.compressed_bytes) as f64 / t.bytes_transferred as f64) * 100.0) as u32;
let ratio = t.bytes_transferred as f64 / t.compressed_bytes.max(1) as f64;
format!(" • 🗜️ {:.1}x (сжато на {}%)", ratio, saved_pct)
} else {
String::new()
};
let size_details = if t.state == TransferState::InProgress || t.state == TransferState::Paused {
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!("~{}с осталось", eta_secs)
} else if eta_secs < 3600 {
format!("~{}м {}с осталось", eta_secs / 60, eta_secs % 60)
} else {
format!("~{}ч {}м осталось", eta_secs / 3600, (eta_secs % 3600) / 60)
};
let speed_mb = (t.speed_bps as f64) / 1_048_576.0;
let state_prefix = if t.state == TransferState::Paused { "⏸ На паузе • " } else { "" };
format!(
"{}{} / {} ({}%) • ⚡ {:.1} MB/s • {}{}",
state_prefix,
human_bytes(t.bytes_transferred as f64),
human_bytes(t.total_bytes as f64),
progress_pct,
speed_mb,
eta_str,
comp_info
)
} else if is_done {
if t.checksum_verified {
format!("Доставлено • Размер: {}{} • 🛡️ SHA-256 Проверено", human_bytes(t.total_bytes as f64), comp_info)
} else {
format!("Доставлено • Размер: {}{}", human_bytes(t.total_bytes as f64), comp_info)
}
} else {
format!("Размер: {}", 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("📂 В проводнике").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("📄 Открыть").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();
}
});
} else if is_active || t.state == TransferState::Paused {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let client_c = self.client.clone();
let s_id = t.session_id.clone();
if t.state == TransferState::Paused {
let resume_btn = Button::new(RichText::new("▶️ Продолжить").strong().color(Color32::WHITE))
.fill(Color32::from_rgb(34, 197, 94))
.rounding(Rounding::same(6.0));
if ui.add(resume_btn).clicked() {
tokio::spawn(async move {
client_c.resume_transfer(&s_id).await;
});
}
} else {
let pause_btn = Button::new(RichText::new("⏸ Пауза").strong().color(Color32::WHITE))
.fill(Color32::from_rgb(234, 179, 8))
.rounding(Rounding::same(6.0));
if ui.add(pause_btn).clicked() {
tokio::spawn(async move {
client_c.pause_transfer(&s_id).await;
});
}
}
});
}
});
ui.add_space(6.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(6.0);
}
});
}
fn render_settings_tab(&mut self, ui: &mut Ui, theme: ThemeColors) {
ScrollArea::vertical().show(ui, |ui| {
// Header Banner
Frame::none()
.fill(if theme.is_dark { Color32::from_rgb(15, 23, 42) } else { Color32::from_rgb(238, 242, 255) })
.rounding(Rounding::same(8.0))
.stroke(Stroke::new(1.0_f32, Color32::from_rgb(99, 102, 241)))
.inner_margin(Margin::symmetric(14.0, 10.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("⚙️").size(24.0));
ui.vertical(|ui| {
ui.label(
RichText::new("Настройки приложения и информация")
.strong()
.size(14.0)
.color(theme.text_primary),
);
ui.label(
RichText::new("Конфигурация сети, папок загрузки, шифрования, темы оформления и обновлений.")
.size(11.5)
.color(theme.text_secondary),
);
});
});
});
ui.add_space(10.0);
// Card 1: Theme & Appearance
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(14.0))
.show(ui, |ui| {
ui.label(RichText::new("🎨 Внешний вид и тема").strong().size(13.5).color(theme.text_accent));
ui.add_space(6.0);
ui.horizontal(|ui| {
let themes = [
(ThemeMode::Auto, "🖥 Системная (Авто)"),
(ThemeMode::Dark, "🌙 Темная тема"),
(ThemeMode::Light, "☀️ Светлая тема"),
];
for (mode, label) in themes {
let is_active = self.theme_mode == mode;
let btn = Button::new(
RichText::new(label)
.size(12.0)
.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() {
"Автоматически повторяет темную тему Windows"
} else {
"Автоматически повторяет светлую тему Windows"
}
}
ThemeMode::Dark => "Всегда используется темная тема",
ThemeMode::Light => "Всегда используется светлая тема",
};
ui.label(RichText::new(current_status).size(11.0).color(theme.text_muted));
});
ui.add_space(10.0);
// Card 2: Network & Identity
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(14.0))
.show(ui, |ui| {
ui.label(RichText::new("🌐 Сеть и идентификация").strong().size(13.5).color(theme.text_accent));
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label("Имя устройства в сети:");
if ui.text_edit_singleline(&mut self.custom_device_name).lost_focus() {
self.persist_config();
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label("Сетевой адаптер (IP):");
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);
// Card 3: Storage & Downloads
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(14.0))
.show(ui, |ui| {
ui.label(RichText::new("💾 Папка сохранения файлов").strong().size(13.5).color(theme.text_accent));
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(RichText::new(format!("{:?}", self.download_dir)).monospace().size(11.5).color(theme.text_secondary));
let change_btn = Button::new(RichText::new("📁 Изменить папку...").strong().color(Color32::WHITE))
.fill(Color32::from_rgb(59, 130, 246))
.rounding(Rounding::same(6.0));
if ui.add(change_btn).clicked() {
if let Some(dir) = FileDialog::new().pick_folder() {
self.download_dir = dir;
self.persist_config();
}
}
if ui.button("📂 Открыть папку").clicked() {
let _ = std::process::Command::new("explorer").arg(&self.download_dir).spawn();
}
});
});
ui.add_space(10.0);
// Card 4: Compression & Speed Cap
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(14.0))
.show(ui, |ui| {
ui.label(RichText::new("⚡ Производительность, сжатие и лимиты скорости").strong().size(13.5).color(theme.text_accent));
ui.add_space(6.0);
if ui.checkbox(&mut self.auto_zip_folders, "⚡ Потоковая передача папок (tar.zst) — без временных файлов на диске, Zstd сжатие Level 1").changed() {
self.persist_config();
}
if ui.checkbox(&mut self.auto_extract_zip, "Автоматически распаковывать полученные ZIP архивы").changed() {
self.persist_config();
}
ui.add_space(8.0);
ui.separator();
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label("Ограничение скорости отдачи:");
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 {
"Без ограничений (0 MB/s)".to_string()
} else {
format!("{:.0} MB/s", n)
}
}),
)
.changed()
{
self.persist_config();
}
ui.add_space(8.0);
ui.label(RichText::new("(0 = максимальная скорость канала)").size(11.0).color(theme.text_muted));
});
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(RichText::new("Быстрые пресеты:").size(11.5).color(theme.text_muted));
for preset in [0, 5, 10, 25, 50, 100] {
let label = if preset == 0 {
"Без лимита".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(10.0);
// Card 5: Security & PIN
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(14.0))
.show(ui, |ui| {
ui.label(RichText::new("🔒 Безопасность и PIN-код").strong().size(13.5).color(theme.text_accent));
ui.add_space(6.0);
if ui.checkbox(&mut self.require_pin, "Требовать 4-значный PIN-код для входящих передач и сообщений").changed() {
self.persist_config();
}
if self.require_pin {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("Установите PIN-код:");
if ui
.add(
egui::TextEdit::singleline(&mut self.pin_code)
.password(true)
.desired_width(80.0),
)
.changed()
{
self.persist_config();
}
});
}
});
ui.add_space(10.0);
// Card 6: System & Tray Behavior
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(14.0))
.show(ui, |ui| {
ui.label(RichText::new("🎛 Системное поведение и уведомления").strong().size(13.5).color(theme.text_accent));
ui.add_space(6.0);
if ui.checkbox(&mut self.close_to_tray, "Сворачивать в системный трей при закрытии крестиком").changed() {
self.persist_config();
}
if ui.checkbox(&mut self.sound_notifications, "Воспроизводить звуковой сигнал при завершении передачи").changed() {
self.persist_config();
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if self.autostart_enabled {
ui.label(RichText::new("Автозапуск с Windows: Включен (в трее)").color(Color32::from_rgb(34, 197, 94)));
if ui.button("➖ Отключить автозапуск").clicked() {
let _ = unregister_autostart();
self.autostart_enabled = is_autostart_registered();
}
} else {
ui.label(RichText::new("Автозапуск с Windows: Отключен").color(theme.text_muted));
if ui.button("➕ Запускать вместе с Windows").clicked() {
let _ = register_autostart();
self.autostart_enabled = is_autostart_registered();
}
}
});
});
ui.add_space(10.0);
// Card 7: Gitea Auto-Updater
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(14.0))
.show(ui, |ui| {
ui.label(RichText::new("🔄 Обновления через Gitea").strong().size(13.5).color(theme.text_accent));
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label("Репозиторий Gitea:");
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, "Автоматически проверять обновления при запуске").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 { "⏳ Проверка Gitea..." } else { "🔍 Проверить обновления сейчас" })
.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();
}
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("✨ Доступна новая версия:").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!("(Текущая: 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!("Загрузка обновления: {} / {} ({}%)", 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("🛡️ Проверка целостности файла...").color(Color32::from_rgb(56, 189, 248)));
}
UpdateProgress::Installing => {
ui.label(RichText::new("⚡ Установка обновления и перезапуск ZeroSend...").color(Color32::from_rgb(34, 197, 94)).strong());
}
UpdateProgress::Completed => {
ui.label(RichText::new("🎉 Обновление успешно установлено! Перезапуск...").color(Color32::from_rgb(34, 197, 94)).strong());
}
UpdateProgress::Error(err) => {
ui.label(RichText::new(format!("❌ Ошибка обновления: {}", 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("🚀 Скачать и установить обновление")
.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("🌐 Страница релиза на 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(10.0);
// Collapsible Credits & About Card
egui::CollapsingHeader::new(RichText::new("👥 О программе и Разработчики").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(format!("v{}", env!("CARGO_PKG_VERSION"))).size(13.0).color(Color32::from_rgb(34, 197, 94)).strong());
});
ui.add_space(4.0);
ui.label(RichText::new("Быстрый децентрализованный обмен файлами, медиа и текстом, оптимизированный для сетей ZeroTier, Tailscale, Radmin VPN и локального Wi-Fi/LAN.").color(theme.text_secondary));
ui.add_space(8.0);
ui.label(RichText::new("🛠 Создатели и разработка:").strong().color(theme.text_primary));
ui.label(RichText::new(" • RarDog (Идея, архитектура, требования и руководство проектом)").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("⚙ Стек технологий и возможности:").strong().color(theme.text_primary));
ui.label(RichText::new(" • Rust 1.98, асинхронный рантайм Tokio, потоковый HTTP сервер Axum").color(theme.text_secondary));
ui.label(RichText::new(" • 🔄 Двусторонняя автосинхронизация папок в реальном времени (Auto-Sync Mirroring)").color(theme.text_secondary));
ui.label(RichText::new(" • 🖧 Монтирование сетевого диска в Проводник Windows (WebDAV RFC 4918, диск Z:\\)").color(theme.text_secondary));
ui.label(RichText::new(" • 🎬 Потоковое воспроизведение видео/аудио без скачивания (HTTP 206) и совместный просмотр Watch Party").color(theme.text_secondary));
ui.label(RichText::new(" • ✏️ Встроенный Vim-редактор: режимы Normal/Insert, сохранение :w по сети, предпросмотр Markdown").color(theme.text_secondary));
ui.label(RichText::new(" • 🔎 Удаленный полнотекстовый поиск Grep по коду и файлам друзей с переходом по строкам").color(theme.text_secondary));
ui.label(RichText::new(" • 📦 Портативный режим (Portable): конфиг и загрузки хранятся рядом с exe в одной папке").color(theme.text_secondary));
ui.label(RichText::new(" • 🛡️ Криптографическая проверка целостности SHA-256 на лету").color(theme.text_secondary));
ui.label(RichText::new(" • ⚡ 4-поточный параллельный чанк-движок быстрой передачи").color(theme.text_secondary));
ui.label(RichText::new(" • 🔄 Докачка и контрольные точки (.zerosend_part) при обрыве связи").color(theme.text_secondary));
ui.label(RichText::new(" • 🌐 Автоматическое UDP-обнаружение узлов в сетях ZeroTier / LAN").color(theme.text_secondary));
});
});
ui.add_space(8.0);
// Collapsible Version History / Changelog Card
egui::CollapsingHeader::new(RichText::new("📜 История версий (Список изменений)").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.5.5
ui.horizontal(|ui| {
ui.label(RichText::new("v2.5.5").strong().color(Color32::from_rgb(34, 197, 94)));
ui.label(RichText::new("(Текущая версия)").size(11.0).color(Color32::GRAY));
});
ui.label(" • 🎨 Редизайн вкладки «Отправить файлы»: Удобные карточки друзей с аватарками ОС, бейджами сетей (🪐 ZeroTier, 🪢 Tailscale, 🏠 LAN), пингом, стильной зоной Drag & Drop и новой большой кнопкой отправки.");
ui.label(" • 🪐 Премиальный статус-блок ZeroTier & IP: Новая фирменная плашка сети в боковой панели с индикатором онлайна, иконкой 🪐 ZeroTier и кнопкой копирования IP:Port в один клик.");
ui.label(" • 🚀 Мгновенный переход в «Передачи и файлы»: При скачивании любых файлов или архивов из общих папок друзей приложение сразу переключается во вкладку передач с отображением живой скорости (MB/s) и прогресса.");
ui.label(" • 🌐 Премиальный проводник Сетевых общих папок: Удобная боковая панель с пингом и статусами друзей, интерактивные хлебные крошки (breadcrumbs), красивые карточки файлов по категориям и мульти-загрузка.");
ui.label(" • 🔄 Все ключевые улучшения v2.5.4: авто-обнаружение папок в реальном времени, отслеживание скачиваний из общих папок, прозрачный стриминг папок и сохранение истории передач на диск.");
ui.add_space(8.0);
ui.separator();
ui.add_space(8.0);
// v2.5.4
ui.horizontal(|ui| {
ui.label(RichText::new("v2.5.4").strong().color(Color32::from_rgb(56, 189, 248)));
});
ui.label(" • 🔄 Мгновенное авто-обнаружение общих папок: Динамическое обновление списка папок друзей при добавлении/удалении в реальном времени и по кнопке Sync/Refresh.");
ui.label(" • 📥 Отслеживание скачиваний из общих папок: Когда друзья качают файлы или архивы из вашей общей папки, передача в реальном времени отображается в списке передач со скоростью.");
ui.label(" • 📁 Прозрачный стриминг папок: Получатель видит настоящее имя папки, примерный размер, прогресс и скорость в MB/s с момента старта стрима.");
ui.label(" • 💾 Постоянное сохранение истории передач: История завершенных и прошлых передач теперь надежно сохраняется на диск и не стирается при перезапуске приложения.");
ui.add_space(8.0);
ui.separator();
ui.add_space(8.0);
// v2.5.3-beta.2
ui.horizontal(|ui| {
ui.label(RichText::new("v2.5.3-beta.2").strong().color(Color32::from_rgb(56, 189, 248)));
});
ui.label(" • ✨ Плавные анимации переходов: Шелковистые кинетические анимации переключения вкладок (Cubic Ease-Out) и плавно скользящий индикатор выбора в меню.");
ui.label(" • ⚡ TCP Socket Tuning: 4MB буферы сокетов, TCP_NODELAY и пул соединений через socket2 для максимальной скорости на VPN и LAN.");
ui.label(" • 🌊 Zero-Drop Auto-Recovery: Авто-восстановление передач при сбоях Wi-Fi/VPN с экспоненциальным backoff до 8 попыток без обрыва.");
ui.label(" • 📡 mDNS & Multicast Discovery: Мультикаст группы (239.255.255.250 и 224.0.0.251) для 100% обнаружения пиров на любых роутерах.");
ui.label(" • 🗜️ Умная Zstd-компрессия: Авто-пропуск сжатия для медиа (.jpg/.mp4/.zip...) и потоковое Zstd Level 1 сжатие кода/текста.");
ui.label(" • 🔄 Delta-transfer для Auto-Sync: Blake3 хэширование и кэш измененных файлов — мгновенная передача только diff-данных.");
ui.label(" • 👥 Мульти-отправка, ⏸️ Пауза / ▶️ Возобновление и 🏎️ Очередь с приоритетами.");
ui.add_space(8.0);
ui.separator();
ui.add_space(8.0);
// v2.5.2
ui.horizontal(|ui| {
ui.label(RichText::new("v2.5.2").strong().color(Color32::from_rgb(56, 189, 248)));
});
ui.label(" • 🎨 Исправление макета передач: Идеальное скругление и выравнивание карточек скорости и статистики дашборда.");
ui.label(" • 🧹 Чистое автообновление: Автоматическое удаление старых версий (.old и временных файлов) при установке обновлений.");
ui.label(" • 🇷🇺 Полная русская локализация: Переведены Credits, стек технологий, модальные окна, Grep, редактор Vim и история версий.");
ui.add_space(8.0);
ui.separator();
ui.add_space(8.0);
// v2.5.1
ui.horizontal(|ui| {
ui.label(RichText::new("v2.5.1").strong().color(Color32::from_rgb(56, 189, 248)));
});
ui.label(" • 🎨 Полная модернизация интерфейса: Единый стиль карточек, дашборд статистики передач, пошаговые мастера отправки и автосинхронизации, баннеры подсказок.");
ui.label(" • 🔄 Улучшенный мастер Auto-Sync: Наглядный 3-шаговый визард выбора папок и онлайн-друзей с интуитивным управлением синхронизацией.");
ui.label(" • 🚀 Gitea Self-Updater: Интеграция автопроверки и бесшовного обновления приложения прямо из встроенного репозитория Gitea.");
ui.add_space(8.0);
ui.separator();
ui.add_space(8.0);
// v2.5.0
ui.horizontal(|ui| {
ui.label(RichText::new("v2.5.0").strong().color(Color32::from_rgb(56, 189, 248)));
});
ui.label(" • 🔄 Двусторонняя автосинхронизация папок: Фоновый движок синхронизации локальных и удаленных каталогов без участия пользователя.");
ui.label(" • 🖧 Сетевой диск Windows (WebDAV): Подключение общих папок напрямую в Проводник Windows как диск Z:\\ в 1 клик.");
ui.label(" • 🎵 Аудиоплеер и 🎬 Watch Party: Док мини-плеера и совместные комнаты просмотра видео с синхронизацией воспроизведения.");
ui.label(" • ✏️ Полноценный Vim-редактор с сохранением по сети: Режимы Normal/Insert, правка буфера, предпросмотр Markdown и запись (:w).");
ui.label(" • 🔎 Удаленный Grep-поиск: Поиск кода и текста на компьютерах друзей с мгновенным переходом к строке.");
ui.label(" • ⬇️ Пакетное скачивание и Drag & Drop: Выбор файлов чекбоксами и прямое перетаскивание в сетевые папки.");
ui.add_space(8.0);
ui.separator();
ui.add_space(8.0);
// v2.4.1
ui.horizontal(|ui| {
ui.label(RichText::new("v2.4.1").strong().color(Color32::from_rgb(56, 189, 248)));
});
ui.label(" • 📁 Вкладка Общие папки: Управление сетевыми папками с правами «Только чтение» / «Чтение и запись» и защитой PIN.");
ui.label(" • 🎬 HTTP 206 Streaming: Мгновенное воспроизведение видео/аудио с перемоткой без предварительного скачивания.");
ui.label(" • ⚡ Встроенный просмотрщик Vim: Терминальный просмотр кода с горячими клавишами Vim, поиском (/) и прыжком по строкам.");
ui.label(" • 📦 Единая папка (Portable): Все настройки (config.json) и загрузки создаются и хранятся рядом с exe.");
ui.label(" • 🔄 Auto-Sync маяки: Оповещение об общих папках при подключении к 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 на лету: Проверка целостности передаваемых файлов с зелеными бейджами гарантии.");
ui.label(" • 📸 Превью фото на мобильных: Галерея миниатюр перед отправкой с возможностью удаления лишних снимков.");
ui.label(" • 🎙️ Голосовые сообщения на мобильных: Встроенный диктофон с пульсирующей анимацией волны.");
ui.label(" • 📲 Поддержка PWA: Установка веб-приложения на главный экран смартфона прямо из браузера.");
ui.label(" • 🎨 Авто-тема: Темная/светлая тема с синхронизацией с реестром Windows.");
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(" • 🚀 Автозапуск с Windows: Опция запуска ZeroSend при старте системы в свёрнутом виде в трей.");
ui.label(" • 🎯 Быстрая отправка Drag & Drop: Перетаскивание файлов прямо на карточку любого друга во вкладке Устройства.");
ui.label(" • Двойные ссылки и QR-коды (ZeroTier/VPN + Домашний Wi-Fi).");
ui.label(" • Всплывающее окно подтверждения передачи через ZeroTier.");
ui.label(" • Сглаживание показаний скорости алгоритмом EMA (без скачков).");
ui.label(" • 4-поточная параллельная передача файлов.");
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(" • Модернизированная мобильная Web Dropzone с кнопками камеры и фото.");
ui.label(" • Раздельные QR-коды для ZeroTier и локальной сети Wi-Fi.");
ui.label(" • Автоматическое переключение на вкладку Передач при старте отправки.");
ui.label(" • Отображение статуса сжатия папок в ZIP в реальном времени.");
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(" • Современный стеклянный интерфейс (Glassmorphism) для Web Dropzone.");
ui.label(" • Быстрая передача текста из браузера и адаптивный Drag & Drop.");
ui.label(" • Глобальная вставка скриншотов из буфера (Ctrl+V).");
ui.label(" • Реальная скорость и прогресс на стороне отправителя.");
ui.label(" • Постоянные UUID устройств и защита от дубликатов.");
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(" • Наглядная визуализация передач, иконки типов файлов и спидометры.");
ui.label(" • Фоновая работа в системном трее Windows и мгновенный выход.");
ui.label(" • Сжатие папок в ZIP на лету и автораспаковка в папке загрузок.");
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(" • Нативный графический интерфейс (GUI) без консоли.");
ui.label(" • Параллельная рассылка файлов и текста нескольким получателям.");
ui.label(" • Генератор QR-кодов для отправки с телефонов.");
ui.label(" • Защита 4-значным PIN-кодом входящих передач.");
ui.label(" • Кнопки «Открыть файл» и «Показать в папке».");
});
});
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("🔍 Прямое подключение (IP)")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO)
.show(ctx, |ui| {
ui.label(RichText::new("Введите IP-адрес узла в сети ZeroTier / Tailscale / Radmin:").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("Подключить и проверить").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 = "Подключение...".to_string();
tokio::spawn(async move {
match client.ping_peer(&ip, 53318).await {
Ok(beacon) => {
let _ = log_tx.send(format!("✨ Добавлен узел: {} ({})", 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!("❌ Ошибка прямого подключения: {}", e));
}
}
});
self.show_direct_ip_modal = false;
}
}
if ui.button("Отмена").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("✏️ Переименовать друга / Псевдоним")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO)
.show(ctx, |ui| {
ui.label(RichText::new(format!("Укажите удобное имя (псевдоним) для {}", 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("Сохранить псевдоним").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("Отмена").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("📋 Входящий быстрый текст")
.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!("От: {}", 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("📋 Скопировать в буфер").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("🌐 Открыть ссылку").clicked() {
ui.ctx().open_url(egui::OpenUrl::same_tab(&content_copy));
}
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.button("Закрыть").clicked() {
self.received_text_modal = None;
}
});
});
});
}
// PROMINENT INCOMING FILE TRANSFER CONFIRMATION MODAL
if let Some(req) = self.pending_request.clone() {
egui::Window::new("📥 ВХОДЯЩИЙ ЗАПРОС НА ПЕРЕДАЧУ ФАЙЛОВ")
.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!("Входящие файлы от: {}", req.sender_name))
.size(16.0)
.strong()
.color(theme.text_accent),
);
ui.label(
RichText::new(format!(
"Всего: {} файл(ов) • Общий размер: {}",
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("Список передаваемых файлов:").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!(" ... и ещё {} файлов", req.files.len() - 8)).color(theme.text_muted));
}
});
});
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(RichText::new("Папка сохранения:").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("✅ ПРИНЯТЬ И СКАЧАТЬ")
.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,
compressed_bytes: 0,
};
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.switch_tab(Tab::Transfers);
}
let reject_btn = Button::new(
RichText::new("❌ ОТКЛОНИТЬ")
.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);
});
}
// REMOTE GREP & CODE SEARCH MODAL
if self.show_grep_modal {
let mut is_open = self.show_grep_modal;
let mut trigger_open_file = None;
egui::Window::new("🔎 Поиск текста и кода в общей папке (Grep)")
.open(&mut is_open)
.collapsible(false)
.resizable(true)
.default_size(Vec2::new(650.0, 420.0))
.anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO)
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("Запрос:").strong().color(theme.text_primary));
let edit = ui.add(
egui::TextEdit::singleline(&mut self.grep_query)
.hint_text("например: fn main, TODO, пароль...")
.desired_width(260.0),
);
let mut trigger_search = false;
if edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
trigger_search = true;
}
let search_btn = Button::new(
RichText::new(if self.is_grepping { "⏳ Поиск..." } else { "🔍 Искать" })
.strong()
.color(Color32::WHITE),
)
.fill(Color32::from_rgb(37, 99, 235))
.rounding(Rounding::same(4.0));
if ui.add_enabled(!self.is_grepping && !self.grep_query.trim().is_empty(), search_btn).clicked() {
trigger_search = true;
}
if trigger_search && !self.grep_query.trim().is_empty() {
if let (Some(peer_id), Some(folder_id)) = (&self.selected_peer_id_for_share, &self.selected_folder_id_for_share) {
if let Some(peer) = self.peers.iter().find(|p| &p.peer_id == peer_id).cloned() {
self.is_grepping = true;
self.grep_status_msg = Some("Поиск по удаленным файлам...".to_string());
let client = self.client.clone();
let peer_url = peer.transfer_url();
let f_id = folder_id.clone();
let q = self.grep_query.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();
tokio::spawn(async move {
match client.grep_shared_folder(&peer_url, &f_id, &q, pin.as_deref(), Some(&sub), false).await {
Ok(resp) => {
let _ = log_tx.send(format!("🔎 Grep: найдено {} совпадений в {} проверенных файлах", resp.matches.len(), resp.total_files_searched));
}
Err(e) => {
let _ = log_tx.send(format!("❌ Ошибка Grep: {}", e));
}
}
});
}
}
}
});
ui.add_space(8.0);
ui.separator();
ui.add_space(8.0);
// Results list
if self.is_grepping {
ui.vertical_centered(|ui| {
ui.spinner();
ui.label("Поиск по удаленным файлам кода и текста...");
});
} else if self.grep_results.is_empty() {
ui.label(RichText::new("Введите поисковый запрос выше и нажмите «Искать» для поиска строк в коде.").size(12.0).color(theme.text_muted));
} else {
ui.label(RichText::new(format!("Найдено совпадений: {}", self.grep_results.len())).strong().color(theme.text_accent));
ui.add_space(4.0);
ScrollArea::vertical().max_height(280.0).show(ui, |ui| {
for m in &self.grep_results {
Frame::none()
.fill(theme.bg_card_secondary)
.rounding(Rounding::same(4.0))
.inner_margin(Margin::symmetric(10.0, 6.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new(format!("{}:{}", m.file_path, m.line_number))
.strong()
.color(Color32::from_rgb(56, 189, 248)),
);
ui.label(RichText::new(&m.line_content).monospace().color(theme.text_primary));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.button("Открыть в Vim").clicked() {
trigger_open_file = Some((m.file_path.clone(), m.line_number));
}
});
});
});
ui.add_space(3.0);
}
});
}
});
if let Some((file_rel, _line)) = trigger_open_file {
if let (Some(peer_id), Some(folder_id)) = (&self.selected_peer_id_for_share, &self.selected_folder_id_for_share) {
if let Some(peer) = self.peers.iter().find(|p| &p.peer_id == peer_id).cloned() {
let client = self.client.clone();
let peer_url = peer.transfer_url();
let f_id = folder_id.clone();
let sub = file_rel.clone();
let fname = std::path::Path::new(&file_rel).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
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((fname, sub, peer_display, text, Some(peer_url), Some(f_id), pin, true));
}
});
}
}
is_open = false;
}
self.show_grep_modal = is_open;
}
self.vim_viewer.render(ctx, theme);
}
pub fn trigger_update_check(&mut self) {
self.is_checking_update = true;
self.update_check_status_msg = Some("Подключение к Gitea и проверка обновлений...".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() {
if self.current_tab == Tab::Shared && self.selected_peer_id_for_share.is_some() && self.selected_folder_id_for_share.is_some() {
let peer_id = self.selected_peer_id_for_share.clone().unwrap();
let folder_id = self.selected_folder_id_for_share.clone().unwrap();
let sub = self.current_share_subpath.clone();
let peer_opt = self.peers.iter().find(|p| p.peer_id == peer_id).cloned();
if let Some(peer) = peer_opt {
let client = self.client.clone();
let peer_url = peer.transfer_url();
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 tree_tx = self.share_tree_tx.clone();
tokio::spawn(async move {
for f in dropped_files {
if let Some(path) = f.path {
let _ = log_tx.send(format!("⬆️ Dropped upload {:?} to shared folder...", path.file_name()));
let _ = client.upload_to_shared_folder(&peer_url, &folder_id, &sub, &path, pin.as_deref()).await;
}
}
if let Ok(tree) = client.fetch_shared_tree(&peer_url, &folder_id, Some(&sub), pin.as_deref()).await {
let _ = tree_tx.send(Some(tree));
}
});
}
} else {
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);
}
}
}
// Fluid Tab Transition Animation (Smooth Slide-in & Fade-in)
let elapsed = self.last_tab_change.elapsed().as_secs_f32();
let duration = 0.20; // 200 ms
let progress = (elapsed / duration).min(1.0);
if progress < 1.0 {
ctx.request_repaint();
}
// Cubic ease-out curve
let ease = 1.0 - (1.0 - progress).powi(3);
let slide_offset = (1.0 - ease) * 12.0;
ui.add_space(slide_offset);
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));
}
}
}