1use lanflare_core::discovery::registry::DiscoveredPeer;
4use lanflare_proto::discovery::{DeviceType, ProtocolType};
5
6pub fn get_device_icon(device_type: Option<DeviceType>, model: Option<&str>) -> &'static str {
8 if let Some(m) = model {
10 let m_lower = m.to_ascii_lowercase();
11 if m_lower.contains("windows") {
12 return "🪟 ";
13 } else if m_lower.contains("mac") || m_lower.contains("darwin") || m_lower.contains("apple") {
14 return "🍎";
15 } else if m_lower.contains("linux") || m_lower.contains("ubuntu") || m_lower.contains("debian") || m_lower.contains("fedora") || m_lower.contains("arch") {
16 return "🐧";
17 } else if m_lower.contains("android") {
18 return "🤖";
19 } else if m_lower.contains("iphone") || m_lower.contains("ipad") || m_lower.contains("ios") {
20 return "📱";
21 }
22 }
23
24 match device_type {
25 Some(DeviceType::Mobile) => "📱",
26 Some(DeviceType::Desktop) => "💻",
27 Some(DeviceType::Web) => "🌐",
28 Some(DeviceType::Headless) => "⚡",
29 Some(DeviceType::Server) => "🖥️ ",
30 None => "💻",
31 }
32}
33
34pub fn format_protocol(protocol: ProtocolType) -> &'static str {
36 match protocol {
37 ProtocolType::Http => "HTTP (plain)",
38 ProtocolType::Https => "HTTPS (mTLS)",
39 }
40}
41
42pub fn shorten_fingerprint(fp: &str) -> String {
44 if fp.len() <= 16 {
45 fp.to_string()
46 } else {
47 format!("{}...{}", &fp[..8], &fp[fp.len() - 6..])
48 }
49}
50
51pub fn render_peer_table(peers: &[DiscoveredPeer]) -> String {
53 if peers.is_empty() {
54 return "No peers discovered on local network.\n".to_string();
55 }
56
57 let mut out = String::new();
58 out.push_str("\nDiscovered Peers:\n");
59 out.push_str(&format!(
60 "{:<3} {:<22} {:<18} {:<14} {:<16} {:<16}\n",
61 "OS", "ALIAS", "PRIMARY IP", "PORT", "PROTOCOL", "FINGERPRINT"
62 ));
63 out.push_str(&format!(
64 "{:-<3} {:-<22} {:-<18} {:-<14} {:-<16} {:-<16}\n",
65 "", "", "", "", "", ""
66 ));
67
68 for peer in peers {
69 let icon = get_device_icon(peer.device_type, peer.device_model.as_deref());
70 let alias = if peer.alias.len() > 20 {
71 format!("{}…", &peer.alias[..19])
72 } else {
73 peer.alias.clone()
74 };
75 let ip_str = peer
76 .primary_ip()
77 .map(|ip| ip.to_string())
78 .unwrap_or_else(|| "-".to_string());
79 let port_str = peer.port.to_string();
80 let proto_str = match peer.protocol {
81 ProtocolType::Http => "HTTP",
82 ProtocolType::Https => "HTTPS (TLS)",
83 };
84 let fp_str = shorten_fingerprint(&peer.fingerprint);
85
86 out.push_str(&format!(
87 "{:<3} {:<22} {:<18} {:<14} {:<16} {:<16}\n",
88 icon, alias, ip_str, port_str, proto_str, fp_str
89 ));
90 }
91
92 out
93}
94
95use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
96use std::collections::HashMap;
97use lanflare_core::client::transfer::TransferProgress;
98
99pub struct TransferProgressTracker {
101 mp: MultiProgress,
102 overall_pb: ProgressBar,
103 file_pbs: HashMap<String, ProgressBar>,
104 spinner_style: ProgressStyle,
105 bar_style: ProgressStyle,
106 is_interactive: bool,
107}
108
109impl Default for TransferProgressTracker {
110 fn default() -> Self {
111 Self::new(true)
112 }
113}
114
115impl TransferProgressTracker {
116 pub fn new(is_interactive: bool) -> Self {
121 let mp = MultiProgress::new();
122
123 let bar_style = ProgressStyle::default_bar()
124 .template("{prefix:>18} [{elapsed_precise}] [{bar:30.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, eta {eta})")
125 .unwrap_or_else(|_| ProgressStyle::default_bar())
126 .progress_chars("━╾─");
127
128 let spinner_style = ProgressStyle::default_spinner()
129 .template("{prefix:>18} {spinner:.green} {msg}")
130 .unwrap_or_else(|_| ProgressStyle::default_spinner());
131
132 let overall_pb = if is_interactive {
133 mp.add(ProgressBar::new(0))
134 } else {
135 ProgressBar::hidden()
136 };
137 overall_pb.set_style(bar_style.clone());
138 overall_pb.set_prefix("Overall Progress");
139
140 Self {
141 mp,
142 overall_pb,
143 file_pbs: HashMap::new(),
144 spinner_style,
145 bar_style,
146 is_interactive,
147 }
148 }
149
150 pub fn handle_event(&mut self, event: &TransferProgress) {
152 match event {
153 TransferProgress::Started { total_bytes, accepted_files, .. } => {
154 self.overall_pb.set_length(*total_bytes);
155 self.overall_pb.set_position(0);
156 self.overall_pb.set_message(format!("Uploading {} file(s)", accepted_files.len()));
157 }
158 TransferProgress::FileProgress {
159 file_id,
160 file_name,
161 file_bytes_sent,
162 file_total_bytes,
163 session_bytes_sent,
164 ..
165 } => {
166 self.overall_pb.set_position(*session_bytes_sent);
167
168 let pb = self.file_pbs.entry(file_id.clone()).or_insert_with(|| {
169 let pb = if self.is_interactive {
170 self.mp.add(ProgressBar::new(*file_total_bytes))
171 } else {
172 ProgressBar::hidden()
173 };
174 pb.set_style(self.bar_style.clone());
175 let prefix = if file_name.len() > 18 {
176 format!("…{}", &file_name[file_name.len() - 17..])
177 } else {
178 file_name.clone()
179 };
180 pb.set_prefix(prefix);
181 pb
182 });
183
184 pb.set_position(*file_bytes_sent);
185 }
186 TransferProgress::FileFinished { file_id, file_name, .. } => {
187 if let Some(pb) = self.file_pbs.get(file_id) {
188 pb.finish_with_message("Done ✓");
189 } else if self.is_interactive {
190 let pb = self.mp.add(ProgressBar::new(0));
191 pb.set_style(self.spinner_style.clone());
192 pb.set_prefix(file_name.clone());
193 pb.finish_with_message("Done ✓");
194 }
195 }
196 TransferProgress::FileFailed { file_id, error, .. } => {
197 if let Some(pb) = self.file_pbs.get(file_id) {
198 pb.abandon_with_message(format!("Failed: {error}"));
199 }
200 }
201 TransferProgress::SessionCompleted { total_files, total_bytes, duration, .. } => {
202 self.overall_pb.set_position(*total_bytes);
203 self.overall_pb.finish_with_message(format!(
204 "Completed {} file(s) in {:.1}s ✓",
205 total_files,
206 duration.as_secs_f64()
207 ));
208 }
209 TransferProgress::SessionCancelled { reason, .. } => {
210 self.overall_pb.abandon_with_message(format!("Cancelled: {reason}"));
211 }
212 }
213 }
214
215 pub fn multi_progress(&self) -> &MultiProgress {
217 &self.mp
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use lanflare_core::discovery::registry::PeerEndpoint;
225 use lanflare_core::discovery::DiscoverySource;
226 use std::collections::{HashMap, HashSet};
227 use std::net::{IpAddr, Ipv4Addr};
228
229 #[test]
230 fn test_device_icon_resolution() {
231 assert_eq!(get_device_icon(Some(DeviceType::Desktop), Some("Linux 6.8")), "🐧");
232 assert_eq!(get_device_icon(Some(DeviceType::Desktop), Some("Windows 11")), "🪟 ");
233 assert_eq!(get_device_icon(Some(DeviceType::Desktop), Some("macOS Sonoma")), "🍎");
234 assert_eq!(get_device_icon(Some(DeviceType::Mobile), Some("Pixel 8 Android")), "🤖");
235 assert_eq!(get_device_icon(Some(DeviceType::Mobile), None), "📱");
236 assert_eq!(get_device_icon(Some(DeviceType::Server), None), "🖥️ ");
237 assert_eq!(get_device_icon(None, None), "💻");
238 }
239
240 #[test]
241 fn test_shorten_fingerprint() {
242 let fp = "ABCD1234EF567890ABCD1234EF567890ABCD1234EF567890ABCD1234EF567890";
243 assert_eq!(shorten_fingerprint(fp), "ABCD1234...567890");
244 assert_eq!(shorten_fingerprint("SHORT"), "SHORT");
245 }
246
247 #[test]
248 fn test_render_peer_table_empty() {
249 let out = render_peer_table(&[]);
250 assert!(out.contains("No peers discovered"));
251 }
252
253 #[test]
254 fn test_render_peer_table_with_peers() {
255 let mut sources = HashSet::new();
256 sources.insert(DiscoverySource::Multicast);
257
258 let peer = DiscoveredPeer {
259 fingerprint: "11223344556677889900AABBCCDDEEFF11223344556677889900AABBCCDDEEFF".to_string(),
260 alias: "Alice's Laptop".to_string(),
261 version: "2.0".to_string(),
262 device_model: Some("Linux 6.8".to_string()),
263 device_type: Some(DeviceType::Desktop),
264 port: 53317,
265 protocol: ProtocolType::Https,
266 download: false,
267 endpoints: vec![PeerEndpoint {
268 ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)),
269 scope_id: None,
270 sources: sources.clone(),
271 first_seen_ms: 1000,
272 last_seen_ms: 1000,
273 }],
274 discovery_sources: sources,
275 first_seen_ms: 1000,
276 last_seen_ms: 1000,
277 metadata: HashMap::new(),
278 };
279
280 let table = render_peer_table(&[peer]);
281 assert!(table.contains("Alice's Laptop"));
282 assert!(table.contains("192.168.1.100"));
283 assert!(table.contains("53317"));
284 assert!(table.contains("HTTPS (TLS)"));
285 assert!(table.contains("11223344...DDEEFF"));
286 }
287
288 #[test]
289 fn test_transfer_progress_tracker_lifecycle() {
290 let mut tracker = TransferProgressTracker::new(false);
291 tracker.handle_event(&TransferProgress::Started {
292 session_id: "sess-1".to_string(),
293 accepted_files: vec!["file-1".to_string()],
294 total_bytes: 1000,
295 });
296
297 tracker.handle_event(&TransferProgress::FileProgress {
298 session_id: "sess-1".to_string(),
299 file_id: "file-1".to_string(),
300 file_name: "test.txt".to_string(),
301 file_bytes_sent: 500,
302 file_total_bytes: 1000,
303 session_bytes_sent: 500,
304 session_total_bytes: 1000,
305 bytes_per_second: 5000,
306 eta: Some(std::time::Duration::from_secs(1)),
307 });
308
309 tracker.handle_event(&TransferProgress::FileFinished {
310 session_id: "sess-1".to_string(),
311 file_id: "file-1".to_string(),
312 file_name: "test.txt".to_string(),
313 });
314
315 tracker.handle_event(&TransferProgress::SessionCompleted {
316 session_id: "sess-1".to_string(),
317 total_files: 1,
318 total_bytes: 1000,
319 duration: std::time::Duration::from_millis(200),
320 });
321 }
322
323 #[test]
324 fn test_transfer_progress_tracker_error_and_cancellation() {
325 let mut tracker = TransferProgressTracker::new(false);
326 tracker.handle_event(&TransferProgress::FileFailed {
327 session_id: "sess-2".to_string(),
328 file_id: "file-2".to_string(),
329 file_name: "fail.bin".to_string(),
330 error: "connection reset".to_string(),
331 });
332
333 tracker.handle_event(&TransferProgress::SessionCancelled {
334 session_id: "sess-2".to_string(),
335 reason: "user aborted".to_string(),
336 });
337 }
338}