use csm_protocol::{Frame, MessageType, HardwareAsset, SoftwareAsset}; use std::time::Duration; use tokio::sync::mpsc::Sender; use tracing::{info, error}; pub async fn start_collecting(tx: Sender, device_uid: String) { let interval = Duration::from_secs(86400); if let Err(e) = collect_and_send(&tx, &device_uid).await { error!("Initial asset collection failed: {}", e); } loop { tokio::time::sleep(interval).await; if let Err(e) = collect_and_send(&tx, &device_uid).await { error!("Asset collection failed: {}", e); } } } async fn collect_and_send(tx: &Sender, device_uid: &str) -> anyhow::Result<()> { // Collect & send hardware let hardware = collect_hardware(device_uid)?; let frame = Frame::new_json(MessageType::AssetReport, &hardware)?; tx.send(frame).await.map_err(|e| anyhow::anyhow!("Channel send failed: {}", e))?; info!("Hardware asset report sent for {}", device_uid); // Collect & send software list let software_list = collect_software(device_uid); let sw_count = software_list.len(); for sw in &software_list { let frame = Frame::new_json(MessageType::SoftwareAssetReport, sw)?; tx.send(frame).await.map_err(|e| anyhow::anyhow!("Channel send failed: {}", e))?; } info!("Software asset reports sent ({} items) for {}", sw_count, device_uid); Ok(()) } fn collect_hardware(device_uid: &str) -> anyhow::Result { let mut sys = sysinfo::System::new_all(); sys.refresh_all(); // CPU let cpu_model = sys.cpus().first() .map(|c| c.brand().to_string()) .unwrap_or_else(|| "Unknown".to_string()); let cpu_cores = sys.cpus().len() as u32; // Memory let memory_total_mb = sys.total_memory() / 1024 / 1024; // Disk — pick the largest non-removable disk let disks = sysinfo::Disks::new_with_refreshed_list(); let (disk_model, disk_total_mb) = disks.iter() .max_by_key(|d| d.total_space()) .map(|d| { let name = d.name().to_string_lossy().to_string(); let total = d.total_space() / 1024 / 1024; (if name.is_empty() { "Unknown".to_string() } else { name }, total) }) .unwrap_or_else(|| ("Unknown".to_string(), 0)); // GPU, motherboard, serial — Windows-specific via PowerShell let (gpu_model, motherboard, serial_number) = collect_system_details(); Ok(HardwareAsset { device_uid: device_uid.to_string(), cpu_model, cpu_cores, memory_total_mb, disk_model, disk_total_mb, gpu_model, motherboard, serial_number, }) } #[cfg(target_os = "windows")] fn collect_system_details() -> (Option, Option, Option) { let gpu = powershell_first("Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Caption"); let mb_manufacturer = powershell_first("Get-CimInstance Win32_BaseBoard | Select-Object -ExpandProperty Manufacturer"); let mb_product = powershell_first("Get-CimInstance Win32_BaseBoard | Select-Object -ExpandProperty Product"); let motherboard = match (mb_manufacturer, mb_product) { (Some(m), Some(p)) => Some(format!("{} {}", m, p)), (single @ Some(_), _) => single, (_, single @ Some(_)) => single, _ => None, }; let serial_number = powershell_first("Get-CimInstance Win32_BIOS | Select-Object -ExpandProperty SerialNumber"); (gpu, motherboard, serial_number) } #[cfg(not(target_os = "windows"))] fn collect_system_details() -> (Option, Option, Option) { (None, None, None) } /// Run a PowerShell command and return the first non-empty line of output. #[cfg(target_os = "windows")] fn powershell_first(command: &str) -> Option { use std::process::Command; let output = Command::new("powershell") .args(["-NoProfile", "-NonInteractive", "-Command", &format!("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}", command)]) .output() .ok()?; let stdout = String::from_utf8_lossy(&output.stdout); stdout.lines() .map(|l| l.trim().to_string()) .find(|l| !l.is_empty()) } /// Collect installed software from Windows Registry via PowerShell. fn collect_software(device_uid: &str) -> Vec { #[cfg(target_os = "windows")] { collect_windows_software(device_uid) } #[cfg(not(target_os = "windows"))] { let _ = device_uid; Vec::new() } } #[cfg(target_os = "windows")] fn collect_windows_software(device_uid: &str) -> Vec { use std::process::Command; let ps_cmd = r#" $paths = @( "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" ) Get-ItemProperty $paths -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -and $_.SystemComponent -ne 1 -and $null -eq $_.ParentDisplayName } | Select-Object DisplayName, DisplayVersion, Publisher, InstallDate, InstallLocation | ConvertTo-Json -Compress "#; let output = match Command::new("powershell") .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd]) .output() { Ok(o) => o, Err(_) => return Vec::new(), }; let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); if stdout.is_empty() { return Vec::new(); } // Single item -> object, multiple -> array let items: Vec = if stdout.starts_with('[') { serde_json::from_str(&stdout).unwrap_or_default() } else { serde_json::from_str::(&stdout) .map(|v| vec![v]) .unwrap_or_default() }; items.iter().filter_map(|item| { let name = item.get("DisplayName")?.as_str()?.to_string(); if name.is_empty() { return None; } Some(SoftwareAsset { device_uid: device_uid.to_string(), name, version: item.get("DisplayVersion").and_then(|v| v.as_str()).map(String::from), publisher: item.get("Publisher").and_then(|v| v.as_str()).map(String::from), install_date: item.get("InstallDate").and_then(|v| v.as_str()).map(String::from), install_path: item.get("InstallLocation").and_then(|v| v.as_str()).map(String::from), }) }).collect() }