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 terminal_font: Option<String>,
106 pub camera: Vec<String>,
108 pub gamepad: Vec<String>,
110}
111
112impl SystemInfo {
113 pub fn collect(opts: CollectOptions) -> anyhow::Result<Self> {
118 let mut sys = System::new_all();
119 sys.refresh_all();
120
121 let os = System::long_os_version()
122 .or_else(System::name)
123 .unwrap_or_else(|| "Unknown".to_string());
124
125 let kernel = System::kernel_version();
126 let hostname = System::host_name();
127
128 let cpu = sys
129 .cpus()
130 .first()
131 .map(|c| c.brand().to_string())
132 .unwrap_or_else(|| "Unknown CPU".to_string());
133
134 let cpu_cores = sys.cpus().len();
135
136 let total_mem = sys.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
137 let used_mem = sys.used_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
138 let memory = format!("{:.1} / {:.1} GB", used_mem, total_mem);
139
140 let total_swap = sys.total_swap() as f64 / 1024.0 / 1024.0 / 1024.0;
141 let used_swap = sys.used_swap() as f64 / 1024.0 / 1024.0 / 1024.0;
142 let swap = if total_swap > 0.0 {
143 format!("{:.1} / {:.1} GB", used_swap, total_swap)
144 } else {
145 "No swap".to_string()
146 };
147
148 let uptime = format!("{}s", System::uptime());
149
150 let disks_list = Disks::new_with_refreshed_list();
151 let disks: Vec<String> = if !opts.long {
152 let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/"));
153 let mut best_match: Option<&sysinfo::Disk> = None;
154 for disk in disks_list.iter() {
155 if home.starts_with(disk.mount_point()) {
156 if let Some(best) = best_match {
157 if disk.mount_point().components().count()
158 > best.mount_point().components().count()
159 {
160 best_match = Some(disk);
161 }
162 } else {
163 best_match = Some(disk);
164 }
165 }
166 }
167 let selected_disks = if let Some(best) = best_match {
168 vec![best]
169 } else {
170 disks_list.iter().collect::<Vec<_>>()
171 };
172
173 selected_disks
174 .iter()
175 .map(|d| {
176 let total = d.total_space() as f64 / 1024.0 / 1024.0 / 1024.0;
177 let avail = d.available_space() as f64 / 1024.0 / 1024.0 / 1024.0;
178 let fs = d.file_system().to_string_lossy();
179 format!(
180 "{} ({}): {:.1} GB free / {:.1} GB",
181 d.mount_point().display(),
182 fs,
183 avail,
184 total
185 )
186 })
187 .collect()
188 } else {
189 disks_list
190 .iter()
191 .map(|d| {
192 let total = d.total_space() as f64 / 1024.0 / 1024.0 / 1024.0;
193 let avail = d.available_space() as f64 / 1024.0 / 1024.0 / 1024.0;
194 let fs = d.file_system().to_string_lossy();
195 format!(
196 "{} ({}): {:.1} GB free / {:.1} GB",
197 d.mount_point().display(),
198 fs,
199 avail,
200 total
201 )
202 })
203 .collect()
204 };
205
206 let battery = crate::battery::get_battery_info().map(|bat| {
207 let pct = bat.percentage;
208 let state = match bat.state {
209 crate::battery::BatteryState::Charging => "charging",
210 crate::battery::BatteryState::Discharging => "discharging",
211 crate::battery::BatteryState::Full => "full",
212 _ => "not charging",
213 };
214 let vendor = bat.vendor;
215 let model = bat.model;
216
217 let time_str = match bat.state {
219 crate::battery::BatteryState::Charging => bat.time_remaining.map(|d| {
220 let total_mins = d.as_secs() / 60;
221 let hours = total_mins / 60;
222 let mins = total_mins % 60;
223 if hours >= 24 {
224 let days = hours / 24;
225 let rem_hours = hours % 24;
226 format!("{}d {}h until full", days, rem_hours)
227 } else if hours > 0 {
228 format!("{}h {}m until full", hours, mins)
229 } else {
230 format!("{}m until full", mins)
231 }
232 }),
233 crate::battery::BatteryState::Discharging => bat.time_remaining.map(|d| {
234 let total_mins = d.as_secs() / 60;
235 let hours = total_mins / 60;
236 let mins = total_mins % 60;
237 if hours >= 24 {
238 let days = hours / 24;
239 let rem_hours = hours % 24;
240 format!("{}d {}h remaining", days, rem_hours)
241 } else if hours > 0 {
242 format!("{}h {}m remaining", hours, mins)
243 } else {
244 format!("{}m remaining", mins)
245 }
246 }),
247 _ => None,
248 };
249
250 let mut parts = vec![state.to_string()];
251 if let Some(t) = time_str {
252 parts.insert(0, t);
253 }
254 if let Some(health) = bat.health {
255 if health < 99.0 {
256 parts.push(format!("{:.0}% health", health));
257 }
258 }
259
260 let base = format!("{:.0}% ({})", pct, parts.join(", "));
261
262 match (vendor, model) {
263 (Some(v), Some(m)) => format!("{} [{} {}]", base, v, m),
264 (Some(v), None) => format!("{} [{}]", base, v),
265 _ => base,
266 }
267 });
268
269 let arch = System::cpu_arch();
270
271 let processes = sys.processes().len();
272
273 let load_avg = {
274 let avg = System::load_average();
275 if avg.one > 0.0 || avg.five > 0.0 {
276 Some(format!(
277 "{:.2}, {:.2}, {:.2}",
278 avg.one, avg.five, avg.fifteen
279 ))
280 } else {
281 None
282 }
283 };
284
285 let (
287 gpu,
288 packages,
289 public_ip,
290 (local_ip, active_interface),
291 motherboard,
292 bios,
293 displays,
294 audio,
295 wifi,
296 bluetooth,
297 (ui_theme, icons, cursor, font),
298 camera,
299 gamepad,
300 ) = std::thread::scope(|s| {
301 let gpu_handle = s.spawn(|| {
302 gpu::detect_gpus()
303 .into_iter()
304 .map(|g| g.format())
305 .collect::<Vec<String>>()
306 });
307 let packages_handle = s.spawn(detect_packages);
308 let public_ip_handle = s.spawn(|| {
309 std::process::Command::new("curl")
310 .args(["-s", "--max-time", "2", "https://api.ipify.org"])
311 .output()
312 .ok()
313 .and_then(|o| String::from_utf8(o.stdout).ok())
314 .map(|s| s.trim().to_string())
315 .filter(|s| !s.is_empty())
316 });
317 let network_ips_handle = s.spawn(|| {
318 let local_ip = std::net::UdpSocket::bind("0.0.0.0:0")
319 .ok()
320 .and_then(|socket| {
321 socket.connect("8.8.8.8:53").ok()?;
322 socket.local_addr().ok().map(|addr| addr.ip().to_string())
323 });
324
325 let active_interface = {
326 #[cfg(target_os = "linux")]
327 {
328 let native_iface = std::fs::read_to_string("/proc/net/route")
329 .ok()
330 .and_then(|content| parse_proc_net_route(&content));
331
332 native_iface.or_else(|| {
333 std::process::Command::new("ip")
334 .args(["route", "show", "default"])
335 .output()
336 .ok()
337 .and_then(|o| String::from_utf8(o.stdout).ok())
338 .and_then(|s| {
339 s.split_whitespace()
340 .position(|w| w == "dev")
341 .and_then(|i| s.split_whitespace().nth(i + 1))
342 .map(|s| s.to_string())
343 })
344 })
345 }
346 #[cfg(target_os = "macos")]
347 {
348 std::process::Command::new("route")
349 .args(["-n", "get", "default"])
350 .output()
351 .ok()
352 .and_then(|o| String::from_utf8(o.stdout).ok())
353 .and_then(|s| {
354 s.lines()
355 .find(|l| l.contains("interface:"))
356 .and_then(|l| l.split_whitespace().last())
357 .map(|s| s.to_string())
358 })
359 }
360 #[cfg(target_os = "windows")]
361 {
362 std::process::Command::new("powershell")
363 .args(["-Command", "Get-NetRoute -DestinationPrefix 0.0.0.0/0 | Select-Object -First 1 -ExpandProperty InterfaceAlias"])
364 .output()
365 .ok()
366 .and_then(|o| String::from_utf8(o.stdout).ok())
367 .map(|s| s.trim().to_string())
368 .filter(|s| !s.is_empty())
369 }
370 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
371 {
372 None
373 }
374 };
375
376 (local_ip, active_interface)
377 });
378 let motherboard_handle = s.spawn(detect_motherboard);
379 let bios_handle = s.spawn(detect_bios);
380 let displays_handle = s.spawn(detect_displays);
381 let audio_handle = s.spawn(|| detect_audio(&sys));
382 let wifi_handle = s.spawn(detect_wifi);
383 let bluetooth_handle = s.spawn(detect_bluetooth);
384 let ui_theme_and_fonts_handle = s.spawn(detect_ui_theme_and_fonts);
385 let camera_handle = s.spawn(detect_camera);
386 let gamepad_handle = s.spawn(detect_gamepad);
387
388 (
389 gpu_handle.join().unwrap_or_default(),
390 packages_handle.join().ok().flatten(),
391 public_ip_handle.join().ok().flatten(),
392 network_ips_handle.join().unwrap_or((None, None)),
393 motherboard_handle.join().ok().flatten(),
394 bios_handle.join().ok().flatten(),
395 displays_handle.join().unwrap_or_default(),
396 audio_handle.join().ok().flatten(),
397 wifi_handle.join().ok().flatten(),
398 bluetooth_handle.join().ok().flatten(),
399 ui_theme_and_fonts_handle
400 .join()
401 .unwrap_or((None, None, None, None)),
402 camera_handle.join().unwrap_or_default(),
403 gamepad_handle.join().unwrap_or_default(),
404 )
405 });
406
407 let mut temps: Vec<String> = Components::new_with_refreshed_list()
408 .iter()
409 .filter_map(|c| {
410 c.temperature().and_then(|t| {
411 if t > 0.0 {
412 Some(format!("{}: {:.0}°C", c.label(), t))
413 } else {
414 None
415 }
416 })
417 })
418 .collect();
419
420 temps.sort_by(|a, b| {
422 let a_cpu = a.to_lowercase().contains("cpu") || a.to_lowercase().contains("core");
423 let b_cpu = b.to_lowercase().contains("cpu") || b.to_lowercase().contains("core");
424 b_cpu.cmp(&a_cpu)
425 });
426
427 let networks = Networks::new_with_refreshed_list()
428 .iter()
429 .map(|(name, data)| {
430 let rx = format_bytes(data.total_received());
431 let tx = format_bytes(data.total_transmitted());
432 let is_up = data.operational_state() == sysinfo::InterfaceOperationalState::Up
433 || data.total_received() > 0
434 || data.total_transmitted() > 0;
435 let status = if is_up {
436 "Up".green().to_string()
437 } else {
438 "Down".red().to_string()
439 };
440
441 let mut ipv4_addresses = Vec::new();
442 let mut ipv6_addresses = Vec::new();
443
444 if is_up {
445 for ip_net in data.ip_networks() {
446 let ip = ip_net.addr;
447 let name_lower = name.to_lowercase();
448 let is_loopback_iface =
449 name_lower.starts_with("lo") || name_lower.contains("loopback");
450 if ip.is_loopback() && !is_loopback_iface {
451 continue;
452 }
453 match ip {
454 std::net::IpAddr::V4(v4) => {
455 ipv4_addresses.push(v4.to_string());
456 }
457 std::net::IpAddr::V6(v6) => {
458 if !v6.is_unicast_link_local() {
459 ipv6_addresses.push(v6.to_string());
460 }
461 }
462 }
463 }
464
465 if ipv4_addresses.is_empty() && ipv6_addresses.is_empty() {
467 if let (Some(ref active), Some(ref ip)) = (&active_interface, &local_ip) {
468 if name == active {
469 ipv4_addresses.push(ip.clone());
470 }
471 }
472 }
473 }
474
475 let ip_str = if !ipv4_addresses.is_empty() || !ipv6_addresses.is_empty() {
476 let mut combined = Vec::new();
477 if !ipv4_addresses.is_empty() {
478 combined.push(ipv4_addresses.join(", "));
479 }
480 if !ipv6_addresses.is_empty() {
481 combined.push(ipv6_addresses.join(", "));
482 }
483 format!(" ({})", combined.join(", "))
484 } else {
485 String::new()
486 };
487
488 format!("{}{} [{}] RX: {} TX: {}", name, ip_str, status, rx, tx)
489 })
490 .collect();
491
492 let boot_timestamp = System::boot_time();
493 let boot_dt = chrono::Local
494 .timestamp_opt(boot_timestamp as i64, 0)
495 .single()
496 .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
497 .unwrap_or_else(|| boot_timestamp.to_string());
498 let boot_time = boot_dt;
499
500 let shell = detect_shell(&sys);
502 let terminal = detect_terminal(&sys);
503 let terminal_font = detect_terminal_font(terminal.as_deref());
504 let desktop = std::env::var("XDG_CURRENT_DESKTOP")
505 .or_else(|_| std::env::var("DESKTOP_SESSION"))
506 .ok();
507
508 let cpu_freq = sys
510 .cpus()
511 .first()
512 .map(|c| format!("{:.2} GHz", c.frequency() as f64 / 1000.0));
513
514 let current_user = std::env::var("USER").ok();
516
517 let users = Users::new_with_refreshed_list()
519 .iter()
520 .filter(|user| {
521 let uid_str = user.id().to_string();
523 if let Ok(uid) = uid_str.parse::<u32>() {
524 uid >= 1000
525 } else {
526 false
527 }
528 })
529 .count();
530
531 Ok(Self {
532 os,
533 kernel,
534 hostname,
535 arch,
536 cpu,
537 cpu_cores,
538 memory,
539 swap,
540 uptime,
541 processes,
542 load_avg,
543 disks,
544 temps,
545 networks,
546 boot_time,
547 battery,
548 shell,
549 terminal,
550 desktop,
551 cpu_freq,
552 users,
553 gpu,
554 packages,
555 current_user,
556 local_ip,
557 public_ip,
558 active_interface,
559 motherboard,
560 bios,
561 displays,
562 audio,
563 wifi,
564 bluetooth,
565 ui_theme,
566 icons,
567 cursor,
568 font,
569 terminal_font,
570 camera,
571 gamepad,
572 })
573 }
574}
575
576#[cfg(any(target_os = "linux", test))]
577fn parse_proc_net_route(content: &str) -> Option<String> {
578 for line in content.lines().skip(1) {
579 let parts: Vec<&str> = line.split_whitespace().collect();
580 if parts.len() >= 8 {
581 let dest = parts[1];
582 let mask = parts[7];
583 if dest == "00000000" && mask == "00000000" {
584 return Some(parts[0].to_string());
585 }
586 }
587 }
588 None
589}
590
591fn detect_packages() -> Option<usize> {
596 #[cfg(target_os = "macos")]
597 {
598 let mut count = 0;
599
600 for cellar_path in &["/opt/homebrew/Cellar", "/usr/local/Cellar"] {
602 if let Ok(entries) = std::fs::read_dir(cellar_path) {
603 count += entries.filter_map(|e| e.ok()).count();
604 }
605 }
606
607 for cask_path in &["/opt/homebrew/Caskroom", "/usr/local/Caskroom"] {
609 if let Ok(entries) = std::fs::read_dir(cask_path) {
610 count += entries.filter_map(|e| e.ok()).count();
611 }
612 }
613
614 if let Ok(entries) = std::fs::read_dir("/opt/local/var/macports/software") {
616 count += entries.filter_map(|e| e.ok()).count();
617 }
618
619 if count > 0 {
620 Some(count)
621 } else {
622 None
623 }
624 }
625
626 #[cfg(target_os = "windows")]
627 {
628 let mut count = 0;
629
630 if let Some(home) = dirs::home_dir() {
632 let scoop_dir = std::env::var("SCOOP")
633 .map(std::path::PathBuf::from)
634 .unwrap_or_else(|_| home.join("scoop"));
635 let scoop_apps = scoop_dir.join("apps");
636 if let Ok(entries) = std::fs::read_dir(scoop_apps) {
637 count += entries.filter_map(|e| e.ok()).count();
638 }
639 }
640
641 let choco_install = std::env::var("ChocolateyInstall")
643 .unwrap_or_else(|_| "C:\\ProgramData\\chocolatey".to_string());
644 let choco_lib = std::path::Path::new(&choco_install).join("lib");
645 if let Ok(entries) = std::fs::read_dir(choco_lib) {
646 count += entries.filter_map(|e| e.ok()).count();
647 }
648
649 if count > 0 {
650 Some(count)
651 } else {
652 None
653 }
654 }
655
656 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
657 {
658 if let Ok(entries) = std::fs::read_dir("/var/lib/pacman/local") {
660 let count = entries.filter_map(|e| e.ok()).count();
661 if count > 0 {
662 return Some(count);
663 }
664 }
665
666 if let Ok(entries) = std::fs::read_dir("/var/lib/dpkg/info") {
668 let count = entries
669 .filter_map(|e| e.ok())
670 .filter(|e| e.path().extension().is_some_and(|ext| ext == "list"))
671 .count();
672 if count > 0 {
673 return Some(count);
674 }
675 }
676
677 if let Ok(entries) = std::fs::read_dir("/var/db/pkg") {
679 let count: usize = entries
680 .filter_map(|e| e.ok())
681 .map(|e| {
682 std::fs::read_dir(e.path())
683 .map(|d| d.filter(|_| true).count())
684 .unwrap_or(0)
685 })
686 .sum();
687 if count > 0 {
688 return Some(count);
689 }
690 }
691
692 if let Ok(entries) = std::fs::read_dir("/var/db/xbps") {
694 let count = entries
695 .filter_map(|e| e.ok())
696 .filter(|e| e.path().extension().is_some_and(|ext| ext == "plist"))
697 .count();
698 if count > 0 {
699 return Some(count);
700 }
701 }
702
703 let rpm_db = "/var/lib/rpm/rpmdb.sqlite";
705 if std::path::Path::new(rpm_db).exists() {
706 match rusqlite::Connection::open(rpm_db) {
707 Ok(conn) => {
708 if let Ok(count) = conn.query_row("SELECT COUNT(*) FROM Packages", [], |row| {
709 row.get::<_, i64>(0)
710 }) {
711 if count > 0 {
712 return Some(count as usize);
713 }
714 }
715 }
716 Err(e) => {
717 eprintln!("warning: failed to open RPM database at {}: {}", rpm_db, e);
718 }
719 }
720 }
721
722 None
723 }
724}
725
726fn detect_wifi() -> Option<String> {
727 #[cfg(target_os = "linux")]
728 {
729 let mut wifi_interface = None;
730 if let Ok(entries) = std::fs::read_dir("/sys/class/net") {
731 for entry in entries.filter_map(|e| e.ok()) {
732 let path = entry.path();
733 if path.join("wireless").exists() || path.join("phy80211").exists() {
734 wifi_interface = Some(entry.file_name().to_string_lossy().to_string());
735 break;
736 }
737 }
738 }
739
740 if let Some(ref iface) = wifi_interface {
741 if let Ok(output) = std::process::Command::new("iw")
742 .args(["dev", iface, "link"])
743 .output()
744 {
745 if let Ok(stdout) = String::from_utf8(output.stdout) {
746 let (ssid, links) = parse_iw_link_output(&stdout);
747 if let Some(s) = ssid {
748 let card_model = get_wifi_card_model(iface);
749 let prefix = if let Some(m) = card_model {
750 format!("{} [{}] - ", m, iface)
751 } else {
752 format!("[{}] - ", iface)
753 };
754
755 if !links.is_empty() {
756 let mut link_strs = Vec::new();
757 for link in links {
758 let freq_str = link.freq.map(|f| {
759 let ghz_mhz = if f >= 1000.0 {
760 format!("{:.1} GHz", f / 1000.0)
761 } else {
762 format!("{} MHz", f)
763 };
764 if let Some(ch) = freq_to_channel(f) {
765 format!("{} ch{}", ghz_mhz, ch)
766 } else {
767 ghz_mhz
768 }
769 });
770
771 let mut rx_tx = Vec::new();
772 if let Some(rx) = link.rx_rate {
773 if rx != "0"
774 && !rx.starts_with("0 ")
775 && rx != "0 Mbps"
776 && rx != "0 MBit/s"
777 {
778 rx_tx.push(format!("↓{}", clean_rate(&rx)));
779 }
780 }
781 if let Some(tx) = link.tx_rate {
782 if tx != "0"
783 && !tx.starts_with("0 ")
784 && tx != "0 Mbps"
785 && tx != "0 MBit/s"
786 {
787 rx_tx.push(format!("↑{}", clean_rate(&tx)));
788 }
789 }
790
791 match (freq_str, rx_tx.is_empty()) {
792 (Some(f), false) => {
793 link_strs.push(format!("{} [{}]", f, rx_tx.join(" ")))
794 }
795 (Some(f), true) => link_strs.push(f),
796 (None, false) => link_strs.push(rx_tx.join(" ")),
797 _ => {}
798 }
799 }
800 if !link_strs.is_empty() {
801 return Some(format!(
802 "{}{}{} ({})",
803 prefix,
804 s,
805 "",
806 link_strs.join(", ")
807 ));
808 } else {
809 return Some(format!("{}{}", prefix, s));
810 }
811 }
812 return Some(format!("{}{}", prefix, s));
813 }
814 }
815 }
816 }
817
818 if let Ok(output) = std::process::Command::new("nmcli")
820 .args(["-t", "-f", "active,ssid,rate", "dev", "wifi"])
821 .output()
822 {
823 if let Ok(stdout) = String::from_utf8(output.stdout) {
824 for line in stdout.lines() {
825 let line = line.trim();
826 if let Some(rest) = line.strip_prefix("yes:") {
827 if let Some(colon_idx) = rest.rfind(':') {
828 let ssid = &rest[..colon_idx];
829 let rate = rest[colon_idx + 1..].trim();
830 if !ssid.is_empty() {
831 if !rate.is_empty()
832 && rate != "0"
833 && !rate.starts_with("0 ")
834 && rate != "0 Mbit/s"
835 && rate != "0 Mbps"
836 {
837 return Some(format!("{} ({})", ssid, clean_rate(rate)));
838 } else {
839 return Some(ssid.to_string());
840 }
841 }
842 } else if !rest.is_empty() {
843 return Some(rest.to_string());
844 }
845 }
846 }
847 }
848 }
849
850 if let Ok(output) = std::process::Command::new("iwgetid").arg("-r").output() {
852 if let Ok(stdout) = String::from_utf8(output.stdout) {
853 let ssid = stdout.trim();
854 if !ssid.is_empty() {
855 return Some(ssid.to_string());
856 }
857 }
858 }
859 None
860 }
861
862 #[cfg(target_os = "macos")]
863 {
864 if let Ok(output) = std::process::Command::new("/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport")
865 .arg("-I")
866 .output()
867 {
868 if let Ok(stdout) = String::from_utf8(output.stdout) {
869 return parse_airport_output(&stdout);
870 }
871 }
872 None
873 }
874
875 #[cfg(target_os = "windows")]
876 {
877 if let Ok(output) = std::process::Command::new("netsh")
878 .args(["wlan", "show", "interfaces"])
879 .output()
880 {
881 if let Ok(stdout) = String::from_utf8(output.stdout) {
882 return parse_netsh_output(&stdout);
883 }
884 }
885 None
886 }
887
888 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
889 {
890 None
891 }
892}
893
894#[allow(
895 clippy::manual_is_multiple_of,
896 clippy::manual_range_contains,
897 dead_code
898)]
899fn freq_to_channel(freq_mhz: f64) -> Option<u32> {
900 let freq = freq_mhz.round() as u32;
901 if freq >= 2412 && freq <= 2472 {
902 Some((freq - 2407) / 5)
903 } else if freq == 2484 {
904 Some(14)
905 } else if freq >= 5160 && freq <= 5885 {
906 if (freq - 5000) % 5 == 0 {
907 Some((freq - 5000) / 5)
908 } else {
909 None
910 }
911 } else if freq >= 5955 && freq <= 7115 {
912 if (freq - 5950) % 5 == 0 {
913 Some((freq - 5950) / 5)
914 } else {
915 None
916 }
917 } else {
918 None
919 }
920}
921
922#[allow(dead_code)]
923fn lookup_pci_vendor(vendor_id: &str) -> Option<String> {
924 let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
925 let paths = ["/usr/share/hwdata/pci.ids", "/usr/share/misc/pci.ids"];
926 for path in &paths {
927 if let Ok(content) = std::fs::read_to_string(path) {
928 for line in content.lines() {
929 if line.starts_with('#') || line.is_empty() {
930 continue;
931 }
932 if !line.starts_with('\t') {
933 let parts: Vec<&str> = line.split_whitespace().collect();
934 if parts.len() >= 2 && parts[0].to_lowercase() == vendor_id {
935 let name = line.strip_prefix(parts[0]).unwrap().trim();
936 return Some(name.to_string());
937 }
938 }
939 }
940 }
941 }
942 None
943}
944
945#[allow(dead_code)]
946fn get_wifi_card_model(iface: &str) -> Option<String> {
947 let vendor = std::fs::read_to_string(format!("/sys/class/net/{}/device/vendor", iface)).ok()?;
948 let device = std::fs::read_to_string(format!("/sys/class/net/{}/device/device", iface)).ok()?;
949 let vendor_clean = vendor.trim().trim_start_matches("0x").to_lowercase();
950 let device_clean = device.trim().trim_start_matches("0x").to_lowercase();
951
952 let vendor_name = lookup_pci_vendor(&vendor_clean);
953 let model_name = crate::gpu::lookup_pci_device(&vendor_clean, &device_clean);
954
955 match (vendor_name, model_name) {
956 (Some(v), Some(m)) => {
957 let v_clean = v.replace(", Inc.", "").replace(" Corporation", "");
958 if m.to_lowercase().contains(&v_clean.to_lowercase())
959 || m.to_lowercase().contains(
960 &v_clean
961 .split_whitespace()
962 .next()
963 .unwrap_or("")
964 .to_lowercase(),
965 )
966 {
967 Some(m)
968 } else {
969 Some(format!("{} {}", v_clean, m))
970 }
971 }
972 (None, Some(m)) => Some(m),
973 _ => None,
974 }
975}
976
977#[allow(dead_code)]
978fn clean_rate(rate: &str) -> String {
979 rate.replace("MBit/s", "Mbps")
980 .replace("GBit/s", "Gbps")
981 .replace("Bit/s", "bps")
982}
983
984#[derive(Debug, Clone)]
985struct WifiLink {
986 freq: Option<f64>,
987 rx_rate: Option<String>,
988 tx_rate: Option<String>,
989}
990
991#[allow(dead_code)]
992fn parse_iw_link_output(stdout: &str) -> (Option<String>, Vec<WifiLink>) {
993 let mut ssid = None;
994 let mut links = Vec::new();
995 let mut current_link = None;
996
997 for line in stdout.lines() {
998 let trimmed = line.trim();
999 if trimmed.starts_with("Connected to") || trimmed.starts_with("link") {
1000 if let Some(link) = current_link.take() {
1001 links.push(link);
1002 }
1003 current_link = Some(WifiLink {
1004 freq: None,
1005 rx_rate: None,
1006 tx_rate: None,
1007 });
1008 } else if trimmed.starts_with("SSID:") {
1009 ssid = Some(trimmed.strip_prefix("SSID:").unwrap().trim().to_string());
1010 } else if trimmed.starts_with("freq:") {
1011 if let Some(ref mut link) = current_link {
1012 let freq_str = trimmed.strip_prefix("freq:").unwrap().trim();
1013 link.freq = freq_str.parse::<f64>().ok();
1014 }
1015 } else if trimmed.starts_with("rx bitrate:") {
1016 if let Some(ref mut link) = current_link {
1017 let rx_str = trimmed.strip_prefix("rx bitrate:").unwrap().trim();
1018 let rate = rx_str
1019 .split_whitespace()
1020 .take(2)
1021 .collect::<Vec<&str>>()
1022 .join(" ");
1023 link.rx_rate = Some(rate);
1024 }
1025 } else if trimmed.starts_with("tx bitrate:") {
1026 if let Some(ref mut link) = current_link {
1027 let tx_str = trimmed.strip_prefix("tx bitrate:").unwrap().trim();
1028 let rate = tx_str
1029 .split_whitespace()
1030 .take(2)
1031 .collect::<Vec<&str>>()
1032 .join(" ");
1033 link.tx_rate = Some(rate);
1034 }
1035 }
1036 }
1037 if let Some(link) = current_link {
1038 links.push(link);
1039 }
1040 (ssid, links)
1041}
1042
1043#[allow(dead_code)]
1044fn parse_airport_output(stdout: &str) -> Option<String> {
1045 let mut ssid = None;
1046 let mut rate = None;
1047 for line in stdout.lines() {
1048 let trimmed = line.trim();
1049 if trimmed.starts_with("SSID:") {
1050 let val = trimmed.strip_prefix("SSID:").unwrap().trim().to_string();
1051 if !val.is_empty() {
1052 ssid = Some(val);
1053 }
1054 } else if trimmed.starts_with("lastTxRate:") {
1055 let val = trimmed
1056 .strip_prefix("lastTxRate:")
1057 .unwrap()
1058 .trim()
1059 .to_string();
1060 if !val.is_empty() {
1061 rate = Some(val);
1062 }
1063 }
1064 }
1065 match (ssid, rate) {
1066 (Some(s), Some(r)) => {
1067 if r != "0" && !r.starts_with("0 ") && r != "0 Mbps" && r != "0 Mbit/s" {
1068 Some(format!("{} (↑{} Mbps)", s, r))
1069 } else {
1070 Some(s)
1071 }
1072 }
1073 (Some(s), None) => Some(s),
1074 _ => None,
1075 }
1076}
1077
1078#[allow(dead_code)]
1079fn parse_netsh_output(stdout: &str) -> Option<String> {
1080 let mut ssid = None;
1081 let mut rx = None;
1082 let mut tx = None;
1083 let mut band = None;
1084 for line in stdout.lines() {
1085 let trimmed = line.trim();
1086 if trimmed.starts_with("SSID") {
1087 if let Some(idx) = trimmed.find(':') {
1088 let val = trimmed[idx + 1..].trim().to_string();
1089 if !val.is_empty() {
1090 ssid = Some(val);
1091 }
1092 }
1093 } else if trimmed.starts_with("Receive rate (Mbps)") {
1094 if let Some(idx) = trimmed.find(':') {
1095 let val = trimmed[idx + 1..].trim().to_string();
1096 if !val.is_empty() {
1097 rx = Some(val);
1098 }
1099 }
1100 } else if trimmed.starts_with("Transmit rate (Mbps)") {
1101 if let Some(idx) = trimmed.find(':') {
1102 let val = trimmed[idx + 1..].trim().to_string();
1103 if !val.is_empty() {
1104 tx = Some(val);
1105 }
1106 }
1107 } else if trimmed.starts_with("Band") {
1108 if let Some(idx) = trimmed.find(':') {
1109 let val = trimmed[idx + 1..].trim().to_string();
1110 if !val.is_empty() {
1111 band = Some(val);
1112 }
1113 }
1114 }
1115 }
1116 if let Some(s) = ssid {
1117 let mut rate_strs = Vec::new();
1118 if let Some(rx_val) = rx {
1119 if rx_val != "0" {
1120 rate_strs.push(format!("↓{} Mbps", rx_val));
1121 }
1122 }
1123 if let Some(tx_val) = tx {
1124 if tx_val != "0" {
1125 rate_strs.push(format!("↑{} Mbps", tx_val));
1126 }
1127 }
1128 let info = match (band, rate_strs.is_empty()) {
1129 (Some(b), false) => format!("{} [{}]", b, rate_strs.join(" ")),
1130 (Some(b), true) => b,
1131 (None, false) => rate_strs.join(" "),
1132 _ => String::new(),
1133 };
1134 if !info.is_empty() {
1135 Some(format!("{} ({})", s, info))
1136 } else {
1137 Some(s)
1138 }
1139 } else {
1140 None
1141 }
1142}
1143
1144#[allow(dead_code)]
1145fn lookup_usb_vendor(vendor_id: &str) -> Option<String> {
1146 let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
1147 let paths = ["/usr/share/hwdata/usb.ids", "/usr/share/misc/usb.ids"];
1148 for path in &paths {
1149 if let Ok(content) = std::fs::read_to_string(path) {
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 if parts.len() >= 2 && parts[0].to_lowercase() == vendor_id {
1157 let name = line.strip_prefix(parts[0]).unwrap().trim();
1158 return Some(name.to_string());
1159 }
1160 }
1161 }
1162 }
1163 }
1164 None
1165}
1166
1167#[allow(dead_code)]
1168fn lookup_usb_device(vendor_id: &str, product_id: &str) -> Option<String> {
1169 let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
1170 let product_id = product_id.trim_start_matches("0x").to_lowercase();
1171 let paths = ["/usr/share/hwdata/usb.ids", "/usr/share/misc/usb.ids"];
1172 for path in &paths {
1173 if let Ok(content) = std::fs::read_to_string(path) {
1174 let mut in_vendor = false;
1175 for line in content.lines() {
1176 if line.starts_with('#') || line.is_empty() {
1177 continue;
1178 }
1179 if !line.starts_with('\t') {
1180 let parts: Vec<&str> = line.split_whitespace().collect();
1181 in_vendor = parts.len() >= 2 && parts[0].to_lowercase() == vendor_id;
1182 } else if in_vendor && line.starts_with('\t') && !line.starts_with("\t\t") {
1183 let trimmed = line.trim_start();
1184 if let Some(stripped) = trimmed.strip_prefix(&product_id) {
1185 let name = stripped.trim();
1186 return Some(name.to_string());
1187 }
1188 }
1189 }
1190 }
1191 }
1192 None
1193}
1194
1195#[allow(dead_code)]
1196fn parse_macos_bluetooth(stdout: &str) -> Option<String> {
1197 let mut state = "Off";
1198 let mut connected_names = Vec::new();
1199 let mut chipset = None;
1200 let mut current_device = None;
1201
1202 for line in stdout.lines() {
1203 let trimmed = line.trim();
1204 if trimmed.starts_with("Bluetooth Power:") || trimmed.starts_with("State:") {
1205 if trimmed.contains("On") {
1206 state = "On";
1207 }
1208 } else if trimmed.starts_with("Chipset:") {
1209 chipset = Some(trimmed.strip_prefix("Chipset:").unwrap().trim().to_string());
1210 } else if line.starts_with(" ") && !trimmed.is_empty() && trimmed.ends_with(':') {
1211 current_device = Some(trimmed.trim_end_matches(':').trim().to_string());
1212 } else if (trimmed.starts_with("Connected:") || trimmed.starts_with("Connection:"))
1213 && trimmed.contains("Yes")
1214 {
1215 if let Some(ref dev) = current_device {
1216 connected_names.push(dev.clone());
1217 }
1218 }
1219 }
1220
1221 let mut info_str = state.to_string();
1222 if let Some(ch) = chipset {
1223 info_str.push_str(&format!(" (Apple {})", ch));
1224 } else {
1225 info_str.push_str(" (Apple Bluetooth)");
1226 }
1227
1228 if state == "On" {
1229 info_str.push_str(&format!(" - {} connected", connected_names.len()));
1230 if !connected_names.is_empty() {
1231 info_str.push_str(&format!(" ({})", connected_names.join(", ")));
1232 }
1233 }
1234 Some(info_str)
1235}
1236
1237#[allow(dead_code)]
1238fn parse_windows_bluetooth_output(stdout: &str) -> Option<String> {
1239 let parts: Vec<&str> = stdout.trim().split('|').collect();
1240 if parts.len() < 3 {
1241 return None;
1242 }
1243 let status_str = parts[0].trim();
1244 let adapter_str = parts[1].trim();
1245 let devices_str = parts[2]
1246 .trim()
1247 .trim_start_matches('(')
1248 .trim_end_matches(')');
1249
1250 let state = if status_str.eq_ignore_ascii_case("running") {
1251 "On"
1252 } else {
1253 "Off"
1254 };
1255
1256 let mut info_str = state.to_string();
1257 if !adapter_str.is_empty() {
1258 info_str.push_str(&format!(" ({})", adapter_str));
1259 }
1260
1261 if state == "On" {
1262 let connected_names: Vec<String> = if devices_str.is_empty() {
1263 Vec::new()
1264 } else {
1265 devices_str
1266 .split(',')
1267 .map(|s| s.trim().to_string())
1268 .filter(|s| !s.is_empty())
1269 .collect()
1270 };
1271
1272 info_str.push_str(&format!(" - {} connected", connected_names.len()));
1273 if !connected_names.is_empty() {
1274 info_str.push_str(&format!(" ({})", connected_names.join(", ")));
1275 }
1276 }
1277 Some(info_str)
1278}
1279
1280fn detect_bluetooth() -> Option<String> {
1281 #[cfg(target_os = "linux")]
1282 {
1283 if let Ok(entries) = std::fs::read_dir("/sys/class/bluetooth") {
1284 let mut hcis = Vec::new();
1285 for entry in entries.filter_map(|e| e.ok()) {
1286 let name = entry.file_name().to_string_lossy().to_string();
1287 if name.starts_with("hci") {
1288 hcis.push(name);
1289 }
1290 }
1291 hcis.sort();
1292
1293 if !hcis.is_empty() {
1294 let hci = &hcis[0];
1295 let mut state = "Off";
1296 if let Ok(subdirs) = std::fs::read_dir(format!("/sys/class/bluetooth/{}", hci)) {
1297 for sub in subdirs.filter_map(|e| e.ok()) {
1298 let sub_name = sub.file_name().to_string_lossy().to_string();
1299 if sub_name.starts_with("rfkill") {
1300 if let Ok(st) = std::fs::read_to_string(sub.path().join("state")) {
1301 if st.trim() == "1" || st.trim() == "3" {
1302 state = "On";
1303 }
1304 }
1305 }
1306 }
1307 }
1308
1309 let mut hw_info = None;
1310 if let Ok(canonical_device) =
1311 std::fs::canonicalize(format!("/sys/class/bluetooth/{}/device", hci))
1312 {
1313 let mut current = Some(canonical_device);
1314 while let Some(path) = current {
1315 let id_vendor = path.join("idVendor");
1316 let id_product = path.join("idProduct");
1317 let pci_vendor = path.join("vendor");
1318 let pci_device = path.join("device");
1319
1320 if id_vendor.exists() && id_product.exists() {
1321 if let (Ok(v), Ok(p)) = (
1322 std::fs::read_to_string(id_vendor),
1323 std::fs::read_to_string(id_product),
1324 ) {
1325 let v_clean = v.trim();
1326 let p_clean = p.trim();
1327 let vendor_name = lookup_usb_vendor(v_clean);
1328 let product_name = lookup_usb_device(v_clean, p_clean);
1329 match (vendor_name, product_name) {
1330 (Some(v_name), Some(p_name)) => {
1331 let v_disp = v_name
1332 .replace(", Inc.", "")
1333 .replace(" Corporation", "")
1334 .replace(" Co., Ltd.", "")
1335 .replace(" Co., Ltd", "");
1336 hw_info = Some(format!("{} {}", v_disp, p_name));
1337 }
1338 (Some(v_name), None) => {
1339 let v_disp = v_name
1340 .replace(", Inc.", "")
1341 .replace(" Corporation", "")
1342 .replace(" Co., Ltd.", "")
1343 .replace(" Co., Ltd", "");
1344 hw_info = Some(v_disp);
1345 }
1346 _ => {}
1347 }
1348 break;
1349 }
1350 } else if pci_vendor.exists()
1351 && pci_device.exists()
1352 && !pci_vendor.is_dir()
1353 && !pci_device.is_dir()
1354 {
1355 if let (Ok(v), Ok(d)) = (
1356 std::fs::read_to_string(pci_vendor),
1357 std::fs::read_to_string(pci_device),
1358 ) {
1359 let v_clean = v.trim().trim_start_matches("0x").to_lowercase();
1360 let d_clean = d.trim().trim_start_matches("0x").to_lowercase();
1361 let vendor_name = lookup_pci_vendor(&v_clean);
1362 let product_name =
1363 crate::gpu::lookup_pci_device(&v_clean, &d_clean);
1364 match (vendor_name, product_name) {
1365 (Some(v_name), Some(p_name)) => {
1366 let v_disp = v_name
1367 .replace(", Inc.", "")
1368 .replace(" Corporation", "")
1369 .replace(" Co., Ltd.", "")
1370 .replace(" Co., Ltd", "");
1371 hw_info = Some(format!("{} {}", v_disp, p_name));
1372 }
1373 (Some(v_name), None) => {
1374 let v_disp = v_name
1375 .replace(", Inc.", "")
1376 .replace(" Corporation", "")
1377 .replace(" Co., Ltd.", "")
1378 .replace(" Co., Ltd", "");
1379 hw_info = Some(v_disp);
1380 }
1381 _ => {}
1382 }
1383 break;
1384 }
1385 }
1386 current = path.parent().map(|p| p.to_path_buf());
1387 }
1388 }
1389
1390 let mut connected_names = Vec::new();
1391 if let Ok(output) = std::process::Command::new("bluetoothctl")
1392 .args(["devices", "Connected"])
1393 .output()
1394 {
1395 if let Ok(stdout) = String::from_utf8(output.stdout) {
1396 for line in stdout.lines() {
1397 let trimmed = line.trim();
1398 if trimmed.starts_with("Device ") {
1399 let parts: Vec<&str> = trimmed.split_whitespace().collect();
1400 if parts.len() >= 3 {
1401 let name = parts[2..].join(" ");
1402 connected_names.push(name);
1403 }
1404 }
1405 }
1406 }
1407 }
1408 let mut info_str = state.to_string();
1409 info_str.push_str(&format!(" [{}]", hci));
1410 if let Some(hw) = hw_info {
1411 info_str.push_str(&format!(" ({})", hw));
1412 }
1413
1414 if state == "On" {
1415 info_str.push_str(&format!(" - {} connected", connected_names.len()));
1416 if !connected_names.is_empty() {
1417 info_str.push_str(&format!(" ({})", connected_names.join(", ")));
1418 }
1419 }
1420
1421 return Some(info_str);
1422 }
1423 }
1424 None
1425 }
1426
1427 #[cfg(target_os = "macos")]
1428 {
1429 if let Ok(output) = std::process::Command::new("system_profiler")
1430 .arg("SPBluetoothDataType")
1431 .output()
1432 {
1433 if let Ok(stdout) = String::from_utf8(output.stdout) {
1434 return parse_macos_bluetooth(&stdout);
1435 }
1436 }
1437 None
1438 }
1439
1440 #[cfg(target_os = "windows")]
1441 {
1442 let cmd = "$state = (Get-Service -Name bthserv -ErrorAction SilentlyContinue).Status; \
1443 $adapter = (Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | Where-Object {$_.FriendlyName -match 'Adapter|Controller|Radio|Intel|Realtek|Broadcom'} | Select-Object -First 1 -ExpandProperty FriendlyName); \
1444 $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); \
1445 Write-Output \"$state|$adapter|($($devices -join ','))\"";
1446
1447 if let Ok(output) = std::process::Command::new("powershell")
1448 .args(["-Command", cmd])
1449 .output()
1450 {
1451 if let Ok(stdout) = String::from_utf8(output.stdout) {
1452 return parse_windows_bluetooth_output(&stdout);
1453 }
1454 }
1455 None
1456 }
1457
1458 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1459 {
1460 None
1461 }
1462}
1463
1464#[allow(dead_code)]
1465fn is_real_camera(name: &str) -> bool {
1466 let name_lower = name.to_lowercase();
1467 !name_lower.contains("infrared")
1468 && !name_lower.contains("ir camera")
1469 && !name_lower.contains("integrated i")
1470 && !name_lower.contains("integrated ir")
1471 && !name_lower.contains("depth camera")
1472}
1473
1474#[allow(dead_code)]
1475fn clean_camera_name(name: &str) -> String {
1476 let trimmed = name.trim();
1477 if trimmed.starts_with("Integrated Camera:") {
1478 return "Integrated Camera".to_string();
1479 }
1480 if trimmed.starts_with("Integrated Webcam:") {
1481 return "Integrated Webcam".to_string();
1482 }
1483 trimmed.to_string()
1484}
1485
1486#[allow(dead_code)]
1487fn parse_macos_camera(stdout: &str) -> Vec<String> {
1488 let mut devices = Vec::new();
1489 let mut in_cameras = false;
1490 for line in stdout.lines() {
1491 let trimmed = line.trim();
1492 let indent = line.len() - line.trim_start().len();
1493 if trimmed.starts_with("Video Support:")
1494 || trimmed.starts_with("Camera:")
1495 || trimmed.starts_with("Cameras:")
1496 {
1497 in_cameras = true;
1498 continue;
1499 }
1500 if in_cameras {
1501 if indent < 4
1502 && !trimmed.is_empty()
1503 && !trimmed.starts_with("Camera")
1504 && !trimmed.starts_with("Video Support")
1505 {
1506 in_cameras = false;
1507 continue;
1508 }
1509 if (indent == 4 || indent == 6 || indent == 8) && trimmed.ends_with(':') {
1510 let name = trimmed.trim_end_matches(':').trim().to_string();
1511 if !name.is_empty() && is_real_camera(&name) {
1512 let cleaned = clean_camera_name(&name);
1513 if !devices.contains(&cleaned) {
1514 devices.push(cleaned);
1515 }
1516 }
1517 }
1518 }
1519 }
1520 devices
1521}
1522
1523#[allow(dead_code)]
1524fn parse_macos_gamepad(usb_stdout: &str, bt_stdout: &str) -> Vec<String> {
1525 let mut gamepads = Vec::new();
1526 let keywords = [
1527 "controller",
1528 "gamepad",
1529 "joystick",
1530 "xbox",
1531 "playstation",
1532 "dualshock",
1533 "dualsense",
1534 "nintendo",
1535 "joy-con",
1536 "joycon",
1537 ];
1538
1539 let is_gamepad = |name: &str| -> bool {
1540 let name_lower = name.to_lowercase();
1541 keywords.iter().any(|&kw| name_lower.contains(kw))
1542 };
1543
1544 for line in usb_stdout.lines() {
1546 let trimmed = line.trim();
1547 let indent = line.len() - line.trim_start().len();
1548 if (indent == 4 || indent == 6 || indent == 8) && trimmed.ends_with(':') {
1549 let name = trimmed.trim_end_matches(':').trim().to_string();
1550 if is_gamepad(&name) && !gamepads.contains(&name) {
1551 gamepads.push(name);
1552 }
1553 }
1554 }
1555
1556 let mut current_device = None;
1558 for line in bt_stdout.lines() {
1559 let trimmed = line.trim();
1560 let indent = line.len() - line.trim_start().len();
1561 if indent >= 8 && trimmed.ends_with(':') {
1562 current_device = Some(trimmed.trim_end_matches(':').trim().to_string());
1563 } else if trimmed.starts_with("Connected: Yes") || trimmed.starts_with("Connection: Yes") {
1564 if let Some(ref dev) = current_device {
1565 if is_gamepad(dev) && !gamepads.contains(dev) {
1566 gamepads.push(dev.clone());
1567 }
1568 }
1569 }
1570 }
1571
1572 gamepads
1573}
1574
1575fn detect_camera() -> Vec<String> {
1576 #[cfg(target_os = "linux")]
1577 {
1578 let mut cameras = Vec::new();
1579 if let Ok(entries) = std::fs::read_dir("/sys/class/video4linux") {
1580 for entry in entries.filter_map(|e| e.ok()) {
1581 let path = entry.path().join("name");
1582 if path.exists() {
1583 if let Ok(name) = std::fs::read_to_string(path) {
1584 let trimmed = name.trim().to_string();
1585 if !trimmed.is_empty() && is_real_camera(&trimmed) {
1586 let cleaned = clean_camera_name(&trimmed);
1587 if !cameras.contains(&cleaned) {
1588 cameras.push(cleaned);
1589 }
1590 }
1591 }
1592 }
1593 }
1594 }
1595 cameras
1596 }
1597
1598 #[cfg(target_os = "macos")]
1599 {
1600 if let Ok(output) = std::process::Command::new("system_profiler")
1601 .arg("SPCameraDataType")
1602 .output()
1603 {
1604 if let Ok(stdout) = String::from_utf8(output.stdout) {
1605 return parse_macos_camera(&stdout);
1606 }
1607 }
1608 Vec::new()
1609 }
1610
1611 #[cfg(target_os = "windows")]
1612 {
1613 let cmd = "Get-PnpDevice -Class Camera,Image -PresentOnly -ErrorAction SilentlyContinue | Where-Object { $_.Status -eq 'OK' } | Select-Object -ExpandProperty FriendlyName";
1614 if let Ok(output) = std::process::Command::new("powershell")
1615 .args(["-Command", cmd])
1616 .output()
1617 {
1618 if let Ok(stdout) = String::from_utf8(output.stdout) {
1619 let mut cameras = Vec::new();
1620 for line in stdout.lines() {
1621 let trimmed = line.trim().to_string();
1622 if !trimmed.is_empty() && is_real_camera(&trimmed) {
1623 let cleaned = clean_camera_name(&trimmed);
1624 if !cameras.contains(&cleaned) {
1625 cameras.push(cleaned);
1626 }
1627 }
1628 }
1629 return cameras;
1630 }
1631 }
1632 Vec::new()
1633 }
1634
1635 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1636 {
1637 Vec::new()
1638 }
1639}
1640
1641fn detect_gamepad() -> Vec<String> {
1642 #[cfg(target_os = "linux")]
1643 {
1644 let mut gamepads = Vec::new();
1645 if let Ok(entries) = std::fs::read_dir("/sys/class/input") {
1646 for entry in entries.filter_map(|e| e.ok()) {
1647 let name = entry.file_name().to_string_lossy().to_string();
1648 if name.starts_with("js") {
1649 let path = entry.path().join("device/name");
1650 if path.exists() {
1651 if let Ok(dev_name) = std::fs::read_to_string(path) {
1652 let trimmed = dev_name.trim().to_string();
1653 if !trimmed.is_empty() && !gamepads.contains(&trimmed) {
1654 gamepads.push(trimmed);
1655 }
1656 }
1657 }
1658 }
1659 }
1660 }
1661 gamepads
1662 }
1663
1664 #[cfg(target_os = "macos")]
1665 {
1666 let usb_stdout = std::process::Command::new("system_profiler")
1667 .arg("SPUSBDataType")
1668 .output()
1669 .ok()
1670 .and_then(|o| String::from_utf8(o.stdout).ok())
1671 .unwrap_or_default();
1672
1673 let bt_stdout = std::process::Command::new("system_profiler")
1674 .arg("SPBluetoothDataType")
1675 .output()
1676 .ok()
1677 .and_then(|o| String::from_utf8(o.stdout).ok())
1678 .unwrap_or_default();
1679
1680 parse_macos_gamepad(&usb_stdout, &bt_stdout)
1681 }
1682
1683 #[cfg(target_os = "windows")]
1684 {
1685 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";
1686 if let Ok(output) = std::process::Command::new("powershell")
1687 .args(["-Command", cmd])
1688 .output()
1689 {
1690 if let Ok(stdout) = String::from_utf8(output.stdout) {
1691 let mut gamepads = Vec::new();
1692 for line in stdout.lines() {
1693 let trimmed = line.trim().to_string();
1694 if !trimmed.is_empty() && !gamepads.contains(&trimmed) {
1695 gamepads.push(trimmed);
1696 }
1697 }
1698 return gamepads;
1699 }
1700 }
1701 Vec::new()
1702 }
1703
1704 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1705 {
1706 Vec::new()
1707 }
1708}
1709
1710fn detect_audio(_sys: &sysinfo::System) -> Option<String> {
1711 #[cfg(target_os = "linux")]
1712 {
1713 let mut server = None;
1715 for process in _sys.processes().values() {
1716 let name = process.name().to_string_lossy().to_lowercase();
1717 if name.contains("pipewire") {
1718 server = Some("PipeWire");
1719 break;
1720 } else if name.contains("pulseaudio") {
1721 server = Some("PulseAudio");
1722 }
1723 }
1724 let server_str = server.unwrap_or("ALSA");
1725
1726 let mut devices = Vec::new();
1728 if let Ok(content) = std::fs::read_to_string("/proc/asound/cards") {
1729 devices = parse_asound_cards(&content, "/proc/asound");
1730 }
1731
1732 if !devices.is_empty() {
1733 Some(format!("{} ({})", server_str, devices.join(", ")))
1734 } else {
1735 Some(server_str.to_string())
1736 }
1737 }
1738
1739 #[cfg(target_os = "macos")]
1740 {
1741 let mut devices = Vec::new();
1742 if let Ok(output) = std::process::Command::new("system_profiler")
1743 .arg("SPAudioDataType")
1744 .output()
1745 {
1746 if let Ok(stdout) = String::from_utf8(output.stdout) {
1747 let mut in_devices = false;
1748 for line in stdout.lines() {
1749 let trimmed = line.trim();
1750 let indent = line.len() - line.trim_start().len();
1751 if trimmed.starts_with("Devices:") {
1752 in_devices = true;
1753 continue;
1754 }
1755 if in_devices {
1756 if indent <= 4 && !trimmed.is_empty() && !trimmed.starts_with("Devices:") {
1757 in_devices = false;
1758 continue;
1759 }
1760 if indent == 8 && trimmed.ends_with(':') {
1761 let name = trimmed.trim_end_matches(':').trim().to_string();
1762 if !name.is_empty() && !devices.contains(&name) {
1763 devices.push(name);
1764 }
1765 }
1766 }
1767 }
1768 }
1769 }
1770
1771 if !devices.is_empty() {
1772 Some(format!("CoreAudio ({})", devices.join(", ")))
1773 } else {
1774 Some("CoreAudio".to_string())
1775 }
1776 }
1777
1778 #[cfg(target_os = "windows")]
1779 {
1780 let mut devices = Vec::new();
1781 if let Ok(output) = std::process::Command::new("wmic")
1782 .args(["path", "Win32_SoundDevice", "get", "Name", "/value"])
1783 .output()
1784 {
1785 if let Ok(stdout) = String::from_utf8(output.stdout) {
1786 for line in stdout.lines() {
1787 let line = line.trim();
1788 if line.starts_with("Name=") {
1789 let name = line.strip_prefix("Name=").unwrap_or("").trim().to_string();
1790 if !name.is_empty() && !devices.contains(&name) {
1791 devices.push(name);
1792 }
1793 }
1794 }
1795 }
1796 }
1797
1798 if !devices.is_empty() {
1799 Some(format!("Windows Audio ({})", devices.join(", ")))
1800 } else {
1801 Some("Windows Audio".to_string())
1802 }
1803 }
1804
1805 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1806 {
1807 None
1808 }
1809}
1810
1811#[allow(dead_code)]
1812fn parse_asound_cards(content: &str, asound_dir: &str) -> Vec<String> {
1813 let mut devices = Vec::new();
1814 if let Ok(entries) = std::fs::read_dir(asound_dir) {
1815 for entry in entries.filter_map(|e| e.ok()) {
1816 let path = entry.path();
1817 if path.is_dir() {
1818 let name = entry.file_name().to_string_lossy().to_string();
1819 if name.starts_with("card") {
1820 if let Ok(sub_entries) = std::fs::read_dir(&path) {
1821 for sub_entry in sub_entries.filter_map(|se| se.ok()) {
1822 let sub_path = sub_entry.path();
1823 let sub_name = sub_entry.file_name().to_string_lossy().to_string();
1824 if sub_name.starts_with("codec#") {
1825 if let Ok(codec_content) = std::fs::read_to_string(&sub_path) {
1826 for line in codec_content.lines() {
1827 if let Some(stripped) = line.strip_prefix("Codec: ") {
1828 let codec_name = stripped.trim().to_string();
1829 if !codec_name.is_empty()
1830 && !devices.contains(&codec_name)
1831 {
1832 devices.push(codec_name);
1833 }
1834 }
1835 }
1836 }
1837 }
1838 }
1839 }
1840 }
1841 }
1842 }
1843 }
1844
1845 if devices.is_empty() {
1846 for line in content.lines() {
1847 if let Some(idx) = line.find("]: ") {
1848 let desc = line[idx + 3..].trim();
1849 let device_name = if let Some(dash_idx) = desc.find(" - ") {
1850 desc[dash_idx + 3..].trim()
1851 } else {
1852 desc
1853 };
1854 if !device_name.is_empty() && !devices.contains(&device_name.to_string()) {
1855 devices.push(device_name.to_string());
1856 }
1857 }
1858 }
1859 }
1860 devices
1861}
1862
1863fn detect_motherboard() -> Option<String> {
1864 #[cfg(target_os = "macos")]
1865 {
1866 extern "C" {
1867 fn sysctlbyname(
1868 name: *const i8,
1869 oldp: *mut std::ffi::c_void,
1870 oldlenp: *mut usize,
1871 newp: *mut std::ffi::c_void,
1872 newlen: usize,
1873 ) -> i32;
1874 }
1875
1876 let name_c = std::ffi::CString::new("hw.model").ok();
1877 let mut model_str = None;
1878
1879 if let Some(name) = name_c {
1880 let mut size: usize = 0;
1881 unsafe {
1882 sysctlbyname(
1883 name.as_ptr(),
1884 std::ptr::null_mut(),
1885 &mut size,
1886 std::ptr::null_mut(),
1887 0,
1888 );
1889 }
1890 if size > 0 {
1891 let mut buf = vec![0u8; size];
1892 let res = unsafe {
1893 sysctlbyname(
1894 name.as_ptr(),
1895 buf.as_mut_ptr() as *mut std::ffi::c_void,
1896 &mut size,
1897 std::ptr::null_mut(),
1898 0,
1899 )
1900 };
1901 if res == 0 {
1902 if let Some(pos) = buf.iter().position(|&x| x == 0) {
1903 buf.truncate(pos);
1904 }
1905 if let Ok(model) = String::from_utf8(buf) {
1906 let trimmed = model.trim();
1907 if !trimmed.is_empty() {
1908 model_str = Some(trimmed.to_string());
1909 }
1910 }
1911 }
1912 }
1913 }
1914
1915 if model_str.is_some() {
1916 return model_str;
1917 }
1918
1919 if let Ok(output) = std::process::Command::new("sysctl")
1920 .args(["-n", "hw.model"])
1921 .output()
1922 {
1923 if let Ok(model) = String::from_utf8(output.stdout) {
1924 let trimmed = model.trim();
1925 if !trimmed.is_empty() {
1926 return Some(trimmed.to_string());
1927 }
1928 }
1929 }
1930 None
1931 }
1932
1933 #[cfg(target_os = "windows")]
1934 {
1935 let manufacturer = win_reg::get_reg_string(
1936 win_reg::HKEY_LOCAL_MACHINE,
1937 "HARDWARE\\DESCRIPTION\\System\\BIOS",
1938 "BaseBoardManufacturer",
1939 )
1940 .unwrap_or_default();
1941 let product = win_reg::get_reg_string(
1942 win_reg::HKEY_LOCAL_MACHINE,
1943 "HARDWARE\\DESCRIPTION\\System\\BIOS",
1944 "BaseBoardProduct",
1945 )
1946 .unwrap_or_default();
1947
1948 let manufacturer = manufacturer.trim();
1949 let product = product.trim();
1950
1951 if !manufacturer.is_empty() && !product.is_empty() {
1952 return Some(format!("{} {}", manufacturer, product));
1953 } else if !product.is_empty() {
1954 return Some(product.to_string());
1955 } else if !manufacturer.is_empty() {
1956 return Some(manufacturer.to_string());
1957 }
1958
1959 if let Ok(output) = std::process::Command::new("wmic")
1960 .args(["baseboard", "get", "manufacturer,product", "/value"])
1961 .output()
1962 {
1963 if let Ok(stdout) = String::from_utf8(output.stdout) {
1964 let mut manufacturer = String::new();
1965 let mut product = String::new();
1966 for line in stdout.lines() {
1967 let line = line.trim();
1968 if line.starts_with("Manufacturer=") {
1969 manufacturer = line
1970 .strip_prefix("Manufacturer=")
1971 .unwrap_or("")
1972 .trim()
1973 .to_string();
1974 } else if line.starts_with("Product=") {
1975 product = line
1976 .strip_prefix("Product=")
1977 .unwrap_or("")
1978 .trim()
1979 .to_string();
1980 }
1981 }
1982 if !manufacturer.is_empty() && !product.is_empty() {
1983 return Some(format!("{} {}", manufacturer, product));
1984 } else if !product.is_empty() {
1985 return Some(product);
1986 } else if !manufacturer.is_empty() {
1987 return Some(manufacturer);
1988 }
1989 }
1990 }
1991 None
1992 }
1993
1994 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
1995 {
1996 let vendor = std::fs::read_to_string("/sys/class/dmi/id/board_vendor")
1997 .map(|s| s.trim().to_string())
1998 .ok();
1999 let name = std::fs::read_to_string("/sys/class/dmi/id/board_name")
2000 .map(|s| s.trim().to_string())
2001 .ok();
2002
2003 match (vendor, name) {
2004 (Some(v), Some(n)) if !v.is_empty() && !n.is_empty() => {
2005 if n.starts_with(&v) {
2006 Some(n)
2007 } else {
2008 Some(format!("{} {}", v, n))
2009 }
2010 }
2011 (Some(v), _) if !v.is_empty() => Some(v),
2012 (_, Some(n)) if !n.is_empty() => Some(n),
2013 _ => None,
2014 }
2015 }
2016}
2017
2018fn detect_bios() -> Option<String> {
2019 #[cfg(target_os = "macos")]
2020 {
2021 if let Ok(output) = std::process::Command::new("system_profiler")
2022 .arg("SPHardwareDataType")
2023 .output()
2024 {
2025 if let Ok(stdout) = String::from_utf8(output.stdout) {
2026 for line in stdout.lines() {
2027 let line = line.trim();
2028 if line.starts_with("System Firmware Version:")
2029 || line.starts_with("Boot ROM Version:")
2030 {
2031 if let Some(val) = line.split(':').nth(1) {
2032 return Some(val.trim().to_string());
2033 }
2034 }
2035 }
2036 }
2037 }
2038 None
2039 }
2040
2041 #[cfg(target_os = "windows")]
2042 {
2043 let manufacturer = win_reg::get_reg_string(
2044 win_reg::HKEY_LOCAL_MACHINE,
2045 "HARDWARE\\DESCRIPTION\\System\\BIOS",
2046 "BIOSVendor",
2047 )
2048 .unwrap_or_default();
2049 let version = win_reg::get_reg_string(
2050 win_reg::HKEY_LOCAL_MACHINE,
2051 "HARDWARE\\DESCRIPTION\\System\\BIOS",
2052 "BIOSVersion",
2053 )
2054 .unwrap_or_default();
2055 let raw_date = win_reg::get_reg_string(
2056 win_reg::HKEY_LOCAL_MACHINE,
2057 "HARDWARE\\DESCRIPTION\\System\\BIOS",
2058 "BIOSReleaseDate",
2059 )
2060 .unwrap_or_default();
2061
2062 let manufacturer = manufacturer.trim();
2063 let version = version.trim();
2064 let raw_date = raw_date.trim();
2065
2066 let date = if raw_date.len() >= 8 {
2067 let year = &raw_date[0..4];
2068 let month = &raw_date[4..6];
2069 let day = &raw_date[6..8];
2070 format!("{}/{}/{}", month, day, year)
2071 } else {
2072 raw_date.to_string()
2073 };
2074
2075 let mut parts = Vec::new();
2076 if !manufacturer.is_empty() {
2077 parts.push(manufacturer.to_string());
2078 }
2079 if !version.is_empty() {
2080 parts.push(version.to_string());
2081 }
2082 let mut res = parts.join(" ");
2083 if !date.is_empty() {
2084 if res.is_empty() {
2085 res = date;
2086 } else {
2087 res = format!("{} ({})", res, date);
2088 }
2089 }
2090 if !res.is_empty() {
2091 return Some(res);
2092 }
2093
2094 if let Ok(output) = std::process::Command::new("wmic")
2095 .args([
2096 "bios",
2097 "get",
2098 "manufacturer,smbiosbiosversion,releasedate",
2099 "/value",
2100 ])
2101 .output()
2102 {
2103 if let Ok(stdout) = String::from_utf8(output.stdout) {
2104 let mut manufacturer = String::new();
2105 let mut version = String::new();
2106 let mut date = String::new();
2107 for line in stdout.lines() {
2108 let line = line.trim();
2109 if line.starts_with("Manufacturer=") {
2110 manufacturer = line
2111 .strip_prefix("Manufacturer=")
2112 .unwrap_or("")
2113 .trim()
2114 .to_string();
2115 } else if line.starts_with("SMBIOSBIOSVersion=") {
2116 version = line
2117 .strip_prefix("SMBIOSBIOSVersion=")
2118 .unwrap_or("")
2119 .trim()
2120 .to_string();
2121 } else if line.starts_with("ReleaseDate=") {
2122 let raw_date = line.strip_prefix("ReleaseDate=").unwrap_or("").trim();
2123 if raw_date.len() >= 8 {
2124 let year = &raw_date[0..4];
2125 let month = &raw_date[4..6];
2126 let day = &raw_date[6..8];
2127 date = format!("{}/{}/{}", month, day, year);
2128 } else {
2129 date = raw_date.to_string();
2130 }
2131 }
2132 }
2133
2134 let mut parts = Vec::new();
2135 if !manufacturer.is_empty() {
2136 parts.push(manufacturer);
2137 }
2138 if !version.is_empty() {
2139 parts.push(version);
2140 }
2141 let mut res = parts.join(" ");
2142 if !date.is_empty() {
2143 if res.is_empty() {
2144 res = date;
2145 } else {
2146 res = format!("{} ({})", res, date);
2147 }
2148 }
2149 if !res.is_empty() {
2150 return Some(res);
2151 }
2152 }
2153 }
2154 None
2155 }
2156
2157 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
2158 {
2159 let vendor = std::fs::read_to_string("/sys/class/dmi/id/bios_vendor")
2160 .map(|s| s.trim().to_string())
2161 .ok();
2162 let version = std::fs::read_to_string("/sys/class/dmi/id/bios_version")
2163 .map(|s| s.trim().to_string())
2164 .ok();
2165 let date = std::fs::read_to_string("/sys/class/dmi/id/bios_date")
2166 .map(|s| s.trim().to_string())
2167 .ok();
2168
2169 let mut parts = Vec::new();
2170 if let Some(v) = vendor {
2171 if !v.is_empty() {
2172 parts.push(v);
2173 }
2174 }
2175 if let Some(ver) = version {
2176 let mut ver_cleaned = ver;
2177 while ver_cleaned.contains(" )") {
2178 ver_cleaned = ver_cleaned.replace(" )", ")");
2179 }
2180 let ver_cleaned = ver_cleaned.trim().to_string();
2181 if !ver_cleaned.is_empty() {
2182 parts.push(ver_cleaned);
2183 }
2184 }
2185 let mut res = parts.join(" ");
2186 if let Some(d) = date {
2187 if !d.is_empty() {
2188 if res.is_empty() {
2189 res = d;
2190 } else {
2191 res = format!("{} ({})", res, d);
2192 }
2193 }
2194 }
2195 if !res.is_empty() {
2196 Some(res)
2197 } else {
2198 None
2199 }
2200 }
2201}
2202
2203#[allow(dead_code)]
2204fn parse_monitor_name_from_edid(edid: &[u8]) -> Option<String> {
2205 if edid.len() < 128 {
2206 return None;
2207 }
2208 let offsets = [54, 72, 90, 108];
2209 for &offset in &offsets {
2210 if offset + 18 <= edid.len() {
2211 let block = &edid[offset..offset + 18];
2212 if block[0] == 0x00 && block[1] == 0x00 && block[2] == 0x00 && block[3] == 0xFC {
2213 let name_bytes = &block[4..17];
2214 let name = String::from_utf8_lossy(name_bytes);
2215 let cleaned = name.trim().replace('\0', "").to_string();
2216 if !cleaned.is_empty() {
2217 return Some(cleaned);
2218 }
2219 }
2220 }
2221 }
2222 None
2223}
2224
2225#[allow(dead_code)]
2226fn parse_refresh_rate_from_edid(edid: &[u8]) -> Option<f64> {
2227 if edid.len() < 72 {
2228 return None;
2229 }
2230 let block = &edid[54..72];
2231 let pixel_clock = ((block[1] as u32) << 8) | (block[0] as u32);
2232 if pixel_clock == 0 {
2233 return None;
2234 }
2235 let pixel_clock_hz = pixel_clock * 10_000;
2236 let h_active = (block[2] as u32) | (((block[4] as u32) & 0xF0) << 4);
2237 let h_blanking = (block[3] as u32) | (((block[4] as u32) & 0x0F) << 8);
2238 let v_active = (block[5] as u32) | (((block[7] as u32) & 0xF0) << 4);
2239 let v_blanking = (block[6] as u32) | (((block[7] as u32) & 0x0F) << 8);
2240
2241 let h_total = h_active + h_blanking;
2242 let v_total = v_active + v_blanking;
2243 if h_total == 0 || v_total == 0 {
2244 return None;
2245 }
2246
2247 let refresh = (pixel_clock_hz as f64) / ((h_total * v_total) as f64);
2248 Some((refresh * 100.0).round() / 100.0)
2249}
2250
2251#[allow(dead_code)]
2252fn format_refresh_rate(refresh: f64) -> String {
2253 if (refresh - refresh.round()).abs() < 0.01 {
2254 format!("{:.0}", refresh)
2255 } else {
2256 format!("{:.2}", refresh)
2257 }
2258}
2259
2260#[allow(dead_code)]
2261fn parse_serial_number_from_edid(edid: &[u8]) -> Option<String> {
2262 if edid.len() < 128 {
2263 return None;
2264 }
2265 let offsets = [54, 72, 90, 108];
2267 for &offset in &offsets {
2268 if offset + 18 <= edid.len() {
2269 let block = &edid[offset..offset + 18];
2270 if block[0] == 0x00 && block[1] == 0x00 && block[2] == 0x00 && block[3] == 0xFF {
2271 let serial_bytes = &block[4..17];
2272 let serial = String::from_utf8_lossy(serial_bytes);
2273 let cleaned = serial.trim().replace('\0', "").to_string();
2274 if !cleaned.is_empty() {
2275 return Some(cleaned);
2276 }
2277 }
2278 }
2279 }
2280
2281 let serial_num = ((edid[15] as u32) << 24)
2283 | ((edid[14] as u32) << 16)
2284 | ((edid[13] as u32) << 8)
2285 | (edid[12] as u32);
2286 if serial_num != 0 && serial_num != 0xFFFFFFFF {
2287 return Some(serial_num.to_string());
2288 }
2289
2290 None
2291}
2292
2293#[allow(dead_code)]
2294fn get_monitor_name_for_port(port: &str) -> Option<String> {
2295 if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
2296 for entry in entries.filter_map(|e| e.ok()) {
2297 let name = entry.file_name().to_string_lossy().to_string();
2298 if name.ends_with(port) {
2299 let edid_path = entry.path().join("edid");
2300 if edid_path.exists() {
2301 if let Ok(edid_bytes) = std::fs::read(&edid_path) {
2302 if let Some(monitor_name) = parse_monitor_name_from_edid(&edid_bytes) {
2303 return Some(monitor_name);
2304 }
2305 }
2306 }
2307 }
2308 }
2309 }
2310 None
2311}
2312
2313fn detect_displays() -> Vec<String> {
2314 #[cfg(target_os = "macos")]
2315 {
2316 if let Ok(output) = std::process::Command::new("system_profiler")
2317 .arg("SPDisplaysDataType")
2318 .output()
2319 {
2320 if let Ok(stdout) = String::from_utf8(output.stdout) {
2321 return parse_macos_displays(&stdout);
2322 }
2323 }
2324 Vec::new()
2325 }
2326
2327 #[cfg(target_os = "windows")]
2328 {
2329 let mut displays = Vec::new();
2330 if let Ok(output) = std::process::Command::new("wmic")
2331 .args([
2332 "path",
2333 "Win32_VideoController",
2334 "get",
2335 "Name,CurrentHorizontalResolution,CurrentVerticalResolution,CurrentRefreshRate",
2336 "/value",
2337 ])
2338 .output()
2339 {
2340 if let Ok(stdout) = String::from_utf8(output.stdout) {
2341 let mut name = String::new();
2342 let mut width = String::new();
2343 let mut height = String::new();
2344 let mut refresh = String::new();
2345 for line in stdout.lines() {
2346 let line = line.trim();
2347 if line.starts_with("Name=") {
2348 name = line.strip_prefix("Name=").unwrap_or("").trim().to_string();
2349 } else if line.starts_with("CurrentHorizontalResolution=") {
2350 width = line
2351 .strip_prefix("CurrentHorizontalResolution=")
2352 .unwrap_or("")
2353 .trim()
2354 .to_string();
2355 } else if line.starts_with("CurrentVerticalResolution=") {
2356 height = line
2357 .strip_prefix("CurrentVerticalResolution=")
2358 .unwrap_or("")
2359 .trim()
2360 .to_string();
2361 } else if line.starts_with("CurrentRefreshRate=") {
2362 refresh = line
2363 .strip_prefix("CurrentRefreshRate=")
2364 .unwrap_or("")
2365 .trim()
2366 .to_string();
2367 }
2368
2369 if !name.is_empty()
2370 && !width.is_empty()
2371 && !height.is_empty()
2372 && !refresh.is_empty()
2373 {
2374 displays.push(format!("{} ({}x{} @ {}Hz)", name, width, height, refresh));
2375 name.clear();
2376 width.clear();
2377 height.clear();
2378 refresh.clear();
2379 }
2380 }
2381 if !name.is_empty() && !width.is_empty() && !height.is_empty() {
2382 displays.push(format!("{} ({}x{})", name, width, height));
2383 }
2384 }
2385 }
2386 displays
2387 }
2388
2389 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
2390 {
2391 let mut displays = Vec::new();
2392
2393 if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
2395 for entry in entries.filter_map(|e| e.ok()) {
2396 let path = entry.path();
2397 let status_path = path.join("status");
2398 let modes_path = path.join("modes");
2399 let edid_path = path.join("edid");
2400 if status_path.exists() && modes_path.exists() {
2401 if let Ok(status) = std::fs::read_to_string(&status_path) {
2402 if status.trim() == "connected" {
2403 if let Ok(modes) = std::fs::read_to_string(&modes_path) {
2404 if let Some(first_mode) = modes.lines().next() {
2405 let res = first_mode.trim().to_string();
2406 let port = entry.file_name().to_string_lossy().to_string();
2407 let clean_port = if let Some(idx) = port.find('-') {
2408 port[idx + 1..].to_string()
2409 } else {
2410 port
2411 };
2412
2413 let edid_bytes = if edid_path.exists() {
2414 std::fs::read(&edid_path).ok()
2415 } else {
2416 None
2417 };
2418
2419 let name = edid_bytes
2420 .as_ref()
2421 .and_then(|bytes| parse_monitor_name_from_edid(bytes))
2422 .unwrap_or(clean_port);
2423
2424 let refresh = edid_bytes
2425 .as_ref()
2426 .and_then(|bytes| parse_refresh_rate_from_edid(bytes));
2427
2428 let serial = edid_bytes
2429 .as_ref()
2430 .and_then(|bytes| parse_serial_number_from_edid(bytes));
2431
2432 let display_name = if let Some(ref s) = serial {
2433 format!("{} #{}", name, s)
2434 } else {
2435 name
2436 };
2437
2438 if let Some(r) = refresh {
2439 displays.push(format!(
2440 "{} ({} @ {}Hz)",
2441 display_name,
2442 res,
2443 format_refresh_rate(r)
2444 ));
2445 } else {
2446 displays.push(format!("{} ({})", display_name, res));
2447 }
2448 }
2449 }
2450 }
2451 }
2452 }
2453 }
2454 }
2455
2456 if displays.is_empty() {
2458 if let Ok(output) = std::process::Command::new("xrandr")
2459 .arg("--current")
2460 .output()
2461 {
2462 if let Ok(stdout) = String::from_utf8(output.stdout) {
2463 displays = parse_xrandr_displays(&stdout);
2464 }
2465 }
2466 }
2467
2468 displays
2469 }
2470}
2471
2472#[cfg(target_os = "macos")]
2473fn parse_macos_displays(stdout: &str) -> Vec<String> {
2474 let mut displays = Vec::new();
2475 let mut current_name = None;
2476 let mut current_res = None;
2477 let mut in_displays = false;
2478
2479 for line in stdout.lines() {
2480 let trimmed = line.trim();
2481 let indent = line.len() - line.trim_start().len();
2482
2483 if trimmed.starts_with("Displays:") {
2484 in_displays = true;
2485 continue;
2486 }
2487
2488 if in_displays {
2489 if indent < 8 && !trimmed.is_empty() && !trimmed.starts_with("Displays:") {
2490 in_displays = false;
2491 continue;
2492 }
2493
2494 if trimmed.ends_with(':') && !trimmed.starts_with("UI Looks like:") {
2495 let name = trimmed.trim_end_matches(':').trim().to_string();
2496 current_name = Some(name);
2497 } else if trimmed.starts_with("Resolution:") {
2498 let res = trimmed.strip_prefix("Resolution:").unwrap_or("").trim();
2499 let cleaned = res.replace(" ", "");
2500 current_res = Some(cleaned);
2501 } else if trimmed.starts_with("UI Looks like:") {
2502 if let Some(res) = current_res.take() {
2503 let name_str = current_name.take().unwrap_or_else(|| "Display".to_string());
2504 if let Some(idx) = trimmed.find('@') {
2505 let freq = trimmed[idx..].trim();
2506 let freq_clean = freq.replace(" ", "").replace(".00", "");
2507 displays.push(format!(
2508 "{} ({} @ {})",
2509 name_str,
2510 res,
2511 freq_clean.trim_start_matches('@')
2512 ));
2513 } else {
2514 displays.push(format!("{} ({})", name_str, res));
2515 }
2516 }
2517 }
2518 }
2519 }
2520 if let Some(res) = current_res {
2521 let name_str = current_name.unwrap_or_else(|| "Display".to_string());
2522 displays.push(format!("{} ({})", name_str, res));
2523 }
2524 displays
2525}
2526
2527#[cfg(not(any(target_os = "macos", target_os = "windows")))]
2528fn parse_xrandr_displays(stdout: &str) -> Vec<String> {
2529 let mut displays = Vec::new();
2530 let mut current_display = None;
2531 let mut current_port = None;
2532 for line in stdout.lines() {
2533 let line = line.trim();
2534 if line.contains(" connected ") {
2535 let parts: Vec<&str> = line.split_whitespace().collect();
2536 if let Some(&port) = parts.first() {
2537 current_port = Some(port.to_string());
2538 }
2539 for part in parts {
2540 if part.contains('x') && part.contains('+') {
2541 if let Some(res) = part.split('+').next() {
2542 current_display = Some(res.to_string());
2543 }
2544 }
2545 }
2546 } else if line.contains('*') {
2547 if let Some(res) = current_display.take() {
2548 let port = current_port.take().unwrap_or_default();
2549 let name = get_monitor_name_for_port(&port).unwrap_or_else(|| port.clone());
2550 let parts: Vec<&str> = line.split_whitespace().collect();
2551 let mut added = false;
2552 for part in parts {
2553 if part.contains('*') {
2554 let freq = part.trim_end_matches(['*', '+']);
2555 displays.push(format!("{} ({} @ {}Hz)", name, res, freq));
2556 added = true;
2557 break;
2558 }
2559 }
2560 if !added {
2561 displays.push(format!("{} ({})", name, res));
2562 }
2563 }
2564 }
2565 }
2566 displays
2567}
2568
2569fn format_bytes(bytes: u64) -> String {
2571 const KB: u64 = 1024;
2572 const MB: u64 = KB * 1024;
2573 const GB: u64 = MB * 1024;
2574
2575 if bytes >= GB {
2576 format!("{:.1} GB", bytes as f64 / GB as f64)
2577 } else if bytes >= MB {
2578 format!("{:.1} MB", bytes as f64 / MB as f64)
2579 } else if bytes >= KB {
2580 format!("{:.1} KB", bytes as f64 / KB as f64)
2581 } else {
2582 format!("{} B", bytes)
2583 }
2584}
2585
2586fn detect_shell(sys: &sysinfo::System) -> Option<String> {
2588 let shell_env = std::env::var("SHELL").ok();
2590
2591 let (shell_path, shell_name) = if let Some(ref path_str) = shell_env {
2593 let path = std::path::Path::new(path_str);
2594 let name = path
2595 .file_name()
2596 .and_then(|n| n.to_str())
2597 .unwrap_or(path_str)
2598 .to_string();
2599 (path_str.clone(), name)
2600 } else {
2601 let mut current_pid = sysinfo::get_current_pid().ok();
2603 let mut detected: Option<(String, String)> = None;
2604 let known_shells = [
2605 "bash",
2606 "zsh",
2607 "fish",
2608 "sh",
2609 "dash",
2610 "nu",
2611 "elvish",
2612 "tcsh",
2613 "csh",
2614 "ksh",
2615 "powershell",
2616 "pwsh",
2617 "cmd",
2618 ];
2619
2620 while let Some(pid) = current_pid {
2621 if let Some(process) = sys.process(pid) {
2622 let proc_name = process.name().to_string_lossy().to_string();
2623 let proc_name_lower = proc_name.to_lowercase();
2624
2625 let clean_name = proc_name_lower
2627 .strip_suffix(".exe")
2628 .unwrap_or(&proc_name_lower)
2629 .to_string();
2630
2631 if known_shells.contains(&clean_name.as_str()) {
2632 detected = Some((proc_name.clone(), clean_name));
2633 break;
2634 }
2635 current_pid = process.parent();
2636 } else {
2637 break;
2638 }
2639 }
2640
2641 if let Some((orig_name, clean_name)) = detected {
2642 (orig_name, clean_name)
2643 } else {
2644 #[cfg(target_os = "windows")]
2646 {
2647 if std::env::var("PSModulePath").is_ok() {
2648 ("powershell.exe".to_string(), "powershell".to_string())
2649 } else {
2650 ("cmd.exe".to_string(), "cmd".to_string())
2651 }
2652 }
2653 #[cfg(not(target_os = "windows"))]
2654 {
2655 ("sh".to_string(), "sh".to_string())
2656 }
2657 }
2658 };
2659
2660 let shell_name_lower = shell_name.to_lowercase();
2662 let shell_name_clean = shell_name_lower
2663 .strip_suffix(".exe")
2664 .unwrap_or(&shell_name_lower);
2665
2666 let version = detect_shell_version(&shell_path, shell_name_clean);
2667
2668 if let Some(ver) = version {
2669 Some(format!("{} {}", shell_name_clean, ver))
2670 } else {
2671 Some(shell_name_clean.to_string())
2672 }
2673}
2674
2675fn detect_shell_version(shell_path: &str, shell_name: &str) -> Option<String> {
2677 let args = match shell_name {
2679 "powershell" => vec![
2680 "-NoProfile",
2681 "-Command",
2682 "$PSVersionTable.PSVersion.ToString()",
2683 ],
2684 "elvish" => vec!["-version"],
2685 _ => vec!["--version"],
2686 };
2687
2688 let output = std::process::Command::new(shell_path)
2689 .args(&args)
2690 .output()
2691 .or_else(|_| {
2692 std::process::Command::new(shell_name).args(&args).output()
2695 })
2696 .ok()?;
2697
2698 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
2699 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
2700
2701 let full_output = format!("{}\n{}", stdout, stderr);
2703
2704 parse_shell_version(shell_name, &full_output)
2705}
2706
2707fn parse_shell_version(shell_name: &str, output: &str) -> Option<String> {
2709 let output_trimmed = output.trim();
2710 if output_trimmed.is_empty() {
2711 return None;
2712 }
2713
2714 match shell_name {
2715 "bash" => {
2716 if let Some(pos) = output.find("version ") {
2717 let rest = &output[pos + 8..];
2718 let ver = rest
2719 .split(|c: char| c.is_whitespace() || c == '(' || c == ',' || c == '-')
2720 .next()
2721 .unwrap_or("");
2722 if !ver.is_empty() {
2723 return Some(ver.to_string());
2724 }
2725 }
2726 }
2727 "zsh" => {
2728 if let Some(pos) = output.find("zsh ") {
2729 let rest = &output[pos + 4..];
2730 let ver = rest
2731 .split(|c: char| c.is_whitespace() || c == '(')
2732 .next()
2733 .unwrap_or("");
2734 if !ver.is_empty() {
2735 return Some(ver.to_string());
2736 }
2737 }
2738 }
2739 "fish" => {
2740 if let Some(pos) = output.find("version ") {
2741 let rest = &output[pos + 8..];
2742 let ver = rest
2743 .split(|c: char| c.is_whitespace() || c == '(' || c == ',')
2744 .next()
2745 .unwrap_or("");
2746 if !ver.is_empty() {
2747 return Some(ver.to_string());
2748 }
2749 }
2750 }
2751 "nu" => {
2752 let ver = output_trimmed.split_whitespace().next().unwrap_or("");
2753 if !ver.is_empty() {
2754 return Some(ver.to_string());
2755 }
2756 }
2757 "pwsh" => {
2758 if let Some(pos) = output.find("PowerShell ") {
2759 let rest = &output[pos + 11..];
2760 let ver = rest.split_whitespace().next().unwrap_or("");
2761 if !ver.is_empty() {
2762 return Some(ver.to_string());
2763 }
2764 }
2765 }
2766 "powershell" => {
2767 let ver = output_trimmed.split_whitespace().next().unwrap_or("");
2768 if !ver.is_empty() {
2769 return Some(ver.to_string());
2770 }
2771 }
2772 "elvish" => {
2773 let ver = output_trimmed.split_whitespace().next().unwrap_or("");
2774 if !ver.is_empty() {
2775 return Some(ver.to_string());
2776 }
2777 }
2778 "tcsh" => {
2779 if let Some(pos) = output.find("tcsh ") {
2780 let rest = &output[pos + 5..];
2781 let ver = rest.split_whitespace().next().unwrap_or("");
2782 if !ver.is_empty() {
2783 return Some(ver.to_string());
2784 }
2785 }
2786 }
2787 _ => {
2788 if let Some(pos) = output.to_lowercase().find("version ") {
2790 let rest = &output[pos + 8..];
2791 let ver = rest
2792 .split(|c: char| c.is_whitespace() || c == '(' || c == ',' || c == '-')
2793 .next()
2794 .unwrap_or("");
2795 if !ver.is_empty() {
2796 return Some(ver.to_string());
2797 }
2798 }
2799 }
2800 }
2801
2802 None
2803}
2804
2805#[allow(dead_code)]
2806fn parse_ini_key(content: &str, key: &str) -> Option<String> {
2807 for line in content.lines() {
2808 let line = line.trim();
2809 if line.starts_with('#') || line.starts_with(';') {
2810 continue;
2811 }
2812 if let Some(pos) = line.find('=') {
2813 let k = line[..pos].trim();
2814 if k == key {
2815 let v = line[pos + 1..].trim();
2816 let v = if (v.starts_with('"') && v.ends_with('"'))
2817 || (v.starts_with('\'') && v.ends_with('\''))
2818 {
2819 if v.len() >= 2 {
2820 v[1..v.len() - 1].to_string()
2821 } else {
2822 v.to_string()
2823 }
2824 } else {
2825 v.to_string()
2826 };
2827 if !v.is_empty() {
2828 return Some(v);
2829 }
2830 }
2831 }
2832 }
2833 None
2834}
2835
2836#[cfg(target_os = "linux")]
2837fn get_gtk_setting(key: &str) -> Option<String> {
2838 let home = dirs::home_dir()?;
2839 let paths = [
2840 home.join(".config/gtk-4.0/settings.ini"),
2841 home.join(".config/gtk-3.0/settings.ini"),
2842 home.join(".config/gtk-2.0/settings.ini"),
2843 home.join(".gtkrc-2.0"),
2844 ];
2845
2846 for path in &paths {
2847 if path.exists() {
2848 if let Ok(contents) = std::fs::read_to_string(path) {
2849 if let Some(val) = parse_ini_key(&contents, key) {
2850 return Some(val);
2851 }
2852 }
2853 }
2854 }
2855 None
2856}
2857
2858#[cfg(target_os = "linux")]
2859fn query_gsettings(schema: &str, key: &str) -> Option<String> {
2860 let output = std::process::Command::new("gsettings")
2861 .args(["get", schema, key])
2862 .output()
2863 .ok()?;
2864 if output.status.success() {
2865 let val = String::from_utf8_lossy(&output.stdout).trim().to_string();
2866 let val = val.trim_matches('\'').trim_matches('"').to_string();
2867 if !val.is_empty() && val != "''" && val != "\"\"" {
2868 return Some(val);
2869 }
2870 }
2871 None
2872}
2873
2874#[cfg(target_os = "linux")]
2875fn get_kde_setting(key: &str) -> Option<String> {
2876 let home = dirs::home_dir()?;
2877 let path = home.join(".config/kdeglobals");
2878 if path.exists() {
2879 if let Ok(contents) = std::fs::read_to_string(path) {
2880 return parse_ini_key(&contents, key);
2881 }
2882 }
2883 None
2884}
2885
2886#[cfg(target_os = "linux")]
2887fn detect_ui_theme_and_fonts() -> (
2888 Option<String>,
2889 Option<String>,
2890 Option<String>,
2891 Option<String>,
2892) {
2893 let gtk_theme = get_gtk_setting("gtk-theme-name")
2895 .or_else(|| query_gsettings("org.gnome.desktop.interface", "gtk-theme"));
2896 let gtk_icons = get_gtk_setting("gtk-icon-theme-name")
2897 .or_else(|| query_gsettings("org.gnome.desktop.interface", "icon-theme"));
2898 let gtk_cursor = get_gtk_setting("gtk-cursor-theme-name")
2899 .or_else(|| query_gsettings("org.gnome.desktop.interface", "cursor-theme"));
2900 let gtk_font = get_gtk_setting("gtk-font-name")
2901 .or_else(|| query_gsettings("org.gnome.desktop.interface", "font-name"));
2902
2903 let qt_theme = get_kde_setting("widgetStyle").or_else(|| get_kde_setting("ColorScheme"));
2905 let qt_icons = get_kde_setting("iconTheme");
2906 let qt_cursor = {
2907 let home = dirs::home_dir();
2908 home.and_then(|h| {
2909 let path = h.join(".config/kcminputrc");
2910 if path.exists() {
2911 std::fs::read_to_string(path)
2912 .ok()
2913 .and_then(|contents| parse_ini_key(&contents, "theme"))
2914 } else {
2915 None
2916 }
2917 })
2918 };
2919 let qt_font = get_kde_setting("font").map(|f| {
2920 let parts: Vec<&str> = f.split(',').collect();
2921 if parts.len() >= 2 {
2922 let name = parts[0].trim();
2923 let size = parts[1].trim();
2924 format!("{} ({}pt)", name, size)
2925 } else {
2926 f
2927 }
2928 });
2929
2930 let de = std::env::var("XDG_CURRENT_DESKTOP")
2932 .or_else(|_| std::env::var("DESKTOP_SESSION"))
2933 .unwrap_or_default()
2934 .to_lowercase();
2935
2936 let is_kde = de.contains("kde") || de.contains("plasma");
2937
2938 let theme = if is_kde {
2939 match (qt_theme, gtk_theme) {
2940 (Some(qt), Some(gt)) => Some(format!("{} [Qt], {} [GTK]", qt, gt)),
2941 (Some(qt), None) => Some(format!("{} [Qt]", qt)),
2942 (None, Some(gt)) => Some(format!("{} [GTK]", gt)),
2943 (None, None) => None,
2944 }
2945 } else {
2946 match (gtk_theme, qt_theme) {
2947 (Some(gt), Some(qt)) => Some(format!("{} [GTK], {} [Qt]", gt, qt)),
2948 (Some(gt), None) => Some(format!("{} [GTK]", gt)),
2949 (None, Some(qt)) => Some(format!("{} [Qt]", qt)),
2950 (None, None) => None,
2951 }
2952 };
2953
2954 let icons = if is_kde {
2955 match (qt_icons, gtk_icons) {
2956 (Some(qi), Some(gi)) => Some(format!("{} [Qt], {} [GTK]", qi, gi)),
2957 (Some(qi), None) => Some(format!("{} [Qt]", qi)),
2958 (None, Some(gi)) => Some(format!("{} [GTK]", gi)),
2959 (None, None) => None,
2960 }
2961 } else {
2962 match (gtk_icons, qt_icons) {
2963 (Some(gi), Some(qi)) => Some(format!("{} [GTK], {} [Qt]", gi, qi)),
2964 (Some(gi), None) => Some(format!("{} [GTK]", gi)),
2965 (None, Some(qi)) => Some(format!("{} [Qt]", qi)),
2966 (None, None) => None,
2967 }
2968 };
2969
2970 let cursor = if is_kde {
2971 match (qt_cursor, gtk_cursor) {
2972 (Some(qc), Some(gc)) => Some(format!("{} [Qt], {} [GTK]", qc, gc)),
2973 (Some(qc), None) => Some(format!("{} [Qt]", qc)),
2974 (None, Some(gc)) => Some(format!("{} [GTK]", gc)),
2975 (None, None) => None,
2976 }
2977 } else {
2978 match (gtk_cursor, qt_cursor) {
2979 (Some(gc), Some(qc)) => Some(format!("{} [GTK], {} [Qt]", gc, qc)),
2980 (Some(gc), None) => Some(format!("{} [GTK]", gc)),
2981 (None, Some(qc)) => Some(format!("{} [Qt]", qc)),
2982 (None, None) => None,
2983 }
2984 };
2985
2986 let font = if is_kde {
2987 match (qt_font, gtk_font) {
2988 (Some(qf), Some(gf)) => Some(format!("{} [Qt], {} [GTK]", qf, gf)),
2989 (Some(qf), None) => Some(format!("{} [Qt]", qf)),
2990 (None, Some(gf)) => Some(format!("{} [GTK]", gf)),
2991 (None, None) => None,
2992 }
2993 } else {
2994 match (gtk_font, qt_font) {
2995 (Some(gf), Some(qf)) => Some(format!("{} [GTK], {} [Qt]", gf, qf)),
2996 (Some(gf), None) => Some(format!("{} [GTK]", gf)),
2997 (None, Some(qf)) => Some(format!("{} [Qt]", qf)),
2998 (None, None) => None,
2999 }
3000 };
3001
3002 (theme, icons, cursor, font)
3003}
3004
3005#[cfg(target_os = "macos")]
3006fn detect_ui_theme_and_fonts() -> (
3007 Option<String>,
3008 Option<String>,
3009 Option<String>,
3010 Option<String>,
3011) {
3012 let interface_style = std::process::Command::new("defaults")
3013 .args(["read", "-g", "AppleInterfaceStyle"])
3014 .output()
3015 .ok()
3016 .and_then(|o| {
3017 if o.status.success() {
3018 Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
3019 } else {
3020 None
3021 }
3022 });
3023
3024 let theme = match interface_style {
3025 Some(style) => Some(format!("Aqua ({})", style)),
3026 None => Some("Aqua (Light)".to_string()),
3027 };
3028
3029 (theme, None, None, Some("San Francisco".to_string()))
3030}
3031
3032#[cfg(target_os = "windows")]
3033fn detect_ui_theme_and_fonts() -> (
3034 Option<String>,
3035 Option<String>,
3036 Option<String>,
3037 Option<String>,
3038) {
3039 let theme = {
3040 let apps_light = win_reg::get_reg_u32(
3041 win_reg::HKEY_CURRENT_USER,
3042 "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
3043 "AppsUseLightTheme",
3044 );
3045
3046 let apps_dark = apps_light.map(|val| val == 0);
3047
3048 match apps_dark {
3049 Some(true) => Some("Dark".to_string()),
3050 Some(false) => Some("Light".to_string()),
3051 None => {
3052 let output = std::process::Command::new("reg")
3053 .args([
3054 "query",
3055 r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
3056 "/v",
3057 "AppsUseLightTheme",
3058 ])
3059 .output()
3060 .ok();
3061
3062 let cmd_dark = output.and_then(|o| {
3063 if o.status.success() {
3064 let s = String::from_utf8_lossy(&o.stdout);
3065 if s.contains("0x0") {
3066 Some(true)
3067 } else if s.contains("0x1") {
3068 Some(false)
3069 } else {
3070 None
3071 }
3072 } else {
3073 None
3074 }
3075 });
3076
3077 match cmd_dark {
3078 Some(true) => Some("Dark".to_string()),
3079 Some(false) => Some("Light".to_string()),
3080 None => Some("Unknown".to_string()),
3081 }
3082 }
3083 }
3084 };
3085
3086 (theme, None, None, Some("Segoe UI".to_string()))
3087}
3088
3089#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
3090fn detect_ui_theme_and_fonts() -> (
3091 Option<String>,
3092 Option<String>,
3093 Option<String>,
3094 Option<String>,
3095) {
3096 (None, None, None, None)
3097}
3098
3099fn detect_terminal(sys: &System) -> Option<String> {
3100 if let Ok(prog) = std::env::var("TERM_PROGRAM") {
3101 if !prog.is_empty() {
3102 return Some(prog);
3103 }
3104 }
3105 if let Ok(prog) = std::env::var("TERMINAL_EMULATOR") {
3106 if !prog.is_empty() {
3107 return Some(prog);
3108 }
3109 }
3110 if std::env::var("ALACRITTY_LOG").is_ok() || std::env::var("ALACRITTY_WINDOW_ID").is_ok() {
3111 return Some("alacritty".to_string());
3112 }
3113
3114 let current_pid = sysinfo::Pid::from_u32(std::process::id());
3115 let mut current_proc = sys.process(current_pid);
3116
3117 let known_terms = [
3118 "kitty",
3119 "alacritty",
3120 "wezterm",
3121 "gnome-terminal",
3122 "konsole",
3123 "iterm2",
3124 "Terminal",
3125 "rio",
3126 "foot",
3127 "tilix",
3128 "xfce4-terminal",
3129 "terminator",
3130 "st",
3131 "urxvt",
3132 "ptyxis",
3133 ];
3134
3135 let mut depth = 0;
3136 while let Some(proc) = current_proc {
3137 if depth > 5 {
3138 break;
3139 }
3140 let name = proc.name().to_string_lossy().to_lowercase();
3141
3142 for term in &known_terms {
3143 if name == *term || name.ends_with(term) || (name.contains(term) && term.len() > 3) {
3144 return Some(term.to_string());
3145 }
3146 }
3147
3148 if let Some(parent_pid) = proc.parent() {
3149 current_proc = sys.process(parent_pid);
3150 } else {
3151 break;
3152 }
3153 depth += 1;
3154 }
3155
3156 if let Ok(term) = std::env::var("TERM") {
3157 if term != "xterm-256color" && term != "xterm" && term != "linux" && term != "cygwin" {
3158 if let Some(stripped) = term.strip_prefix("xterm-") {
3159 return Some(stripped.to_string());
3160 }
3161 return Some(term);
3162 }
3163 }
3164
3165 None
3166}
3167
3168fn get_default_monospace_font() -> Option<String> {
3169 #[cfg(target_os = "linux")]
3170 {
3171 if let Ok(output) = std::process::Command::new("fc-match")
3172 .arg("monospace")
3173 .output()
3174 {
3175 if output.status.success() {
3176 let s = String::from_utf8_lossy(&output.stdout);
3177 if let Some(start) = s.find('"') {
3178 if let Some(end) = s[start + 1..].find('"') {
3179 return Some(s[start + 1..start + 1 + end].to_string());
3180 }
3181 }
3182 }
3183 }
3184 return None;
3185 }
3186
3187 #[cfg(target_os = "macos")]
3188 {
3189 return Some("SF Mono".to_string());
3190 }
3191
3192 #[cfg(target_os = "windows")]
3193 {
3194 return Some("Consolas".to_string());
3195 }
3196
3197 #[allow(unreachable_code)]
3198 None
3199}
3200
3201fn detect_terminal_font(terminal: Option<&str>) -> Option<String> {
3202 let term = terminal?;
3203 let term_lower = term.to_lowercase();
3204 let home = dirs::home_dir()?;
3205
3206 if term_lower.contains("kitty") {
3207 let conf_path = home.join(".config/kitty/kitty.conf");
3208 if let Ok(content) = std::fs::read_to_string(&conf_path) {
3209 let mut family = None;
3210 let mut size = None;
3211 for line in content.lines() {
3212 let line = line.trim();
3213 if line.starts_with("font_family") {
3214 let parts: Vec<&str> = line.split_whitespace().collect();
3215 if parts.len() >= 2 {
3216 family = Some(parts[1..].join(" "));
3217 }
3218 } else if line.starts_with("font_size") {
3219 let parts: Vec<&str> = line.split_whitespace().collect();
3220 if parts.len() >= 2 {
3221 size = Some(parts[1].to_string());
3222 }
3223 }
3224 }
3225 match (family, size) {
3226 (Some(f), Some(s)) => return Some(format!("{} ({})", f, s)),
3227 (Some(f), None) => return Some(f),
3228 (None, Some(s)) => {
3229 let fallback =
3230 get_default_monospace_font().unwrap_or_else(|| "Default".to_string());
3231 return Some(format!("{} ({})", fallback, s));
3232 }
3233 (None, None) => {}
3234 }
3235 }
3236 } else if term_lower.contains("alacritty") {
3237 let paths = [
3238 home.join(".config/alacritty/alacritty.toml"),
3239 home.join(".config/alacritty/alacritty.yml"),
3240 home.join(".alacritty.toml"),
3241 home.join(".alacritty.yml"),
3242 ];
3243 for path in paths {
3244 if let Ok(content) = std::fs::read_to_string(&path) {
3245 let mut family = None;
3246 let mut size = None;
3247 for line in content.lines() {
3248 let line = line.trim();
3249 if line.starts_with("family") {
3250 if let Some(idx) = line.find('=') {
3251 let val = line[idx + 1..].trim().trim_matches('"').trim_matches('\'');
3252 family = Some(val.to_string());
3253 } else if let Some(idx) = line.find(':') {
3254 let val = line[idx + 1..].trim().trim_matches('"').trim_matches('\'');
3255 family = Some(val.to_string());
3256 }
3257 } else if line.starts_with("size") {
3258 if let Some(idx) = line.find('=') {
3259 size = Some(line[idx + 1..].trim().to_string());
3260 } else if let Some(idx) = line.find(':') {
3261 size = Some(line[idx + 1..].trim().to_string());
3262 }
3263 }
3264 }
3265 match (family, size) {
3266 (Some(f), Some(s)) => return Some(format!("{} ({})", f, s)),
3267 (Some(f), None) => return Some(f),
3268 (None, Some(s)) => {
3269 let fallback =
3270 get_default_monospace_font().unwrap_or_else(|| "Default".to_string());
3271 return Some(format!("{} ({})", fallback, s));
3272 }
3273 (None, None) => {}
3274 }
3275 }
3276 }
3277 } else if term_lower.contains("wezterm") {
3278 let paths = [
3279 home.join(".wezterm.lua"),
3280 home.join(".config/wezterm/wezterm.lua"),
3281 ];
3282 for path in paths {
3283 if let Ok(content) = std::fs::read_to_string(&path) {
3284 let mut family = None;
3285 let mut size = None;
3286 for line in content.lines() {
3287 if line.contains("wezterm.font") {
3288 if let Some(start) = line.find("wezterm.font") {
3289 let rest = &line[start..];
3290 if let Some(quote1) = rest.find('\'').or(rest.find('"')) {
3291 let quote_char = rest.chars().nth(quote1).unwrap();
3292 if let Some(quote2) = rest[quote1 + 1..].find(quote_char) {
3293 family =
3294 Some(rest[quote1 + 1..quote1 + 1 + quote2].to_string());
3295 }
3296 }
3297 }
3298 }
3299 if line.contains("font_size") {
3300 if let Some(idx) = line.find('=') {
3301 let val = line[idx + 1..].trim().trim_end_matches(',');
3302 size = Some(val.to_string());
3303 }
3304 }
3305 }
3306 match (family, size) {
3307 (Some(f), Some(s)) => return Some(format!("{} ({})", f, s)),
3308 (Some(f), None) => return Some(f),
3309 (None, Some(s)) => {
3310 let fallback =
3311 get_default_monospace_font().unwrap_or_else(|| "Default".to_string());
3312 return Some(format!("{} ({})", fallback, s));
3313 }
3314 (None, None) => {}
3315 }
3316 }
3317 }
3318 } else if term_lower.contains("foot") {
3319 let conf_path = home.join(".config/foot/foot.ini");
3320 if let Ok(content) = std::fs::read_to_string(&conf_path) {
3321 for line in content.lines() {
3322 let line = line.trim();
3323 if line.starts_with("font=") {
3324 let val = line.trim_start_matches("font=");
3325 let parts: Vec<&str> = val.split(':').collect();
3326 let family = parts[0].trim();
3327 let mut size = None;
3328 for part in &parts[1..] {
3329 if part.starts_with("size=") {
3330 size = Some(part.trim_start_matches("size=").trim());
3331 }
3332 }
3333 if let Some(s) = size {
3334 return Some(format!("{} ({})", family, s));
3335 } else {
3336 return Some(family.to_string());
3337 }
3338 }
3339 }
3340 }
3341 } else if term_lower.contains("ptyxis") {
3342 #[cfg(target_os = "linux")]
3343 if let Ok(output) = std::process::Command::new("gsettings")
3344 .args(["get", "org.gnome.Ptyxis", "use-system-font"])
3345 .output()
3346 {
3347 if output.status.success() {
3348 let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
3349 if s == "false" {
3350 if let Ok(font_out) = std::process::Command::new("gsettings")
3351 .args(["get", "org.gnome.Ptyxis", "font-name"])
3352 .output()
3353 {
3354 if font_out.status.success() {
3355 let mut font_str =
3356 String::from_utf8_lossy(&font_out.stdout).trim().to_string();
3357 font_str = font_str.trim_matches('\'').to_string();
3358 if !font_str.is_empty() {
3359 if let Some(last_space) = font_str.rfind(' ') {
3360 let family = &font_str[..last_space];
3361 let size = &font_str[last_space + 1..];
3362 if size.chars().all(|c| c.is_ascii_digit() || c == '.') {
3363 return Some(format!("{} ({})", family, size));
3364 }
3365 }
3366 return Some(font_str);
3367 }
3368 }
3369 }
3370 } else {
3371 if let Ok(font_out) = std::process::Command::new("gsettings")
3372 .args(["get", "org.gnome.desktop.interface", "monospace-font-name"])
3373 .output()
3374 {
3375 if font_out.status.success() {
3376 let mut font_str =
3377 String::from_utf8_lossy(&font_out.stdout).trim().to_string();
3378 font_str = font_str.trim_matches('\'').to_string();
3379 if !font_str.is_empty() {
3380 if let Some(last_space) = font_str.rfind(' ') {
3381 let family = &font_str[..last_space];
3382 let size = &font_str[last_space + 1..];
3383 if size.chars().all(|c| c.is_ascii_digit() || c == '.') {
3384 return Some(format!("{} ({})", family, size));
3385 }
3386 }
3387 return Some(font_str);
3388 }
3389 }
3390 }
3391 return get_default_monospace_font();
3392 }
3393 }
3394 }
3395 } else if term_lower.contains("konsole") {
3396 let rc_path = home.join(".config/konsolerc");
3397 let mut profile_name = "Default.profile".to_string();
3398 if let Ok(content) = std::fs::read_to_string(&rc_path) {
3399 for line in content.lines() {
3400 let line = line.trim();
3401 if line.starts_with("DefaultProfile=") {
3402 profile_name = line.trim_start_matches("DefaultProfile=").to_string();
3403 break;
3404 }
3405 }
3406 }
3407 let profile_path = home.join(".local/share/konsole").join(profile_name);
3408 if let Ok(content) = std::fs::read_to_string(&profile_path) {
3409 for line in content.lines() {
3410 let line = line.trim();
3411 if line.starts_with("Font=") {
3412 let val = line.trim_start_matches("Font=");
3413 let parts: Vec<&str> = val.split(',').collect();
3414 if !parts.is_empty() {
3415 let family = parts[0];
3416 if parts.len() > 1 {
3417 let size = parts[1];
3418 return Some(format!("{} ({})", family, size));
3419 }
3420 return Some(family.to_string());
3421 }
3422 }
3423 }
3424 }
3425 return get_default_monospace_font();
3426 }
3427
3428 #[cfg(target_os = "macos")]
3429 if term_lower == "iterm.app" || term_lower.contains("iterm2") {
3430 if let Ok(output) = std::process::Command::new("defaults")
3431 .args(["read", "com.googlecode.iterm2", "Normal Font"])
3432 .output()
3433 {
3434 if let Ok(s) = String::from_utf8(output.stdout) {
3435 let font = s.trim();
3436 if !font.is_empty() {
3437 return Some(font.to_string());
3438 }
3439 }
3440 }
3441 }
3442
3443 None
3444}
3445
3446#[cfg(test)]
3447mod tests {
3448 use super::*;
3449
3450 #[test]
3451 fn test_format_bytes() {
3452 assert_eq!(format_bytes(500), "500 B");
3453 assert_eq!(format_bytes(1024), "1.0 KB");
3454 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
3455 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
3456 assert_eq!(format_bytes(1536), "1.5 KB");
3457 }
3458
3459 #[test]
3460 fn test_parse_ini_key() {
3461 let sample = r#"[Settings]
3462gtk-theme-name=Adwaita-dark
3463# Comment line
3464 ; Another comment
3465gtk-icon-theme-name = "Adwaita"
3466gtk-font-name='Cantarell 11'
3467invalid_line_no_equal
3468empty_value =
3469"#;
3470 assert_eq!(
3471 parse_ini_key(sample, "gtk-theme-name"),
3472 Some("Adwaita-dark".to_string())
3473 );
3474 assert_eq!(
3475 parse_ini_key(sample, "gtk-icon-theme-name"),
3476 Some("Adwaita".to_string())
3477 );
3478 assert_eq!(
3479 parse_ini_key(sample, "gtk-font-name"),
3480 Some("Cantarell 11".to_string())
3481 );
3482 assert_eq!(parse_ini_key(sample, "invalid_line_no_equal"), None);
3483 assert_eq!(parse_ini_key(sample, "empty_value"), None);
3484 assert_eq!(parse_ini_key(sample, "nonexistent"), None);
3485 }
3486
3487 #[test]
3488 fn test_system_info_collect() {
3489 let opts = crate::fetch::CollectOptions::default();
3490 let res = SystemInfo::collect(opts);
3491 assert!(res.is_ok());
3492 let info = res.unwrap();
3493 assert!(!info.os.is_empty());
3494 assert!(info.cpu_cores > 0);
3495 }
3496
3497 #[cfg(target_os = "macos")]
3498 #[test]
3499 fn test_parse_macos_displays() {
3500 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";
3501 let parsed = parse_macos_displays(sample);
3502 assert_eq!(parsed, vec!["Color LCD (3024x1964 @ 60Hz)".to_string()]);
3503 }
3504
3505 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
3506 #[test]
3507 fn test_parse_xrandr_displays() {
3508 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";
3509 let parsed = parse_xrandr_displays(sample);
3510 assert_eq!(parsed, vec!["DP-1 (2560x1440 @ 143.97Hz)".to_string()]);
3511 }
3512
3513 #[test]
3514 fn test_parse_refresh_rate_from_edid() {
3515 let mut edid = vec![0u8; 128];
3517 edid[54] = 0x02;
3519 edid[55] = 0x3A;
3520 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);
3530 assert!(refresh.is_some());
3531 assert_eq!(refresh.unwrap(), 60.0);
3533 }
3534
3535 #[test]
3536 fn test_format_refresh_rate() {
3537 assert_eq!(format_refresh_rate(60.0), "60");
3538 assert_eq!(format_refresh_rate(59.94), "59.94");
3539 assert_eq!(format_refresh_rate(143.971), "143.97");
3540 }
3541
3542 #[test]
3543 fn test_parse_serial_number_from_edid() {
3544 let mut edid = vec![0u8; 128];
3545 edid[12] = 0x78;
3547 edid[13] = 0x56;
3548 edid[14] = 0x34;
3549 edid[15] = 0x12; assert_eq!(
3551 parse_serial_number_from_edid(&edid),
3552 Some("305419896".to_string())
3553 );
3554
3555 edid[72] = 0x00;
3557 edid[73] = 0x00;
3558 edid[74] = 0x00;
3559 edid[75] = 0xFF; let serial_str = b"CN0123456789\n";
3561 for i in 0..serial_str.len() {
3562 edid[76 + i] = serial_str[i];
3563 }
3564 assert_eq!(
3565 parse_serial_number_from_edid(&edid),
3566 Some("CN0123456789".to_string())
3567 );
3568 }
3569
3570 #[test]
3571 fn test_parse_asound_cards() {
3572 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";
3573 let parsed = parse_asound_cards(sample, "/nonexistent");
3574 assert_eq!(
3575 parsed,
3576 vec![
3577 "HDA Intel PCH".to_string(),
3578 "HDA NVIDIA HDMI".to_string(),
3579 "sof-hda-dsp".to_string()
3580 ]
3581 );
3582 }
3583
3584 #[test]
3585 fn test_parse_proc_net_route() {
3586 let sample =
3587 "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
3588 wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t0000FFFF\t0\t0\t0\n\
3589 wlan0\t00000000\t0100A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0\n";
3590 assert_eq!(parse_proc_net_route(sample), Some("wlan0".to_string()));
3591
3592 let sample_no_default =
3593 "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
3594 wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t0000FFFF\t0\t0\t0\n";
3595 assert_eq!(parse_proc_net_route(sample_no_default), None);
3596 }
3597
3598 #[test]
3599 fn test_parse_airport_output() {
3600 let sample = " agrCtlRSSI: -45\n lastTxRate: 866\n SSID: MyHomeWiFi\n";
3601 assert_eq!(
3602 parse_airport_output(sample),
3603 Some("MyHomeWiFi (↑866 Mbps)".to_string())
3604 );
3605
3606 let sample_no_rate = " agrCtlRSSI: -45\n SSID: GuestNetwork\n";
3607 assert_eq!(
3608 parse_airport_output(sample_no_rate),
3609 Some("GuestNetwork".to_string())
3610 );
3611 }
3612
3613 #[test]
3614 fn test_parse_netsh_output() {
3615 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";
3616 assert_eq!(
3617 parse_netsh_output(sample),
3618 Some("Office_Wi-Fi (5 GHz [↓433 Mbps ↑866 Mbps])".to_string())
3619 );
3620 }
3621
3622 #[test]
3623 fn test_parse_iw_link_output() {
3624 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";
3625 let (ssid, links) = parse_iw_link_output(sample);
3626 assert_eq!(ssid, Some("OfficeNet".to_string()));
3627 assert_eq!(links.len(), 1);
3628 assert_eq!(links[0].freq, Some(6135.0));
3629 assert_eq!(links[0].rx_rate, Some("6.0 MBit/s".to_string()));
3630 assert_eq!(links[0].tx_rate, Some("864.6 MBit/s".to_string()));
3631
3632 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";
3634 let (ssid_mlo, links_mlo) = parse_iw_link_output(sample_mlo);
3635 assert_eq!(ssid_mlo, Some("HomeWiFi".to_string()));
3636 assert_eq!(links_mlo.len(), 2);
3637 assert_eq!(links_mlo[0].freq, Some(5180.0));
3638 assert_eq!(links_mlo[1].freq, Some(6135.0));
3639 }
3640
3641 #[test]
3642 fn test_parse_macos_bluetooth() {
3643 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";
3644 assert_eq!(
3645 parse_macos_bluetooth(sample),
3646 Some(
3647 "On (Apple BCM4350) - 2 connected (Sony WH-1000XM4, Logitech MX Master)"
3648 .to_string()
3649 )
3650 );
3651
3652 let sample_off = "Bluetooth:\n\n Bluetooth Power: Off\n";
3653 assert_eq!(
3654 parse_macos_bluetooth(sample_off),
3655 Some("Off (Apple Bluetooth)".to_string())
3656 );
3657
3658 let sample_state_on = "Bluetooth:\n\n State: On\n Chipset: BCM_4388\n";
3659 assert_eq!(
3660 parse_macos_bluetooth(sample_state_on),
3661 Some("On (Apple BCM_4388) - 0 connected".to_string())
3662 );
3663 }
3664
3665 #[test]
3666 fn test_parse_windows_bluetooth_output() {
3667 let sample =
3668 "Running | Intel(R) Wireless Bluetooth(R) | (Sony WH-1000XM4,Logitech MX Master)\n";
3669 assert_eq!(
3670 parse_windows_bluetooth_output(sample),
3671 Some("On (Intel(R) Wireless Bluetooth(R)) - 2 connected (Sony WH-1000XM4, Logitech MX Master)".to_string())
3672 );
3673
3674 let sample_off = "Stopped | | ()\n";
3675 assert_eq!(
3676 parse_windows_bluetooth_output(sample_off),
3677 Some("Off".to_string())
3678 );
3679 }
3680
3681 #[test]
3682 fn test_parse_shell_version() {
3683 let bash_out = "GNU bash, version 5.2.15(1)-release (x86_64-pc-linux-gnu)\nCopyright (C) 2022 Free Software Foundation, Inc.";
3685 assert_eq!(
3686 parse_shell_version("bash", bash_out),
3687 Some("5.2.15".to_string())
3688 );
3689
3690 let zsh_out = "zsh 5.9 (x86_64-pc-linux-gnu)";
3692 assert_eq!(parse_shell_version("zsh", zsh_out), Some("5.9".to_string()));
3693
3694 let fish_out = "fish, version 3.6.0";
3696 assert_eq!(
3697 parse_shell_version("fish", fish_out),
3698 Some("3.6.0".to_string())
3699 );
3700
3701 let nu_out = "0.93.0";
3703 assert_eq!(
3704 parse_shell_version("nu", nu_out),
3705 Some("0.93.0".to_string())
3706 );
3707
3708 let pwsh_out = "PowerShell 7.4.1";
3710 assert_eq!(
3711 parse_shell_version("pwsh", pwsh_out),
3712 Some("7.4.1".to_string())
3713 );
3714
3715 let powershell_out = "5.1.22621.2428";
3717 assert_eq!(
3718 parse_shell_version("powershell", powershell_out),
3719 Some("5.1.22621.2428".to_string())
3720 );
3721
3722 let elvish_out = "0.20.1";
3724 assert_eq!(
3725 parse_shell_version("elvish", elvish_out),
3726 Some("0.20.1".to_string())
3727 );
3728
3729 let tcsh_out = "tcsh 6.24.10 (Astron) 2023-04-20 (x86_64-amd-linux) options wide,nls,dl,al,kan,sm,color,filec";
3731 assert_eq!(
3732 parse_shell_version("tcsh", tcsh_out),
3733 Some("6.24.10".to_string())
3734 );
3735
3736 let custom_out = "CustomShell version 1.2.3-patch4";
3738 assert_eq!(
3739 parse_shell_version("custom", custom_out),
3740 Some("1.2.3".to_string())
3741 );
3742 }
3743
3744 #[test]
3745 fn test_parse_macos_camera() {
3746 let sample = "Camera:\n\n FaceTime HD Camera:\n\n Model ID: UVC Camera VendorID_1452 ProductID_34068\n Unique ID: 0x8020000005ac8514\n";
3747 assert_eq!(
3748 parse_macos_camera(sample),
3749 vec!["FaceTime HD Camera".to_string()]
3750 );
3751 }
3752
3753 #[test]
3754 fn test_parse_macos_gamepad() {
3755 let usb_sample = "USB 3.1 Bus:\n\n Xbox Wireless Controller:\n\n Product ID: 0x02e0\n Vendor ID: 0x045e\n";
3756 let bt_sample = "Bluetooth:\n\n Devices (Connected):\n DualSense Wireless Controller:\n Address: AA-BB-CC\n Connected: Yes\n";
3757 let parsed = parse_macos_gamepad(usb_sample, bt_sample);
3758 assert_eq!(
3759 parsed,
3760 vec![
3761 "Xbox Wireless Controller".to_string(),
3762 "DualSense Wireless Controller".to_string()
3763 ]
3764 );
3765 }
3766}
3767
3768#[cfg(target_os = "windows")]
3769#[allow(clippy::upper_case_acronyms)]
3770mod win_reg {
3771 use std::ffi::OsStr;
3772 use std::os::windows::ffi::OsStrExt;
3773 use std::ptr;
3774
3775 type HKEY = *mut std::ffi::c_void;
3776 pub const HKEY_LOCAL_MACHINE: HKEY = 0x80000002 as HKEY;
3777 pub const HKEY_CURRENT_USER: HKEY = 0x80000001 as HKEY;
3778 const KEY_READ: u32 = 0x20019;
3779
3780 extern "system" {
3781 fn RegOpenKeyExW(
3782 hKey: HKEY,
3783 lpSubKey: *const u16,
3784 ulOptions: u32,
3785 samDesired: u32,
3786 phkResult: *mut HKEY,
3787 ) -> i32;
3788
3789 fn RegQueryValueExW(
3790 hKey: HKEY,
3791 lpValueName: *const u16,
3792 lpReserved: *mut u32,
3793 lpType: *mut u32,
3794 lpData: *mut u8,
3795 lpcbData: *mut u32,
3796 ) -> i32;
3797
3798 fn RegCloseKey(hKey: HKEY) -> i32;
3799 }
3800
3801 pub fn get_reg_string(hkey: HKEY, subkey: &str, value: &str) -> Option<String> {
3802 let subkey_w: Vec<u16> = OsStr::new(subkey).encode_wide().chain(Some(0)).collect();
3803 let value_w: Vec<u16> = OsStr::new(value).encode_wide().chain(Some(0)).collect();
3804 let mut hk: HKEY = ptr::null_mut();
3805
3806 let res = unsafe { RegOpenKeyExW(hkey, subkey_w.as_ptr(), 0, KEY_READ, &mut hk) };
3807 if res != 0 {
3808 return None;
3809 }
3810
3811 let mut size: u32 = 0;
3812 let mut ty: u32 = 0;
3813 unsafe {
3814 RegQueryValueExW(
3815 hk,
3816 value_w.as_ptr(),
3817 ptr::null_mut(),
3818 &mut ty,
3819 ptr::null_mut(),
3820 &mut size,
3821 );
3822 }
3823
3824 if size == 0 {
3825 unsafe {
3826 RegCloseKey(hk);
3827 }
3828 return None;
3829 }
3830
3831 let mut buf = vec![0u8; size as usize];
3832 let res = unsafe {
3833 RegQueryValueExW(
3834 hk,
3835 value_w.as_ptr(),
3836 ptr::null_mut(),
3837 &mut ty,
3838 buf.as_mut_ptr(),
3839 &mut size,
3840 )
3841 };
3842 unsafe {
3843 RegCloseKey(hk);
3844 }
3845
3846 if res == 0 {
3847 if ty == 1 || ty == 2 {
3848 let words = unsafe {
3850 std::slice::from_raw_parts(buf.as_ptr() as *const u16, buf.len() / 2)
3851 };
3852 let len = words.iter().position(|&x| x == 0).unwrap_or(words.len());
3853 String::from_utf16(&words[..len]).ok()
3854 } else {
3855 None
3856 }
3857 } else {
3858 None
3859 }
3860 }
3861
3862 pub fn get_reg_u32(hkey: HKEY, subkey: &str, value: &str) -> Option<u32> {
3863 let subkey_w: Vec<u16> = OsStr::new(subkey).encode_wide().chain(Some(0)).collect();
3864 let value_w: Vec<u16> = OsStr::new(value).encode_wide().chain(Some(0)).collect();
3865 let mut hk: HKEY = ptr::null_mut();
3866
3867 let res = unsafe { RegOpenKeyExW(hkey, subkey_w.as_ptr(), 0, KEY_READ, &mut hk) };
3868 if res != 0 {
3869 return None;
3870 }
3871
3872 let mut size: u32 = 4;
3873 let mut ty: u32 = 0;
3874 let mut val: u32 = 0;
3875 let res = unsafe {
3876 RegQueryValueExW(
3877 hk,
3878 value_w.as_ptr(),
3879 ptr::null_mut(),
3880 &mut ty,
3881 &mut val as *mut u32 as *mut u8,
3882 &mut size,
3883 )
3884 };
3885 unsafe {
3886 RegCloseKey(hk);
3887 }
3888
3889 if res == 0 && ty == 4 {
3890 Some(val)
3892 } else {
3893 None
3894 }
3895 }
3896}