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