1use crate::gpu;
10use chrono::TimeZone;
11use sysinfo::{Components, Disks, System, Users};
12
13#[derive(Debug, Default, Clone)]
18pub struct CollectOptions {
19 pub long: bool,
21}
22
23#[derive(Debug)]
28pub struct SystemInfo {
29 pub os: String,
31 pub kernel: Option<String>,
33 pub hostname: Option<String>,
35 pub arch: String,
37 pub cpu: String,
39 pub cpu_cores: usize,
41 pub memory: String,
43 pub swap: String,
45 pub uptime: String,
47 pub processes: usize,
49 pub load_avg: Option<String>,
51 pub disks: Vec<String>,
53 pub temps: Vec<String>,
55 pub networks: Vec<String>,
57 pub boot_time: String,
59 pub battery: Option<String>,
61 pub shell: Option<String>,
63 pub terminal: Option<String>,
65 pub desktop: Option<String>,
67 pub cpu_freq: Option<String>,
69 pub users: usize,
71 pub gpu: Vec<String>,
73 pub packages: Option<usize>,
75 pub current_user: Option<String>,
77 pub local_ip: Option<String>,
79 pub public_ip: Option<String>,
81 pub active_interface: Option<String>,
83 pub motherboard: Option<String>,
85 pub bios: Option<String>,
87 pub displays: Vec<String>,
89 pub audio: Option<String>,
91 pub wifi: Option<String>,
93 pub bluetooth: Option<String>,
95 pub ui_theme: Option<String>,
97 pub icons: Option<String>,
99 pub cursor: Option<String>,
101 pub font: Option<String>,
103 pub terminal_font: Option<String>,
105 pub camera: Vec<String>,
107 pub gamepad: Vec<String>,
109}
110
111impl SystemInfo {
112 pub fn collect(opts: CollectOptions) -> anyhow::Result<Self> {
117 let mut sys = System::new_all();
118 sys.refresh_all();
119
120 let os = System::long_os_version()
121 .or_else(System::name)
122 .unwrap_or_else(|| "Unknown".to_string());
123
124 let kernel = System::kernel_version();
125 let hostname = System::host_name();
126
127 let cpu = sys
128 .cpus()
129 .first()
130 .map(|c| c.brand().to_string())
131 .unwrap_or_else(|| "Unknown CPU".to_string());
132
133 let cpu_cores = sys.cpus().len();
134
135 let total_mem = sys.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
136 let used_mem = sys.used_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
137 let memory = format!("{:.1} / {:.1} GB", used_mem, total_mem);
138
139 let total_swap = sys.total_swap() as f64 / 1024.0 / 1024.0 / 1024.0;
140 let used_swap = sys.used_swap() as f64 / 1024.0 / 1024.0 / 1024.0;
141 let swap = if total_swap > 0.0 {
142 format!("{:.1} / {:.1} GB", used_swap, total_swap)
143 } else {
144 "No swap".to_string()
145 };
146
147 let uptime = format!("{}s", System::uptime());
148
149 let disks_list = Disks::new_with_refreshed_list();
150 let disks: Vec<String> = if !opts.long {
151 let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/"));
152 let mut best_match: Option<&sysinfo::Disk> = None;
153 for disk in disks_list.iter() {
154 if home.starts_with(disk.mount_point()) {
155 if let Some(best) = best_match {
156 if disk.mount_point().components().count()
157 > best.mount_point().components().count()
158 {
159 best_match = Some(disk);
160 }
161 } else {
162 best_match = Some(disk);
163 }
164 }
165 }
166 let selected_disks = if let Some(best) = best_match {
167 vec![best]
168 } else {
169 disks_list.iter().collect::<Vec<_>>()
170 };
171
172 selected_disks
173 .iter()
174 .map(|d| {
175 let total = d.total_space() as f64 / 1024.0 / 1024.0 / 1024.0;
176 let avail = d.available_space() as f64 / 1024.0 / 1024.0 / 1024.0;
177 let fs = d.file_system().to_string_lossy();
178 format!(
179 "{} ({}): {:.1} GB free / {:.1} GB",
180 d.mount_point().display(),
181 fs,
182 avail,
183 total
184 )
185 })
186 .collect()
187 } else {
188 disks_list
189 .iter()
190 .map(|d| {
191 let total = d.total_space() as f64 / 1024.0 / 1024.0 / 1024.0;
192 let avail = d.available_space() as f64 / 1024.0 / 1024.0 / 1024.0;
193 let fs = d.file_system().to_string_lossy();
194 format!(
195 "{} ({}): {:.1} GB free / {:.1} GB",
196 d.mount_point().display(),
197 fs,
198 avail,
199 total
200 )
201 })
202 .collect()
203 };
204
205 let battery = crate::battery::get_battery_info().map(|bat| {
206 let pct = bat.percentage;
207 let state = match bat.state {
208 crate::battery::BatteryState::Charging => "charging",
209 crate::battery::BatteryState::Discharging => "discharging",
210 crate::battery::BatteryState::Full => "full",
211 _ => "not charging",
212 };
213 let vendor = bat.vendor;
214 let model = bat.model;
215
216 let time_str = match bat.state {
218 crate::battery::BatteryState::Charging => bat.time_remaining.map(|d| {
219 let total_mins = d.as_secs() / 60;
220 let hours = total_mins / 60;
221 let mins = total_mins % 60;
222 if hours >= 24 {
223 let days = hours / 24;
224 let rem_hours = hours % 24;
225 format!("{}d {}h until full", days, rem_hours)
226 } else if hours > 0 {
227 format!("{}h {}m until full", hours, mins)
228 } else {
229 format!("{}m until full", mins)
230 }
231 }),
232 crate::battery::BatteryState::Discharging => bat.time_remaining.map(|d| {
233 let total_mins = d.as_secs() / 60;
234 let hours = total_mins / 60;
235 let mins = total_mins % 60;
236 if hours >= 24 {
237 let days = hours / 24;
238 let rem_hours = hours % 24;
239 format!("{}d {}h remaining", days, rem_hours)
240 } else if hours > 0 {
241 format!("{}h {}m remaining", hours, mins)
242 } else {
243 format!("{}m remaining", mins)
244 }
245 }),
246 _ => None,
247 };
248
249 let mut parts = vec![state.to_string()];
250 if let Some(t) = time_str {
251 parts.insert(0, t);
252 }
253 if let Some(health) = bat.health {
254 if health < 99.0 {
255 parts.push(format!("{:.0}% health", health));
256 }
257 }
258
259 let base = format!("{:.0}% ({})", pct, parts.join(", "));
260
261 match (vendor, model) {
262 (Some(v), Some(m)) => format!("{} [{} {}]", base, v, m),
263 (Some(v), None) => format!("{} [{}]", base, v),
264 _ => base,
265 }
266 });
267
268 let arch = System::cpu_arch();
269
270 let processes = sys.processes().len();
271
272 let load_avg = {
273 let avg = System::load_average();
274 if avg.one > 0.0 || avg.five > 0.0 {
275 Some(format!(
276 "{:.2}, {:.2}, {:.2}",
277 avg.one, avg.five, avg.fifteen
278 ))
279 } else {
280 None
281 }
282 };
283
284 let (
286 gpu,
287 packages,
288 public_ip,
289 (local_ip, active_interface),
290 motherboard,
291 bios,
292 displays,
293 audio,
294 wifi,
295 bluetooth,
296 (ui_theme, icons, cursor, font),
297 camera,
298 gamepad,
299 ) = std::thread::scope(|s| {
300 let gpu_handle = s.spawn(|| {
301 gpu::detect_gpus()
302 .into_iter()
303 .map(|g| g.format())
304 .collect::<Vec<String>>()
305 });
306 let packages_handle = s.spawn(detect_packages);
307 let public_ip_handle = s.spawn(crate::network::detect_public_ip);
308 let network_ips_handle = s.spawn(crate::network::detect_active_interface_and_local_ip);
309 let motherboard_handle = s.spawn(detect_motherboard);
310 let bios_handle = s.spawn(detect_bios);
311 let displays_handle = s.spawn(crate::display::detect_displays);
312 let audio_handle = s.spawn(|| crate::audio::detect_audio(&sys));
313 let wifi_handle = s.spawn(crate::network::detect_wifi);
314 let bluetooth_handle = s.spawn(crate::bluetooth::detect_bluetooth);
315 let ui_theme_and_fonts_handle = s.spawn(detect_ui_theme_and_fonts);
316 let camera_handle = s.spawn(detect_camera);
317 let gamepad_handle = s.spawn(detect_gamepad);
318
319 (
320 gpu_handle.join().unwrap_or_default(),
321 packages_handle.join().ok().flatten(),
322 public_ip_handle.join().ok().flatten(),
323 network_ips_handle.join().unwrap_or((None, None)),
324 motherboard_handle.join().ok().flatten(),
325 bios_handle.join().ok().flatten(),
326 displays_handle.join().unwrap_or_default(),
327 audio_handle.join().ok().flatten(),
328 wifi_handle.join().ok().flatten(),
329 bluetooth_handle.join().ok().flatten(),
330 ui_theme_and_fonts_handle
331 .join()
332 .unwrap_or((None, None, None, None)),
333 camera_handle.join().unwrap_or_default(),
334 gamepad_handle.join().unwrap_or_default(),
335 )
336 });
337
338 let mut temps: Vec<String> = Components::new_with_refreshed_list()
339 .iter()
340 .filter_map(|c| {
341 c.temperature().and_then(|t| {
342 if t > 0.0 {
343 Some(format!("{}: {:.0}°C", c.label(), t))
344 } else {
345 None
346 }
347 })
348 })
349 .collect();
350
351 temps.sort_by(|a, b| {
353 let a_cpu = a.to_lowercase().contains("cpu") || a.to_lowercase().contains("core");
354 let b_cpu = b.to_lowercase().contains("cpu") || b.to_lowercase().contains("core");
355 b_cpu.cmp(&a_cpu)
356 });
357
358 let networks =
359 crate::network::detect_networks(active_interface.as_deref(), local_ip.as_deref());
360
361 let boot_timestamp = System::boot_time();
362 let boot_dt = chrono::Local
363 .timestamp_opt(boot_timestamp as i64, 0)
364 .single()
365 .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
366 .unwrap_or_else(|| boot_timestamp.to_string());
367 let boot_time = boot_dt;
368
369 let shell = detect_shell(&sys);
371 let terminal = detect_terminal(&sys);
372 let terminal_font = detect_terminal_font(terminal.as_deref());
373 let desktop = std::env::var("XDG_CURRENT_DESKTOP")
374 .or_else(|_| std::env::var("DESKTOP_SESSION"))
375 .ok();
376
377 let cpu_freq = sys
379 .cpus()
380 .first()
381 .map(|c| format!("{:.2} GHz", c.frequency() as f64 / 1000.0));
382
383 let current_user = std::env::var("USER").ok();
385
386 let users = Users::new_with_refreshed_list()
388 .iter()
389 .filter(|user| {
390 let uid_str = user.id().to_string();
392 if let Ok(uid) = uid_str.parse::<u32>() {
393 uid >= 1000
394 } else {
395 false
396 }
397 })
398 .count();
399
400 Ok(Self {
401 os,
402 kernel,
403 hostname,
404 arch,
405 cpu,
406 cpu_cores,
407 memory,
408 swap,
409 uptime,
410 processes,
411 load_avg,
412 disks,
413 temps,
414 networks,
415 boot_time,
416 battery,
417 shell,
418 terminal,
419 desktop,
420 cpu_freq,
421 users,
422 gpu,
423 packages,
424 current_user,
425 local_ip,
426 public_ip,
427 active_interface,
428 motherboard,
429 bios,
430 displays,
431 audio,
432 wifi,
433 bluetooth,
434 ui_theme,
435 icons,
436 cursor,
437 font,
438 terminal_font,
439 camera,
440 gamepad,
441 })
442 }
443}
444
445fn detect_packages() -> Option<usize> {
450 #[cfg(target_os = "macos")]
451 {
452 let mut count = 0;
453
454 for cellar_path in &["/opt/homebrew/Cellar", "/usr/local/Cellar"] {
456 if let Ok(entries) = std::fs::read_dir(cellar_path) {
457 count += entries.filter_map(|e| e.ok()).count();
458 }
459 }
460
461 for cask_path in &["/opt/homebrew/Caskroom", "/usr/local/Caskroom"] {
463 if let Ok(entries) = std::fs::read_dir(cask_path) {
464 count += entries.filter_map(|e| e.ok()).count();
465 }
466 }
467
468 if let Ok(entries) = std::fs::read_dir("/opt/local/var/macports/software") {
470 count += entries.filter_map(|e| e.ok()).count();
471 }
472
473 if count > 0 {
474 Some(count)
475 } else {
476 None
477 }
478 }
479
480 #[cfg(target_os = "windows")]
481 {
482 let mut count = 0;
483
484 if let Some(home) = dirs::home_dir() {
486 let scoop_dir = std::env::var("SCOOP")
487 .map(std::path::PathBuf::from)
488 .unwrap_or_else(|_| home.join("scoop"));
489 let scoop_apps = scoop_dir.join("apps");
490 if let Ok(entries) = std::fs::read_dir(scoop_apps) {
491 count += entries.filter_map(|e| e.ok()).count();
492 }
493 }
494
495 let choco_install = std::env::var("ChocolateyInstall")
497 .unwrap_or_else(|_| "C:\\ProgramData\\chocolatey".to_string());
498 let choco_lib = std::path::Path::new(&choco_install).join("lib");
499 if let Ok(entries) = std::fs::read_dir(choco_lib) {
500 count += entries.filter_map(|e| e.ok()).count();
501 }
502
503 if count > 0 {
504 Some(count)
505 } else {
506 None
507 }
508 }
509
510 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
511 {
512 if let Ok(entries) = std::fs::read_dir("/var/lib/pacman/local") {
514 let count = entries.filter_map(|e| e.ok()).count();
515 if count > 0 {
516 return Some(count);
517 }
518 }
519
520 if let Ok(entries) = std::fs::read_dir("/var/lib/dpkg/info") {
522 let count = entries
523 .filter_map(|e| e.ok())
524 .filter(|e| e.path().extension().is_some_and(|ext| ext == "list"))
525 .count();
526 if count > 0 {
527 return Some(count);
528 }
529 }
530
531 if let Ok(entries) = std::fs::read_dir("/var/db/pkg") {
533 let count: usize = entries
534 .filter_map(|e| e.ok())
535 .map(|e| {
536 std::fs::read_dir(e.path())
537 .map(|d| d.filter(|_| true).count())
538 .unwrap_or(0)
539 })
540 .sum();
541 if count > 0 {
542 return Some(count);
543 }
544 }
545
546 if let Ok(entries) = std::fs::read_dir("/var/db/xbps") {
548 let count = entries
549 .filter_map(|e| e.ok())
550 .filter(|e| e.path().extension().is_some_and(|ext| ext == "plist"))
551 .count();
552 if count > 0 {
553 return Some(count);
554 }
555 }
556
557 let rpm_db = "/var/lib/rpm/rpmdb.sqlite";
559 if std::path::Path::new(rpm_db).exists() {
560 match rusqlite::Connection::open(rpm_db) {
561 Ok(conn) => {
562 if let Ok(count) = conn.query_row("SELECT COUNT(*) FROM Packages", [], |row| {
563 row.get::<_, i64>(0)
564 }) {
565 if count > 0 {
566 return Some(count as usize);
567 }
568 }
569 }
570 Err(e) => {
571 eprintln!("warning: failed to open RPM database at {}: {}", rpm_db, e);
572 }
573 }
574 }
575
576 None
577 }
578}
579
580#[allow(dead_code)]
581pub fn is_real_camera(name: &str) -> bool {
582 let name_lower = name.to_lowercase();
583 !name_lower.contains("infrared")
584 && !name_lower.contains("ir camera")
585 && !name_lower.contains("integrated i")
586 && !name_lower.contains("integrated ir")
587 && !name_lower.contains("depth camera")
588}
589
590#[allow(dead_code)]
591pub fn clean_camera_name(name: &str) -> String {
592 let trimmed = name.trim();
593 if trimmed.starts_with("Integrated Camera:") {
594 return "Integrated Camera".to_string();
595 }
596 if trimmed.starts_with("Integrated Webcam:") {
597 return "Integrated Webcam".to_string();
598 }
599 trimmed.to_string()
600}
601
602#[allow(dead_code)]
603pub fn parse_macos_camera(stdout: &str) -> Vec<String> {
604 let mut devices = Vec::new();
605 let mut in_cameras = false;
606 for line in stdout.lines() {
607 let trimmed = line.trim();
608 let indent = line.len() - line.trim_start().len();
609 if trimmed.starts_with("Video Support:")
610 || trimmed.starts_with("Camera:")
611 || trimmed.starts_with("Cameras:")
612 {
613 in_cameras = true;
614 continue;
615 }
616 if in_cameras {
617 if indent < 4
618 && !trimmed.is_empty()
619 && !trimmed.starts_with("Camera")
620 && !trimmed.starts_with("Video Support")
621 {
622 in_cameras = false;
623 continue;
624 }
625 if (indent == 4 || indent == 6 || indent == 8) && trimmed.ends_with(':') {
626 let name = trimmed.trim_end_matches(':').trim().to_string();
627 if !name.is_empty() && is_real_camera(&name) {
628 let cleaned = clean_camera_name(&name);
629 if !devices.contains(&cleaned) {
630 devices.push(cleaned);
631 }
632 }
633 }
634 }
635 }
636 devices
637}
638
639#[allow(dead_code)]
640pub fn parse_macos_gamepad(usb_stdout: &str, bt_stdout: &str) -> Vec<String> {
641 let mut gamepads = Vec::new();
642 let keywords = [
643 "controller",
644 "gamepad",
645 "joystick",
646 "xbox",
647 "playstation",
648 "dualshock",
649 "dualsense",
650 "nintendo",
651 "joy-con",
652 "joycon",
653 ];
654
655 let is_gamepad = |name: &str| -> bool {
656 let name_lower = name.to_lowercase();
657 keywords.iter().any(|&kw| name_lower.contains(kw))
658 };
659
660 for line in usb_stdout.lines() {
662 let trimmed = line.trim();
663 let indent = line.len() - line.trim_start().len();
664 if (indent == 4 || indent == 6 || indent == 8) && trimmed.ends_with(':') {
665 let name = trimmed.trim_end_matches(':').trim().to_string();
666 if is_gamepad(&name) && !gamepads.contains(&name) {
667 gamepads.push(name);
668 }
669 }
670 }
671
672 let mut current_device = None;
674 for line in bt_stdout.lines() {
675 let trimmed = line.trim();
676 let indent = line.len() - line.trim_start().len();
677 if indent >= 8 && trimmed.ends_with(':') {
678 current_device = Some(trimmed.trim_end_matches(':').trim().to_string());
679 } else if trimmed.starts_with("Connected: Yes") || trimmed.starts_with("Connection: Yes") {
680 if let Some(ref dev) = current_device {
681 if is_gamepad(dev) && !gamepads.contains(dev) {
682 gamepads.push(dev.clone());
683 }
684 }
685 }
686 }
687
688 gamepads
689}
690
691fn detect_camera() -> Vec<String> {
692 #[cfg(target_os = "linux")]
693 {
694 let mut cameras = Vec::new();
695 if let Ok(entries) = std::fs::read_dir("/sys/class/video4linux") {
696 for entry in entries.filter_map(|e| e.ok()) {
697 let path = entry.path().join("name");
698 if path.exists() {
699 if let Ok(name) = std::fs::read_to_string(path) {
700 let trimmed = name.trim().to_string();
701 if !trimmed.is_empty() && is_real_camera(&trimmed) {
702 let cleaned = clean_camera_name(&trimmed);
703 if !cameras.contains(&cleaned) {
704 cameras.push(cleaned);
705 }
706 }
707 }
708 }
709 }
710 }
711 cameras
712 }
713
714 #[cfg(target_os = "macos")]
715 {
716 if let Ok(output) = std::process::Command::new("system_profiler")
717 .arg("SPCameraDataType")
718 .output()
719 {
720 if let Ok(stdout) = String::from_utf8(output.stdout) {
721 return parse_macos_camera(&stdout);
722 }
723 }
724 Vec::new()
725 }
726
727 #[cfg(target_os = "windows")]
728 {
729 let cmd = "Get-PnpDevice -Class Camera,Image -PresentOnly -ErrorAction SilentlyContinue | Where-Object { $_.Status -eq 'OK' } | Select-Object -ExpandProperty FriendlyName";
730 if let Ok(output) = std::process::Command::new("powershell")
731 .args(["-Command", cmd])
732 .output()
733 {
734 if let Ok(stdout) = String::from_utf8(output.stdout) {
735 let mut cameras = Vec::new();
736 for line in stdout.lines() {
737 let trimmed = line.trim().to_string();
738 if !trimmed.is_empty() && is_real_camera(&trimmed) {
739 let cleaned = clean_camera_name(&trimmed);
740 if !cameras.contains(&cleaned) {
741 cameras.push(cleaned);
742 }
743 }
744 }
745 return cameras;
746 }
747 }
748 Vec::new()
749 }
750
751 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
752 {
753 Vec::new()
754 }
755}
756
757fn detect_gamepad() -> Vec<String> {
758 #[cfg(target_os = "linux")]
759 {
760 let mut gamepads = Vec::new();
761 if let Ok(entries) = std::fs::read_dir("/sys/class/input") {
762 for entry in entries.filter_map(|e| e.ok()) {
763 let name = entry.file_name().to_string_lossy().to_string();
764 if name.starts_with("js") {
765 let path = entry.path().join("device/name");
766 if path.exists() {
767 if let Ok(dev_name) = std::fs::read_to_string(path) {
768 let trimmed = dev_name.trim().to_string();
769 if !trimmed.is_empty() && !gamepads.contains(&trimmed) {
770 gamepads.push(trimmed);
771 }
772 }
773 }
774 }
775 }
776 }
777 gamepads
778 }
779
780 #[cfg(target_os = "macos")]
781 {
782 let usb_stdout = std::process::Command::new("system_profiler")
783 .arg("SPUSBDataType")
784 .output()
785 .ok()
786 .and_then(|o| String::from_utf8(o.stdout).ok())
787 .unwrap_or_default();
788
789 let bt_stdout = std::process::Command::new("system_profiler")
790 .arg("SPBluetoothDataType")
791 .output()
792 .ok()
793 .and_then(|o| String::from_utf8(o.stdout).ok())
794 .unwrap_or_default();
795
796 parse_macos_gamepad(&usb_stdout, &bt_stdout)
797 }
798
799 #[cfg(target_os = "windows")]
800 {
801 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";
802 if let Ok(output) = std::process::Command::new("powershell")
803 .args(["-Command", cmd])
804 .output()
805 {
806 if let Ok(stdout) = String::from_utf8(output.stdout) {
807 let mut gamepads = Vec::new();
808 for line in stdout.lines() {
809 let trimmed = line.trim().to_string();
810 if !trimmed.is_empty() && !gamepads.contains(&trimmed) {
811 gamepads.push(trimmed);
812 }
813 }
814 return gamepads;
815 }
816 }
817 Vec::new()
818 }
819
820 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
821 {
822 Vec::new()
823 }
824}
825
826fn detect_motherboard() -> Option<String> {
827 #[cfg(target_os = "macos")]
828 {
829 extern "C" {
830 fn sysctlbyname(
831 name: *const i8,
832 oldp: *mut std::ffi::c_void,
833 oldlenp: *mut usize,
834 newp: *mut std::ffi::c_void,
835 newlen: usize,
836 ) -> i32;
837 }
838
839 let name_c = std::ffi::CString::new("hw.model").ok();
840 let mut model_str = None;
841
842 if let Some(name) = name_c {
843 let mut size: usize = 0;
844 unsafe {
845 sysctlbyname(
846 name.as_ptr(),
847 std::ptr::null_mut(),
848 &mut size,
849 std::ptr::null_mut(),
850 0,
851 );
852 }
853 if size > 0 {
854 let mut buf = vec![0u8; size];
855 let res = unsafe {
856 sysctlbyname(
857 name.as_ptr(),
858 buf.as_mut_ptr() as *mut std::ffi::c_void,
859 &mut size,
860 std::ptr::null_mut(),
861 0,
862 )
863 };
864 if res == 0 {
865 if let Some(pos) = buf.iter().position(|&x| x == 0) {
866 buf.truncate(pos);
867 }
868 if let Ok(model) = String::from_utf8(buf) {
869 let trimmed = model.trim();
870 if !trimmed.is_empty() {
871 model_str = Some(trimmed.to_string());
872 }
873 }
874 }
875 }
876 }
877
878 if model_str.is_some() {
879 return model_str;
880 }
881
882 if let Ok(output) = std::process::Command::new("sysctl")
883 .args(["-n", "hw.model"])
884 .output()
885 {
886 if let Ok(model) = String::from_utf8(output.stdout) {
887 let trimmed = model.trim();
888 if !trimmed.is_empty() {
889 return Some(trimmed.to_string());
890 }
891 }
892 }
893 None
894 }
895
896 #[cfg(target_os = "windows")]
897 {
898 let manufacturer = win_reg::get_reg_string(
899 win_reg::HKEY_LOCAL_MACHINE,
900 "HARDWARE\\DESCRIPTION\\System\\BIOS",
901 "BaseBoardManufacturer",
902 )
903 .unwrap_or_default();
904 let product = win_reg::get_reg_string(
905 win_reg::HKEY_LOCAL_MACHINE,
906 "HARDWARE\\DESCRIPTION\\System\\BIOS",
907 "BaseBoardProduct",
908 )
909 .unwrap_or_default();
910
911 let manufacturer = manufacturer.trim();
912 let product = product.trim();
913
914 if !manufacturer.is_empty() && !product.is_empty() {
915 return Some(format!("{} {}", manufacturer, product));
916 } else if !product.is_empty() {
917 return Some(product.to_string());
918 } else if !manufacturer.is_empty() {
919 return Some(manufacturer.to_string());
920 }
921
922 if let Ok(output) = std::process::Command::new("wmic")
923 .args(["baseboard", "get", "manufacturer,product", "/value"])
924 .output()
925 {
926 if let Ok(stdout) = String::from_utf8(output.stdout) {
927 let mut manufacturer = String::new();
928 let mut product = String::new();
929 for line in stdout.lines() {
930 let line = line.trim();
931 if line.starts_with("Manufacturer=") {
932 manufacturer = line
933 .strip_prefix("Manufacturer=")
934 .unwrap_or("")
935 .trim()
936 .to_string();
937 } else if line.starts_with("Product=") {
938 product = line
939 .strip_prefix("Product=")
940 .unwrap_or("")
941 .trim()
942 .to_string();
943 }
944 }
945 if !manufacturer.is_empty() && !product.is_empty() {
946 return Some(format!("{} {}", manufacturer, product));
947 } else if !product.is_empty() {
948 return Some(product);
949 } else if !manufacturer.is_empty() {
950 return Some(manufacturer);
951 }
952 }
953 }
954 None
955 }
956
957 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
958 {
959 let vendor = std::fs::read_to_string("/sys/class/dmi/id/board_vendor")
960 .map(|s| s.trim().to_string())
961 .ok();
962 let name = std::fs::read_to_string("/sys/class/dmi/id/board_name")
963 .map(|s| s.trim().to_string())
964 .ok();
965
966 match (vendor, name) {
967 (Some(v), Some(n)) if !v.is_empty() && !n.is_empty() => {
968 if n.starts_with(&v) {
969 Some(n)
970 } else {
971 Some(format!("{} {}", v, n))
972 }
973 }
974 (Some(v), _) if !v.is_empty() => Some(v),
975 (_, Some(n)) if !n.is_empty() => Some(n),
976 _ => None,
977 }
978 }
979}
980
981fn detect_bios() -> Option<String> {
982 #[cfg(target_os = "macos")]
983 {
984 if let Ok(output) = std::process::Command::new("system_profiler")
985 .arg("SPHardwareDataType")
986 .output()
987 {
988 if let Ok(stdout) = String::from_utf8(output.stdout) {
989 for line in stdout.lines() {
990 let line = line.trim();
991 if line.starts_with("System Firmware Version:")
992 || line.starts_with("Boot ROM Version:")
993 {
994 if let Some(val) = line.split(':').nth(1) {
995 return Some(val.trim().to_string());
996 }
997 }
998 }
999 }
1000 }
1001 None
1002 }
1003
1004 #[cfg(target_os = "windows")]
1005 {
1006 let manufacturer = win_reg::get_reg_string(
1007 win_reg::HKEY_LOCAL_MACHINE,
1008 "HARDWARE\\DESCRIPTION\\System\\BIOS",
1009 "BIOSVendor",
1010 )
1011 .unwrap_or_default();
1012 let version = win_reg::get_reg_string(
1013 win_reg::HKEY_LOCAL_MACHINE,
1014 "HARDWARE\\DESCRIPTION\\System\\BIOS",
1015 "BIOSVersion",
1016 )
1017 .unwrap_or_default();
1018 let raw_date = win_reg::get_reg_string(
1019 win_reg::HKEY_LOCAL_MACHINE,
1020 "HARDWARE\\DESCRIPTION\\System\\BIOS",
1021 "BIOSReleaseDate",
1022 )
1023 .unwrap_or_default();
1024
1025 let manufacturer = manufacturer.trim();
1026 let version = version.trim();
1027 let raw_date = raw_date.trim();
1028
1029 let date = if raw_date.len() >= 8 {
1030 let year = &raw_date[0..4];
1031 let month = &raw_date[4..6];
1032 let day = &raw_date[6..8];
1033 format!("{}/{}/{}", month, day, year)
1034 } else {
1035 raw_date.to_string()
1036 };
1037
1038 let mut parts = Vec::new();
1039 if !manufacturer.is_empty() {
1040 parts.push(manufacturer.to_string());
1041 }
1042 if !version.is_empty() {
1043 parts.push(version.to_string());
1044 }
1045 let mut res = parts.join(" ");
1046 if !date.is_empty() {
1047 if res.is_empty() {
1048 res = date;
1049 } else {
1050 res = format!("{} ({})", res, date);
1051 }
1052 }
1053 if !res.is_empty() {
1054 return Some(res);
1055 }
1056
1057 if let Ok(output) = std::process::Command::new("wmic")
1058 .args([
1059 "bios",
1060 "get",
1061 "manufacturer,smbiosbiosversion,releasedate",
1062 "/value",
1063 ])
1064 .output()
1065 {
1066 if let Ok(stdout) = String::from_utf8(output.stdout) {
1067 let mut manufacturer = String::new();
1068 let mut version = String::new();
1069 let mut date = String::new();
1070 for line in stdout.lines() {
1071 let line = line.trim();
1072 if line.starts_with("Manufacturer=") {
1073 manufacturer = line
1074 .strip_prefix("Manufacturer=")
1075 .unwrap_or("")
1076 .trim()
1077 .to_string();
1078 } else if line.starts_with("SMBIOSBIOSVersion=") {
1079 version = line
1080 .strip_prefix("SMBIOSBIOSVersion=")
1081 .unwrap_or("")
1082 .trim()
1083 .to_string();
1084 } else if line.starts_with("ReleaseDate=") {
1085 let raw_date = line.strip_prefix("ReleaseDate=").unwrap_or("").trim();
1086 if raw_date.len() >= 8 {
1087 let year = &raw_date[0..4];
1088 let month = &raw_date[4..6];
1089 let day = &raw_date[6..8];
1090 date = format!("{}/{}/{}", month, day, year);
1091 } else {
1092 date = raw_date.to_string();
1093 }
1094 }
1095 }
1096
1097 let mut parts = Vec::new();
1098 if !manufacturer.is_empty() {
1099 parts.push(manufacturer);
1100 }
1101 if !version.is_empty() {
1102 parts.push(version);
1103 }
1104 let mut res = parts.join(" ");
1105 if !date.is_empty() {
1106 if res.is_empty() {
1107 res = date;
1108 } else {
1109 res = format!("{} ({})", res, date);
1110 }
1111 }
1112 if !res.is_empty() {
1113 return Some(res);
1114 }
1115 }
1116 }
1117 None
1118 }
1119
1120 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
1121 {
1122 let vendor = std::fs::read_to_string("/sys/class/dmi/id/bios_vendor")
1123 .map(|s| s.trim().to_string())
1124 .ok();
1125 let version = std::fs::read_to_string("/sys/class/dmi/id/bios_version")
1126 .map(|s| s.trim().to_string())
1127 .ok();
1128 let date = std::fs::read_to_string("/sys/class/dmi/id/bios_date")
1129 .map(|s| s.trim().to_string())
1130 .ok();
1131
1132 let mut parts = Vec::new();
1133 if let Some(v) = vendor {
1134 if !v.is_empty() {
1135 parts.push(v);
1136 }
1137 }
1138 if let Some(ver) = version {
1139 let mut ver_cleaned = ver;
1140 while ver_cleaned.contains(" )") {
1141 ver_cleaned = ver_cleaned.replace(" )", ")");
1142 }
1143 let ver_cleaned = ver_cleaned.trim().to_string();
1144 if !ver_cleaned.is_empty() {
1145 parts.push(ver_cleaned);
1146 }
1147 }
1148 let mut res = parts.join(" ");
1149 if let Some(d) = date {
1150 if !d.is_empty() {
1151 if res.is_empty() {
1152 res = d;
1153 } else {
1154 res = format!("{} ({})", res, d);
1155 }
1156 }
1157 }
1158 if !res.is_empty() {
1159 Some(res)
1160 } else {
1161 None
1162 }
1163 }
1164}
1165
1166fn detect_shell(sys: &sysinfo::System) -> Option<String> {
1170 let shell_env = std::env::var("SHELL").ok();
1172
1173 let (shell_path, shell_name) = if let Some(ref path_str) = shell_env {
1175 let path = std::path::Path::new(path_str);
1176 let name = path
1177 .file_name()
1178 .and_then(|n| n.to_str())
1179 .unwrap_or(path_str)
1180 .to_string();
1181 (path_str.clone(), name)
1182 } else {
1183 let mut current_pid = sysinfo::get_current_pid().ok();
1185 let mut detected: Option<(String, String)> = None;
1186 let known_shells = [
1187 "bash",
1188 "zsh",
1189 "fish",
1190 "sh",
1191 "dash",
1192 "nu",
1193 "elvish",
1194 "tcsh",
1195 "csh",
1196 "ksh",
1197 "powershell",
1198 "pwsh",
1199 "cmd",
1200 ];
1201
1202 while let Some(pid) = current_pid {
1203 if let Some(process) = sys.process(pid) {
1204 let proc_name = process.name().to_string_lossy().to_string();
1205 let proc_name_lower = proc_name.to_lowercase();
1206
1207 let clean_name = proc_name_lower
1209 .strip_suffix(".exe")
1210 .unwrap_or(&proc_name_lower)
1211 .to_string();
1212
1213 if known_shells.contains(&clean_name.as_str()) {
1214 detected = Some((proc_name.clone(), clean_name));
1215 break;
1216 }
1217 current_pid = process.parent();
1218 } else {
1219 break;
1220 }
1221 }
1222
1223 if let Some((orig_name, clean_name)) = detected {
1224 (orig_name, clean_name)
1225 } else {
1226 #[cfg(target_os = "windows")]
1228 {
1229 if std::env::var("PSModulePath").is_ok() {
1230 ("powershell.exe".to_string(), "powershell".to_string())
1231 } else {
1232 ("cmd.exe".to_string(), "cmd".to_string())
1233 }
1234 }
1235 #[cfg(not(target_os = "windows"))]
1236 {
1237 ("sh".to_string(), "sh".to_string())
1238 }
1239 }
1240 };
1241
1242 let shell_name_lower = shell_name.to_lowercase();
1244 let shell_name_clean = shell_name_lower
1245 .strip_suffix(".exe")
1246 .unwrap_or(&shell_name_lower);
1247
1248 let version = detect_shell_version(&shell_path, shell_name_clean);
1249
1250 if let Some(ver) = version {
1251 Some(format!("{} {}", shell_name_clean, ver))
1252 } else {
1253 Some(shell_name_clean.to_string())
1254 }
1255}
1256
1257fn detect_shell_version(shell_path: &str, shell_name: &str) -> Option<String> {
1259 let args = match shell_name {
1261 "powershell" => vec![
1262 "-NoProfile",
1263 "-Command",
1264 "$PSVersionTable.PSVersion.ToString()",
1265 ],
1266 "elvish" => vec!["-version"],
1267 _ => vec!["--version"],
1268 };
1269
1270 let output = std::process::Command::new(shell_path)
1271 .args(&args)
1272 .output()
1273 .or_else(|_| {
1274 std::process::Command::new(shell_name).args(&args).output()
1277 })
1278 .ok()?;
1279
1280 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1281 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1282
1283 let full_output = format!("{}\n{}", stdout, stderr);
1285
1286 parse_shell_version(shell_name, &full_output)
1287}
1288
1289fn parse_shell_version(shell_name: &str, output: &str) -> Option<String> {
1291 let output_trimmed = output.trim();
1292 if output_trimmed.is_empty() {
1293 return None;
1294 }
1295
1296 match shell_name {
1297 "bash" => {
1298 if let Some(pos) = output.find("version ") {
1299 let rest = &output[pos + 8..];
1300 let ver = rest
1301 .split(|c: char| c.is_whitespace() || c == '(' || c == ',' || c == '-')
1302 .next()
1303 .unwrap_or("");
1304 if !ver.is_empty() {
1305 return Some(ver.to_string());
1306 }
1307 }
1308 }
1309 "zsh" => {
1310 if let Some(pos) = output.find("zsh ") {
1311 let rest = &output[pos + 4..];
1312 let ver = rest
1313 .split(|c: char| c.is_whitespace() || c == '(')
1314 .next()
1315 .unwrap_or("");
1316 if !ver.is_empty() {
1317 return Some(ver.to_string());
1318 }
1319 }
1320 }
1321 "fish" => {
1322 if let Some(pos) = output.find("version ") {
1323 let rest = &output[pos + 8..];
1324 let ver = rest
1325 .split(|c: char| c.is_whitespace() || c == '(' || c == ',')
1326 .next()
1327 .unwrap_or("");
1328 if !ver.is_empty() {
1329 return Some(ver.to_string());
1330 }
1331 }
1332 }
1333 "nu" => {
1334 let ver = output_trimmed.split_whitespace().next().unwrap_or("");
1335 if !ver.is_empty() {
1336 return Some(ver.to_string());
1337 }
1338 }
1339 "pwsh" => {
1340 if let Some(pos) = output.find("PowerShell ") {
1341 let rest = &output[pos + 11..];
1342 let ver = rest.split_whitespace().next().unwrap_or("");
1343 if !ver.is_empty() {
1344 return Some(ver.to_string());
1345 }
1346 }
1347 }
1348 "powershell" => {
1349 let ver = output_trimmed.split_whitespace().next().unwrap_or("");
1350 if !ver.is_empty() {
1351 return Some(ver.to_string());
1352 }
1353 }
1354 "elvish" => {
1355 let ver = output_trimmed.split_whitespace().next().unwrap_or("");
1356 if !ver.is_empty() {
1357 return Some(ver.to_string());
1358 }
1359 }
1360 "tcsh" => {
1361 if let Some(pos) = output.find("tcsh ") {
1362 let rest = &output[pos + 5..];
1363 let ver = rest.split_whitespace().next().unwrap_or("");
1364 if !ver.is_empty() {
1365 return Some(ver.to_string());
1366 }
1367 }
1368 }
1369 _ => {
1370 if let Some(pos) = output.to_lowercase().find("version ") {
1372 let rest = &output[pos + 8..];
1373 let ver = rest
1374 .split(|c: char| c.is_whitespace() || c == '(' || c == ',' || c == '-')
1375 .next()
1376 .unwrap_or("");
1377 if !ver.is_empty() {
1378 return Some(ver.to_string());
1379 }
1380 }
1381 }
1382 }
1383
1384 None
1385}
1386
1387#[allow(dead_code)]
1388fn parse_ini_key(content: &str, key: &str) -> Option<String> {
1389 for line in content.lines() {
1390 let line = line.trim();
1391 if line.starts_with('#') || line.starts_with(';') {
1392 continue;
1393 }
1394 if let Some(pos) = line.find('=') {
1395 let k = line[..pos].trim();
1396 if k == key {
1397 let v = line[pos + 1..].trim();
1398 let v = if (v.starts_with('"') && v.ends_with('"'))
1399 || (v.starts_with('\'') && v.ends_with('\''))
1400 {
1401 if v.len() >= 2 {
1402 v[1..v.len() - 1].to_string()
1403 } else {
1404 v.to_string()
1405 }
1406 } else {
1407 v.to_string()
1408 };
1409 if !v.is_empty() {
1410 return Some(v);
1411 }
1412 }
1413 }
1414 }
1415 None
1416}
1417
1418#[cfg(target_os = "linux")]
1419fn get_gtk_setting(key: &str) -> Option<String> {
1420 let home = dirs::home_dir()?;
1421 let paths = [
1422 home.join(".config/gtk-4.0/settings.ini"),
1423 home.join(".config/gtk-3.0/settings.ini"),
1424 home.join(".config/gtk-2.0/settings.ini"),
1425 home.join(".gtkrc-2.0"),
1426 ];
1427
1428 for path in &paths {
1429 if path.exists() {
1430 if let Ok(contents) = std::fs::read_to_string(path) {
1431 if let Some(val) = parse_ini_key(&contents, key) {
1432 return Some(val);
1433 }
1434 }
1435 }
1436 }
1437 None
1438}
1439
1440#[cfg(target_os = "linux")]
1441fn query_gsettings(schema: &str, key: &str) -> Option<String> {
1442 let output = std::process::Command::new("gsettings")
1443 .args(["get", schema, key])
1444 .output()
1445 .ok()?;
1446 if output.status.success() {
1447 let val = String::from_utf8_lossy(&output.stdout).trim().to_string();
1448 let val = val.trim_matches('\'').trim_matches('"').to_string();
1449 if !val.is_empty() && val != "''" && val != "\"\"" {
1450 return Some(val);
1451 }
1452 }
1453 None
1454}
1455
1456#[cfg(target_os = "linux")]
1457fn get_kde_setting(key: &str) -> Option<String> {
1458 let home = dirs::home_dir()?;
1459 let path = home.join(".config/kdeglobals");
1460 if path.exists() {
1461 if let Ok(contents) = std::fs::read_to_string(path) {
1462 return parse_ini_key(&contents, key);
1463 }
1464 }
1465 None
1466}
1467
1468#[cfg(target_os = "linux")]
1469fn detect_ui_theme_and_fonts() -> (
1470 Option<String>,
1471 Option<String>,
1472 Option<String>,
1473 Option<String>,
1474) {
1475 let gtk_theme = get_gtk_setting("gtk-theme-name")
1477 .or_else(|| query_gsettings("org.gnome.desktop.interface", "gtk-theme"));
1478 let gtk_icons = get_gtk_setting("gtk-icon-theme-name")
1479 .or_else(|| query_gsettings("org.gnome.desktop.interface", "icon-theme"));
1480 let gtk_cursor = get_gtk_setting("gtk-cursor-theme-name")
1481 .or_else(|| query_gsettings("org.gnome.desktop.interface", "cursor-theme"));
1482 let gtk_font = get_gtk_setting("gtk-font-name")
1483 .or_else(|| query_gsettings("org.gnome.desktop.interface", "font-name"));
1484
1485 let qt_theme = get_kde_setting("widgetStyle").or_else(|| get_kde_setting("ColorScheme"));
1487 let qt_icons = get_kde_setting("iconTheme");
1488 let qt_cursor = {
1489 let home = dirs::home_dir();
1490 home.and_then(|h| {
1491 let path = h.join(".config/kcminputrc");
1492 if path.exists() {
1493 std::fs::read_to_string(path)
1494 .ok()
1495 .and_then(|contents| parse_ini_key(&contents, "theme"))
1496 } else {
1497 None
1498 }
1499 })
1500 };
1501 let qt_font = get_kde_setting("font").map(|f| {
1502 let parts: Vec<&str> = f.split(',').collect();
1503 if parts.len() >= 2 {
1504 let name = parts[0].trim();
1505 let size = parts[1].trim();
1506 format!("{} ({}pt)", name, size)
1507 } else {
1508 f
1509 }
1510 });
1511
1512 let de = std::env::var("XDG_CURRENT_DESKTOP")
1514 .or_else(|_| std::env::var("DESKTOP_SESSION"))
1515 .unwrap_or_default()
1516 .to_lowercase();
1517
1518 let is_kde = de.contains("kde") || de.contains("plasma");
1519
1520 let theme = if is_kde {
1521 match (qt_theme, gtk_theme) {
1522 (Some(qt), Some(gt)) => Some(format!("{} [Qt], {} [GTK]", qt, gt)),
1523 (Some(qt), None) => Some(format!("{} [Qt]", qt)),
1524 (None, Some(gt)) => Some(format!("{} [GTK]", gt)),
1525 (None, None) => None,
1526 }
1527 } else {
1528 match (gtk_theme, qt_theme) {
1529 (Some(gt), Some(qt)) => Some(format!("{} [GTK], {} [Qt]", gt, qt)),
1530 (Some(gt), None) => Some(format!("{} [GTK]", gt)),
1531 (None, Some(qt)) => Some(format!("{} [Qt]", qt)),
1532 (None, None) => None,
1533 }
1534 };
1535
1536 let icons = if is_kde {
1537 match (qt_icons, gtk_icons) {
1538 (Some(qi), Some(gi)) => Some(format!("{} [Qt], {} [GTK]", qi, gi)),
1539 (Some(qi), None) => Some(format!("{} [Qt]", qi)),
1540 (None, Some(gi)) => Some(format!("{} [GTK]", gi)),
1541 (None, None) => None,
1542 }
1543 } else {
1544 match (gtk_icons, qt_icons) {
1545 (Some(gi), Some(qi)) => Some(format!("{} [GTK], {} [Qt]", gi, qi)),
1546 (Some(gi), None) => Some(format!("{} [GTK]", gi)),
1547 (None, Some(qi)) => Some(format!("{} [Qt]", qi)),
1548 (None, None) => None,
1549 }
1550 };
1551
1552 let cursor = if is_kde {
1553 match (qt_cursor, gtk_cursor) {
1554 (Some(qc), Some(gc)) => Some(format!("{} [Qt], {} [GTK]", qc, gc)),
1555 (Some(qc), None) => Some(format!("{} [Qt]", qc)),
1556 (None, Some(gc)) => Some(format!("{} [GTK]", gc)),
1557 (None, None) => None,
1558 }
1559 } else {
1560 match (gtk_cursor, qt_cursor) {
1561 (Some(gc), Some(qc)) => Some(format!("{} [GTK], {} [Qt]", gc, qc)),
1562 (Some(gc), None) => Some(format!("{} [GTK]", gc)),
1563 (None, Some(qc)) => Some(format!("{} [Qt]", qc)),
1564 (None, None) => None,
1565 }
1566 };
1567
1568 let font = if is_kde {
1569 match (qt_font, gtk_font) {
1570 (Some(qf), Some(gf)) => Some(format!("{} [Qt], {} [GTK]", qf, gf)),
1571 (Some(qf), None) => Some(format!("{} [Qt]", qf)),
1572 (None, Some(gf)) => Some(format!("{} [GTK]", gf)),
1573 (None, None) => None,
1574 }
1575 } else {
1576 match (gtk_font, qt_font) {
1577 (Some(gf), Some(qf)) => Some(format!("{} [GTK], {} [Qt]", gf, qf)),
1578 (Some(gf), None) => Some(format!("{} [GTK]", gf)),
1579 (None, Some(qf)) => Some(format!("{} [Qt]", qf)),
1580 (None, None) => None,
1581 }
1582 };
1583
1584 (theme, icons, cursor, font)
1585}
1586
1587#[cfg(target_os = "macos")]
1588fn detect_ui_theme_and_fonts() -> (
1589 Option<String>,
1590 Option<String>,
1591 Option<String>,
1592 Option<String>,
1593) {
1594 let interface_style = std::process::Command::new("defaults")
1595 .args(["read", "-g", "AppleInterfaceStyle"])
1596 .output()
1597 .ok()
1598 .and_then(|o| {
1599 if o.status.success() {
1600 Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
1601 } else {
1602 None
1603 }
1604 });
1605
1606 let theme = match interface_style {
1607 Some(style) => Some(format!("Aqua ({})", style)),
1608 None => Some("Aqua (Light)".to_string()),
1609 };
1610
1611 (theme, None, None, Some("San Francisco".to_string()))
1612}
1613
1614#[cfg(target_os = "windows")]
1615fn detect_ui_theme_and_fonts() -> (
1616 Option<String>,
1617 Option<String>,
1618 Option<String>,
1619 Option<String>,
1620) {
1621 let theme = {
1622 let apps_light = win_reg::get_reg_u32(
1623 win_reg::HKEY_CURRENT_USER,
1624 "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
1625 "AppsUseLightTheme",
1626 );
1627
1628 let apps_dark = apps_light.map(|val| val == 0);
1629
1630 match apps_dark {
1631 Some(true) => Some("Dark".to_string()),
1632 Some(false) => Some("Light".to_string()),
1633 None => {
1634 let output = std::process::Command::new("reg")
1635 .args([
1636 "query",
1637 r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
1638 "/v",
1639 "AppsUseLightTheme",
1640 ])
1641 .output()
1642 .ok();
1643
1644 let cmd_dark = output.and_then(|o| {
1645 if o.status.success() {
1646 let s = String::from_utf8_lossy(&o.stdout);
1647 if s.contains("0x0") {
1648 Some(true)
1649 } else if s.contains("0x1") {
1650 Some(false)
1651 } else {
1652 None
1653 }
1654 } else {
1655 None
1656 }
1657 });
1658
1659 match cmd_dark {
1660 Some(true) => Some("Dark".to_string()),
1661 Some(false) => Some("Light".to_string()),
1662 None => Some("Unknown".to_string()),
1663 }
1664 }
1665 }
1666 };
1667
1668 (theme, None, None, Some("Segoe UI".to_string()))
1669}
1670
1671#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1672fn detect_ui_theme_and_fonts() -> (
1673 Option<String>,
1674 Option<String>,
1675 Option<String>,
1676 Option<String>,
1677) {
1678 (None, None, None, None)
1679}
1680
1681fn detect_terminal(sys: &System) -> Option<String> {
1682 if let Ok(prog) = std::env::var("TERM_PROGRAM") {
1683 if !prog.is_empty() {
1684 return Some(prog);
1685 }
1686 }
1687 if let Ok(prog) = std::env::var("TERMINAL_EMULATOR") {
1688 if !prog.is_empty() {
1689 return Some(prog);
1690 }
1691 }
1692 if std::env::var("ALACRITTY_LOG").is_ok() || std::env::var("ALACRITTY_WINDOW_ID").is_ok() {
1693 return Some("alacritty".to_string());
1694 }
1695
1696 let current_pid = sysinfo::Pid::from_u32(std::process::id());
1697 let mut current_proc = sys.process(current_pid);
1698
1699 let known_terms = [
1700 "kitty",
1701 "alacritty",
1702 "wezterm",
1703 "gnome-terminal",
1704 "konsole",
1705 "iterm2",
1706 "Terminal",
1707 "rio",
1708 "foot",
1709 "tilix",
1710 "xfce4-terminal",
1711 "terminator",
1712 "st",
1713 "urxvt",
1714 "ptyxis",
1715 ];
1716
1717 let mut depth = 0;
1718 while let Some(proc) = current_proc {
1719 if depth > 5 {
1720 break;
1721 }
1722 let name = proc.name().to_string_lossy().to_lowercase();
1723
1724 for term in &known_terms {
1725 if name == *term || name.ends_with(term) || (name.contains(term) && term.len() > 3) {
1726 return Some(term.to_string());
1727 }
1728 }
1729
1730 if let Some(parent_pid) = proc.parent() {
1731 current_proc = sys.process(parent_pid);
1732 } else {
1733 break;
1734 }
1735 depth += 1;
1736 }
1737
1738 if let Ok(term) = std::env::var("TERM") {
1739 if term != "xterm-256color" && term != "xterm" && term != "linux" && term != "cygwin" {
1740 if let Some(stripped) = term.strip_prefix("xterm-") {
1741 return Some(stripped.to_string());
1742 }
1743 return Some(term);
1744 }
1745 }
1746
1747 None
1748}
1749
1750fn get_default_monospace_font() -> Option<String> {
1751 #[cfg(target_os = "linux")]
1752 {
1753 if let Ok(output) = std::process::Command::new("fc-match")
1754 .arg("monospace")
1755 .output()
1756 {
1757 if output.status.success() {
1758 let s = String::from_utf8_lossy(&output.stdout);
1759 if let Some(start) = s.find('"') {
1760 if let Some(end) = s[start + 1..].find('"') {
1761 return Some(s[start + 1..start + 1 + end].to_string());
1762 }
1763 }
1764 }
1765 }
1766 return None;
1767 }
1768
1769 #[cfg(target_os = "macos")]
1770 {
1771 return Some("SF Mono".to_string());
1772 }
1773
1774 #[cfg(target_os = "windows")]
1775 {
1776 return Some("Consolas".to_string());
1777 }
1778
1779 #[allow(unreachable_code)]
1780 None
1781}
1782
1783fn detect_terminal_font(terminal: Option<&str>) -> Option<String> {
1784 let term = terminal?;
1785 let term_lower = term.to_lowercase();
1786 let home = dirs::home_dir()?;
1787
1788 if term_lower.contains("kitty") {
1789 let conf_path = home.join(".config/kitty/kitty.conf");
1790 if let Ok(content) = std::fs::read_to_string(&conf_path) {
1791 let mut family = None;
1792 let mut size = None;
1793 for line in content.lines() {
1794 let line = line.trim();
1795 if line.starts_with("font_family") {
1796 let parts: Vec<&str> = line.split_whitespace().collect();
1797 if parts.len() >= 2 {
1798 family = Some(parts[1..].join(" "));
1799 }
1800 } else if line.starts_with("font_size") {
1801 let parts: Vec<&str> = line.split_whitespace().collect();
1802 if parts.len() >= 2 {
1803 size = Some(parts[1].to_string());
1804 }
1805 }
1806 }
1807 match (family, size) {
1808 (Some(f), Some(s)) => return Some(format!("{} ({})", f, s)),
1809 (Some(f), None) => return Some(f),
1810 (None, Some(s)) => {
1811 let fallback =
1812 get_default_monospace_font().unwrap_or_else(|| "Default".to_string());
1813 return Some(format!("{} ({})", fallback, s));
1814 }
1815 (None, None) => {}
1816 }
1817 }
1818 } else if term_lower.contains("alacritty") {
1819 let paths = [
1820 home.join(".config/alacritty/alacritty.toml"),
1821 home.join(".config/alacritty/alacritty.yml"),
1822 home.join(".alacritty.toml"),
1823 home.join(".alacritty.yml"),
1824 ];
1825 for path in paths {
1826 if let Ok(content) = std::fs::read_to_string(&path) {
1827 let mut family = None;
1828 let mut size = None;
1829 for line in content.lines() {
1830 let line = line.trim();
1831 if line.starts_with("family") {
1832 if let Some(idx) = line.find('=') {
1833 let val = line[idx + 1..].trim().trim_matches('"').trim_matches('\'');
1834 family = Some(val.to_string());
1835 } else if let Some(idx) = line.find(':') {
1836 let val = line[idx + 1..].trim().trim_matches('"').trim_matches('\'');
1837 family = Some(val.to_string());
1838 }
1839 } else if line.starts_with("size") {
1840 if let Some(idx) = line.find('=') {
1841 size = Some(line[idx + 1..].trim().to_string());
1842 } else if let Some(idx) = line.find(':') {
1843 size = Some(line[idx + 1..].trim().to_string());
1844 }
1845 }
1846 }
1847 match (family, size) {
1848 (Some(f), Some(s)) => return Some(format!("{} ({})", f, s)),
1849 (Some(f), None) => return Some(f),
1850 (None, Some(s)) => {
1851 let fallback =
1852 get_default_monospace_font().unwrap_or_else(|| "Default".to_string());
1853 return Some(format!("{} ({})", fallback, s));
1854 }
1855 (None, None) => {}
1856 }
1857 }
1858 }
1859 } else if term_lower.contains("wezterm") {
1860 let paths = [
1861 home.join(".wezterm.lua"),
1862 home.join(".config/wezterm/wezterm.lua"),
1863 ];
1864 for path in paths {
1865 if let Ok(content) = std::fs::read_to_string(&path) {
1866 let mut family = None;
1867 let mut size = None;
1868 for line in content.lines() {
1869 if line.contains("wezterm.font") {
1870 if let Some(start) = line.find("wezterm.font") {
1871 let rest = &line[start..];
1872 if let Some(quote1) = rest.find('\'').or(rest.find('"')) {
1873 let quote_char = rest.chars().nth(quote1).unwrap();
1874 if let Some(quote2) = rest[quote1 + 1..].find(quote_char) {
1875 family =
1876 Some(rest[quote1 + 1..quote1 + 1 + quote2].to_string());
1877 }
1878 }
1879 }
1880 }
1881 if line.contains("font_size") {
1882 if let Some(idx) = line.find('=') {
1883 let val = line[idx + 1..].trim().trim_end_matches(',');
1884 size = Some(val.to_string());
1885 }
1886 }
1887 }
1888 match (family, size) {
1889 (Some(f), Some(s)) => return Some(format!("{} ({})", f, s)),
1890 (Some(f), None) => return Some(f),
1891 (None, Some(s)) => {
1892 let fallback =
1893 get_default_monospace_font().unwrap_or_else(|| "Default".to_string());
1894 return Some(format!("{} ({})", fallback, s));
1895 }
1896 (None, None) => {}
1897 }
1898 }
1899 }
1900 } else if term_lower.contains("foot") {
1901 let conf_path = home.join(".config/foot/foot.ini");
1902 if let Ok(content) = std::fs::read_to_string(&conf_path) {
1903 for line in content.lines() {
1904 let line = line.trim();
1905 if line.starts_with("font=") {
1906 let val = line.trim_start_matches("font=");
1907 let parts: Vec<&str> = val.split(':').collect();
1908 let family = parts[0].trim();
1909 let mut size = None;
1910 for part in &parts[1..] {
1911 if part.starts_with("size=") {
1912 size = Some(part.trim_start_matches("size=").trim());
1913 }
1914 }
1915 if let Some(s) = size {
1916 return Some(format!("{} ({})", family, s));
1917 } else {
1918 return Some(family.to_string());
1919 }
1920 }
1921 }
1922 }
1923 } else if term_lower.contains("ptyxis") {
1924 #[cfg(target_os = "linux")]
1925 if let Ok(output) = std::process::Command::new("gsettings")
1926 .args(["get", "org.gnome.Ptyxis", "use-system-font"])
1927 .output()
1928 {
1929 if output.status.success() {
1930 let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
1931 if s == "false" {
1932 if let Ok(font_out) = std::process::Command::new("gsettings")
1933 .args(["get", "org.gnome.Ptyxis", "font-name"])
1934 .output()
1935 {
1936 if font_out.status.success() {
1937 let mut font_str =
1938 String::from_utf8_lossy(&font_out.stdout).trim().to_string();
1939 font_str = font_str.trim_matches('\'').to_string();
1940 if !font_str.is_empty() {
1941 if let Some(last_space) = font_str.rfind(' ') {
1942 let family = &font_str[..last_space];
1943 let size = &font_str[last_space + 1..];
1944 if size.chars().all(|c| c.is_ascii_digit() || c == '.') {
1945 return Some(format!("{} ({})", family, size));
1946 }
1947 }
1948 return Some(font_str);
1949 }
1950 }
1951 }
1952 } else {
1953 if let Ok(font_out) = std::process::Command::new("gsettings")
1954 .args(["get", "org.gnome.desktop.interface", "monospace-font-name"])
1955 .output()
1956 {
1957 if font_out.status.success() {
1958 let mut font_str =
1959 String::from_utf8_lossy(&font_out.stdout).trim().to_string();
1960 font_str = font_str.trim_matches('\'').to_string();
1961 if !font_str.is_empty() {
1962 if let Some(last_space) = font_str.rfind(' ') {
1963 let family = &font_str[..last_space];
1964 let size = &font_str[last_space + 1..];
1965 if size.chars().all(|c| c.is_ascii_digit() || c == '.') {
1966 return Some(format!("{} ({})", family, size));
1967 }
1968 }
1969 return Some(font_str);
1970 }
1971 }
1972 }
1973 return get_default_monospace_font();
1974 }
1975 }
1976 }
1977 } else if term_lower.contains("konsole") {
1978 let rc_path = home.join(".config/konsolerc");
1979 let mut profile_name = "Default.profile".to_string();
1980 if let Ok(content) = std::fs::read_to_string(&rc_path) {
1981 for line in content.lines() {
1982 let line = line.trim();
1983 if line.starts_with("DefaultProfile=") {
1984 profile_name = line.trim_start_matches("DefaultProfile=").to_string();
1985 break;
1986 }
1987 }
1988 }
1989 let profile_path = home.join(".local/share/konsole").join(profile_name);
1990 if let Ok(content) = std::fs::read_to_string(&profile_path) {
1991 for line in content.lines() {
1992 let line = line.trim();
1993 if line.starts_with("Font=") {
1994 let val = line.trim_start_matches("Font=");
1995 let parts: Vec<&str> = val.split(',').collect();
1996 if !parts.is_empty() {
1997 let family = parts[0];
1998 if parts.len() > 1 {
1999 let size = parts[1];
2000 return Some(format!("{} ({})", family, size));
2001 }
2002 return Some(family.to_string());
2003 }
2004 }
2005 }
2006 }
2007 return get_default_monospace_font();
2008 }
2009
2010 #[cfg(target_os = "macos")]
2011 if term_lower == "iterm.app" || term_lower.contains("iterm2") {
2012 if let Ok(output) = std::process::Command::new("defaults")
2013 .args(["read", "com.googlecode.iterm2", "Normal Font"])
2014 .output()
2015 {
2016 if let Ok(s) = String::from_utf8(output.stdout) {
2017 let font = s.trim();
2018 if !font.is_empty() {
2019 return Some(font.to_string());
2020 }
2021 }
2022 }
2023 }
2024
2025 None
2026}
2027
2028#[cfg(test)]
2029mod tests {
2030 use super::*;
2031
2032 #[test]
2033 fn test_parse_shell_version() {
2034 let bash_out = "GNU bash, version 5.2.15(1)-release (x86_64-pc-linux-gnu)\nCopyright (C) 2022 Free Software Foundation, Inc.";
2036 assert_eq!(
2037 parse_shell_version("bash", bash_out),
2038 Some("5.2.15".to_string())
2039 );
2040
2041 let zsh_out = "zsh 5.9 (x86_64-pc-linux-gnu)";
2043 assert_eq!(parse_shell_version("zsh", zsh_out), Some("5.9".to_string()));
2044
2045 let fish_out = "fish, version 3.6.0";
2047 assert_eq!(
2048 parse_shell_version("fish", fish_out),
2049 Some("3.6.0".to_string())
2050 );
2051
2052 let nu_out = "0.93.0";
2054 assert_eq!(
2055 parse_shell_version("nu", nu_out),
2056 Some("0.93.0".to_string())
2057 );
2058
2059 let pwsh_out = "PowerShell 7.4.1";
2061 assert_eq!(
2062 parse_shell_version("pwsh", pwsh_out),
2063 Some("7.4.1".to_string())
2064 );
2065
2066 let powershell_out = "5.1.22621.2428";
2068 assert_eq!(
2069 parse_shell_version("powershell", powershell_out),
2070 Some("5.1.22621.2428".to_string())
2071 );
2072
2073 let elvish_out = "0.20.1";
2075 assert_eq!(
2076 parse_shell_version("elvish", elvish_out),
2077 Some("0.20.1".to_string())
2078 );
2079
2080 let tcsh_out = "tcsh 6.24.10 (Astron) 2023-04-20 (x86_64-amd-linux) options wide,nls,dl,al,kan,sm,color,filec";
2082 assert_eq!(
2083 parse_shell_version("tcsh", tcsh_out),
2084 Some("6.24.10".to_string())
2085 );
2086
2087 let custom_out = "CustomShell version 1.2.3-patch4";
2089 assert_eq!(
2090 parse_shell_version("custom", custom_out),
2091 Some("1.2.3".to_string())
2092 );
2093 }
2094
2095 #[test]
2096 fn test_is_real_camera() {
2097 assert!(!is_real_camera("Infrared Camera"));
2098 assert!(!is_real_camera("IR Camera"));
2099 assert!(!is_real_camera("Integrated IR Camera"));
2100 assert!(!is_real_camera("Depth Camera"));
2101 assert!(is_real_camera("FaceTime HD Camera"));
2102 assert!(is_real_camera("Integrated Camera"));
2103 assert!(is_real_camera("HD Webcam C920"));
2104 }
2105
2106 #[test]
2107 fn test_clean_camera_name() {
2108 assert_eq!(
2109 clean_camera_name("Integrated Camera: Real"),
2110 "Integrated Camera"
2111 );
2112 assert_eq!(
2113 clean_camera_name("Integrated Webcam: HD"),
2114 "Integrated Webcam"
2115 );
2116 assert_eq!(clean_camera_name(" HD Webcam C920 "), "HD Webcam C920");
2117 assert_eq!(
2118 clean_camera_name("FaceTime HD Camera"),
2119 "FaceTime HD Camera"
2120 );
2121 }
2122
2123 #[test]
2124 fn test_parse_macos_camera() {
2125 let sample = "Camera:\n\n FaceTime HD Camera:\n\n Model ID: UVC Camera VendorID_1452 ProductID_34068\n Unique ID: 0x8020000005ac8514\n";
2126 assert_eq!(
2127 parse_macos_camera(sample),
2128 vec!["FaceTime HD Camera".to_string()]
2129 );
2130 }
2131
2132 #[test]
2133 fn test_parse_macos_gamepad() {
2134 let usb_sample = "USB 3.1 Bus:\n\n Xbox Wireless Controller:\n\n Product ID: 0x02e0\n Vendor ID: 0x045e\n";
2135 let bt_sample = "Bluetooth:\n\n Devices (Connected):\n DualSense Wireless Controller:\n Address: AA-BB-CC\n Connected: Yes\n";
2136 let parsed = parse_macos_gamepad(usb_sample, bt_sample);
2137 assert_eq!(
2138 parsed,
2139 vec![
2140 "Xbox Wireless Controller".to_string(),
2141 "DualSense Wireless Controller".to_string()
2142 ]
2143 );
2144 }
2145}
2146
2147#[cfg(target_os = "windows")]
2148#[allow(clippy::upper_case_acronyms)]
2149mod win_reg {
2150 use std::ffi::OsStr;
2151 use std::os::windows::ffi::OsStrExt;
2152 use std::ptr;
2153
2154 type HKEY = *mut std::ffi::c_void;
2155 pub const HKEY_LOCAL_MACHINE: HKEY = 0x80000002 as HKEY;
2156 pub const HKEY_CURRENT_USER: HKEY = 0x80000001 as HKEY;
2157 const KEY_READ: u32 = 0x20019;
2158
2159 extern "system" {
2160 fn RegOpenKeyExW(
2161 hKey: HKEY,
2162 lpSubKey: *const u16,
2163 ulOptions: u32,
2164 samDesired: u32,
2165 phkResult: *mut HKEY,
2166 ) -> i32;
2167
2168 fn RegQueryValueExW(
2169 hKey: HKEY,
2170 lpValueName: *const u16,
2171 lpReserved: *mut u32,
2172 lpType: *mut u32,
2173 lpData: *mut u8,
2174 lpcbData: *mut u32,
2175 ) -> i32;
2176
2177 fn RegCloseKey(hKey: HKEY) -> i32;
2178 }
2179
2180 pub fn get_reg_string(hkey: HKEY, subkey: &str, value: &str) -> Option<String> {
2181 let subkey_w: Vec<u16> = OsStr::new(subkey).encode_wide().chain(Some(0)).collect();
2182 let value_w: Vec<u16> = OsStr::new(value).encode_wide().chain(Some(0)).collect();
2183 let mut hk: HKEY = ptr::null_mut();
2184
2185 let res = unsafe { RegOpenKeyExW(hkey, subkey_w.as_ptr(), 0, KEY_READ, &mut hk) };
2186 if res != 0 {
2187 return None;
2188 }
2189
2190 let mut size: u32 = 0;
2191 let mut ty: u32 = 0;
2192 unsafe {
2193 RegQueryValueExW(
2194 hk,
2195 value_w.as_ptr(),
2196 ptr::null_mut(),
2197 &mut ty,
2198 ptr::null_mut(),
2199 &mut size,
2200 );
2201 }
2202
2203 if size == 0 {
2204 unsafe {
2205 RegCloseKey(hk);
2206 }
2207 return None;
2208 }
2209
2210 let mut buf = vec![0u8; size as usize];
2211 let res = unsafe {
2212 RegQueryValueExW(
2213 hk,
2214 value_w.as_ptr(),
2215 ptr::null_mut(),
2216 &mut ty,
2217 buf.as_mut_ptr(),
2218 &mut size,
2219 )
2220 };
2221 unsafe {
2222 RegCloseKey(hk);
2223 }
2224
2225 if res == 0 {
2226 if ty == 1 || ty == 2 {
2227 let words = unsafe {
2229 std::slice::from_raw_parts(buf.as_ptr() as *const u16, buf.len() / 2)
2230 };
2231 let len = words.iter().position(|&x| x == 0).unwrap_or(words.len());
2232 String::from_utf16(&words[..len]).ok()
2233 } else {
2234 None
2235 }
2236 } else {
2237 None
2238 }
2239 }
2240
2241 pub fn get_reg_u32(hkey: HKEY, subkey: &str, value: &str) -> Option<u32> {
2242 let subkey_w: Vec<u16> = OsStr::new(subkey).encode_wide().chain(Some(0)).collect();
2243 let value_w: Vec<u16> = OsStr::new(value).encode_wide().chain(Some(0)).collect();
2244 let mut hk: HKEY = ptr::null_mut();
2245
2246 let res = unsafe { RegOpenKeyExW(hkey, subkey_w.as_ptr(), 0, KEY_READ, &mut hk) };
2247 if res != 0 {
2248 return None;
2249 }
2250
2251 let mut size: u32 = 4;
2252 let mut ty: u32 = 0;
2253 let mut val: u32 = 0;
2254 let res = unsafe {
2255 RegQueryValueExW(
2256 hk,
2257 value_w.as_ptr(),
2258 ptr::null_mut(),
2259 &mut ty,
2260 &mut val as *mut u32 as *mut u8,
2261 &mut size,
2262 )
2263 };
2264 unsafe {
2265 RegCloseKey(hk);
2266 }
2267
2268 if res == 0 && ty == 4 {
2269 Some(val)
2271 } else {
2272 None
2273 }
2274 }
2275}