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()
}