1use crate::gpu;
10use chrono::TimeZone;
11use owo_colors::OwoColorize;
12use sysinfo::{Components, Disks, Networks, System, Users};
13
14#[derive(Debug, Default, Clone)]
19pub struct CollectOptions {
20 pub long: bool,
22}
23
24#[derive(Debug)]
29pub struct SystemInfo {
30 pub os: String,
32 pub kernel: Option<String>,
34 pub hostname: Option<String>,
36 pub arch: String,
38 pub cpu: String,
40 pub cpu_cores: usize,
42 pub memory: String,
44 pub swap: String,
46 pub uptime: String,
48 pub processes: usize,
50 pub load_avg: Option<String>,
52 pub disks: Vec<String>,
54 pub temps: Vec<String>,
56 pub networks: Vec<String>,
58 pub boot_time: String,
60 pub battery: Option<String>,
62 pub shell: Option<String>,
64 pub terminal: Option<String>,
66 pub desktop: Option<String>,
68 pub cpu_freq: Option<String>,
70 pub users: usize,
72 pub gpu: Vec<String>,
74 pub packages: Option<usize>,
76 pub current_user: Option<String>,
78 pub local_ip: Option<String>,
80 pub public_ip: Option<String>,
82 pub active_interface: Option<String>,
84 pub motherboard: Option<String>,
86 pub bios: Option<String>,
88 pub displays: Vec<String>,
90 pub audio: Option<String>,
92 pub wifi: Option<String>,
94 pub bluetooth: Option<String>,
96 pub ui_theme: Option<String>,
98 pub icons: Option<String>,
100 pub cursor: Option<String>,
102 pub font: Option<String>,
104 pub camera: Vec<String>,
106 pub gamepad: Vec<String>,
108}
109
110impl SystemInfo {
111 pub fn collect(opts: CollectOptions) -> anyhow::Result<Self> {
116 let mut sys = System::new_all();
117 sys.refresh_all();
118
119 let os = System::long_os_version()
120 .or_else(System::name)
121 .unwrap_or_else(|| "Unknown".to_string());
122
123 let kernel = System::kernel_version();
124 let hostname = System::host_name();
125
126 let cpu = sys
127 .cpus()
128 .first()
129 .map(|c| c.brand().to_string())
130 .unwrap_or_else(|| "Unknown CPU".to_string());
131
132 let cpu_cores = sys.cpus().len();
133
134 let total_mem = sys.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
135 let used_mem = sys.used_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
136 let memory = format!("{:.1} / {:.1} GB", used_mem, total_mem);
137
138 let total_swap = sys.total_swap() as f64 / 1024.0 / 1024.0 / 1024.0;
139 let used_swap = sys.used_swap() as f64 / 1024.0 / 1024.0 / 1024.0;
140 let swap = if total_swap > 0.0 {
141 format!("{:.1} / {:.1} GB", used_swap, total_swap)
142 } else {
143 "No swap".to_string()
144 };
145
146 let uptime = format!("{}s", System::uptime());
147
148 let disks_list = Disks::new_with_refreshed_list();
149 let disks: Vec<String> = if !opts.long {
150 let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/"));
151 let mut best_match: Option<&sysinfo::Disk> = None;
152 for disk in disks_list.iter() {
153 if home.starts_with(disk.mount_point()) {
154 if let Some(best) = best_match {
155 if disk.mount_point().components().count()
156 > best.mount_point().components().count()
157 {
158 best_match = Some(disk);
159 }
160 } else {
161 best_match = Some(disk);
162 }
163 }
164 }
165 let selected_disks = if let Some(best) = best_match {
166 vec![best]
167 } else {
168 disks_list.iter().collect::<Vec<_>>()
169 };
170
171 selected_disks
172 .iter()
173 .map(|d| {
174 let total = d.total_space() as f64 / 1024.0 / 1024.0 / 1024.0;
175 let avail = d.available_space() as f64 / 1024.0 / 1024.0 / 1024.0;
176 let fs = d.file_system().to_string_lossy();
177 format!(
178 "{} ({}): {:.1} GB free / {:.1} GB",
179 d.mount_point().display(),
180 fs,
181 avail,
182 total
183 )
184 })
185 .collect()
186 } else {
187 disks_list
188 .iter()
189 .map(|d| {
190 let total = d.total_space() as f64 / 1024.0 / 1024.0 / 1024.0;
191 let avail = d.available_space() as f64 / 1024.0 / 1024.0 / 1024.0;
192 let fs = d.file_system().to_string_lossy();
193 format!(
194 "{} ({}): {:.1} GB free / {:.1} GB",
195 d.mount_point().display(),
196 fs,
197 avail,
198 total
199 )
200 })
201 .collect()
202 };
203
204 let battery = crate::battery::get_battery_info().map(|bat| {
205 let pct = bat.percentage;
206 let state = match bat.state {
207 crate::battery::BatteryState::Charging => "charging",
208 crate::battery::BatteryState::Discharging => "discharging",
209 crate::battery::BatteryState::Full => "full",
210 _ => "not charging",
211 };
212 let vendor = bat.vendor;
213 let model = bat.model;
214
215 let time_str = match bat.state {
217 crate::battery::BatteryState::Charging => bat.time_remaining.map(|d| {
218 let total_mins = d.as_secs() / 60;
219 let hours = total_mins / 60;
220 let mins = total_mins % 60;
221 if hours >= 24 {
222 let days = hours / 24;
223 let rem_hours = hours % 24;
224 format!("{}d {}h until full", days, rem_hours)
225 } else if hours > 0 {
226 format!("{}h {}m until full", hours, mins)
227 } else {
228 format!("{}m until full", mins)
229 }
230 }),
231 crate::battery::BatteryState::Discharging => bat.time_remaining.map(|d| {
232 let total_mins = d.as_secs() / 60;
233 let hours = total_mins / 60;
234 let mins = total_mins % 60;
235 if hours >= 24 {
236 let days = hours / 24;
237 let rem_hours = hours % 24;
238 format!("{}d {}h remaining", days, rem_hours)
239 } else if hours > 0 {
240 format!("{}h {}m remaining", hours, mins)
241 } else {
242 format!("{}m remaining", mins)
243 }
244 }),
245 _ => None,
246 };
247
248 let mut parts = vec![state.to_string()];
249 if let Some(t) = time_str {
250 parts.insert(0, t);
251 }
252 if let Some(health) = bat.health {
253 if health < 99.0 {
254 parts.push(format!("{:.0}% health", health));
255 }
256 }
257
258 let base = format!("{:.0}% ({})", pct, parts.join(", "));
259
260 match (vendor, model) {
261 (Some(v), Some(m)) => format!("{} [{} {}]", base, v, m),
262 (Some(v), None) => format!("{} [{}]", base, v),
263 _ => base,
264 }
265 });
266
267 let arch = System::cpu_arch();
268
269 let processes = sys.processes().len();
270
271 let load_avg = {
272 let avg = System::load_average();
273 if avg.one > 0.0 || avg.five > 0.0 {
274 Some(format!(
275 "{:.2}, {:.2}, {:.2}",
276 avg.one, avg.five, avg.fifteen
277 ))
278 } else {
279 None
280 }
281 };
282
283 let (
285 gpu,
286 packages,
287 public_ip,
288 (local_ip, active_interface),
289 motherboard,
290 bios,
291 displays,
292 audio,
293 wifi,
294 bluetooth,
295 (ui_theme, icons, cursor, font),
296 camera,
297 gamepad,
298 ) = std::thread::scope(|s| {
299 let gpu_handle = s.spawn(|| {
300 gpu::detect_gpus()
301 .into_iter()
302 .map(|g| g.format())
303 .collect::<Vec<String>>()
304 });
305 let packages_handle = s.spawn(detect_packages);
306 let public_ip_handle = s.spawn(|| {
307 std::process::Command::new("curl")
308 .args(["-s", "--max-time", "2", "https://api.ipify.org"])
309 .output()
310 .ok()
311 .and_then(|o| String::from_utf8(o.stdout).ok())
312 .map(|s| s.trim().to_string())
313 .filter(|s| !s.is_empty())
314 });
315 let network_ips_handle = s.spawn(|| {
316 let local_ip = std::net::UdpSocket::bind("0.0.0.0:0")
317 .ok()
318 .and_then(|socket| {
319 socket.connect("8.8.8.8:53").ok()?;
320 socket.local_addr().ok().map(|addr| addr.ip().to_string())
321 });
322
323 let active_interface = {
324 #[cfg(target_os = "linux")]
325 {
326 std::process::Command::new("ip")
327 .args(["route", "show", "default"])
328 .output()
329 .ok()
330 .and_then(|o| String::from_utf8(o.stdout).ok())
331 .and_then(|s| {
332 s.split_whitespace()
333 .position(|w| w == "dev")
334 .and_then(|i| s.split_whitespace().nth(i + 1))
335 .map(|s| s.to_string())
336 })
337 }
338 #[cfg(target_os = "macos")]
339 {
340 std::process::Command::new("route")
341 .args(["-n", "get", "default"])
342 .output()
343 .ok()
344 .and_then(|o| String::from_utf8(o.stdout).ok())
345 .and_then(|s| {
346 s.lines()
347 .find(|l| l.contains("interface:"))
348 .and_then(|l| l.split_whitespace().last())
349 .map(|s| s.to_string())
350 })
351 }
352 #[cfg(target_os = "windows")]
353 {
354 std::process::Command::new("powershell")
355 .args(["-Command", "Get-NetRoute -DestinationPrefix 0.0.0.0/0 | Select-Object -First 1 -ExpandProperty InterfaceAlias"])
356 .output()
357 .ok()
358 .and_then(|o| String::from_utf8(o.stdout).ok())
359 .map(|s| s.trim().to_string())
360 .filter(|s| !s.is_empty())
361 }
362 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
363 {
364 None
365 }
366 };
367
368 (local_ip, active_interface)
369 });
370 let motherboard_handle = s.spawn(detect_motherboard);
371 let bios_handle = s.spawn(detect_bios);
372 let displays_handle = s.spawn(detect_displays);
373 let audio_handle = s.spawn(|| detect_audio(&sys));
374 let wifi_handle = s.spawn(detect_wifi);
375 let bluetooth_handle = s.spawn(detect_bluetooth);
376 let ui_theme_and_fonts_handle = s.spawn(detect_ui_theme_and_fonts);
377 let camera_handle = s.spawn(detect_camera);
378 let gamepad_handle = s.spawn(detect_gamepad);
379
380 (
381 gpu_handle.join().unwrap_or_default(),
382 packages_handle.join().ok().flatten(),
383 public_ip_handle.join().ok().flatten(),
384 network_ips_handle.join().unwrap_or((None, None)),
385 motherboard_handle.join().ok().flatten(),
386 bios_handle.join().ok().flatten(),
387 displays_handle.join().unwrap_or_default(),
388 audio_handle.join().ok().flatten(),
389 wifi_handle.join().ok().flatten(),
390 bluetooth_handle.join().ok().flatten(),
391 ui_theme_and_fonts_handle
392 .join()
393 .unwrap_or((None, None, None, None)),
394 camera_handle.join().unwrap_or_default(),
395 gamepad_handle.join().unwrap_or_default(),
396 )
397 });
398
399 let mut temps: Vec<String> = Components::new_with_refreshed_list()
400 .iter()
401 .filter_map(|c| {
402 c.temperature().and_then(|t| {
403 if t > 0.0 {
404 Some(format!("{}: {:.0}°C", c.label(), t))
405 } else {
406 None
407 }
408 })
409 })
410 .collect();
411
412 temps.sort_by(|a, b| {
414 let a_cpu = a.to_lowercase().contains("cpu") || a.to_lowercase().contains("core");
415 let b_cpu = b.to_lowercase().contains("cpu") || b.to_lowercase().contains("core");
416 b_cpu.cmp(&a_cpu)
417 });
418
419 let networks = Networks::new_with_refreshed_list()
420 .iter()
421 .map(|(name, data)| {
422 let rx = format_bytes(data.total_received());
423 let tx = format_bytes(data.total_transmitted());
424 let is_up = data.operational_state() == sysinfo::InterfaceOperationalState::Up
425 || data.total_received() > 0
426 || data.total_transmitted() > 0;
427 let status = if is_up {
428 "Up".green().to_string()
429 } else {
430 "Down".red().to_string()
431 };
432
433 let mut ipv4_addresses = Vec::new();
434 let mut ipv6_addresses = Vec::new();
435
436 if is_up {
437 for ip_net in data.ip_networks() {
438 let ip = ip_net.addr;
439 let name_lower = name.to_lowercase();
440 let is_loopback_iface =
441 name_lower.starts_with("lo") || name_lower.contains("loopback");
442 if ip.is_loopback() && !is_loopback_iface {
443 continue;
444 }
445 match ip {
446 std::net::IpAddr::V4(v4) => {
447 ipv4_addresses.push(v4.to_string());
448 }
449 std::net::IpAddr::V6(v6) => {
450 if !v6.is_unicast_link_local() {
451 ipv6_addresses.push(v6.to_string());
452 }
453 }
454 }
455 }
456
457 if ipv4_addresses.is_empty() && ipv6_addresses.is_empty() {
459 if let (Some(ref active), Some(ref ip)) = (&active_interface, &local_ip) {
460 if name == active {
461 ipv4_addresses.push(ip.clone());
462 }
463 }
464 }
465 }
466
467 let ip_str = if !ipv4_addresses.is_empty() || !ipv6_addresses.is_empty() {
468 let mut combined = Vec::new();
469 if !ipv4_addresses.is_empty() {
470 combined.push(ipv4_addresses.join(", "));
471 }
472 if !ipv6_addresses.is_empty() {
473 combined.push(ipv6_addresses.join(", "));
474 }
475 format!(" ({})", combined.join(", "))
476 } else {
477 String::new()
478 };
479
480 format!("{}{} [{}] RX: {} TX: {}", name, ip_str, status, rx, tx)
481 })
482 .collect();
483
484 let boot_timestamp = System::boot_time();
485 let boot_dt = chrono::Local
486 .timestamp_opt(boot_timestamp as i64, 0)
487 .single()
488 .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
489 .unwrap_or_else(|| boot_timestamp.to_string());
490 let boot_time = boot_dt;
491
492 let shell = detect_shell(&sys);
494 let terminal = std::env::var("TERM").ok();
495 let desktop = std::env::var("XDG_CURRENT_DESKTOP")
496 .or_else(|_| std::env::var("DESKTOP_SESSION"))
497 .ok();
498
499 let cpu_freq = sys
501 .cpus()
502 .first()
503 .map(|c| format!("{:.2} GHz", c.frequency() as f64 / 1000.0));
504
505 let current_user = std::env::var("USER").ok();
507
508 let users = Users::new_with_refreshed_list()
510 .iter()
511 .filter(|user| {
512 let uid_str = user.id().to_string();
514 if let Ok(uid) = uid_str.parse::<u32>() {
515 uid >= 1000
516 } else {
517 false
518 }
519 })
520 .count();
521
522 Ok(Self {
523 os,
524 kernel,
525 hostname,
526 arch,
527 cpu,
528 cpu_cores,
529 memory,
530 swap,
531 uptime,
532 processes,
533 load_avg,
534 disks,
535 temps,
536 networks,
537 boot_time,
538 battery,
539 shell,
540 terminal,
541 desktop,
542 cpu_freq,
543 users,
544 gpu,
545 packages,
546 current_user,
547 local_ip,
548 public_ip,
549 active_interface,
550 motherboard,
551 bios,
552 displays,
553 audio,
554 wifi,
555 bluetooth,
556 ui_theme,
557 icons,
558 cursor,
559 font,
560 camera,
561 gamepad,
562 })
563 }
564}
565
566fn detect_packages() -> Option<usize> {
571 #[cfg(target_os = "macos")]
572 {
573 let mut count = 0;
574
575 for cellar_path in &["/opt/homebrew/Cellar", "/usr/local/Cellar"] {
577 if let Ok(entries) = std::fs::read_dir(cellar_path) {
578 count += entries.filter_map(|e| e.ok()).count();
579 }
580 }
581
582 for cask_path in &["/opt/homebrew/Caskroom", "/usr/local/Caskroom"] {
584 if let Ok(entries) = std::fs::read_dir(cask_path) {
585 count += entries.filter_map(|e| e.ok()).count();
586 }
587 }
588
589 if let Ok(entries) = std::fs::read_dir("/opt/local/var/macports/software") {
591 count += entries.filter_map(|e| e.ok()).count();
592 }
593
594 if count > 0 {
595 Some(count)
596 } else {
597 None
598 }
599 }
600
601 #[cfg(target_os = "windows")]
602 {
603 let mut count = 0;
604
605 if let Some(home) = dirs::home_dir() {
607 let scoop_dir = std::env::var("SCOOP")
608 .map(std::path::PathBuf::from)
609 .unwrap_or_else(|_| home.join("scoop"));
610 let scoop_apps = scoop_dir.join("apps");
611 if let Ok(entries) = std::fs::read_dir(scoop_apps) {
612 count += entries.filter_map(|e| e.ok()).count();
613 }
614 }
615
616 let choco_install = std::env::var("ChocolateyInstall")
618 .unwrap_or_else(|_| "C:\\ProgramData\\chocolatey".to_string());
619 let choco_lib = std::path::Path::new(&choco_install).join("lib");
620 if let Ok(entries) = std::fs::read_dir(choco_lib) {
621 count += entries.filter_map(|e| e.ok()).count();
622 }
623
624 if count > 0 {
625 Some(count)
626 } else {
627 None
628 }
629 }
630
631 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
632 {
633 if let Ok(entries) = std::fs::read_dir("/var/lib/pacman/local") {
635 let count = entries.filter_map(|e| e.ok()).count();
636 if count > 0 {
637 return Some(count);
638 }
639 }
640
641 if let Ok(entries) = std::fs::read_dir("/var/lib/dpkg/info") {
643 let count = entries
644 .filter_map(|e| e.ok())
645 .filter(|e| e.path().extension().is_some_and(|ext| ext == "list"))
646 .count();
647 if count > 0 {
648 return Some(count);
649 }
650 }
651
652 if let Ok(entries) = std::fs::read_dir("/var/db/pkg") {
654 let count: usize = entries
655 .filter_map(|e| e.ok())
656 .map(|e| {
657 std::fs::read_dir(e.path())
658 .map(|d| d.filter(|_| true).count())
659 .unwrap_or(0)
660 })
661 .sum();
662 if count > 0 {
663 return Some(count);
664 }
665 }
666
667 if let Ok(entries) = std::fs::read_dir("/var/db/xbps") {
669 let count = entries
670 .filter_map(|e| e.ok())
671 .filter(|e| e.path().extension().is_some_and(|ext| ext == "plist"))
672 .count();
673 if count > 0 {
674 return Some(count);
675 }
676 }
677
678 let rpm_db = "/var/lib/rpm/rpmdb.sqlite";
680 if std::path::Path::new(rpm_db).exists() {
681 match rusqlite::Connection::open(rpm_db) {
682 Ok(conn) => {
683 if let Ok(count) = conn.query_row("SELECT COUNT(*) FROM Packages", [], |row| {
684 row.get::<_, i64>(0)
685 }) {
686 if count > 0 {
687 return Some(count as usize);
688 }
689 }
690 }
691 Err(e) => {
692 eprintln!("warning: failed to open RPM database at {}: {}", rpm_db, e);
693 }
694 }
695 }
696
697 None
698 }
699}
700
701fn detect_wifi() -> Option<String> {
702 #[cfg(target_os = "linux")]
703 {
704 let mut wifi_interface = None;
705 if let Ok(entries) = std::fs::read_dir("/sys/class/net") {
706 for entry in entries.filter_map(|e| e.ok()) {
707 let path = entry.path();
708 if path.join("wireless").exists() || path.join("phy80211").exists() {
709 wifi_interface = Some(entry.file_name().to_string_lossy().to_string());
710 break;
711 }
712 }
713 }
714
715 if let Some(ref iface) = wifi_interface {
716 if let Ok(output) = std::process::Command::new("iw")
717 .args(["dev", iface, "link"])
718 .output()
719 {
720 if let Ok(stdout) = String::from_utf8(output.stdout) {
721 let (ssid, links) = parse_iw_link_output(&stdout);
722 if let Some(s) = ssid {
723 let card_model = get_wifi_card_model(iface);
724 let prefix = if let Some(m) = card_model {
725 format!("{} [{}] - ", m, iface)
726 } else {
727 format!("[{}] - ", iface)
728 };
729
730 if !links.is_empty() {
731 let mut link_strs = Vec::new();
732 for link in links {
733 let freq_str = link.freq.map(|f| {
734 let ghz_mhz = if f >= 1000.0 {
735 format!("{:.1} GHz", f / 1000.0)
736 } else {
737 format!("{} MHz", f)
738 };
739 if let Some(ch) = freq_to_channel(f) {
740 format!("{} ch{}", ghz_mhz, ch)
741 } else {
742 ghz_mhz
743 }
744 });
745
746 let mut rx_tx = Vec::new();
747 if let Some(rx) = link.rx_rate {
748 if rx != "0"
749 && !rx.starts_with("0 ")
750 && rx != "0 Mbps"
751 && rx != "0 MBit/s"
752 {
753 rx_tx.push(format!("↓{}", clean_rate(&rx)));
754 }
755 }
756 if let Some(tx) = link.tx_rate {
757 if tx != "0"
758 && !tx.starts_with("0 ")
759 && tx != "0 Mbps"
760 && tx != "0 MBit/s"
761 {
762 rx_tx.push(format!("↑{}", clean_rate(&tx)));
763 }
764 }
765
766 match (freq_str, rx_tx.is_empty()) {
767 (Some(f), false) => {
768 link_strs.push(format!("{} [{}]", f, rx_tx.join(" ")))
769 }
770 (Some(f), true) => link_strs.push(f),
771 (None, false) => link_strs.push(rx_tx.join(" ")),
772 _ => {}
773 }
774 }
775 if !link_strs.is_empty() {
776 return Some(format!(
777 "{}{}{} ({})",
778 prefix,
779 s,
780 "",
781 link_strs.join(", ")
782 ));
783 } else {
784 return Some(format!("{}{}", prefix, s));
785 }
786 }
787 return Some(format!("{}{}", prefix, s));
788 }
789 }
790 }
791 }
792
793 if let Ok(output) = std::process::Command::new("nmcli")
795 .args(["-t", "-f", "active,ssid,rate", "dev", "wifi"])
796 .output()
797 {
798 if let Ok(stdout) = String::from_utf8(output.stdout) {
799 for line in stdout.lines() {
800 let line = line.trim();
801 if let Some(rest) = line.strip_prefix("yes:") {
802 if let Some(colon_idx) = rest.rfind(':') {
803 let ssid = &rest[..colon_idx];
804 let rate = rest[colon_idx + 1..].trim();
805 if !ssid.is_empty() {
806 if !rate.is_empty()
807 && rate != "0"
808 && !rate.starts_with("0 ")
809 && rate != "0 Mbit/s"
810 && rate != "0 Mbps"
811 {
812 return Some(format!("{} ({})", ssid, clean_rate(rate)));
813 } else {
814 return Some(ssid.to_string());
815 }
816 }
817 } else if !rest.is_empty() {
818 return Some(rest.to_string());
819 }
820 }
821 }
822 }
823 }
824
825 if let Ok(output) = std::process::Command::new("iwgetid").arg("-r").output() {
827 if let Ok(stdout) = String::from_utf8(output.stdout) {
828 let ssid = stdout.trim();
829 if !ssid.is_empty() {
830 return Some(ssid.to_string());
831 }
832 }
833 }
834 None
835 }
836
837 #[cfg(target_os = "macos")]
838 {
839 if let Ok(output) = std::process::Command::new("/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport")
840 .arg("-I")
841 .output()
842 {
843 if let Ok(stdout) = String::from_utf8(output.stdout) {
844 return parse_airport_output(&stdout);
845 }
846 }
847 None
848 }
849
850 #[cfg(target_os = "windows")]
851 {
852 if let Ok(output) = std::process::Command::new("netsh")
853 .args(["wlan", "show", "interfaces"])
854 .output()
855 {
856 if let Ok(stdout) = String::from_utf8(output.stdout) {
857 return parse_netsh_output(&stdout);
858 }
859 }
860 None
861 }
862
863 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
864 {
865 None
866 }
867}
868
869#[allow(
870 clippy::manual_is_multiple_of,
871 clippy::manual_range_contains,
872 dead_code
873)]
874fn freq_to_channel(freq_mhz: f64) -> Option<u32> {
875 let freq = freq_mhz.round() as u32;
876 if freq >= 2412 && freq <= 2472 {
877 Some((freq - 2407) / 5)
878 } else if freq == 2484 {
879 Some(14)
880 } else if freq >= 5160 && freq <= 5885 {
881 if (freq - 5000) % 5 == 0 {
882 Some((freq - 5000) / 5)
883 } else {
884 None
885 }
886 } else if freq >= 5955 && freq <= 7115 {
887 if (freq - 5950) % 5 == 0 {
888 Some((freq - 5950) / 5)
889 } else {
890 None
891 }
892 } else {
893 None
894 }
895}
896
897#[allow(dead_code)]
898fn lookup_pci_vendor(vendor_id: &str) -> Option<String> {
899 let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
900 let paths = ["/usr/share/hwdata/pci.ids", "/usr/share/misc/pci.ids"];
901 for path in &paths {
902 if let Ok(content) = std::fs::read_to_string(path) {
903 for line in content.lines() {
904 if line.starts_with('#') || line.is_empty() {
905 continue;
906 }
907 if !line.starts_with('\t') {
908 let parts: Vec<&str> = line.split_whitespace().collect();
909 if parts.len() >= 2 && parts[0].to_lowercase() == vendor_id {
910 let name = line.strip_prefix(parts[0]).unwrap().trim();
911 return Some(name.to_string());
912 }
913 }
914 }
915 }
916 }
917 None
918}
919
920#[allow(dead_code)]
921fn get_wifi_card_model(iface: &str) -> Option<String> {
922 let vendor = std::fs::read_to_string(format!("/sys/class/net/{}/device/vendor", iface)).ok()?;
923 let device = std::fs::read_to_string(format!("/sys/class/net/{}/device/device", iface)).ok()?;
924 let vendor_clean = vendor.trim().trim_start_matches("0x").to_lowercase();
925 let device_clean = device.trim().trim_start_matches("0x").to_lowercase();
926
927 let vendor_name = lookup_pci_vendor(&vendor_clean);
928 let model_name = crate::gpu::lookup_pci_device(&vendor_clean, &device_clean);
929
930 match (vendor_name, model_name) {
931 (Some(v), Some(m)) => {
932 let v_clean = v.replace(", Inc.", "").replace(" Corporation", "");
933 if m.to_lowercase().contains(&v_clean.to_lowercase())
934 || m.to_lowercase().contains(
935 &v_clean
936 .split_whitespace()
937 .next()
938 .unwrap_or("")
939 .to_lowercase(),
940 )
941 {
942 Some(m)
943 } else {
944 Some(format!("{} {}", v_clean, m))
945 }
946 }
947 (None, Some(m)) => Some(m),
948 _ => None,
949 }
950}
951
952#[allow(dead_code)]
953fn clean_rate(rate: &str) -> String {
954 rate.replace("MBit/s", "Mbps")
955 .replace("GBit/s", "Gbps")
956 .replace("Bit/s", "bps")
957}
958
959#[derive(Debug, Clone)]
960struct WifiLink {
961 freq: Option<f64>,
962 rx_rate: Option<String>,
963 tx_rate: Option<String>,
964}
965
966#[allow(dead_code)]
967fn parse_iw_link_output(stdout: &str) -> (Option<String>, Vec<WifiLink>) {
968 let mut ssid = None;
969 let mut links = Vec::new();
970 let mut current_link = None;
971
972 for line in stdout.lines() {
973 let trimmed = line.trim();
974 if trimmed.starts_with("Connected to") || trimmed.starts_with("link") {
975 if let Some(link) = current_link.take() {
976 links.push(link);
977 }
978 current_link = Some(WifiLink {
979 freq: None,
980 rx_rate: None,
981 tx_rate: None,
982 });
983 } else if trimmed.starts_with("SSID:") {
984 ssid = Some(trimmed.strip_prefix("SSID:").unwrap().trim().to_string());
985 } else if trimmed.starts_with("freq:") {
986 if let Some(ref mut link) = current_link {
987 let freq_str = trimmed.strip_prefix("freq:").unwrap().trim();
988 link.freq = freq_str.parse::<f64>().ok();
989 }
990 } else if trimmed.starts_with("rx bitrate:") {
991 if let Some(ref mut link) = current_link {
992 let rx_str = trimmed.strip_prefix("rx bitrate:").unwrap().trim();
993 let rate = rx_str
994 .split_whitespace()
995 .take(2)
996 .collect::<Vec<&str>>()
997 .join(" ");
998 link.rx_rate = Some(rate);
999 }
1000 } else if trimmed.starts_with("tx bitrate:") {
1001 if let Some(ref mut link) = current_link {
1002 let tx_str = trimmed.strip_prefix("tx bitrate:").unwrap().trim();
1003 let rate = tx_str
1004 .split_whitespace()
1005 .take(2)
1006 .collect::<Vec<&str>>()
1007 .join(" ");
1008 link.tx_rate = Some(rate);
1009 }
1010 }
1011 }
1012 if let Some(link) = current_link {
1013 links.push(link);
1014 }
1015 (ssid, links)
1016}
1017
1018#[allow(dead_code)]
1019fn parse_airport_output(stdout: &str) -> Option<String> {
1020 let mut ssid = None;
1021 let mut rate = None;
1022 for line in stdout.lines() {
1023 let trimmed = line.trim();
1024 if trimmed.starts_with("SSID:") {
1025 let val = trimmed.strip_prefix("SSID:").unwrap().trim().to_string();
1026 if !val.is_empty() {
1027 ssid = Some(val);
1028 }
1029 } else if trimmed.starts_with("lastTxRate:") {
1030 let val = trimmed
1031 .strip_prefix("lastTxRate:")
1032 .unwrap()
1033 .trim()
1034 .to_string();
1035 if !val.is_empty() {
1036 rate = Some(val);
1037 }
1038 }
1039 }
1040 match (ssid, rate) {
1041 (Some(s), Some(r)) => {
1042 if r != "0" && !r.starts_with("0 ") && r != "0 Mbps" && r != "0 Mbit/s" {
1043 Some(format!("{} (↑{} Mbps)", s, r))
1044 } else {
1045 Some(s)
1046 }
1047 }
1048 (Some(s), None) => Some(s),
1049 _ => None,
1050 }
1051}
1052
1053#[allow(dead_code)]
1054fn parse_netsh_output(stdout: &str) -> Option<String> {
1055 let mut ssid = None;
1056 let mut rx = None;
1057 let mut tx = None;
1058 let mut band = None;
1059 for line in stdout.lines() {
1060 let trimmed = line.trim();
1061 if trimmed.starts_with("SSID") {
1062 if let Some(idx) = trimmed.find(':') {
1063 let val = trimmed[idx + 1..].trim().to_string();
1064 if !val.is_empty() {
1065 ssid = Some(val);
1066 }
1067 }
1068 } else if trimmed.starts_with("Receive rate (Mbps)") {
1069 if let Some(idx) = trimmed.find(':') {
1070 let val = trimmed[idx + 1..].trim().to_string();
1071 if !val.is_empty() {
1072 rx = Some(val);
1073 }
1074 }
1075 } else if trimmed.starts_with("Transmit rate (Mbps)") {
1076 if let Some(idx) = trimmed.find(':') {
1077 let val = trimmed[idx + 1..].trim().to_string();
1078 if !val.is_empty() {
1079 tx = Some(val);
1080 }
1081 }
1082 } else if trimmed.starts_with("Band") {
1083 if let Some(idx) = trimmed.find(':') {
1084 let val = trimmed[idx + 1..].trim().to_string();
1085 if !val.is_empty() {
1086 band = Some(val);
1087 }
1088 }
1089 }
1090 }
1091 if let Some(s) = ssid {
1092 let mut rate_strs = Vec::new();
1093 if let Some(rx_val) = rx {
1094 if rx_val != "0" {
1095 rate_strs.push(format!("↓{} Mbps", rx_val));
1096 }
1097 }
1098 if let Some(tx_val) = tx {
1099 if tx_val != "0" {
1100 rate_strs.push(format!("↑{} Mbps", tx_val));
1101 }
1102 }
1103 let info = match (band, rate_strs.is_empty()) {
1104 (Some(b), false) => format!("{} [{}]", b, rate_strs.join(" ")),
1105 (Some(b), true) => b,
1106 (None, false) => rate_strs.join(" "),
1107 _ => String::new(),
1108 };
1109 if !info.is_empty() {
1110 Some(format!("{} ({})", s, info))
1111 } else {
1112 Some(s)
1113 }
1114 } else {
1115 None
1116 }
1117}
1118
1119#[allow(dead_code)]
1120fn lookup_usb_vendor(vendor_id: &str) -> Option<String> {
1121 let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
1122 let paths = ["/usr/share/hwdata/usb.ids", "/usr/share/misc/usb.ids"];
1123 for path in &paths {
1124 if let Ok(content) = std::fs::read_to_string(path) {
1125 for line in content.lines() {
1126 if line.starts_with('#') || line.is_empty() {
1127 continue;
1128 }
1129 if !line.starts_with('\t') {
1130 let parts: Vec<&str> = line.split_whitespace().collect();
1131 if parts.len() >= 2 && parts[0].to_lowercase() == vendor_id {
1132 let name = line.strip_prefix(parts[0]).unwrap().trim();
1133 return Some(name.to_string());
1134 }
1135 }
1136 }
1137 }
1138 }
1139 None
1140}
1141
1142#[allow(dead_code)]
1143fn lookup_usb_device(vendor_id: &str, product_id: &str) -> Option<String> {
1144 let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
1145 let product_id = product_id.trim_start_matches("0x").to_lowercase();
1146 let paths = ["/usr/share/hwdata/usb.ids", "/usr/share/misc/usb.ids"];
1147 for path in &paths {
1148 if let Ok(content) = std::fs::read_to_string(path) {
1149 let mut in_vendor = false;
1150 for line in content.lines() {
1151 if line.starts_with('#') || line.is_empty() {
1152 continue;
1153 }
1154 if !line.starts_with('\t') {
1155 let parts: Vec<&str> = line.split_whitespace().collect();
1156 in_vendor = parts.len() >= 2 && parts[0].to_lowercase() == vendor_id;
1157 } else if in_vendor && line.starts_with('\t') && !line.starts_with("\t\t") {
1158 let trimmed = line.trim_start();
1159 if let Some(stripped) = trimmed.strip_prefix(&product_id) {
1160 let name = stripped.trim();
1161 return Some(name.to_string());
1162 }
1163 }
1164 }
1165 }
1166 }
1167 None
1168}
1169
1170#[allow(dead_code)]
1171fn parse_macos_bluetooth(stdout: &str) -> Option<String> {
1172 let mut state = "Off";
1173 let mut connected_names = Vec::new();
1174 let mut chipset = None;
1175 let mut current_device = None;
1176
1177 for line in stdout.lines() {
1178 let trimmed = line.trim();
1179 if trimmed.starts_with("Bluetooth Power:") {
1180 if trimmed.contains("On") {
1181 state = "On";
1182 }
1183 } else if trimmed.starts_with("Chipset:") {
1184 chipset = Some(trimmed.strip_prefix("Chipset:").unwrap().trim().to_string());
1185 } else if line.starts_with(" ") && !trimmed.is_empty() && trimmed.ends_with(':') {
1186 current_device = Some(trimmed.trim_end_matches(':').trim().to_string());
1187 } else if (trimmed.starts_with("Connected:") || trimmed.starts_with("Connection:"))
1188 && trimmed.contains("Yes")
1189 {
1190 if let Some(ref dev) = current_device {
1191 connected_names.push(dev.clone());
1192 }
1193 }
1194 }
1195
1196 let mut info_str = state.to_string();
1197 if let Some(ch) = chipset {
1198 info_str.push_str(&format!(" (Apple {})", ch));
1199 } else {
1200 info_str.push_str(" (Apple Bluetooth)");
1201 }
1202
1203 if state == "On" {
1204 info_str.push_str(&format!(" - {} connected", connected_names.len()));
1205 if !connected_names.is_empty() {
1206 info_str.push_str(&format!(" ({})", connected_names.join(", ")));
1207 }
1208 }
1209 Some(info_str)
1210}
1211
1212#[allow(dead_code)]
1213fn parse_windows_bluetooth_output(stdout: &str) -> Option<String> {
1214 let parts: Vec<&str> = stdout.trim().split('|').collect();
1215 if parts.len() < 3 {
1216 return None;
1217 }
1218 let status_str = parts[0].trim();
1219 let adapter_str = parts[1].trim();
1220 let devices_str = parts[2]
1221 .trim()
1222 .trim_start_matches('(')
1223 .trim_end_matches(')');
1224
1225 let state = if status_str.eq_ignore_ascii_case("running") {
1226 "On"
1227 } else {
1228 "Off"
1229 };
1230
1231 let mut info_str = state.to_string();
1232 if !adapter_str.is_empty() {
1233 info_str.push_str(&format!(" ({})", adapter_str));
1234 }
1235
1236 if state == "On" {
1237 let connected_names: Vec<String> = if devices_str.is_empty() {
1238 Vec::new()
1239 } else {
1240 devices_str
1241 .split(',')
1242 .map(|s| s.trim().to_string())
1243 .filter(|s| !s.is_empty())
1244 .collect()
1245 };
1246
1247 info_str.push_str(&format!(" - {} connected", connected_names.len()));
1248 if !connected_names.is_empty() {
1249 info_str.push_str(&format!(" ({})", connected_names.join(", ")));
1250 }
1251 }
1252 Some(info_str)
1253}
1254
1255fn detect_bluetooth() -> Option<String> {
1256 #[cfg(target_os = "linux")]
1257 {
1258 if let Ok(entries) = std::fs::read_dir("/sys/class/bluetooth") {
1259 let mut hcis = Vec::new();
1260 for entry in entries.filter_map(|e| e.ok()) {
1261 let name = entry.file_name().to_string_lossy().to_string();
1262 if name.starts_with("hci") {
1263 hcis.push(name);
1264 }
1265 }
1266 hcis.sort();
1267
1268 if !hcis.is_empty() {
1269 let hci = &hcis[0];
1270 let mut state = "Off";
1271 if let Ok(subdirs) = std::fs::read_dir(format!("/sys/class/bluetooth/{}", hci)) {
1272 for sub in subdirs.filter_map(|e| e.ok()) {
1273 let sub_name = sub.file_name().to_string_lossy().to_string();
1274 if sub_name.starts_with("rfkill") {
1275 if let Ok(st) = std::fs::read_to_string(sub.path().join("state")) {
1276 if st.trim() == "1" || st.trim() == "3" {
1277 state = "On";
1278 }
1279 }
1280 }
1281 }
1282 }
1283
1284 let mut hw_info = None;
1285 if let Ok(canonical_device) =
1286 std::fs::canonicalize(format!("/sys/class/bluetooth/{}/device", hci))
1287 {
1288 let mut current = Some(canonical_device);
1289 while let Some(path) = current {
1290 let id_vendor = path.join("idVendor");
1291 let id_product = path.join("idProduct");
1292 let pci_vendor = path.join("vendor");
1293 let pci_device = path.join("device");
1294
1295 if id_vendor.exists() && id_product.exists() {
1296 if let (Ok(v), Ok(p)) = (
1297 std::fs::read_to_string(id_vendor),
1298 std::fs::read_to_string(id_product),
1299 ) {
1300 let v_clean = v.trim();
1301 let p_clean = p.trim();
1302 let vendor_name = lookup_usb_vendor(v_clean);
1303 let product_name = lookup_usb_device(v_clean, p_clean);
1304 match (vendor_name, product_name) {
1305 (Some(v_name), Some(p_name)) => {
1306 let v_disp = v_name
1307 .replace(", Inc.", "")
1308 .replace(" Corporation", "")
1309 .replace(" Co., Ltd.", "")
1310 .replace(" Co., Ltd", "");
1311 hw_info = Some(format!("{} {}", v_disp, p_name));
1312 }
1313 (Some(v_name), None) => {
1314 let v_disp = v_name
1315 .replace(", Inc.", "")
1316 .replace(" Corporation", "")
1317 .replace(" Co., Ltd.", "")
1318 .replace(" Co., Ltd", "");
1319 hw_info = Some(v_disp);
1320 }
1321 _ => {}
1322 }
1323 break;
1324 }
1325 } else if pci_vendor.exists()
1326 && pci_device.exists()
1327 && !pci_vendor.is_dir()
1328 && !pci_device.is_dir()
1329 {
1330 if let (Ok(v), Ok(d)) = (
1331 std::fs::read_to_string(pci_vendor),
1332 std::fs::read_to_string(pci_device),
1333 ) {
1334 let v_clean = v.trim().trim_start_matches("0x").to_lowercase();
1335 let d_clean = d.trim().trim_start_matches("0x").to_lowercase();
1336 let vendor_name = lookup_pci_vendor(&v_clean);
1337 let product_name =
1338 crate::gpu::lookup_pci_device(&v_clean, &d_clean);
1339 match (vendor_name, product_name) {
1340 (Some(v_name), Some(p_name)) => {
1341 let v_disp = v_name
1342 .replace(", Inc.", "")
1343 .replace(" Corporation", "")
1344 .replace(" Co., Ltd.", "")
1345 .replace(" Co., Ltd", "");
1346 hw_info = Some(format!("{} {}", v_disp, p_name));
1347 }
1348 (Some(v_name), None) => {
1349 let v_disp = v_name
1350 .replace(", Inc.", "")
1351 .replace(" Corporation", "")
1352 .replace(" Co., Ltd.", "")
1353 .replace(" Co., Ltd", "");
1354 hw_info = Some(v_disp);
1355 }
1356 _ => {}
1357 }
1358 break;
1359 }
1360 }
1361 current = path.parent().map(|p| p.to_path_buf());
1362 }
1363 }
1364
1365 let mut connected_names = Vec::new();
1366 if let Ok(output) = std::process::Command::new("bluetoothctl")
1367 .args(["devices", "Connected"])
1368 .output()
1369 {
1370 if let Ok(stdout) = String::from_utf8(output.stdout) {
1371 for line in stdout.lines() {
1372 let trimmed = line.trim();
1373 if trimmed.starts_with("Device ") {
1374 let parts: Vec<&str> = trimmed.split_whitespace().collect();
1375 if parts.len() >= 3 {
1376 let name = parts[2..].join(" ");
1377 connected_names.push(name);
1378 }
1379 }
1380 }
1381 }
1382 }
1383 let mut info_str = state.to_string();
1384 info_str.push_str(&format!(" [{}]", hci));
1385 if let Some(hw) = hw_info {
1386 info_str.push_str(&format!(" ({})", hw));
1387 }
1388
1389 if state == "On" {
1390 info_str.push_str(&format!(" - {} connected", connected_names.len()));
1391 if !connected_names.is_empty() {
1392 info_str.push_str(&format!(" ({})", connected_names.join(", ")));
1393 }
1394 }
1395
1396 return Some(info_str);
1397 }
1398 }
1399 None
1400 }
1401
1402 #[cfg(target_os = "macos")]
1403 {
1404 if let Ok(output) = std::process::Command::new("system_profiler")
1405 .arg("SPBluetoothDataType")
1406 .output()
1407 {
1408 if let Ok(stdout) = String::from_utf8(output.stdout) {
1409 return parse_macos_bluetooth(&stdout);
1410 }
1411 }
1412 None
1413 }
1414
1415 #[cfg(target_os = "windows")]
1416 {
1417 let cmd = "$state = (Get-Service -Name bthserv -ErrorAction SilentlyContinue).Status; \
1418 $adapter = (Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | Where-Object {$_.FriendlyName -match 'Adapter|Controller|Radio|Intel|Realtek|Broadcom'} | Select-Object -First 1 -ExpandProperty FriendlyName); \
1419 $devices = (Get-PnpDevice -Class Bluetooth -Status OK -ErrorAction SilentlyContinue | Where-Object {$_.FriendlyName -notmatch 'Adapter|Enumerator|Controller|LE Device|RFCOMM|Module|Service|Generic|Computer|Protocol|Phone|Device'} | Select-Object -ExpandProperty FriendlyName); \
1420 Write-Output \"$state|$adapter|($($devices -join ','))\"";
1421
1422 if let Ok(output) = std::process::Command::new("powershell")
1423 .args(["-Command", cmd])
1424 .output()
1425 {
1426 if let Ok(stdout) = String::from_utf8(output.stdout) {
1427 return parse_windows_bluetooth_output(&stdout);
1428 }
1429 }
1430 None
1431 }
1432
1433 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1434 {
1435 None
1436 }
1437}
1438
1439#[allow(dead_code)]
1440fn is_real_camera(name: &str) -> bool {
1441 let name_lower = name.to_lowercase();
1442 !name_lower.contains("infrared")
1443 && !name_lower.contains("ir camera")
1444 && !name_lower.contains("integrated i")
1445 && !name_lower.contains("integrated ir")
1446 && !name_lower.contains("depth camera")
1447}
1448
1449#[allow(dead_code)]
1450fn clean_camera_name(name: &str) -> String {
1451 let trimmed = name.trim();
1452 if trimmed.starts_with("Integrated Camera:") {
1453 return "Integrated Camera".to_string();
1454 }
1455 if trimmed.starts_with("Integrated Webcam:") {
1456 return "Integrated Webcam".to_string();
1457 }
1458 trimmed.to_string()
1459}
1460
1461#[allow(dead_code)]
1462fn parse_macos_camera(stdout: &str) -> Vec<String> {
1463 let mut devices = Vec::new();
1464 let mut in_cameras = false;
1465 for line in stdout.lines() {
1466 let trimmed = line.trim();
1467 let indent = line.len() - line.trim_start().len();
1468 if trimmed.starts_with("Video Support:")
1469 || trimmed.starts_with("Camera:")
1470 || trimmed.starts_with("Cameras:")
1471 {
1472 in_cameras = true;
1473 continue;
1474 }
1475 if in_cameras {
1476 if indent < 4
1477 && !trimmed.is_empty()
1478 && !trimmed.starts_with("Camera")
1479 && !trimmed.starts_with("Video Support")
1480 {
1481 in_cameras = false;
1482 continue;
1483 }
1484 if (indent == 4 || indent == 6 || indent == 8) && trimmed.ends_with(':') {
1485 let name = trimmed.trim_end_matches(':').trim().to_string();
1486 if !name.is_empty() && is_real_camera(&name) {
1487 let cleaned = clean_camera_name(&name);
1488 if !devices.contains(&cleaned) {
1489 devices.push(cleaned);
1490 }
1491 }
1492 }
1493 }
1494 }
1495 devices
1496}
1497
1498#[allow(dead_code)]
1499fn parse_macos_gamepad(usb_stdout: &str, bt_stdout: &str) -> Vec<String> {
1500 let mut gamepads = Vec::new();
1501 let keywords = [
1502 "controller",
1503 "gamepad",
1504 "joystick",
1505 "xbox",
1506 "playstation",
1507 "dualshock",
1508 "dualsense",
1509 "nintendo",
1510 "joy-con",
1511 "joycon",
1512 ];
1513
1514 let is_gamepad = |name: &str| -> bool {
1515 let name_lower = name.to_lowercase();
1516 keywords.iter().any(|&kw| name_lower.contains(kw))
1517 };
1518
1519 for line in usb_stdout.lines() {
1521 let trimmed = line.trim();
1522 let indent = line.len() - line.trim_start().len();
1523 if (indent == 4 || indent == 6 || indent == 8) && trimmed.ends_with(':') {
1524 let name = trimmed.trim_end_matches(':').trim().to_string();
1525 if is_gamepad(&name) && !gamepads.contains(&name) {
1526 gamepads.push(name);
1527 }
1528 }
1529 }
1530
1531 let mut current_device = None;
1533 for line in bt_stdout.lines() {
1534 let trimmed = line.trim();
1535 let indent = line.len() - line.trim_start().len();
1536 if indent >= 8 && trimmed.ends_with(':') {
1537 current_device = Some(trimmed.trim_end_matches(':').trim().to_string());
1538 } else if trimmed.starts_with("Connected: Yes") || trimmed.starts_with("Connection: Yes") {
1539 if let Some(ref dev) = current_device {
1540 if is_gamepad(dev) && !gamepads.contains(dev) {
1541 gamepads.push(dev.clone());
1542 }
1543 }
1544 }
1545 }
1546
1547 gamepads
1548}
1549
1550fn detect_camera() -> Vec<String> {
1551 #[cfg(target_os = "linux")]
1552 {
1553 let mut cameras = Vec::new();
1554 if let Ok(entries) = std::fs::read_dir("/sys/class/video4linux") {
1555 for entry in entries.filter_map(|e| e.ok()) {
1556 let path = entry.path().join("name");
1557 if path.exists() {
1558 if let Ok(name) = std::fs::read_to_string(path) {
1559 let trimmed = name.trim().to_string();
1560 if !trimmed.is_empty() && is_real_camera(&trimmed) {
1561 let cleaned = clean_camera_name(&trimmed);
1562 if !cameras.contains(&cleaned) {
1563 cameras.push(cleaned);
1564 }
1565 }
1566 }
1567 }
1568 }
1569 }
1570 cameras
1571 }
1572
1573 #[cfg(target_os = "macos")]
1574 {
1575 if let Ok(output) = std::process::Command::new("system_profiler")
1576 .arg("SPCameraDataType")
1577 .output()
1578 {
1579 if let Ok(stdout) = String::from_utf8(output.stdout) {
1580 return parse_macos_camera(&stdout);
1581 }
1582 }
1583 Vec::new()
1584 }
1585
1586 #[cfg(target_os = "windows")]
1587 {
1588 let cmd = "Get-PnpDevice -Class Camera,Image -PresentOnly -ErrorAction SilentlyContinue | Where-Object { $_.Status -eq 'OK' } | Select-Object -ExpandProperty FriendlyName";
1589 if let Ok(output) = std::process::Command::new("powershell")
1590 .args(["-Command", cmd])
1591 .output()
1592 {
1593 if let Ok(stdout) = String::from_utf8(output.stdout) {
1594 let mut cameras = Vec::new();
1595 for line in stdout.lines() {
1596 let trimmed = line.trim().to_string();
1597 if !trimmed.is_empty() && is_real_camera(&trimmed) {
1598 let cleaned = clean_camera_name(&trimmed);
1599 if !cameras.contains(&cleaned) {
1600 cameras.push(cleaned);
1601 }
1602 }
1603 }
1604 return cameras;
1605 }
1606 }
1607 Vec::new()
1608 }
1609
1610 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1611 {
1612 Vec::new()
1613 }
1614}
1615
1616fn detect_gamepad() -> Vec<String> {
1617 #[cfg(target_os = "linux")]
1618 {
1619 let mut gamepads = Vec::new();
1620 if let Ok(entries) = std::fs::read_dir("/sys/class/input") {
1621 for entry in entries.filter_map(|e| e.ok()) {
1622 let name = entry.file_name().to_string_lossy().to_string();
1623 if name.starts_with("js") {
1624 let path = entry.path().join("device/name");
1625 if path.exists() {
1626 if let Ok(dev_name) = std::fs::read_to_string(path) {
1627 let trimmed = dev_name.trim().to_string();
1628 if !trimmed.is_empty() && !gamepads.contains(&trimmed) {
1629 gamepads.push(trimmed);
1630 }
1631 }
1632 }
1633 }
1634 }
1635 }
1636 gamepads
1637 }
1638
1639 #[cfg(target_os = "macos")]
1640 {
1641 let usb_stdout = std::process::Command::new("system_profiler")
1642 .arg("SPUSBDataType")
1643 .output()
1644 .ok()
1645 .and_then(|o| String::from_utf8(o.stdout).ok())
1646 .unwrap_or_default();
1647
1648 let bt_stdout = std::process::Command::new("system_profiler")
1649 .arg("SPBluetoothDataType")
1650 .output()
1651 .ok()
1652 .and_then(|o| String::from_utf8(o.stdout).ok())
1653 .unwrap_or_default();
1654
1655 parse_macos_gamepad(&usb_stdout, &bt_stdout)
1656 }
1657
1658 #[cfg(target_os = "windows")]
1659 {
1660 let cmd = "Get-PnpDevice -PresentOnly -ErrorAction SilentlyContinue | Where-Object { $_.Class -eq 'HIDClass' -and ($_.HardwareID -match 'HID_DEVICE_SYSTEM_GAME' -or $_.HardwareID -match 'HID_DEVICE_GAME') -or $_.FriendlyName -match 'Xbox Controller|Gamepad|Joystick' } | Select-Object -ExpandProperty FriendlyName";
1661 if let Ok(output) = std::process::Command::new("powershell")
1662 .args(["-Command", cmd])
1663 .output()
1664 {
1665 if let Ok(stdout) = String::from_utf8(output.stdout) {
1666 let mut gamepads = Vec::new();
1667 for line in stdout.lines() {
1668 let trimmed = line.trim().to_string();
1669 if !trimmed.is_empty() && !gamepads.contains(&trimmed) {
1670 gamepads.push(trimmed);
1671 }
1672 }
1673 return gamepads;
1674 }
1675 }
1676 Vec::new()
1677 }
1678
1679 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1680 {
1681 Vec::new()
1682 }
1683}
1684
1685fn detect_audio(_sys: &sysinfo::System) -> Option<String> {
1686 #[cfg(target_os = "linux")]
1687 {
1688 let mut server = None;
1690 for process in _sys.processes().values() {
1691 let name = process.name().to_string_lossy().to_lowercase();
1692 if name.contains("pipewire") {
1693 server = Some("PipeWire");
1694 break;
1695 } else if name.contains("pulseaudio") {
1696 server = Some("PulseAudio");
1697 }
1698 }
1699 let server_str = server.unwrap_or("ALSA");
1700
1701 let mut devices = Vec::new();
1703 if let Ok(content) = std::fs::read_to_string("/proc/asound/cards") {
1704 devices = parse_asound_cards(&content, "/proc/asound");
1705 }
1706
1707 if !devices.is_empty() {
1708 Some(format!("{} ({})", server_str, devices.join(", ")))
1709 } else {
1710 Some(server_str.to_string())
1711 }
1712 }
1713
1714 #[cfg(target_os = "macos")]
1715 {
1716 let mut devices = Vec::new();
1717 if let Ok(output) = std::process::Command::new("system_profiler")
1718 .arg("SPAudioDataType")
1719 .output()
1720 {
1721 if let Ok(stdout) = String::from_utf8(output.stdout) {
1722 let mut in_devices = false;
1723 for line in stdout.lines() {
1724 let trimmed = line.trim();
1725 let indent = line.len() - line.trim_start().len();
1726 if trimmed.starts_with("Devices:") {
1727 in_devices = true;
1728 continue;
1729 }
1730 if in_devices {
1731 if indent <= 4 && !trimmed.is_empty() && !trimmed.starts_with("Devices:") {
1732 in_devices = false;
1733 continue;
1734 }
1735 if indent == 8 && trimmed.ends_with(':') {
1736 let name = trimmed.trim_end_matches(':').trim().to_string();
1737 if !name.is_empty() && !devices.contains(&name) {
1738 devices.push(name);
1739 }
1740 }
1741 }
1742 }
1743 }
1744 }
1745
1746 if !devices.is_empty() {
1747 Some(format!("CoreAudio ({})", devices.join(", ")))
1748 } else {
1749 Some("CoreAudio".to_string())
1750 }
1751 }
1752
1753 #[cfg(target_os = "windows")]
1754 {
1755 let mut devices = Vec::new();
1756 if let Ok(output) = std::process::Command::new("wmic")
1757 .args(["path", "Win32_SoundDevice", "get", "Name", "/value"])
1758 .output()
1759 {
1760 if let Ok(stdout) = String::from_utf8(output.stdout) {
1761 for line in stdout.lines() {
1762 let line = line.trim();
1763 if line.starts_with("Name=") {
1764 let name = line.strip_prefix("Name=").unwrap_or("").trim().to_string();
1765 if !name.is_empty() && !devices.contains(&name) {
1766 devices.push(name);
1767 }
1768 }
1769 }
1770 }
1771 }
1772
1773 if !devices.is_empty() {
1774 Some(format!("Windows Audio ({})", devices.join(", ")))
1775 } else {
1776 Some("Windows Audio".to_string())
1777 }
1778 }
1779
1780 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1781 {
1782 None
1783 }
1784}
1785
1786#[allow(dead_code)]
1787fn parse_asound_cards(content: &str, asound_dir: &str) -> Vec<String> {
1788 let mut devices = Vec::new();
1789 if let Ok(entries) = std::fs::read_dir(asound_dir) {
1790 for entry in entries.filter_map(|e| e.ok()) {
1791 let path = entry.path();
1792 if path.is_dir() {
1793 let name = entry.file_name().to_string_lossy().to_string();
1794 if name.starts_with("card") {
1795 if let Ok(sub_entries) = std::fs::read_dir(&path) {
1796 for sub_entry in sub_entries.filter_map(|se| se.ok()) {
1797 let sub_path = sub_entry.path();
1798 let sub_name = sub_entry.file_name().to_string_lossy().to_string();
1799 if sub_name.starts_with("codec#") {
1800 if let Ok(codec_content) = std::fs::read_to_string(&sub_path) {
1801 for line in codec_content.lines() {
1802 if let Some(stripped) = line.strip_prefix("Codec: ") {
1803 let codec_name = stripped.trim().to_string();
1804 if !codec_name.is_empty()
1805 && !devices.contains(&codec_name)
1806 {
1807 devices.push(codec_name);
1808 }
1809 }
1810 }
1811 }
1812 }
1813 }
1814 }
1815 }
1816 }
1817 }
1818 }
1819
1820 if devices.is_empty() {
1821 for line in content.lines() {
1822 if let Some(idx) = line.find("]: ") {
1823 let desc = line[idx + 3..].trim();
1824 let device_name = if let Some(dash_idx) = desc.find(" - ") {
1825 desc[dash_idx + 3..].trim()
1826 } else {
1827 desc
1828 };
1829 if !device_name.is_empty() && !devices.contains(&device_name.to_string()) {
1830 devices.push(device_name.to_string());
1831 }
1832 }
1833 }
1834 }
1835 devices
1836}
1837
1838fn detect_motherboard() -> Option<String> {
1839 #[cfg(target_os = "macos")]
1840 {
1841 if let Ok(output) = std::process::Command::new("sysctl")
1842 .args(["-n", "hw.model"])
1843 .output()
1844 {
1845 if let Ok(model) = String::from_utf8(output.stdout) {
1846 let trimmed = model.trim();
1847 if !trimmed.is_empty() {
1848 return Some(trimmed.to_string());
1849 }
1850 }
1851 }
1852 None
1853 }
1854
1855 #[cfg(target_os = "windows")]
1856 {
1857 if let Ok(output) = std::process::Command::new("wmic")
1858 .args(["baseboard", "get", "manufacturer,product", "/value"])
1859 .output()
1860 {
1861 if let Ok(stdout) = String::from_utf8(output.stdout) {
1862 let mut manufacturer = String::new();
1863 let mut product = String::new();
1864 for line in stdout.lines() {
1865 let line = line.trim();
1866 if line.starts_with("Manufacturer=") {
1867 manufacturer = line
1868 .strip_prefix("Manufacturer=")
1869 .unwrap_or("")
1870 .trim()
1871 .to_string();
1872 } else if line.starts_with("Product=") {
1873 product = line
1874 .strip_prefix("Product=")
1875 .unwrap_or("")
1876 .trim()
1877 .to_string();
1878 }
1879 }
1880 if !manufacturer.is_empty() && !product.is_empty() {
1881 return Some(format!("{} {}", manufacturer, product));
1882 } else if !product.is_empty() {
1883 return Some(product);
1884 } else if !manufacturer.is_empty() {
1885 return Some(manufacturer);
1886 }
1887 }
1888 }
1889 None
1890 }
1891
1892 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
1893 {
1894 let vendor = std::fs::read_to_string("/sys/class/dmi/id/board_vendor")
1895 .map(|s| s.trim().to_string())
1896 .ok();
1897 let name = std::fs::read_to_string("/sys/class/dmi/id/board_name")
1898 .map(|s| s.trim().to_string())
1899 .ok();
1900
1901 match (vendor, name) {
1902 (Some(v), Some(n)) if !v.is_empty() && !n.is_empty() => {
1903 if n.starts_with(&v) {
1904 Some(n)
1905 } else {
1906 Some(format!("{} {}", v, n))
1907 }
1908 }
1909 (Some(v), _) if !v.is_empty() => Some(v),
1910 (_, Some(n)) if !n.is_empty() => Some(n),
1911 _ => None,
1912 }
1913 }
1914}
1915
1916fn detect_bios() -> Option<String> {
1917 #[cfg(target_os = "macos")]
1918 {
1919 if let Ok(output) = std::process::Command::new("system_profiler")
1920 .arg("SPHardwareDataType")
1921 .output()
1922 {
1923 if let Ok(stdout) = String::from_utf8(output.stdout) {
1924 for line in stdout.lines() {
1925 let line = line.trim();
1926 if line.starts_with("System Firmware Version:")
1927 || line.starts_with("Boot ROM Version:")
1928 {
1929 if let Some(val) = line.split(':').nth(1) {
1930 return Some(val.trim().to_string());
1931 }
1932 }
1933 }
1934 }
1935 }
1936 None
1937 }
1938
1939 #[cfg(target_os = "windows")]
1940 {
1941 if let Ok(output) = std::process::Command::new("wmic")
1942 .args([
1943 "bios",
1944 "get",
1945 "manufacturer,smbiosbiosversion,releasedate",
1946 "/value",
1947 ])
1948 .output()
1949 {
1950 if let Ok(stdout) = String::from_utf8(output.stdout) {
1951 let mut manufacturer = String::new();
1952 let mut version = String::new();
1953 let mut date = String::new();
1954 for line in stdout.lines() {
1955 let line = line.trim();
1956 if line.starts_with("Manufacturer=") {
1957 manufacturer = line
1958 .strip_prefix("Manufacturer=")
1959 .unwrap_or("")
1960 .trim()
1961 .to_string();
1962 } else if line.starts_with("SMBIOSBIOSVersion=") {
1963 version = line
1964 .strip_prefix("SMBIOSBIOSVersion=")
1965 .unwrap_or("")
1966 .trim()
1967 .to_string();
1968 } else if line.starts_with("ReleaseDate=") {
1969 let raw_date = line.strip_prefix("ReleaseDate=").unwrap_or("").trim();
1970 if raw_date.len() >= 8 {
1971 let year = &raw_date[0..4];
1972 let month = &raw_date[4..6];
1973 let day = &raw_date[6..8];
1974 date = format!("{}/{}/{}", month, day, year);
1975 } else {
1976 date = raw_date.to_string();
1977 }
1978 }
1979 }
1980
1981 let mut parts = Vec::new();
1982 if !manufacturer.is_empty() {
1983 parts.push(manufacturer);
1984 }
1985 if !version.is_empty() {
1986 parts.push(version);
1987 }
1988 let mut res = parts.join(" ");
1989 if !date.is_empty() {
1990 if res.is_empty() {
1991 res = date;
1992 } else {
1993 res = format!("{} ({})", res, date);
1994 }
1995 }
1996 if !res.is_empty() {
1997 return Some(res);
1998 }
1999 }
2000 }
2001 None
2002 }
2003
2004 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
2005 {
2006 let vendor = std::fs::read_to_string("/sys/class/dmi/id/bios_vendor")
2007 .map(|s| s.trim().to_string())
2008 .ok();
2009 let version = std::fs::read_to_string("/sys/class/dmi/id/bios_version")
2010 .map(|s| s.trim().to_string())
2011 .ok();
2012 let date = std::fs::read_to_string("/sys/class/dmi/id/bios_date")
2013 .map(|s| s.trim().to_string())
2014 .ok();
2015
2016 let mut parts = Vec::new();
2017 if let Some(v) = vendor {
2018 if !v.is_empty() {
2019 parts.push(v);
2020 }
2021 }
2022 if let Some(ver) = version {
2023 let mut ver_cleaned = ver;
2024 while ver_cleaned.contains(" )") {
2025 ver_cleaned = ver_cleaned.replace(" )", ")");
2026 }
2027 let ver_cleaned = ver_cleaned.trim().to_string();
2028 if !ver_cleaned.is_empty() {
2029 parts.push(ver_cleaned);
2030 }
2031 }
2032 let mut res = parts.join(" ");
2033 if let Some(d) = date {
2034 if !d.is_empty() {
2035 if res.is_empty() {
2036 res = d;
2037 } else {
2038 res = format!("{} ({})", res, d);
2039 }
2040 }
2041 }
2042 if !res.is_empty() {
2043 Some(res)
2044 } else {
2045 None
2046 }
2047 }
2048}
2049
2050#[allow(dead_code)]
2051fn parse_monitor_name_from_edid(edid: &[u8]) -> Option<String> {
2052 if edid.len() < 128 {
2053 return None;
2054 }
2055 let offsets = [54, 72, 90, 108];
2056 for &offset in &offsets {
2057 if offset + 18 <= edid.len() {
2058 let block = &edid[offset..offset + 18];
2059 if block[0] == 0x00 && block[1] == 0x00 && block[2] == 0x00 && block[3] == 0xFC {
2060 let name_bytes = &block[4..17];
2061 let name = String::from_utf8_lossy(name_bytes);
2062 let cleaned = name.trim().replace('\0', "").to_string();
2063 if !cleaned.is_empty() {
2064 return Some(cleaned);
2065 }
2066 }
2067 }
2068 }
2069 None
2070}
2071
2072#[allow(dead_code)]
2073fn parse_refresh_rate_from_edid(edid: &[u8]) -> Option<f64> {
2074 if edid.len() < 72 {
2075 return None;
2076 }
2077 let block = &edid[54..72];
2078 let pixel_clock = ((block[1] as u32) << 8) | (block[0] as u32);
2079 if pixel_clock == 0 {
2080 return None;
2081 }
2082 let pixel_clock_hz = pixel_clock * 10_000;
2083 let h_active = (block[2] as u32) | (((block[4] as u32) & 0xF0) << 4);
2084 let h_blanking = (block[3] as u32) | (((block[4] as u32) & 0x0F) << 8);
2085 let v_active = (block[5] as u32) | (((block[7] as u32) & 0xF0) << 4);
2086 let v_blanking = (block[6] as u32) | (((block[7] as u32) & 0x0F) << 8);
2087
2088 let h_total = h_active + h_blanking;
2089 let v_total = v_active + v_blanking;
2090 if h_total == 0 || v_total == 0 {
2091 return None;
2092 }
2093
2094 let refresh = (pixel_clock_hz as f64) / ((h_total * v_total) as f64);
2095 Some((refresh * 100.0).round() / 100.0)
2096}
2097
2098#[allow(dead_code)]
2099fn format_refresh_rate(refresh: f64) -> String {
2100 if (refresh - refresh.round()).abs() < 0.01 {
2101 format!("{:.0}", refresh)
2102 } else {
2103 format!("{:.2}", refresh)
2104 }
2105}
2106
2107#[allow(dead_code)]
2108fn parse_serial_number_from_edid(edid: &[u8]) -> Option<String> {
2109 if edid.len() < 128 {
2110 return None;
2111 }
2112 let offsets = [54, 72, 90, 108];
2114 for &offset in &offsets {
2115 if offset + 18 <= edid.len() {
2116 let block = &edid[offset..offset + 18];
2117 if block[0] == 0x00 && block[1] == 0x00 && block[2] == 0x00 && block[3] == 0xFF {
2118 let serial_bytes = &block[4..17];
2119 let serial = String::from_utf8_lossy(serial_bytes);
2120 let cleaned = serial.trim().replace('\0', "").to_string();
2121 if !cleaned.is_empty() {
2122 return Some(cleaned);
2123 }
2124 }
2125 }
2126 }
2127
2128 let serial_num = ((edid[15] as u32) << 24)
2130 | ((edid[14] as u32) << 16)
2131 | ((edid[13] as u32) << 8)
2132 | (edid[12] as u32);
2133 if serial_num != 0 && serial_num != 0xFFFFFFFF {
2134 return Some(serial_num.to_string());
2135 }
2136
2137 None
2138}
2139
2140#[allow(dead_code)]
2141fn get_monitor_name_for_port(port: &str) -> Option<String> {
2142 if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
2143 for entry in entries.filter_map(|e| e.ok()) {
2144 let name = entry.file_name().to_string_lossy().to_string();
2145 if name.ends_with(port) {
2146 let edid_path = entry.path().join("edid");
2147 if edid_path.exists() {
2148 if let Ok(edid_bytes) = std::fs::read(&edid_path) {
2149 if let Some(monitor_name) = parse_monitor_name_from_edid(&edid_bytes) {
2150 return Some(monitor_name);
2151 }
2152 }
2153 }
2154 }
2155 }
2156 }
2157 None
2158}
2159
2160fn detect_displays() -> Vec<String> {
2161 #[cfg(target_os = "macos")]
2162 {
2163 if let Ok(output) = std::process::Command::new("system_profiler")
2164 .arg("SPDisplaysDataType")
2165 .output()
2166 {
2167 if let Ok(stdout) = String::from_utf8(output.stdout) {
2168 return parse_macos_displays(&stdout);
2169 }
2170 }
2171 Vec::new()
2172 }
2173
2174 #[cfg(target_os = "windows")]
2175 {
2176 let mut displays = Vec::new();
2177 if let Ok(output) = std::process::Command::new("wmic")
2178 .args([
2179 "path",
2180 "Win32_VideoController",
2181 "get",
2182 "Name,CurrentHorizontalResolution,CurrentVerticalResolution,CurrentRefreshRate",
2183 "/value",
2184 ])
2185 .output()
2186 {
2187 if let Ok(stdout) = String::from_utf8(output.stdout) {
2188 let mut name = String::new();
2189 let mut width = String::new();
2190 let mut height = String::new();
2191 let mut refresh = String::new();
2192 for line in stdout.lines() {
2193 let line = line.trim();
2194 if line.starts_with("Name=") {
2195 name = line.strip_prefix("Name=").unwrap_or("").trim().to_string();
2196 } else if line.starts_with("CurrentHorizontalResolution=") {
2197 width = line
2198 .strip_prefix("CurrentHorizontalResolution=")
2199 .unwrap_or("")
2200 .trim()
2201 .to_string();
2202 } else if line.starts_with("CurrentVerticalResolution=") {
2203 height = line
2204 .strip_prefix("CurrentVerticalResolution=")
2205 .unwrap_or("")
2206 .trim()
2207 .to_string();
2208 } else if line.starts_with("CurrentRefreshRate=") {
2209 refresh = line
2210 .strip_prefix("CurrentRefreshRate=")
2211 .unwrap_or("")
2212 .trim()
2213 .to_string();
2214 }
2215
2216 if !name.is_empty()
2217 && !width.is_empty()
2218 && !height.is_empty()
2219 && !refresh.is_empty()
2220 {
2221 displays.push(format!("{} ({}x{} @ {}Hz)", name, width, height, refresh));
2222 name.clear();
2223 width.clear();
2224 height.clear();
2225 refresh.clear();
2226 }
2227 }
2228 if !name.is_empty() && !width.is_empty() && !height.is_empty() {
2229 displays.push(format!("{} ({}x{})", name, width, height));
2230 }
2231 }
2232 }
2233 displays
2234 }
2235
2236 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
2237 {
2238 let mut displays = Vec::new();
2239
2240 if let Ok(output) = std::process::Command::new("xrandr")
2241 .arg("--current")
2242 .output()
2243 {
2244 if let Ok(stdout) = String::from_utf8(output.stdout) {
2245 displays = parse_xrandr_displays(&stdout);
2246 }
2247 }
2248
2249 if displays.is_empty() {
2250 if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
2251 for entry in entries.filter_map(|e| e.ok()) {
2252 let path = entry.path();
2253 let status_path = path.join("status");
2254 let modes_path = path.join("modes");
2255 let edid_path = path.join("edid");
2256 if status_path.exists() && modes_path.exists() {
2257 if let Ok(status) = std::fs::read_to_string(&status_path) {
2258 if status.trim() == "connected" {
2259 if let Ok(modes) = std::fs::read_to_string(&modes_path) {
2260 if let Some(first_mode) = modes.lines().next() {
2261 let res = first_mode.trim().to_string();
2262 let port = entry.file_name().to_string_lossy().to_string();
2263 let clean_port = if let Some(idx) = port.find('-') {
2264 port[idx + 1..].to_string()
2265 } else {
2266 port
2267 };
2268
2269 let edid_bytes = if edid_path.exists() {
2270 std::fs::read(&edid_path).ok()
2271 } else {
2272 None
2273 };
2274
2275 let name = edid_bytes
2276 .as_ref()
2277 .and_then(|bytes| parse_monitor_name_from_edid(bytes))
2278 .unwrap_or(clean_port);
2279
2280 let refresh = edid_bytes
2281 .as_ref()
2282 .and_then(|bytes| parse_refresh_rate_from_edid(bytes));
2283
2284 let serial = edid_bytes
2285 .as_ref()
2286 .and_then(|bytes| parse_serial_number_from_edid(bytes));
2287
2288 let display_name = if let Some(ref s) = serial {
2289 format!("{} #{}", name, s)
2290 } else {
2291 name
2292 };
2293
2294 if let Some(r) = refresh {
2295 displays.push(format!(
2296 "{} ({} @ {}Hz)",
2297 display_name,
2298 res,
2299 format_refresh_rate(r)
2300 ));
2301 } else {
2302 displays.push(format!("{} ({})", display_name, res));
2303 }
2304 }
2305 }
2306 }
2307 }
2308 }
2309 }
2310 }
2311 }
2312
2313 displays
2314 }
2315}
2316
2317#[cfg(target_os = "macos")]
2318fn parse_macos_displays(stdout: &str) -> Vec<String> {
2319 let mut displays = Vec::new();
2320 let mut current_name = None;
2321 let mut current_res = None;
2322 let mut in_displays = false;
2323
2324 for line in stdout.lines() {
2325 let trimmed = line.trim();
2326 let indent = line.len() - line.trim_start().len();
2327
2328 if trimmed.starts_with("Displays:") {
2329 in_displays = true;
2330 continue;
2331 }
2332
2333 if in_displays {
2334 if indent < 8 && !trimmed.is_empty() && !trimmed.starts_with("Displays:") {
2335 in_displays = false;
2336 continue;
2337 }
2338
2339 if trimmed.ends_with(':') && !trimmed.starts_with("UI Looks like:") {
2340 let name = trimmed.trim_end_matches(':').trim().to_string();
2341 current_name = Some(name);
2342 } else if trimmed.starts_with("Resolution:") {
2343 let res = trimmed.strip_prefix("Resolution:").unwrap_or("").trim();
2344 let cleaned = res.replace(" ", "");
2345 current_res = Some(cleaned);
2346 } else if trimmed.starts_with("UI Looks like:") {
2347 if let Some(res) = current_res.take() {
2348 let name_str = current_name.take().unwrap_or_else(|| "Display".to_string());
2349 if let Some(idx) = trimmed.find('@') {
2350 let freq = trimmed[idx..].trim();
2351 let freq_clean = freq.replace(" ", "").replace(".00", "");
2352 displays.push(format!(
2353 "{} ({} @ {})",
2354 name_str,
2355 res,
2356 freq_clean.trim_start_matches('@')
2357 ));
2358 } else {
2359 displays.push(format!("{} ({})", name_str, res));
2360 }
2361 }
2362 }
2363 }
2364 }
2365 if let Some(res) = current_res {
2366 let name_str = current_name.unwrap_or_else(|| "Display".to_string());
2367 displays.push(format!("{} ({})", name_str, res));
2368 }
2369 displays
2370}
2371
2372#[cfg(not(any(target_os = "macos", target_os = "windows")))]
2373fn parse_xrandr_displays(stdout: &str) -> Vec<String> {
2374 let mut displays = Vec::new();
2375 let mut current_display = None;
2376 let mut current_port = None;
2377 for line in stdout.lines() {
2378 let line = line.trim();
2379 if line.contains(" connected ") {
2380 let parts: Vec<&str> = line.split_whitespace().collect();
2381 if let Some(&port) = parts.first() {
2382 current_port = Some(port.to_string());
2383 }
2384 for part in parts {
2385 if part.contains('x') && part.contains('+') {
2386 if let Some(res) = part.split('+').next() {
2387 current_display = Some(res.to_string());
2388 }
2389 }
2390 }
2391 } else if line.contains('*') {
2392 if let Some(res) = current_display.take() {
2393 let port = current_port.take().unwrap_or_default();
2394 let name = get_monitor_name_for_port(&port).unwrap_or_else(|| port.clone());
2395 let parts: Vec<&str> = line.split_whitespace().collect();
2396 let mut added = false;
2397 for part in parts {
2398 if part.contains('*') {
2399 let freq = part.trim_end_matches(['*', '+']);
2400 displays.push(format!("{} ({} @ {}Hz)", name, res, freq));
2401 added = true;
2402 break;
2403 }
2404 }
2405 if !added {
2406 displays.push(format!("{} ({})", name, res));
2407 }
2408 }
2409 }
2410 }
2411 displays
2412}
2413
2414fn format_bytes(bytes: u64) -> String {
2416 const KB: u64 = 1024;
2417 const MB: u64 = KB * 1024;
2418 const GB: u64 = MB * 1024;
2419
2420 if bytes >= GB {
2421 format!("{:.1} GB", bytes as f64 / GB as f64)
2422 } else if bytes >= MB {
2423 format!("{:.1} MB", bytes as f64 / MB as f64)
2424 } else if bytes >= KB {
2425 format!("{:.1} KB", bytes as f64 / KB as f64)
2426 } else {
2427 format!("{} B", bytes)
2428 }
2429}
2430
2431fn detect_shell(sys: &sysinfo::System) -> Option<String> {
2433 let shell_env = std::env::var("SHELL").ok();
2435
2436 let (shell_path, shell_name) = if let Some(ref path_str) = shell_env {
2438 let path = std::path::Path::new(path_str);
2439 let name = path
2440 .file_name()
2441 .and_then(|n| n.to_str())
2442 .unwrap_or(path_str)
2443 .to_string();
2444 (path_str.clone(), name)
2445 } else {
2446 let mut current_pid = sysinfo::get_current_pid().ok();
2448 let mut detected: Option<(String, String)> = None;
2449 let known_shells = [
2450 "bash",
2451 "zsh",
2452 "fish",
2453 "sh",
2454 "dash",
2455 "nu",
2456 "elvish",
2457 "tcsh",
2458 "csh",
2459 "ksh",
2460 "powershell",
2461 "pwsh",
2462 "cmd",
2463 ];
2464
2465 while let Some(pid) = current_pid {
2466 if let Some(process) = sys.process(pid) {
2467 let proc_name = process.name().to_string_lossy().to_string();
2468 let proc_name_lower = proc_name.to_lowercase();
2469
2470 let clean_name = proc_name_lower
2472 .strip_suffix(".exe")
2473 .unwrap_or(&proc_name_lower)
2474 .to_string();
2475
2476 if known_shells.contains(&clean_name.as_str()) {
2477 detected = Some((proc_name.clone(), clean_name));
2478 break;
2479 }
2480 current_pid = process.parent();
2481 } else {
2482 break;
2483 }
2484 }
2485
2486 if let Some((orig_name, clean_name)) = detected {
2487 (orig_name, clean_name)
2488 } else {
2489 #[cfg(target_os = "windows")]
2491 {
2492 if std::env::var("PSModulePath").is_ok() {
2493 ("powershell.exe".to_string(), "powershell".to_string())
2494 } else {
2495 ("cmd.exe".to_string(), "cmd".to_string())
2496 }
2497 }
2498 #[cfg(not(target_os = "windows"))]
2499 {
2500 ("sh".to_string(), "sh".to_string())
2501 }
2502 }
2503 };
2504
2505 let shell_name_lower = shell_name.to_lowercase();
2507 let shell_name_clean = shell_name_lower
2508 .strip_suffix(".exe")
2509 .unwrap_or(&shell_name_lower);
2510
2511 let version = detect_shell_version(&shell_path, shell_name_clean);
2512
2513 if let Some(ver) = version {
2514 Some(format!("{} {}", shell_name_clean, ver))
2515 } else {
2516 Some(shell_name_clean.to_string())
2517 }
2518}
2519
2520fn detect_shell_version(shell_path: &str, shell_name: &str) -> Option<String> {
2522 let args = match shell_name {
2524 "powershell" => vec![
2525 "-NoProfile",
2526 "-Command",
2527 "$PSVersionTable.PSVersion.ToString()",
2528 ],
2529 "elvish" => vec!["-version"],
2530 _ => vec!["--version"],
2531 };
2532
2533 let output = std::process::Command::new(shell_path)
2534 .args(&args)
2535 .output()
2536 .or_else(|_| {
2537 std::process::Command::new(shell_name).args(&args).output()
2540 })
2541 .ok()?;
2542
2543 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
2544 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
2545
2546 let full_output = format!("{}\n{}", stdout, stderr);
2548
2549 parse_shell_version(shell_name, &full_output)
2550}
2551
2552fn parse_shell_version(shell_name: &str, output: &str) -> Option<String> {
2554 let output_trimmed = output.trim();
2555 if output_trimmed.is_empty() {
2556 return None;
2557 }
2558
2559 match shell_name {
2560 "bash" => {
2561 if let Some(pos) = output.find("version ") {
2562 let rest = &output[pos + 8..];
2563 let ver = rest
2564 .split(|c: char| c.is_whitespace() || c == '(' || c == ',' || c == '-')
2565 .next()
2566 .unwrap_or("");
2567 if !ver.is_empty() {
2568 return Some(ver.to_string());
2569 }
2570 }
2571 }
2572 "zsh" => {
2573 if let Some(pos) = output.find("zsh ") {
2574 let rest = &output[pos + 4..];
2575 let ver = rest
2576 .split(|c: char| c.is_whitespace() || c == '(')
2577 .next()
2578 .unwrap_or("");
2579 if !ver.is_empty() {
2580 return Some(ver.to_string());
2581 }
2582 }
2583 }
2584 "fish" => {
2585 if let Some(pos) = output.find("version ") {
2586 let rest = &output[pos + 8..];
2587 let ver = rest
2588 .split(|c: char| c.is_whitespace() || c == '(' || c == ',')
2589 .next()
2590 .unwrap_or("");
2591 if !ver.is_empty() {
2592 return Some(ver.to_string());
2593 }
2594 }
2595 }
2596 "nu" => {
2597 let ver = output_trimmed.split_whitespace().next().unwrap_or("");
2598 if !ver.is_empty() {
2599 return Some(ver.to_string());
2600 }
2601 }
2602 "pwsh" => {
2603 if let Some(pos) = output.find("PowerShell ") {
2604 let rest = &output[pos + 11..];
2605 let ver = rest.split_whitespace().next().unwrap_or("");
2606 if !ver.is_empty() {
2607 return Some(ver.to_string());
2608 }
2609 }
2610 }
2611 "powershell" => {
2612 let ver = output_trimmed.split_whitespace().next().unwrap_or("");
2613 if !ver.is_empty() {
2614 return Some(ver.to_string());
2615 }
2616 }
2617 "elvish" => {
2618 let ver = output_trimmed.split_whitespace().next().unwrap_or("");
2619 if !ver.is_empty() {
2620 return Some(ver.to_string());
2621 }
2622 }
2623 "tcsh" => {
2624 if let Some(pos) = output.find("tcsh ") {
2625 let rest = &output[pos + 5..];
2626 let ver = rest.split_whitespace().next().unwrap_or("");
2627 if !ver.is_empty() {
2628 return Some(ver.to_string());
2629 }
2630 }
2631 }
2632 _ => {
2633 if let Some(pos) = output.to_lowercase().find("version ") {
2635 let rest = &output[pos + 8..];
2636 let ver = rest
2637 .split(|c: char| c.is_whitespace() || c == '(' || c == ',' || c == '-')
2638 .next()
2639 .unwrap_or("");
2640 if !ver.is_empty() {
2641 return Some(ver.to_string());
2642 }
2643 }
2644 }
2645 }
2646
2647 None
2648}
2649
2650#[allow(dead_code)]
2651fn parse_ini_key(content: &str, key: &str) -> Option<String> {
2652 for line in content.lines() {
2653 let line = line.trim();
2654 if line.starts_with('#') || line.starts_with(';') {
2655 continue;
2656 }
2657 if let Some(pos) = line.find('=') {
2658 let k = line[..pos].trim();
2659 if k == key {
2660 let v = line[pos + 1..].trim();
2661 let v = if (v.starts_with('"') && v.ends_with('"'))
2662 || (v.starts_with('\'') && v.ends_with('\''))
2663 {
2664 if v.len() >= 2 {
2665 v[1..v.len() - 1].to_string()
2666 } else {
2667 v.to_string()
2668 }
2669 } else {
2670 v.to_string()
2671 };
2672 if !v.is_empty() {
2673 return Some(v);
2674 }
2675 }
2676 }
2677 }
2678 None
2679}
2680
2681#[cfg(target_os = "linux")]
2682fn get_gtk_setting(key: &str) -> Option<String> {
2683 let home = dirs::home_dir()?;
2684 let paths = [
2685 home.join(".config/gtk-4.0/settings.ini"),
2686 home.join(".config/gtk-3.0/settings.ini"),
2687 home.join(".config/gtk-2.0/settings.ini"),
2688 home.join(".gtkrc-2.0"),
2689 ];
2690
2691 for path in &paths {
2692 if path.exists() {
2693 if let Ok(contents) = std::fs::read_to_string(path) {
2694 if let Some(val) = parse_ini_key(&contents, key) {
2695 return Some(val);
2696 }
2697 }
2698 }
2699 }
2700 None
2701}
2702
2703#[cfg(target_os = "linux")]
2704fn query_gsettings(schema: &str, key: &str) -> Option<String> {
2705 let output = std::process::Command::new("gsettings")
2706 .args(["get", schema, key])
2707 .output()
2708 .ok()?;
2709 if output.status.success() {
2710 let val = String::from_utf8_lossy(&output.stdout).trim().to_string();
2711 let val = val.trim_matches('\'').trim_matches('"').to_string();
2712 if !val.is_empty() && val != "''" && val != "\"\"" {
2713 return Some(val);
2714 }
2715 }
2716 None
2717}
2718
2719#[cfg(target_os = "linux")]
2720fn get_kde_setting(key: &str) -> Option<String> {
2721 let home = dirs::home_dir()?;
2722 let path = home.join(".config/kdeglobals");
2723 if path.exists() {
2724 if let Ok(contents) = std::fs::read_to_string(path) {
2725 return parse_ini_key(&contents, key);
2726 }
2727 }
2728 None
2729}
2730
2731#[cfg(target_os = "linux")]
2732fn detect_ui_theme_and_fonts() -> (
2733 Option<String>,
2734 Option<String>,
2735 Option<String>,
2736 Option<String>,
2737) {
2738 let gtk_theme = get_gtk_setting("gtk-theme-name")
2740 .or_else(|| query_gsettings("org.gnome.desktop.interface", "gtk-theme"));
2741 let gtk_icons = get_gtk_setting("gtk-icon-theme-name")
2742 .or_else(|| query_gsettings("org.gnome.desktop.interface", "icon-theme"));
2743 let gtk_cursor = get_gtk_setting("gtk-cursor-theme-name")
2744 .or_else(|| query_gsettings("org.gnome.desktop.interface", "cursor-theme"));
2745 let gtk_font = get_gtk_setting("gtk-font-name")
2746 .or_else(|| query_gsettings("org.gnome.desktop.interface", "font-name"));
2747
2748 let qt_theme = get_kde_setting("widgetStyle").or_else(|| get_kde_setting("ColorScheme"));
2750 let qt_icons = get_kde_setting("iconTheme");
2751 let qt_cursor = {
2752 let home = dirs::home_dir();
2753 home.and_then(|h| {
2754 let path = h.join(".config/kcminputrc");
2755 if path.exists() {
2756 std::fs::read_to_string(path)
2757 .ok()
2758 .and_then(|contents| parse_ini_key(&contents, "theme"))
2759 } else {
2760 None
2761 }
2762 })
2763 };
2764 let qt_font = get_kde_setting("font").map(|f| {
2765 let parts: Vec<&str> = f.split(',').collect();
2766 if parts.len() >= 2 {
2767 let name = parts[0].trim();
2768 let size = parts[1].trim();
2769 format!("{} ({}pt)", name, size)
2770 } else {
2771 f
2772 }
2773 });
2774
2775 let de = std::env::var("XDG_CURRENT_DESKTOP")
2777 .or_else(|_| std::env::var("DESKTOP_SESSION"))
2778 .unwrap_or_default()
2779 .to_lowercase();
2780
2781 let is_kde = de.contains("kde") || de.contains("plasma");
2782
2783 let theme = if is_kde {
2784 match (qt_theme, gtk_theme) {
2785 (Some(qt), Some(gt)) => Some(format!("{} [Qt], {} [GTK]", qt, gt)),
2786 (Some(qt), None) => Some(format!("{} [Qt]", qt)),
2787 (None, Some(gt)) => Some(format!("{} [GTK]", gt)),
2788 (None, None) => None,
2789 }
2790 } else {
2791 match (gtk_theme, qt_theme) {
2792 (Some(gt), Some(qt)) => Some(format!("{} [GTK], {} [Qt]", gt, qt)),
2793 (Some(gt), None) => Some(format!("{} [GTK]", gt)),
2794 (None, Some(qt)) => Some(format!("{} [Qt]", qt)),
2795 (None, None) => None,
2796 }
2797 };
2798
2799 let icons = if is_kde {
2800 match (qt_icons, gtk_icons) {
2801 (Some(qi), Some(gi)) => Some(format!("{} [Qt], {} [GTK]", qi, gi)),
2802 (Some(qi), None) => Some(format!("{} [Qt]", qi)),
2803 (None, Some(gi)) => Some(format!("{} [GTK]", gi)),
2804 (None, None) => None,
2805 }
2806 } else {
2807 match (gtk_icons, qt_icons) {
2808 (Some(gi), Some(qi)) => Some(format!("{} [GTK], {} [Qt]", gi, qi)),
2809 (Some(gi), None) => Some(format!("{} [GTK]", gi)),
2810 (None, Some(qi)) => Some(format!("{} [Qt]", qi)),
2811 (None, None) => None,
2812 }
2813 };
2814
2815 let cursor = if is_kde {
2816 match (qt_cursor, gtk_cursor) {
2817 (Some(qc), Some(gc)) => Some(format!("{} [Qt], {} [GTK]", qc, gc)),
2818 (Some(qc), None) => Some(format!("{} [Qt]", qc)),
2819 (None, Some(gc)) => Some(format!("{} [GTK]", gc)),
2820 (None, None) => None,
2821 }
2822 } else {
2823 match (gtk_cursor, qt_cursor) {
2824 (Some(gc), Some(qc)) => Some(format!("{} [GTK], {} [Qt]", gc, qc)),
2825 (Some(gc), None) => Some(format!("{} [GTK]", gc)),
2826 (None, Some(qc)) => Some(format!("{} [Qt]", qc)),
2827 (None, None) => None,
2828 }
2829 };
2830
2831 let font = if is_kde {
2832 match (qt_font, gtk_font) {
2833 (Some(qf), Some(gf)) => Some(format!("{} [Qt], {} [GTK]", qf, gf)),
2834 (Some(qf), None) => Some(format!("{} [Qt]", qf)),
2835 (None, Some(gf)) => Some(format!("{} [GTK]", gf)),
2836 (None, None) => None,
2837 }
2838 } else {
2839 match (gtk_font, qt_font) {
2840 (Some(gf), Some(qf)) => Some(format!("{} [GTK], {} [Qt]", gf, qf)),
2841 (Some(gf), None) => Some(format!("{} [GTK]", gf)),
2842 (None, Some(qf)) => Some(format!("{} [Qt]", qf)),
2843 (None, None) => None,
2844 }
2845 };
2846
2847 (theme, icons, cursor, font)
2848}
2849
2850#[cfg(target_os = "macos")]
2851fn detect_ui_theme_and_fonts() -> (
2852 Option<String>,
2853 Option<String>,
2854 Option<String>,
2855 Option<String>,
2856) {
2857 let interface_style = std::process::Command::new("defaults")
2858 .args(["read", "-g", "AppleInterfaceStyle"])
2859 .output()
2860 .ok()
2861 .and_then(|o| {
2862 if o.status.success() {
2863 Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
2864 } else {
2865 None
2866 }
2867 });
2868
2869 let theme = match interface_style {
2870 Some(style) => Some(format!("Aqua ({})", style)),
2871 None => Some("Aqua (Light)".to_string()),
2872 };
2873
2874 (theme, None, None, Some("San Francisco".to_string()))
2875}
2876
2877#[cfg(target_os = "windows")]
2878fn detect_ui_theme_and_fonts() -> (
2879 Option<String>,
2880 Option<String>,
2881 Option<String>,
2882 Option<String>,
2883) {
2884 let theme = {
2885 let output = std::process::Command::new("reg")
2886 .args([
2887 "query",
2888 r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
2889 "/v",
2890 "AppsUseLightTheme",
2891 ])
2892 .output()
2893 .ok();
2894
2895 let apps_dark = output.and_then(|o| {
2896 if o.status.success() {
2897 let s = String::from_utf8_lossy(&o.stdout);
2898 if s.contains("0x0") {
2899 Some(true) } else if s.contains("0x1") {
2901 Some(false) } else {
2903 None
2904 }
2905 } else {
2906 None
2907 }
2908 });
2909
2910 match apps_dark {
2911 Some(true) => Some("Dark".to_string()),
2912 Some(false) => Some("Light".to_string()),
2913 None => Some("Unknown".to_string()),
2914 }
2915 };
2916
2917 (theme, None, None, Some("Segoe UI".to_string()))
2918}
2919
2920#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
2921fn detect_ui_theme_and_fonts() -> (
2922 Option<String>,
2923 Option<String>,
2924 Option<String>,
2925 Option<String>,
2926) {
2927 (None, None, None, None)
2928}
2929
2930#[cfg(test)]
2931mod tests {
2932 use super::*;
2933
2934 #[test]
2935 fn test_format_bytes() {
2936 assert_eq!(format_bytes(500), "500 B");
2937 assert_eq!(format_bytes(1024), "1.0 KB");
2938 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
2939 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
2940 assert_eq!(format_bytes(1536), "1.5 KB");
2941 }
2942
2943 #[test]
2944 fn test_parse_ini_key() {
2945 let sample = r#"[Settings]
2946gtk-theme-name=Adwaita-dark
2947# Comment line
2948 ; Another comment
2949gtk-icon-theme-name = "Adwaita"
2950gtk-font-name='Cantarell 11'
2951invalid_line_no_equal
2952empty_value =
2953"#;
2954 assert_eq!(
2955 parse_ini_key(sample, "gtk-theme-name"),
2956 Some("Adwaita-dark".to_string())
2957 );
2958 assert_eq!(
2959 parse_ini_key(sample, "gtk-icon-theme-name"),
2960 Some("Adwaita".to_string())
2961 );
2962 assert_eq!(
2963 parse_ini_key(sample, "gtk-font-name"),
2964 Some("Cantarell 11".to_string())
2965 );
2966 assert_eq!(parse_ini_key(sample, "invalid_line_no_equal"), None);
2967 assert_eq!(parse_ini_key(sample, "empty_value"), None);
2968 assert_eq!(parse_ini_key(sample, "nonexistent"), None);
2969 }
2970
2971 #[test]
2972 fn test_system_info_collect() {
2973 let opts = crate::fetch::CollectOptions::default();
2974 let res = SystemInfo::collect(opts);
2975 assert!(res.is_ok());
2976 let info = res.unwrap();
2977 assert!(!info.os.is_empty());
2978 assert!(info.cpu_cores > 0);
2979 }
2980
2981 #[cfg(target_os = "macos")]
2982 #[test]
2983 fn test_parse_macos_displays() {
2984 let sample = "Graphics/Displays:\n\n Apple M2:\n\n Chipset Model: Apple M2\n Displays:\n Color LCD:\n Resolution: 3024 x 1964\n UI Looks like: 1512 x 982 @ 60.00Hz\n";
2985 let parsed = parse_macos_displays(sample);
2986 assert_eq!(parsed, vec!["Color LCD (3024x1964 @ 60Hz)".to_string()]);
2987 }
2988
2989 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
2990 #[test]
2991 fn test_parse_xrandr_displays() {
2992 let sample = "Screen 0: minimum 320 x 200, current 2560 x 1440\nDP-1 connected primary 2560x1440+0+0\n 2560x1440 143.97*+\n 1920x1080 60.00\n";
2993 let parsed = parse_xrandr_displays(sample);
2994 assert_eq!(parsed, vec!["DP-1 (2560x1440 @ 143.97Hz)".to_string()]);
2995 }
2996
2997 #[test]
2998 fn test_parse_refresh_rate_from_edid() {
2999 let mut edid = vec![0u8; 128];
3001 edid[54] = 0x02;
3003 edid[55] = 0x3A;
3004 edid[56] = 0x80; edid[57] = 0x18; edid[58] = 0x71; edid[59] = 0x38; edid[60] = 0x2D; edid[61] = 0x40; let refresh = parse_refresh_rate_from_edid(&edid);
3014 assert!(refresh.is_some());
3015 assert_eq!(refresh.unwrap(), 60.0);
3017 }
3018
3019 #[test]
3020 fn test_format_refresh_rate() {
3021 assert_eq!(format_refresh_rate(60.0), "60");
3022 assert_eq!(format_refresh_rate(59.94), "59.94");
3023 assert_eq!(format_refresh_rate(143.971), "143.97");
3024 }
3025
3026 #[test]
3027 fn test_parse_serial_number_from_edid() {
3028 let mut edid = vec![0u8; 128];
3029 edid[12] = 0x78;
3031 edid[13] = 0x56;
3032 edid[14] = 0x34;
3033 edid[15] = 0x12; assert_eq!(
3035 parse_serial_number_from_edid(&edid),
3036 Some("305419896".to_string())
3037 );
3038
3039 edid[72] = 0x00;
3041 edid[73] = 0x00;
3042 edid[74] = 0x00;
3043 edid[75] = 0xFF; let serial_str = b"CN0123456789\n";
3045 for i in 0..serial_str.len() {
3046 edid[76 + i] = serial_str[i];
3047 }
3048 assert_eq!(
3049 parse_serial_number_from_edid(&edid),
3050 Some("CN0123456789".to_string())
3051 );
3052 }
3053
3054 #[test]
3055 fn test_parse_asound_cards() {
3056 let sample = " 0 [PCH ]: HDA-Intel - HDA Intel PCH\n 1 [NVidia ]: HDA-Intel - HDA NVIDIA HDMI\n 2 [sofhdadsp ]: sof-hda-dsp - sof-hda-dsp\n DellInc.-Inspiron1676302_in_1-0DR8JD\n";
3057 let parsed = parse_asound_cards(sample, "/nonexistent");
3058 assert_eq!(
3059 parsed,
3060 vec![
3061 "HDA Intel PCH".to_string(),
3062 "HDA NVIDIA HDMI".to_string(),
3063 "sof-hda-dsp".to_string()
3064 ]
3065 );
3066 }
3067
3068 #[test]
3069 fn test_parse_airport_output() {
3070 let sample = " agrCtlRSSI: -45\n lastTxRate: 866\n SSID: MyHomeWiFi\n";
3071 assert_eq!(
3072 parse_airport_output(sample),
3073 Some("MyHomeWiFi (↑866 Mbps)".to_string())
3074 );
3075
3076 let sample_no_rate = " agrCtlRSSI: -45\n SSID: GuestNetwork\n";
3077 assert_eq!(
3078 parse_airport_output(sample_no_rate),
3079 Some("GuestNetwork".to_string())
3080 );
3081 }
3082
3083 #[test]
3084 fn test_parse_netsh_output() {
3085 let sample = " Name : Wi-Fi\n State : connected\n SSID : Office_Wi-Fi\n Receive rate (Mbps) : 433\n Transmit rate (Mbps) : 866\n Band : 5 GHz\n";
3086 assert_eq!(
3087 parse_netsh_output(sample),
3088 Some("Office_Wi-Fi (5 GHz [↓433 Mbps ↑866 Mbps])".to_string())
3089 );
3090 }
3091
3092 #[test]
3093 fn test_parse_iw_link_output() {
3094 let sample = "Connected to 84:78:48:dc:97:23 (on wlp2s0)\n SSID: OfficeNet\n freq: 6135.0\n rx bitrate: 6.0 MBit/s\n tx bitrate: 864.6 MBit/s 160MHz HE-MCS 4\n";
3095 let (ssid, links) = parse_iw_link_output(sample);
3096 assert_eq!(ssid, Some("OfficeNet".to_string()));
3097 assert_eq!(links.len(), 1);
3098 assert_eq!(links[0].freq, Some(6135.0));
3099 assert_eq!(links[0].rx_rate, Some("6.0 MBit/s".to_string()));
3100 assert_eq!(links[0].tx_rate, Some("864.6 MBit/s".to_string()));
3101
3102 let sample_mlo = "Connected to aa:bb:cc:dd:ee:ff (on wlan0)\n SSID: HomeWiFi\n freq: 5180.0\n rx bitrate: 866.0 MBit/s\n tx bitrate: 866.0 MBit/s\nConnected to aa:bb:cc:dd:ee:01 (on wlan0)\n freq: 6135.0\n rx bitrate: 1200.0 MBit/s\n tx bitrate: 1200.0 MBit/s\n";
3104 let (ssid_mlo, links_mlo) = parse_iw_link_output(sample_mlo);
3105 assert_eq!(ssid_mlo, Some("HomeWiFi".to_string()));
3106 assert_eq!(links_mlo.len(), 2);
3107 assert_eq!(links_mlo[0].freq, Some(5180.0));
3108 assert_eq!(links_mlo[1].freq, Some(6135.0));
3109 }
3110
3111 #[test]
3112 fn test_parse_macos_bluetooth() {
3113 let sample = "Bluetooth:\n\n Bluetooth Power: On\n Chipset: BCM4350\n Devices (Connected):\n Sony WH-1000XM4:\n Address: AA-BB-CC\n Connected: Yes\n Logitech MX Master:\n Address: DD-EE-FF\n Connected: Yes\n";
3114 assert_eq!(
3115 parse_macos_bluetooth(sample),
3116 Some(
3117 "On (Apple BCM4350) - 2 connected (Sony WH-1000XM4, Logitech MX Master)"
3118 .to_string()
3119 )
3120 );
3121
3122 let sample_off = "Bluetooth:\n\n Bluetooth Power: Off\n";
3123 assert_eq!(
3124 parse_macos_bluetooth(sample_off),
3125 Some("Off (Apple Bluetooth)".to_string())
3126 );
3127 }
3128
3129 #[test]
3130 fn test_parse_windows_bluetooth_output() {
3131 let sample =
3132 "Running | Intel(R) Wireless Bluetooth(R) | (Sony WH-1000XM4,Logitech MX Master)\n";
3133 assert_eq!(
3134 parse_windows_bluetooth_output(sample),
3135 Some("On (Intel(R) Wireless Bluetooth(R)) - 2 connected (Sony WH-1000XM4, Logitech MX Master)".to_string())
3136 );
3137
3138 let sample_off = "Stopped | | ()\n";
3139 assert_eq!(
3140 parse_windows_bluetooth_output(sample_off),
3141 Some("Off".to_string())
3142 );
3143 }
3144
3145 #[test]
3146 fn test_parse_shell_version() {
3147 let bash_out = "GNU bash, version 5.2.15(1)-release (x86_64-pc-linux-gnu)\nCopyright (C) 2022 Free Software Foundation, Inc.";
3149 assert_eq!(
3150 parse_shell_version("bash", bash_out),
3151 Some("5.2.15".to_string())
3152 );
3153
3154 let zsh_out = "zsh 5.9 (x86_64-pc-linux-gnu)";
3156 assert_eq!(parse_shell_version("zsh", zsh_out), Some("5.9".to_string()));
3157
3158 let fish_out = "fish, version 3.6.0";
3160 assert_eq!(
3161 parse_shell_version("fish", fish_out),
3162 Some("3.6.0".to_string())
3163 );
3164
3165 let nu_out = "0.93.0";
3167 assert_eq!(
3168 parse_shell_version("nu", nu_out),
3169 Some("0.93.0".to_string())
3170 );
3171
3172 let pwsh_out = "PowerShell 7.4.1";
3174 assert_eq!(
3175 parse_shell_version("pwsh", pwsh_out),
3176 Some("7.4.1".to_string())
3177 );
3178
3179 let powershell_out = "5.1.22621.2428";
3181 assert_eq!(
3182 parse_shell_version("powershell", powershell_out),
3183 Some("5.1.22621.2428".to_string())
3184 );
3185
3186 let elvish_out = "0.20.1";
3188 assert_eq!(
3189 parse_shell_version("elvish", elvish_out),
3190 Some("0.20.1".to_string())
3191 );
3192
3193 let tcsh_out = "tcsh 6.24.10 (Astron) 2023-04-20 (x86_64-amd-linux) options wide,nls,dl,al,kan,sm,color,filec";
3195 assert_eq!(
3196 parse_shell_version("tcsh", tcsh_out),
3197 Some("6.24.10".to_string())
3198 );
3199
3200 let custom_out = "CustomShell version 1.2.3-patch4";
3202 assert_eq!(
3203 parse_shell_version("custom", custom_out),
3204 Some("1.2.3".to_string())
3205 );
3206 }
3207
3208 #[test]
3209 fn test_parse_macos_camera() {
3210 let sample = "Camera:\n\n FaceTime HD Camera:\n\n Model ID: UVC Camera VendorID_1452 ProductID_34068\n Unique ID: 0x8020000005ac8514\n";
3211 assert_eq!(
3212 parse_macos_camera(sample),
3213 vec!["FaceTime HD Camera".to_string()]
3214 );
3215 }
3216
3217 #[test]
3218 fn test_parse_macos_gamepad() {
3219 let usb_sample = "USB 3.1 Bus:\n\n Xbox Wireless Controller:\n\n Product ID: 0x02e0\n Vendor ID: 0x045e\n";
3220 let bt_sample = "Bluetooth:\n\n Devices (Connected):\n DualSense Wireless Controller:\n Address: AA-BB-CC\n Connected: Yes\n";
3221 let parsed = parse_macos_gamepad(usb_sample, bt_sample);
3222 assert_eq!(
3223 parsed,
3224 vec![
3225 "Xbox Wireless Controller".to_string(),
3226 "DualSense Wireless Controller".to_string()
3227 ]
3228 );
3229 }
3230}