diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..b6711c2f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,21 @@ +# Normalise line endings in the tree the CI actually builds. +# +# Five of the Rust sources were committed with CRLF and the other fifty with +# LF. Git then reported a whole-file rewrite whenever one of the five was +# touched from a checkout with different `core.autocrlf` settings — a 941-line +# diff on `firmware.rs` that was nine real lines. That noise is what hid the +# reverted CPU codename tables in 6b59f28: the real deletion was indistinguish- +# able from the line-ending churn around it. +# +# `text eol=lf` stores and checks out LF regardless of the contributor's +# `core.autocrlf`, so a diff means a change from here on. +*.rs text eol=lf +*.toml text eol=lf +*.yml text eol=lf +*.md text eol=lf + +# The C# tree predates this and is reference material the build never touches; +# leaving it alone keeps the normalisation commit reviewable. +*.cs -text +*.csproj -text +*.sln -text diff --git a/app/src/source/firmware.rs b/app/src/source/firmware.rs index 44d01193..a8dc1a48 100644 --- a/app/src/source/firmware.rs +++ b/app/src/source/firmware.rs @@ -1,473 +1,473 @@ -//! Firmware table dumps for the Hex Viewer — ACPI and SMBIOS. -//! -//! # Why these tables, and not PCI config space -//! -//! The obvious hex-viewer target is PCI configuration space, but reading it -//! means port I/O through a kernel driver — the same WinRing0 path that -//! Windows' vulnerable-driver blocklist blocks (see the Driver Management tab). -//! ACPI and SMBIOS come out of `GetSystemFirmwareTable`, a plain kernel32 call -//! that needs **no driver and no elevation**, and yields multi-kilobyte dumps -//! with real structure in them. So the viewer is useful today, on a machine -//! with no working Ring-0 driver at all. -//! -//! On Linux the same tables are files under `/sys/firmware/`, though reading -//! them usually requires root. -//! -//! Collected on the slow lane: firmware tables are fixed at boot. - -use crate::inventory::{Inventory, InventorySource}; -#[allow(unused_imports)] -use crate::model::hexblob::{HexBlob, HexRegion, HexSource, RegionKind}; - -/// Enumerates the machine's firmware tables once per slow-lane pass. -pub struct FirmwareTables; - -impl InventorySource for FirmwareTables { - fn name(&self) -> &'static str { - "firmware tables" - } - - fn collect(&mut self) -> Inventory { - Inventory { hex: read_all(), ..Default::default() } - } -} - -fn read_all() -> Vec { - #[allow(unused_mut)] - let mut out = Vec::new(); - #[cfg(windows)] - { - out.extend(windows_impl::acpi_tables()); - out.extend(windows_impl::smbios()); - } - #[cfg(target_os = "linux")] - { - out.extend(linux_impl::acpi_tables()); - } - out -} - -// --------------------------------------------------------------------------- -// Structure annotation -// --------------------------------------------------------------------------- - -/// Length of the standard ACPI System Description Table header. -#[allow(dead_code)] -const ACPI_HEADER_LEN: usize = 36; - -/// Label the fields of the ACPI header (ACPI spec §21.2.1), so the viewer can -/// tint them and name whatever is under the cursor. -#[allow(dead_code)] -pub fn acpi_header_regions(bytes: &[u8]) -> Vec { - if bytes.len() < ACPI_HEADER_LEN { - return Vec::new(); - } - let r = |start: usize, len: usize, label: &str, kind: RegionKind| HexRegion { - start, - len, - label: label.to_string(), - kind, - }; - let mut regions = vec![ - r(0, ACPI_HEADER_LEN, "ACPI header", RegionKind::Payload), - r(0, 4, "Signature", RegionKind::Identity), - r(4, 4, "Length", RegionKind::Length), - r(8, 1, "Revision", RegionKind::Checksum), - r(9, 1, "Checksum", RegionKind::Checksum), - r(10, 6, "OEM ID", RegionKind::Identity), - r(16, 8, "OEM Table ID", RegionKind::Identity), - r(24, 4, "OEM Revision", RegionKind::Checksum), - r(28, 4, "Creator ID", RegionKind::Identity), - r(32, 4, "Creator Revision", RegionKind::Checksum), - ]; - if bytes.len() > ACPI_HEADER_LEN { - regions.push(r( - ACPI_HEADER_LEN, - bytes.len() - ACPI_HEADER_LEN, - "Table data", - RegionKind::Payload, - )); - } - regions -} - -/// The 8-byte `RawSMBIOSData` header Windows prepends to the DMI blob. -#[allow(dead_code)] -pub fn smbios_header_regions(bytes: &[u8]) -> Vec { - if bytes.len() < 8 { - return Vec::new(); - } - let r = |start: usize, len: usize, label: &str, kind: RegionKind| HexRegion { - start, - len, - label: label.to_string(), - kind, - }; - let mut regions = vec![ - r(0, 1, "Used 2.0 calling method", RegionKind::Checksum), - r(1, 1, "SMBIOS major version", RegionKind::Identity), - r(2, 1, "SMBIOS minor version", RegionKind::Identity), - r(3, 1, "DMI revision", RegionKind::Checksum), - r(4, 4, "Table length", RegionKind::Length), - ]; - if bytes.len() > 8 { - regions.push(r(8, bytes.len() - 8, "DMI structure table", RegionKind::Payload)); - } - regions -} - -/// ACPI signature/OEM fields are fixed-length ASCII; render them readably and -/// fall back to hex for the (malformed) non-printable case. -#[allow(dead_code)] -pub fn ascii_tag(bytes: &[u8]) -> String { - if bytes.iter().all(|&b| (0x20..0x7f).contains(&b)) { - String::from_utf8_lossy(bytes).trim_end().to_string() - } else { - bytes.iter().map(|b| format!("{b:02X}")).collect() - } -} - -// --------------------------------------------------------------------------- -// Windows -// --------------------------------------------------------------------------- - -#[cfg(windows)] -mod windows_impl { - use super::*; - - #[link(name = "kernel32")] - extern "system" { - fn EnumSystemFirmwareTables(provider: u32, buffer: *mut u8, size: u32) -> u32; - fn GetSystemFirmwareTable(provider: u32, table: u32, buffer: *mut u8, size: u32) -> u32; - } - - /// Provider signatures are the 4-character code packed **big-endian**: - /// 'ACPI' is 0x41435049, not the little-endian 0x49504341 you get from - /// reinterpreting the bytes. Getting this backwards is the classic way to - /// have every call return 0 with no error. - const fn provider(tag: &[u8; 4]) -> u32 { - u32::from_be_bytes(*tag) - } - - const ACPI: u32 = provider(b"ACPI"); - const RSMB: u32 = provider(b"RSMB"); - - /// Refuse absurd allocations if the API ever reports a nonsense size. - const MAX_TABLE_BYTES: u32 = 16 * 1024 * 1024; - - fn read_table(provider_sig: u32, table_id: u32) -> Option> { - unsafe { - // First call with a null buffer asks for the required size. - let size = GetSystemFirmwareTable(provider_sig, table_id, std::ptr::null_mut(), 0); - if size == 0 || size > MAX_TABLE_BYTES { - return None; - } - let mut buf = vec![0u8; size as usize]; - let written = GetSystemFirmwareTable(provider_sig, table_id, buf.as_mut_ptr(), size); - if written == 0 { - return None; - } - // A second call can legitimately return less than the probe did. - buf.truncate(written.min(size) as usize); - Some(buf) - } - } - - /// Every ACPI table the firmware published. - pub fn acpi_tables() -> Vec { - let ids = unsafe { - let size = EnumSystemFirmwareTables(ACPI, std::ptr::null_mut(), 0); - if size == 0 || size > MAX_TABLE_BYTES { - return Vec::new(); - } - let mut buf = vec![0u8; size as usize]; - let written = EnumSystemFirmwareTables(ACPI, buf.as_mut_ptr(), size); - if written == 0 { - return Vec::new(); - } - buf.truncate(written.min(size) as usize); - // The enumeration is an array of table IDs (4-byte signatures). - // `as_chunks` rather than `chunks_exact`: it yields `&[u8; 4]`, - // so the signature converts without re-indexing, and clippy's - // `chunks_exact_to_as_chunks` (new in Rust 1.98) asks for it. - buf.as_chunks::<4>() - .0 - .iter() - .map(|c| u32::from_ne_bytes(*c)) - .collect::>() - }; - - // Firmware typically declares a dozen-plus SSDTs, but this API keys - // tables by *signature* — asking for 'SSDT' fifteen times returns the - // same bytes fifteen times. Read each signature once and report how - // many the firmware declared, rather than listing identical copies as - // if they were distinct tables. - let mut declared: std::collections::HashMap = Default::default(); - let mut order: Vec = Vec::new(); - for id in ids { - let seen_before = declared.entry(id).or_insert(0); - if *seen_before == 0 { - order.push(id); - } - *seen_before += 1; - } - - let mut out = Vec::new(); - for id in order { - let Some(bytes) = read_table(ACPI, id) else { continue }; - // The table's own header carries its signature — more trustworthy - // than re-deriving it from the enumerated DWORD's byte order. - let signature = if bytes.len() >= 4 { - ascii_tag(&bytes[..4]) - } else { - ascii_tag(&id.to_ne_bytes()) - }; - let regions = acpi_header_regions(&bytes); - out.push( - HexBlob::new( - HexSource::AcpiTable { - signature, - index: 0, - of: declared.get(&id).copied().unwrap_or(1), - }, - 0, - bytes, - ) - .with_regions(regions), - ); - } - out - } - - /// The raw SMBIOS/DMI table. - pub fn smbios() -> Vec { - let Some(bytes) = read_table(RSMB, 0) else { return Vec::new() }; - let version = if bytes.len() >= 3 { - format!("{}.{}", bytes[1], bytes[2]) - } else { - "?".into() - }; - let regions = smbios_header_regions(&bytes); - vec![HexBlob::new(HexSource::Smbios { version }, 0, bytes).with_regions(regions)] - } -} - -// --------------------------------------------------------------------------- -// Linux -// --------------------------------------------------------------------------- - -#[cfg(target_os = "linux")] -mod linux_impl { - use super::*; - - /// `/sys/firmware/acpi/tables/*` — one file per table. Readable only by - /// root on most distributions, so an empty result here is normal and not - /// worth surfacing as an error. - pub fn acpi_tables() -> Vec { - let Ok(dir) = std::fs::read_dir("/sys/firmware/acpi/tables") else { - return Vec::new(); - }; - let mut entries: Vec<_> = dir - .flatten() - .filter(|e| e.path().is_file()) - .map(|e| e.path()) - .collect(); - entries.sort(); - - // Unlike the Windows API, sysfs exposes each duplicate SSDT as its own - // file with its own contents, so index and count are both real here. - let mut tables: Vec<(String, Vec)> = Vec::new(); - for path in entries { - let Ok(bytes) = std::fs::read(&path) else { continue }; - if bytes.is_empty() { - continue; - } - // Prefer the in-table signature; the filename encodes the duplicate - // index (SSDT1, SSDT2, …) which we recompute anyway. - let signature = if bytes.len() >= 4 { - ascii_tag(&bytes[..4]) - } else { - path.file_name().unwrap_or_default().to_string_lossy().to_string() - }; - tables.push((signature, bytes)); - } - - let mut totals: std::collections::HashMap = Default::default(); - for (sig, _) in &tables { - *totals.entry(sig.clone()).or_insert(0) += 1; - } - - let mut seen: std::collections::HashMap = Default::default(); - let mut out = Vec::new(); - for (signature, bytes) in tables { - let index = seen.entry(signature.clone()).or_insert(0); - let of = totals.get(&signature).copied().unwrap_or(1); - let regions = acpi_header_regions(&bytes); - out.push( - HexBlob::new( - HexSource::AcpiTable { signature: signature.clone(), index: *index, of }, - 0, - bytes, - ) - .with_regions(regions), - ); - *index += 1; - } - out - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A minimal but well-formed ACPI table: header + 4 bytes of payload. - fn acpi_fixture() -> Vec { - let mut b = Vec::new(); - b.extend_from_slice(b"DSDT"); // signature - b.extend_from_slice(&40u32.to_le_bytes()); // length - b.push(2); // revision - b.push(0x5A); // checksum - b.extend_from_slice(b"ALASKA"); // OEM ID (6) - b.extend_from_slice(b"A M I \0"); // OEM table ID (8, incl. the NUL) - b.push(0); - b.extend_from_slice(&1u32.to_le_bytes()); // OEM revision - b.extend_from_slice(b"INTL"); // creator ID - b.extend_from_slice(&0x2020_0110u32.to_le_bytes()); // creator revision - b.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); // payload - assert_eq!(b.len(), 40); - b - } - - #[test] - fn acpi_header_fields_land_at_spec_offsets() { - let bytes = acpi_fixture(); - let blob = HexBlob::new( - HexSource::AcpiTable { signature: "DSDT".into(), index: 0, of: 1 }, - 0, - bytes.clone(), - ) - .with_regions(acpi_header_regions(&bytes)); - - assert_eq!(blob.region_at(0).unwrap().label, "Signature"); - assert_eq!(blob.region_at(4).unwrap().label, "Length"); - assert_eq!(blob.region_at(9).unwrap().label, "Checksum"); - assert_eq!(blob.region_at(10).unwrap().label, "OEM ID"); - assert_eq!(blob.region_at(32).unwrap().label, "Creator Revision"); - // Past the 36-byte header the payload region takes over. - assert_eq!(blob.region_at(36).unwrap().label, "Table data"); - } - - #[test] - fn a_truncated_table_gets_no_annotations_rather_than_wrong_ones() { - // Better to show plain hex than to label fields that aren't there. - assert!(acpi_header_regions(&[0u8; 10]).is_empty()); - assert!(smbios_header_regions(&[0u8; 4]).is_empty()); - // A header with no payload has no "Table data" region. - let header_only = acpi_header_regions(&[0u8; ACPI_HEADER_LEN]); - assert!(!header_only.iter().any(|r| r.label == "Table data")); - } - - #[test] - fn ascii_tags_fall_back_to_hex_when_not_printable() { - assert_eq!(ascii_tag(b"DSDT"), "DSDT"); - assert_eq!(ascii_tag(b"A M I "), "A M I"); - assert_eq!(ascii_tag(&[0x00, 0xFF]), "00FF"); - } - - #[cfg(windows)] - #[test] - fn provider_signature_is_packed_big_endian() { - // 'ACPI' == 0x41435049. The little-endian packing (0x49504341) is the - // classic mistake and makes every call silently return zero. - assert_eq!(u32::from_be_bytes(*b"ACPI"), 0x4143_5049); - assert_eq!(u32::from_be_bytes(*b"RSMB"), 0x5253_4D42); - } - - /// Exercises the real firmware on this machine when tests run on Windows. - /// Asserts only what must hold on any conforming system, so it stays green - /// on CI runners and inside VMs. - #[cfg(windows)] - #[test] - fn reads_real_acpi_tables() { - let blobs = windows_impl::acpi_tables(); - assert!(!blobs.is_empty(), "every x86 Windows machine publishes ACPI tables"); - - for b in &blobs { - // Each table's declared length must agree with what we read. - assert!(b.bytes.len() >= 4, "table too short to hold a signature"); - if b.bytes.len() >= 8 { - let declared = - u32::from_le_bytes([b.bytes[4], b.bytes[5], b.bytes[6], b.bytes[7]]) as usize; - assert_eq!( - declared, - b.bytes.len(), - "{}: header length disagrees with the bytes returned", - b.source.label() - ); - } - } - // The FADT ('FACP') is mandatory on every ACPI system and is one of the - // few this API reliably exposes. Note the DSDT is deliberately *not* - // asserted: Windows does not publish it through EnumSystemFirmwareTables - // even though it exists — verified on this machine, which lists 29 - // tables without one. - assert!( - blobs.iter().any(|b| matches!( - &b.source, - HexSource::AcpiTable { signature, .. } if signature == "FACP" - )), - "FADT is mandatory; got {:?}", - blobs.iter().map(|b| b.source.label()).collect::>() - ); - } - - /// The trap this API sets: ACPI tables are keyed by *signature*, so asking - /// for 'SSDT' fifteen times returns the same bytes fifteen times. Listing - /// those as fifteen tables would be a straight-up lie about the hardware. - #[cfg(windows)] - #[test] - fn duplicate_signatures_are_collapsed_and_counted() { - let blobs = windows_impl::acpi_tables(); - - let mut labels: Vec<_> = blobs.iter().map(|b| b.source.label()).collect(); - let before = labels.len(); - labels.sort(); - labels.dedup(); - assert_eq!(before, labels.len(), "every entry must be distinct: {labels:?}"); - - // No two entries may carry identical bytes. - for (i, a) in blobs.iter().enumerate() { - for b in &blobs[i + 1..] { - assert_ne!( - a.bytes, b.bytes, - "{} and {} are byte-identical", - a.source.label(), - b.source.label() - ); - } - } - - // Where firmware declared duplicates, the count is surfaced rather than - // silently dropped. - if let Some(dup) = blobs.iter().find( - |b| matches!(&b.source, HexSource::AcpiTable { of, .. } if *of > 1), - ) { - assert!(dup.source.label().contains(" of "), "{}", dup.source.label()); - } - } -} - -#[cfg(all(windows, test))] -mod probe { - #[test] - #[ignore = "diagnostic: prints what this machine's firmware actually publishes"] - fn list_tables() { - for b in super::windows_impl::acpi_tables() { - println!("{:<20} {:>8} bytes", b.source.label(), b.bytes.len()); - } - for b in super::windows_impl::smbios() { - println!("{:<20} {:>8} bytes", b.source.label(), b.bytes.len()); - } - } -} +//! Firmware table dumps for the Hex Viewer — ACPI and SMBIOS. +//! +//! # Why these tables, and not PCI config space +//! +//! The obvious hex-viewer target is PCI configuration space, but reading it +//! means port I/O through a kernel driver — the same WinRing0 path that +//! Windows' vulnerable-driver blocklist blocks (see the Driver Management tab). +//! ACPI and SMBIOS come out of `GetSystemFirmwareTable`, a plain kernel32 call +//! that needs **no driver and no elevation**, and yields multi-kilobyte dumps +//! with real structure in them. So the viewer is useful today, on a machine +//! with no working Ring-0 driver at all. +//! +//! On Linux the same tables are files under `/sys/firmware/`, though reading +//! them usually requires root. +//! +//! Collected on the slow lane: firmware tables are fixed at boot. + +use crate::inventory::{Inventory, InventorySource}; +#[allow(unused_imports)] +use crate::model::hexblob::{HexBlob, HexRegion, HexSource, RegionKind}; + +/// Enumerates the machine's firmware tables once per slow-lane pass. +pub struct FirmwareTables; + +impl InventorySource for FirmwareTables { + fn name(&self) -> &'static str { + "firmware tables" + } + + fn collect(&mut self) -> Inventory { + Inventory { hex: read_all(), ..Default::default() } + } +} + +fn read_all() -> Vec { + #[allow(unused_mut)] + let mut out = Vec::new(); + #[cfg(windows)] + { + out.extend(windows_impl::acpi_tables()); + out.extend(windows_impl::smbios()); + } + #[cfg(target_os = "linux")] + { + out.extend(linux_impl::acpi_tables()); + } + out +} + +// --------------------------------------------------------------------------- +// Structure annotation +// --------------------------------------------------------------------------- + +/// Length of the standard ACPI System Description Table header. +#[allow(dead_code)] +const ACPI_HEADER_LEN: usize = 36; + +/// Label the fields of the ACPI header (ACPI spec §21.2.1), so the viewer can +/// tint them and name whatever is under the cursor. +#[allow(dead_code)] +pub fn acpi_header_regions(bytes: &[u8]) -> Vec { + if bytes.len() < ACPI_HEADER_LEN { + return Vec::new(); + } + let r = |start: usize, len: usize, label: &str, kind: RegionKind| HexRegion { + start, + len, + label: label.to_string(), + kind, + }; + let mut regions = vec![ + r(0, ACPI_HEADER_LEN, "ACPI header", RegionKind::Payload), + r(0, 4, "Signature", RegionKind::Identity), + r(4, 4, "Length", RegionKind::Length), + r(8, 1, "Revision", RegionKind::Checksum), + r(9, 1, "Checksum", RegionKind::Checksum), + r(10, 6, "OEM ID", RegionKind::Identity), + r(16, 8, "OEM Table ID", RegionKind::Identity), + r(24, 4, "OEM Revision", RegionKind::Checksum), + r(28, 4, "Creator ID", RegionKind::Identity), + r(32, 4, "Creator Revision", RegionKind::Checksum), + ]; + if bytes.len() > ACPI_HEADER_LEN { + regions.push(r( + ACPI_HEADER_LEN, + bytes.len() - ACPI_HEADER_LEN, + "Table data", + RegionKind::Payload, + )); + } + regions +} + +/// The 8-byte `RawSMBIOSData` header Windows prepends to the DMI blob. +#[allow(dead_code)] +pub fn smbios_header_regions(bytes: &[u8]) -> Vec { + if bytes.len() < 8 { + return Vec::new(); + } + let r = |start: usize, len: usize, label: &str, kind: RegionKind| HexRegion { + start, + len, + label: label.to_string(), + kind, + }; + let mut regions = vec![ + r(0, 1, "Used 2.0 calling method", RegionKind::Checksum), + r(1, 1, "SMBIOS major version", RegionKind::Identity), + r(2, 1, "SMBIOS minor version", RegionKind::Identity), + r(3, 1, "DMI revision", RegionKind::Checksum), + r(4, 4, "Table length", RegionKind::Length), + ]; + if bytes.len() > 8 { + regions.push(r(8, bytes.len() - 8, "DMI structure table", RegionKind::Payload)); + } + regions +} + +/// ACPI signature/OEM fields are fixed-length ASCII; render them readably and +/// fall back to hex for the (malformed) non-printable case. +#[allow(dead_code)] +pub fn ascii_tag(bytes: &[u8]) -> String { + if bytes.iter().all(|&b| (0x20..0x7f).contains(&b)) { + String::from_utf8_lossy(bytes).trim_end().to_string() + } else { + bytes.iter().map(|b| format!("{b:02X}")).collect() + } +} + +// --------------------------------------------------------------------------- +// Windows +// --------------------------------------------------------------------------- + +#[cfg(windows)] +mod windows_impl { + use super::*; + + #[link(name = "kernel32")] + extern "system" { + fn EnumSystemFirmwareTables(provider: u32, buffer: *mut u8, size: u32) -> u32; + fn GetSystemFirmwareTable(provider: u32, table: u32, buffer: *mut u8, size: u32) -> u32; + } + + /// Provider signatures are the 4-character code packed **big-endian**: + /// 'ACPI' is 0x41435049, not the little-endian 0x49504341 you get from + /// reinterpreting the bytes. Getting this backwards is the classic way to + /// have every call return 0 with no error. + const fn provider(tag: &[u8; 4]) -> u32 { + u32::from_be_bytes(*tag) + } + + const ACPI: u32 = provider(b"ACPI"); + const RSMB: u32 = provider(b"RSMB"); + + /// Refuse absurd allocations if the API ever reports a nonsense size. + const MAX_TABLE_BYTES: u32 = 16 * 1024 * 1024; + + fn read_table(provider_sig: u32, table_id: u32) -> Option> { + unsafe { + // First call with a null buffer asks for the required size. + let size = GetSystemFirmwareTable(provider_sig, table_id, std::ptr::null_mut(), 0); + if size == 0 || size > MAX_TABLE_BYTES { + return None; + } + let mut buf = vec![0u8; size as usize]; + let written = GetSystemFirmwareTable(provider_sig, table_id, buf.as_mut_ptr(), size); + if written == 0 { + return None; + } + // A second call can legitimately return less than the probe did. + buf.truncate(written.min(size) as usize); + Some(buf) + } + } + + /// Every ACPI table the firmware published. + pub fn acpi_tables() -> Vec { + let ids = unsafe { + let size = EnumSystemFirmwareTables(ACPI, std::ptr::null_mut(), 0); + if size == 0 || size > MAX_TABLE_BYTES { + return Vec::new(); + } + let mut buf = vec![0u8; size as usize]; + let written = EnumSystemFirmwareTables(ACPI, buf.as_mut_ptr(), size); + if written == 0 { + return Vec::new(); + } + buf.truncate(written.min(size) as usize); + // The enumeration is an array of table IDs (4-byte signatures). + // `as_chunks` rather than `chunks_exact`: it yields `&[u8; 4]`, + // so the signature converts without re-indexing, and clippy's + // `chunks_exact_to_as_chunks` (new in Rust 1.98) asks for it. + buf.as_chunks::<4>() + .0 + .iter() + .map(|c| u32::from_ne_bytes(*c)) + .collect::>() + }; + + // Firmware typically declares a dozen-plus SSDTs, but this API keys + // tables by *signature* — asking for 'SSDT' fifteen times returns the + // same bytes fifteen times. Read each signature once and report how + // many the firmware declared, rather than listing identical copies as + // if they were distinct tables. + let mut declared: std::collections::HashMap = Default::default(); + let mut order: Vec = Vec::new(); + for id in ids { + let seen_before = declared.entry(id).or_insert(0); + if *seen_before == 0 { + order.push(id); + } + *seen_before += 1; + } + + let mut out = Vec::new(); + for id in order { + let Some(bytes) = read_table(ACPI, id) else { continue }; + // The table's own header carries its signature — more trustworthy + // than re-deriving it from the enumerated DWORD's byte order. + let signature = if bytes.len() >= 4 { + ascii_tag(&bytes[..4]) + } else { + ascii_tag(&id.to_ne_bytes()) + }; + let regions = acpi_header_regions(&bytes); + out.push( + HexBlob::new( + HexSource::AcpiTable { + signature, + index: 0, + of: declared.get(&id).copied().unwrap_or(1), + }, + 0, + bytes, + ) + .with_regions(regions), + ); + } + out + } + + /// The raw SMBIOS/DMI table. + pub fn smbios() -> Vec { + let Some(bytes) = read_table(RSMB, 0) else { return Vec::new() }; + let version = if bytes.len() >= 3 { + format!("{}.{}", bytes[1], bytes[2]) + } else { + "?".into() + }; + let regions = smbios_header_regions(&bytes); + vec![HexBlob::new(HexSource::Smbios { version }, 0, bytes).with_regions(regions)] + } +} + +// --------------------------------------------------------------------------- +// Linux +// --------------------------------------------------------------------------- + +#[cfg(target_os = "linux")] +mod linux_impl { + use super::*; + + /// `/sys/firmware/acpi/tables/*` — one file per table. Readable only by + /// root on most distributions, so an empty result here is normal and not + /// worth surfacing as an error. + pub fn acpi_tables() -> Vec { + let Ok(dir) = std::fs::read_dir("/sys/firmware/acpi/tables") else { + return Vec::new(); + }; + let mut entries: Vec<_> = dir + .flatten() + .filter(|e| e.path().is_file()) + .map(|e| e.path()) + .collect(); + entries.sort(); + + // Unlike the Windows API, sysfs exposes each duplicate SSDT as its own + // file with its own contents, so index and count are both real here. + let mut tables: Vec<(String, Vec)> = Vec::new(); + for path in entries { + let Ok(bytes) = std::fs::read(&path) else { continue }; + if bytes.is_empty() { + continue; + } + // Prefer the in-table signature; the filename encodes the duplicate + // index (SSDT1, SSDT2, …) which we recompute anyway. + let signature = if bytes.len() >= 4 { + ascii_tag(&bytes[..4]) + } else { + path.file_name().unwrap_or_default().to_string_lossy().to_string() + }; + tables.push((signature, bytes)); + } + + let mut totals: std::collections::HashMap = Default::default(); + for (sig, _) in &tables { + *totals.entry(sig.clone()).or_insert(0) += 1; + } + + let mut seen: std::collections::HashMap = Default::default(); + let mut out = Vec::new(); + for (signature, bytes) in tables { + let index = seen.entry(signature.clone()).or_insert(0); + let of = totals.get(&signature).copied().unwrap_or(1); + let regions = acpi_header_regions(&bytes); + out.push( + HexBlob::new( + HexSource::AcpiTable { signature: signature.clone(), index: *index, of }, + 0, + bytes, + ) + .with_regions(regions), + ); + *index += 1; + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal but well-formed ACPI table: header + 4 bytes of payload. + fn acpi_fixture() -> Vec { + let mut b = Vec::new(); + b.extend_from_slice(b"DSDT"); // signature + b.extend_from_slice(&40u32.to_le_bytes()); // length + b.push(2); // revision + b.push(0x5A); // checksum + b.extend_from_slice(b"ALASKA"); // OEM ID (6) + b.extend_from_slice(b"A M I \0"); // OEM table ID (8, incl. the NUL) + b.push(0); + b.extend_from_slice(&1u32.to_le_bytes()); // OEM revision + b.extend_from_slice(b"INTL"); // creator ID + b.extend_from_slice(&0x2020_0110u32.to_le_bytes()); // creator revision + b.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); // payload + assert_eq!(b.len(), 40); + b + } + + #[test] + fn acpi_header_fields_land_at_spec_offsets() { + let bytes = acpi_fixture(); + let blob = HexBlob::new( + HexSource::AcpiTable { signature: "DSDT".into(), index: 0, of: 1 }, + 0, + bytes.clone(), + ) + .with_regions(acpi_header_regions(&bytes)); + + assert_eq!(blob.region_at(0).unwrap().label, "Signature"); + assert_eq!(blob.region_at(4).unwrap().label, "Length"); + assert_eq!(blob.region_at(9).unwrap().label, "Checksum"); + assert_eq!(blob.region_at(10).unwrap().label, "OEM ID"); + assert_eq!(blob.region_at(32).unwrap().label, "Creator Revision"); + // Past the 36-byte header the payload region takes over. + assert_eq!(blob.region_at(36).unwrap().label, "Table data"); + } + + #[test] + fn a_truncated_table_gets_no_annotations_rather_than_wrong_ones() { + // Better to show plain hex than to label fields that aren't there. + assert!(acpi_header_regions(&[0u8; 10]).is_empty()); + assert!(smbios_header_regions(&[0u8; 4]).is_empty()); + // A header with no payload has no "Table data" region. + let header_only = acpi_header_regions(&[0u8; ACPI_HEADER_LEN]); + assert!(!header_only.iter().any(|r| r.label == "Table data")); + } + + #[test] + fn ascii_tags_fall_back_to_hex_when_not_printable() { + assert_eq!(ascii_tag(b"DSDT"), "DSDT"); + assert_eq!(ascii_tag(b"A M I "), "A M I"); + assert_eq!(ascii_tag(&[0x00, 0xFF]), "00FF"); + } + + #[cfg(windows)] + #[test] + fn provider_signature_is_packed_big_endian() { + // 'ACPI' == 0x41435049. The little-endian packing (0x49504341) is the + // classic mistake and makes every call silently return zero. + assert_eq!(u32::from_be_bytes(*b"ACPI"), 0x4143_5049); + assert_eq!(u32::from_be_bytes(*b"RSMB"), 0x5253_4D42); + } + + /// Exercises the real firmware on this machine when tests run on Windows. + /// Asserts only what must hold on any conforming system, so it stays green + /// on CI runners and inside VMs. + #[cfg(windows)] + #[test] + fn reads_real_acpi_tables() { + let blobs = windows_impl::acpi_tables(); + assert!(!blobs.is_empty(), "every x86 Windows machine publishes ACPI tables"); + + for b in &blobs { + // Each table's declared length must agree with what we read. + assert!(b.bytes.len() >= 4, "table too short to hold a signature"); + if b.bytes.len() >= 8 { + let declared = + u32::from_le_bytes([b.bytes[4], b.bytes[5], b.bytes[6], b.bytes[7]]) as usize; + assert_eq!( + declared, + b.bytes.len(), + "{}: header length disagrees with the bytes returned", + b.source.label() + ); + } + } + // The FADT ('FACP') is mandatory on every ACPI system and is one of the + // few this API reliably exposes. Note the DSDT is deliberately *not* + // asserted: Windows does not publish it through EnumSystemFirmwareTables + // even though it exists — verified on this machine, which lists 29 + // tables without one. + assert!( + blobs.iter().any(|b| matches!( + &b.source, + HexSource::AcpiTable { signature, .. } if signature == "FACP" + )), + "FADT is mandatory; got {:?}", + blobs.iter().map(|b| b.source.label()).collect::>() + ); + } + + /// The trap this API sets: ACPI tables are keyed by *signature*, so asking + /// for 'SSDT' fifteen times returns the same bytes fifteen times. Listing + /// those as fifteen tables would be a straight-up lie about the hardware. + #[cfg(windows)] + #[test] + fn duplicate_signatures_are_collapsed_and_counted() { + let blobs = windows_impl::acpi_tables(); + + let mut labels: Vec<_> = blobs.iter().map(|b| b.source.label()).collect(); + let before = labels.len(); + labels.sort(); + labels.dedup(); + assert_eq!(before, labels.len(), "every entry must be distinct: {labels:?}"); + + // No two entries may carry identical bytes. + for (i, a) in blobs.iter().enumerate() { + for b in &blobs[i + 1..] { + assert_ne!( + a.bytes, b.bytes, + "{} and {} are byte-identical", + a.source.label(), + b.source.label() + ); + } + } + + // Where firmware declared duplicates, the count is surfaced rather than + // silently dropped. + if let Some(dup) = blobs.iter().find( + |b| matches!(&b.source, HexSource::AcpiTable { of, .. } if *of > 1), + ) { + assert!(dup.source.label().contains(" of "), "{}", dup.source.label()); + } + } +} + +#[cfg(all(windows, test))] +mod probe { + #[test] + #[ignore = "diagnostic: prints what this machine's firmware actually publishes"] + fn list_tables() { + for b in super::windows_impl::acpi_tables() { + println!("{:<20} {:>8} bytes", b.source.label(), b.bytes.len()); + } + for b in super::windows_impl::smbios() { + println!("{:<20} {:>8} bytes", b.source.label(), b.bytes.len()); + } + } +} diff --git a/app/src/source/macos/dvfs.rs b/app/src/source/macos/dvfs.rs index b35d51d0..8613e2b1 100644 --- a/app/src/source/macos/dvfs.rs +++ b/app/src/source/macos/dvfs.rs @@ -1,177 +1,177 @@ -//! DVFS (frequency/voltage) tables from the SoC power manager. -//! -//! Apple Silicon does not expose a "current MHz" register. Frequency has to be -//! reconstructed: the `pmgr` device-tree node lists the discrete performance -//! states each block can run at, and IOReport reports how long the block spent -//! in each one (see [`super::ioreport`]). Multiplying the two gives an -//! effective clock — the same thing `powermetrics` prints. -//! -//! The tables are packed arrays of `(frequency, voltage)` `u32` pairs. Units -//! are **not** consistent between blocks on the same machine: on this M5 the -//! CPU tables are in kHz (max 4,464,000 = 4464 MHz) while the GPU table is in -//! Hz (max 1,578,000,000 = 1578 MHz), so the scale is detected from the -//! magnitude rather than assumed. - -use super::iokit; - -/// Device-tree path to the power manager. -const PMGR_PATH: &str = "IODeviceTree:/arm-io/pmgr"; - -/// Which block's performance-state table to read. -/// -/// The `-sram` variants are used for the CPU clusters because the plain -/// `voltage-states1`/`5` entries describe a different rail; the SRAM tables are -/// the ones whose frequencies match the cores. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Block { - /// Efficiency cluster. - Ecpu, - /// Performance cluster. - Pcpu, - Gpu, -} - -impl Block { - fn property(self) -> &'static str { - match self { - Block::Ecpu => "voltage-states1-sram", - Block::Pcpu => "voltage-states5-sram", - Block::Gpu => "voltage-states9", - } - } -} - -/// One DVFS performance state. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct State { - pub mhz: f32, - /// Rail voltage for this state, in volts. The tables pair every frequency - /// with the voltage needed to sustain it, which is where the CPU "VID" - /// reading comes from — there is no separate voltage sensor on Apple - /// Silicon. - pub volts: f32, -} - -/// Available frequencies for a block, in MHz, in performance-state order. -/// -/// Empty when the node or property is missing — every caller treats that as -/// "no frequency sensors for this block" rather than an error. -pub fn frequencies_mhz(block: Block) -> Vec { - states(block).into_iter().map(|s| s.mhz).collect() -} - -/// Full performance-state table (frequency + voltage). -pub fn states(block: Block) -> Vec { - let Some(entry) = iokit::entry_from_path(PMGR_PATH) else { - return Vec::new(); - }; - let Some(props) = iokit::properties(entry.0) else { - return Vec::new(); - }; - let Some(bytes) = iokit::dict_data(&props, block.property()) else { - return Vec::new(); - }; - parse_states(&bytes) -} - -/// Decode packed `(freq, voltage)` `u32` pairs. -fn parse_states(bytes: &[u8]) -> Vec { - // `as_chunks` rather than `chunks_exact` — same semantics (the trailing - // partial pair is dropped either way), but it yields a fixed-size array and - // satisfies clippy's `chunks_exact_to_as_chunks`, new in Rust 1.98. - let pairs: Vec<(u32, u32)> = bytes - .as_chunks::<8>() - .0 - .iter() - .map(|c| { - ( - u32::from_le_bytes([c[0], c[1], c[2], c[3]]), - u32::from_le_bytes([c[4], c[5], c[6], c[7]]), - ) - }) - .collect(); - if pairs.is_empty() { - return Vec::new(); - } - - // Detect the unit from the largest entry. No Apple SoC runs at 100 GHz, and - // none has a 100 MHz *maximum*, so this threshold separates Hz from kHz - // without needing a per-block table that would rot on the next chip. - let max = pairs.iter().map(|(f, _)| *f).max().unwrap_or(0) as f64; - let to_mhz: f64 = if max >= 100_000_000.0 { 1.0e6 } else { 1.0e3 }; - - pairs - .iter() - .map(|(freq, mv)| State { - mhz: (*freq as f64 / to_mhz) as f32, - // Voltages are millivolts (790 => 0.790 V). - volts: *mv as f32 / 1000.0, - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cpu_tables_are_plausible_and_ascending() { - for block in [Block::Ecpu, Block::Pcpu] { - let states = frequencies_mhz(block); - if states.is_empty() { - crate::source::macos::absent(&format!("{block:?} DVFS table")); - continue; - } - // Every Apple core sits between a few hundred MHz and ~6 GHz. A - // unit-scale mistake lands far outside this on either side. - for mhz in &states { - assert!( - (100.0..=6000.0).contains(mhz), - "{block:?} state {mhz} MHz implies the kHz/Hz scale was misread" - ); - } - let top = states.last().copied().unwrap(); - assert!(top >= 2000.0, "{block:?} top state {top} MHz is too low"); - } - } - - /// The GPU table is stored in Hz where the CPU tables are in kHz — this is - /// the case the magnitude heuristic exists for. - #[test] - fn gpu_table_uses_a_different_unit_but_still_decodes_to_mhz() { - let states = frequencies_mhz(Block::Gpu); - if states.is_empty() { - return crate::source::macos::absent("GPU DVFS table"); - } - let top = states.last().copied().unwrap(); - assert!( - (300.0..=4000.0).contains(&top), - "GPU top state {top} MHz implies the Hz/kHz scale was misread" - ); - } - - #[test] - fn scale_detection_handles_both_units() { - // kHz-encoded: 972 MHz and 4464 MHz. - let khz = [972_000u32, 790, 4_464_000, 980] - .iter() - .flat_map(|v| v.to_le_bytes()) - .collect::>(); - assert_eq!(parse_states(&khz).iter().map(|s| s.mhz).collect::>(), vec![972.0, 4464.0]); - assert_eq!(parse_states(&khz)[0].volts, 0.790); - - // Hz-encoded: 338 MHz and 1578 MHz. - let hz = [338_000_000u32, 500, 1_578_000_000, 900] - .iter() - .flat_map(|v| v.to_le_bytes()) - .collect::>(); - assert_eq!(parse_states(&hz).iter().map(|s| s.mhz).collect::>(), vec![338.0, 1578.0]); - } - - #[test] - fn truncated_table_does_not_panic() { - assert!(parse_states(&[]).is_empty()); - // Fewer than one full pair — chunks_exact drops the remainder. - assert!(parse_states(&[1, 2, 3]).is_empty()); - } -} +//! DVFS (frequency/voltage) tables from the SoC power manager. +//! +//! Apple Silicon does not expose a "current MHz" register. Frequency has to be +//! reconstructed: the `pmgr` device-tree node lists the discrete performance +//! states each block can run at, and IOReport reports how long the block spent +//! in each one (see [`super::ioreport`]). Multiplying the two gives an +//! effective clock — the same thing `powermetrics` prints. +//! +//! The tables are packed arrays of `(frequency, voltage)` `u32` pairs. Units +//! are **not** consistent between blocks on the same machine: on this M5 the +//! CPU tables are in kHz (max 4,464,000 = 4464 MHz) while the GPU table is in +//! Hz (max 1,578,000,000 = 1578 MHz), so the scale is detected from the +//! magnitude rather than assumed. + +use super::iokit; + +/// Device-tree path to the power manager. +const PMGR_PATH: &str = "IODeviceTree:/arm-io/pmgr"; + +/// Which block's performance-state table to read. +/// +/// The `-sram` variants are used for the CPU clusters because the plain +/// `voltage-states1`/`5` entries describe a different rail; the SRAM tables are +/// the ones whose frequencies match the cores. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Block { + /// Efficiency cluster. + Ecpu, + /// Performance cluster. + Pcpu, + Gpu, +} + +impl Block { + fn property(self) -> &'static str { + match self { + Block::Ecpu => "voltage-states1-sram", + Block::Pcpu => "voltage-states5-sram", + Block::Gpu => "voltage-states9", + } + } +} + +/// One DVFS performance state. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct State { + pub mhz: f32, + /// Rail voltage for this state, in volts. The tables pair every frequency + /// with the voltage needed to sustain it, which is where the CPU "VID" + /// reading comes from — there is no separate voltage sensor on Apple + /// Silicon. + pub volts: f32, +} + +/// Available frequencies for a block, in MHz, in performance-state order. +/// +/// Empty when the node or property is missing — every caller treats that as +/// "no frequency sensors for this block" rather than an error. +pub fn frequencies_mhz(block: Block) -> Vec { + states(block).into_iter().map(|s| s.mhz).collect() +} + +/// Full performance-state table (frequency + voltage). +pub fn states(block: Block) -> Vec { + let Some(entry) = iokit::entry_from_path(PMGR_PATH) else { + return Vec::new(); + }; + let Some(props) = iokit::properties(entry.0) else { + return Vec::new(); + }; + let Some(bytes) = iokit::dict_data(&props, block.property()) else { + return Vec::new(); + }; + parse_states(&bytes) +} + +/// Decode packed `(freq, voltage)` `u32` pairs. +fn parse_states(bytes: &[u8]) -> Vec { + // `as_chunks` rather than `chunks_exact` — same semantics (the trailing + // partial pair is dropped either way), but it yields a fixed-size array and + // satisfies clippy's `chunks_exact_to_as_chunks`, new in Rust 1.98. + let pairs: Vec<(u32, u32)> = bytes + .as_chunks::<8>() + .0 + .iter() + .map(|c| { + ( + u32::from_le_bytes([c[0], c[1], c[2], c[3]]), + u32::from_le_bytes([c[4], c[5], c[6], c[7]]), + ) + }) + .collect(); + if pairs.is_empty() { + return Vec::new(); + } + + // Detect the unit from the largest entry. No Apple SoC runs at 100 GHz, and + // none has a 100 MHz *maximum*, so this threshold separates Hz from kHz + // without needing a per-block table that would rot on the next chip. + let max = pairs.iter().map(|(f, _)| *f).max().unwrap_or(0) as f64; + let to_mhz: f64 = if max >= 100_000_000.0 { 1.0e6 } else { 1.0e3 }; + + pairs + .iter() + .map(|(freq, mv)| State { + mhz: (*freq as f64 / to_mhz) as f32, + // Voltages are millivolts (790 => 0.790 V). + volts: *mv as f32 / 1000.0, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cpu_tables_are_plausible_and_ascending() { + for block in [Block::Ecpu, Block::Pcpu] { + let states = frequencies_mhz(block); + if states.is_empty() { + crate::source::macos::absent(&format!("{block:?} DVFS table")); + continue; + } + // Every Apple core sits between a few hundred MHz and ~6 GHz. A + // unit-scale mistake lands far outside this on either side. + for mhz in &states { + assert!( + (100.0..=6000.0).contains(mhz), + "{block:?} state {mhz} MHz implies the kHz/Hz scale was misread" + ); + } + let top = states.last().copied().unwrap(); + assert!(top >= 2000.0, "{block:?} top state {top} MHz is too low"); + } + } + + /// The GPU table is stored in Hz where the CPU tables are in kHz — this is + /// the case the magnitude heuristic exists for. + #[test] + fn gpu_table_uses_a_different_unit_but_still_decodes_to_mhz() { + let states = frequencies_mhz(Block::Gpu); + if states.is_empty() { + return crate::source::macos::absent("GPU DVFS table"); + } + let top = states.last().copied().unwrap(); + assert!( + (300.0..=4000.0).contains(&top), + "GPU top state {top} MHz implies the Hz/kHz scale was misread" + ); + } + + #[test] + fn scale_detection_handles_both_units() { + // kHz-encoded: 972 MHz and 4464 MHz. + let khz = [972_000u32, 790, 4_464_000, 980] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(); + assert_eq!(parse_states(&khz).iter().map(|s| s.mhz).collect::>(), vec![972.0, 4464.0]); + assert_eq!(parse_states(&khz)[0].volts, 0.790); + + // Hz-encoded: 338 MHz and 1578 MHz. + let hz = [338_000_000u32, 500, 1_578_000_000, 900] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(); + assert_eq!(parse_states(&hz).iter().map(|s| s.mhz).collect::>(), vec![338.0, 1578.0]); + } + + #[test] + fn truncated_table_does_not_panic() { + assert!(parse_states(&[]).is_empty()); + // Fewer than one full pair — chunks_exact drops the remainder. + assert!(parse_states(&[1, 2, 3]).is_empty()); + } +} diff --git a/app/src/sysinfo.rs b/app/src/sysinfo.rs index 4e07a818..2b3a9d50 100644 --- a/app/src/sysinfo.rs +++ b/app/src/sysinfo.rs @@ -1,930 +1,1211 @@ -//! Static system information for the Main window tree and the System Summary. -//! -//! Queried once at startup on a background thread (WMI/COM on Windows; minimal -//! fallbacks elsewhere). Anything a source can't provide stays `None` and the -//! UI renders "—" — honest placeholders until the native engine (SMBus SPD, -//! CPUID, NVML/ADL) fills them in. - -use std::sync::{Arc, RwLock}; - -#[derive(Debug, Default, Clone, serde::Serialize)] -pub struct CpuInfo { - pub name: String, - pub cores: Option, - pub threads: Option, - pub base_clock_mhz: Option, - pub max_clock_mhz: Option, - pub l2_kb: Option, - pub l3_kb: Option, - pub socket: Option, - /// CPUID(1).EAX signature, HWiNFO-style hex (e.g. "00A60F12"). - pub cpuid: String, - /// Best-effort microarchitecture codename (e.g. "Raphael (Zen 4)"). - pub codename: String, - pub vendor: String, - /// ISA feature names detected at runtime (for the Summary features grid). - pub features: Vec<(&'static str, bool)>, -} - -/// Raw CPUID(1).EAX signature + vendor + codename, computed on x86_64. -fn cpuid_info() -> (String, String, String) { - #[cfg(target_arch = "x86_64")] - { - use core::arch::x86_64::__cpuid; - // __cpuid is safe on x86_64 (CPUID is always available). - let vendor_leaf = __cpuid(0); - let mut vbytes = Vec::new(); - vbytes.extend_from_slice(&vendor_leaf.ebx.to_le_bytes()); - vbytes.extend_from_slice(&vendor_leaf.edx.to_le_bytes()); - vbytes.extend_from_slice(&vendor_leaf.ecx.to_le_bytes()); - let vendor = String::from_utf8_lossy(&vbytes).to_string(); - - let leaf1 = __cpuid(1); - let eax = leaf1.eax; - let base_family = (eax >> 8) & 0xf; - let ext_family = (eax >> 20) & 0xff; - let family = if base_family == 0xf { base_family + ext_family } else { base_family }; - let base_model = (eax >> 4) & 0xf; - let ext_model = (eax >> 16) & 0xf; - let model = (ext_model << 4) | base_model; - - let codename = codename_for(&vendor, family, model); - (format!("{eax:08X}"), vendor, codename) - } - // Apple Silicon has no CPUID. The nearest equivalents are the board id - // (`hw.model`, e.g. "Mac17,3") and the SoC name from the brand string, so - // report those rather than leaving the Summary window blank. - #[cfg(all(target_arch = "aarch64", target_os = "macos"))] - { - let vendor = if sysctl_string("machdep.cpu.brand_string") - .is_some_and(|b| b.starts_with("Apple")) - { - "Apple".to_string() - } else { - String::new() - }; - (String::new(), vendor, sysctl_string("hw.model").unwrap_or_default()) - } - #[cfg(not(any(target_arch = "x86_64", all(target_arch = "aarch64", target_os = "macos"))))] - { - (String::new(), String::new(), String::new()) - } -} - -/// Coarse codename map for recent AMD/Intel desktop parts (best effort). -#[allow(dead_code)] -fn codename_for(vendor: &str, family: u32, model: u32) -> String { - if vendor.contains("AuthenticAMD") { - match (family, model) { - (0x19, 0x60..=0x6f) => "Raphael (Zen 4)", - (0x19, 0x70..=0x7f) => "Phoenix (Zen 4)", - (0x19, 0x40..=0x4f) => "Rembrandt (Zen 3+)", - (0x19, 0x20..=0x2f) => "Vermeer (Zen 3)", - (0x19, 0x50..=0x5f) => "Cezanne (Zen 3)", - (0x1a, _) => "Granite Ridge (Zen 5)", - (0x17, _) => "Matisse/Renoir (Zen 2)", - _ => "", - } - .to_string() - } else if vendor.contains("GenuineIntel") { - match family { - 0x6 => "Intel Core", - _ => "", - } - .to_string() - } else { - String::new() - } -} - -/// SMBIOS *Memory Device* (structure type 17) memory-type code → display name. -/// -/// Codes are the DMTF SMBIOS specification's, cross-checked against -/// dmidecode's `dmi_memory_device_type` table, which runs from `0x01` to -/// `0x24`. Every assigned code in that range is decoded; `0x15`–`0x17` are -/// Reserved rather than memory types, so they fall to the unknown path along -/// with anything DMTF has yet to assign. -/// -/// This used to decode exactly three values — DDR3, DDR4, DDR5 — and answer -/// `"DRAM"` for everything else. The Summary appends " SDRAM" to whatever it -/// gets, so the fallback rendered as the literal string "DRAM SDRAM". Every -/// soldered-memory machine reports an LPDDR code, so that was most current -/// laptop hardware. -/// -/// A code the table does not know is reported as `Unknown (type 0x??)` rather -/// than guessed at: the raw code is what lets someone look it up, and DMTF -/// assigns new ones as memory generations ship. -#[allow(dead_code)] // Only reachable from the Windows WMI path. -fn smbios_memory_type(code: u32) -> String { - let name = match code { - 0x01 | 0x02 => "Unknown", // "Other" and "Unknown" are both non-answers. - 0x03 => "DRAM", - 0x04 => "EDRAM", - 0x05 => "VRAM", - 0x06 => "SRAM", - 0x07 => "RAM", - 0x08 => "ROM", - 0x09 => "Flash", - 0x0A => "EEPROM", - 0x0B => "FEPROM", - 0x0C => "EPROM", - 0x0D => "CDRAM", - 0x0E => "3DRAM", - 0x0F => "SDRAM", - 0x10 => "SGRAM", - 0x11 => "RDRAM", - 0x12 => "DDR", - 0x13 => "DDR2", - 0x14 => "DDR2 FB-DIMM", - 0x18 => "DDR3", - 0x19 => "FBD2", - 0x1A => "DDR4", - 0x1B => "LPDDR", - 0x1C => "LPDDR2", - 0x1D => "LPDDR3", - 0x1E => "LPDDR4", - 0x1F => "Logical non-volatile device", - 0x20 => "HBM", - 0x21 => "HBM2", - 0x22 => "DDR5", - 0x23 => "LPDDR5", - 0x24 => "HBM3", - _ => return format!("Unknown (type {code:#04X})"), - }; - name.to_string() -} - -/// How the Summary labels a module: "DDR5" becomes "DDR5 SDRAM", but "HBM3" -/// and "Unknown" are left alone. -/// -/// The suffix used to be appended unconditionally, which is where "DRAM SDRAM" -/// came from. Only the DDR and LPDDR families are synchronous DRAM in the -/// sense that suffix means. -#[allow(dead_code)] // Only the GUI renders this; headless builds don't link it. -pub fn memory_type_label(memory_type: &str) -> String { - if memory_type.starts_with("DDR") || memory_type.starts_with("LPDDR") { - format!("{memory_type} SDRAM") - } else { - memory_type.to_string() - } -} - -#[derive(Debug, Default, Clone, serde::Serialize)] -pub struct BoardInfo { - pub product: String, - pub manufacturer: String, - pub bios_version: String, - pub bios_date: String, -} - -#[derive(Debug, Default, Clone, serde::Serialize)] -pub struct MemoryModule { - pub bank: String, - pub manufacturer: String, - pub part_number: String, - pub capacity_gb: f64, - pub speed_mts: Option, - pub configured_speed_mts: Option, - pub voltage_mv: Option, - pub memory_type: String, -} - -#[derive(Debug, Default, Clone, serde::Serialize)] -pub struct GpuInfo { - pub name: String, - /// WMI AdapterRAM (u32, capped at 4 GB) — kept for the native engine to - /// replace with NVML/ADL truth; not displayed while unreliable. - #[allow(dead_code)] - pub vram_gb: Option, - pub driver_version: String, -} - -#[derive(Debug, Default, Clone, serde::Serialize)] -pub struct DriveInfo { - pub model: String, - pub interface: String, - pub size_gb: Option, -} - -#[derive(Debug, Default, Clone, serde::Serialize)] -pub struct OsInfo { - pub caption: String, - pub build: String, - pub arch: String, - pub uefi_boot: Option, - pub secure_boot: Option, -} - -#[derive(Debug, Default, Clone, serde::Serialize)] -pub struct SystemInfo { - pub computer_name: String, - pub user_name: String, - pub cpu: CpuInfo, - pub board: BoardInfo, - pub memory_modules: Vec, - pub total_memory_gb: Option, - pub gpus: Vec, - pub drives: Vec, - pub os: OsInfo, -} - -/// Shared handle: `None` until the background query completes. -pub type SystemInfoHandle = Arc>>; - -/// Whether this process is running elevated (`Some(true/false)` on Windows, -/// `None` elsewhere). Reliable and independent of the sidecar — the sidecar is -/// our child, so it inherits our elevation. -// Surfaced as the GUI's "Running as Administrator" badge. -#[allow(dead_code)] -pub fn is_elevated() -> Option { - #[cfg(windows)] - { - #[repr(C)] - struct TokenElevation { - token_is_elevated: u32, - } - const TOKEN_QUERY: u32 = 0x0008; - const TOKEN_ELEVATION_CLASS: i32 = 20; // TokenElevation - - #[link(name = "advapi32")] - extern "system" { - fn OpenProcessToken(process: isize, desired: u32, handle: *mut isize) -> i32; - fn GetTokenInformation( - token: isize, - class: i32, - info: *mut core::ffi::c_void, - len: u32, - ret_len: *mut u32, - ) -> i32; - } - extern "system" { - fn GetCurrentProcess() -> isize; - fn CloseHandle(h: isize) -> i32; - } - - unsafe { - let mut token: isize = 0; - if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { - return None; - } - let mut elevation = TokenElevation { token_is_elevated: 0 }; - let mut ret_len = 0u32; - let ok = GetTokenInformation( - token, - TOKEN_ELEVATION_CLASS, - &mut elevation as *mut _ as *mut core::ffi::c_void, - core::mem::size_of::() as u32, - &mut ret_len, - ); - CloseHandle(token); - if ok == 0 { - None - } else { - Some(elevation.token_is_elevated != 0) - } - } - } - // Reported for the status badge only. Nothing on macOS *needs* root: the - // IOKit backend reads every sensor unprivileged, so no feature is gated on - // this (see ui/settings_dialog.rs). - #[cfg(target_os = "macos")] - { - Some(unsafe { libc::geteuid() } == 0) - } - #[cfg(not(any(windows, target_os = "macos")))] - { - None - } -} - -// ---- sysctl helpers (macOS) --------------------------------------------- - -/// Read a string-valued sysctl by name. `None` if the key doesn't exist — -/// keys come and go between macOS releases, so every caller must tolerate it. -#[cfg(target_os = "macos")] -pub(crate) fn sysctl_string(name: &str) -> Option { - let cname = std::ffi::CString::new(name).ok()?; - let mut len = 0usize; - // First call with a null buffer asks for the required size. - if unsafe { - libc::sysctlbyname(cname.as_ptr(), std::ptr::null_mut(), &mut len, std::ptr::null_mut(), 0) - } != 0 - || len == 0 - { - return None; - } - let mut buf = vec![0u8; len]; - if unsafe { - libc::sysctlbyname( - cname.as_ptr(), - buf.as_mut_ptr().cast(), - &mut len, - std::ptr::null_mut(), - 0, - ) - } != 0 - { - return None; - } - buf.truncate(len); - // sysctl strings are NUL-terminated; drop the terminator and anything after. - if let Some(nul) = buf.iter().position(|&b| b == 0) { - buf.truncate(nul); - } - let s = String::from_utf8_lossy(&buf).trim().to_string(); - (!s.is_empty()).then_some(s) -} - -/// Read an integer-valued sysctl. Handles both the 4-byte and 8-byte widths the -/// kernel uses (`hw.ncpu` is 32-bit, `hw.memsize` is 64-bit). -#[cfg(target_os = "macos")] -pub(crate) fn sysctl_u64(name: &str) -> Option { - let cname = std::ffi::CString::new(name).ok()?; - let mut value = 0u64; - let mut len = std::mem::size_of::(); - if unsafe { - libc::sysctlbyname( - cname.as_ptr(), - (&mut value as *mut u64).cast(), - &mut len, - std::ptr::null_mut(), - 0, - ) - } != 0 - { - return None; - } - match len { - 8 => Some(value), - // The kernel wrote only the low 4 bytes; the upper half is our zeroed - // initialiser, so mask rather than trusting the whole u64. - 4 => Some(value & 0xffff_ffff), - _ => None, - } -} - -/// Synchronous `query()` for tests and diagnostics — `spawn_query` returns a -/// handle that only fills in later, which is awkward to assert against. -/// -/// Only the macOS system-profile test consumes it today; `allow(dead_code)` -/// keeps `clippy -D warnings` green on platforms that have no caller yet. -#[cfg(test)] -#[allow(dead_code)] -pub fn query_for_test() -> SystemInfo { - query() -} - -/// Kick off the (slow) WMI enumeration without blocking the UI. -pub fn spawn_query() -> SystemInfoHandle { - let handle: SystemInfoHandle = Arc::new(RwLock::new(None)); - let sink = handle.clone(); - std::thread::spawn(move || { - let info = query(); - if let Ok(mut slot) = sink.write() { - *slot = Some(info); - } - }); - handle -} - -fn cpu_features() -> Vec<(&'static str, bool)> { - #[cfg(target_arch = "x86_64")] - { - vec![ - ("MMX", is_x86_feature_detected!("mmx")), - ("SSE", is_x86_feature_detected!("sse")), - ("SSE2", is_x86_feature_detected!("sse2")), - ("SSE3", is_x86_feature_detected!("sse3")), - ("SSSE3", is_x86_feature_detected!("ssse3")), - ("SSE4.1", is_x86_feature_detected!("sse4.1")), - ("SSE4.2", is_x86_feature_detected!("sse4.2")), - ("SSE4A", is_x86_feature_detected!("sse4a")), - ("AVX", is_x86_feature_detected!("avx")), - ("AVX2", is_x86_feature_detected!("avx2")), - ("AVX-512F", is_x86_feature_detected!("avx512f")), - ("FMA", is_x86_feature_detected!("fma")), - ("BMI1", is_x86_feature_detected!("bmi1")), - ("BMI2", is_x86_feature_detected!("bmi2")), - ("AES-NI", is_x86_feature_detected!("aes")), - ("SHA", is_x86_feature_detected!("sha")), - ("RDRAND", is_x86_feature_detected!("rdrand")), - ("RDSEED", is_x86_feature_detected!("rdseed")), - ("POPCNT", is_x86_feature_detected!("popcnt")), - ("F16C", is_x86_feature_detected!("f16c")), - ] - } - // Apple Silicon: the kernel publishes ~80 `hw.optional.arm.FEAT_*` flags. - // Curated rather than enumerated so the grid stays readable and the labels - // stay `&'static str` — the full list is mostly MTE/SME sub-variants. - #[cfg(all(target_arch = "aarch64", target_os = "macos"))] - { - const FEATURES: &[(&str, &str)] = &[ - ("NEON", "hw.optional.arm.AdvSIMD"), - ("FP16", "hw.optional.arm.FEAT_FP16"), - ("BF16", "hw.optional.arm.FEAT_BF16"), - ("I8MM", "hw.optional.arm.FEAT_I8MM"), - ("DotProd", "hw.optional.arm.FEAT_DotProd"), - ("FHM", "hw.optional.arm.FEAT_FHM"), - ("CRC32", "hw.optional.arm.FEAT_CRC32"), - ("AES", "hw.optional.arm.FEAT_AES"), - ("PMULL", "hw.optional.arm.FEAT_PMULL"), - ("SHA1", "hw.optional.arm.FEAT_SHA1"), - ("SHA256", "hw.optional.arm.FEAT_SHA256"), - ("SHA3", "hw.optional.arm.FEAT_SHA3"), - ("SHA512", "hw.optional.arm.FEAT_SHA512"), - ("LSE", "hw.optional.arm.FEAT_LSE"), - ("LSE2", "hw.optional.arm.FEAT_LSE2"), - ("RDM", "hw.optional.arm.FEAT_RDM"), - ("JSCVT", "hw.optional.arm.FEAT_JSCVT"), - ("FCMA", "hw.optional.arm.FEAT_FCMA"), - ("LRCPC", "hw.optional.arm.FEAT_LRCPC"), - ("PAuth", "hw.optional.arm.FEAT_PAuth"), - ("BTI", "hw.optional.arm.FEAT_BTI"), - ("MTE", "hw.optional.arm.FEAT_MTE"), - ("DIT", "hw.optional.arm.FEAT_DIT"), - ("ECV", "hw.optional.arm.FEAT_ECV"), - ("SME", "hw.optional.arm.FEAT_SME"), - ("SME2", "hw.optional.arm.FEAT_SME2"), - ("SSBS", "hw.optional.arm.FEAT_SSBS"), - ("SPECRES", "hw.optional.arm.FEAT_SPECRES"), - ]; - FEATURES - .iter() - .map(|(label, key)| (*label, sysctl_u64(key).unwrap_or(0) != 0)) - .collect() - } - #[cfg(not(any(target_arch = "x86_64", all(target_arch = "aarch64", target_os = "macos"))))] - { - Vec::new() - } -} - -#[cfg(windows)] -fn query() -> SystemInfo { - use std::collections::HashMap; - use wmi::{Variant, WMIConnection}; - - let mut info = SystemInfo { - computer_name: std::env::var("COMPUTERNAME").unwrap_or_default(), - user_name: std::env::var("USERNAME").unwrap_or_default(), - ..Default::default() - }; - info.cpu.features = cpu_features(); - let (cpuid, vendor, codename) = cpuid_info(); - info.cpu.cpuid = cpuid; - info.cpu.vendor = vendor; - info.cpu.codename = codename; - - let Ok(wmi) = WMIConnection::new() else { return info }; - - type Row = HashMap; - - let s = |v: Option<&Variant>| -> String { - match v { - Some(Variant::String(x)) => x.trim().to_string(), - _ => String::new(), - } - }; - let u = |v: Option<&Variant>| -> Option { - match v { - Some(Variant::UI4(x)) => Some(*x), - Some(Variant::I4(x)) => u32::try_from(*x).ok(), - Some(Variant::UI2(x)) => Some(*x as u32), - Some(Variant::String(x)) => x.parse().ok(), - _ => None, - } - }; - let u64v = |v: Option<&Variant>| -> Option { - match v { - Some(Variant::UI8(x)) => Some(*x), - Some(Variant::I8(x)) => u64::try_from(*x).ok(), - Some(Variant::UI4(x)) => Some(*x as u64), - Some(Variant::String(x)) => x.parse().ok(), - _ => None, - } - }; - - if let Ok(rows) = wmi.raw_query::( - "SELECT Name, NumberOfCores, NumberOfLogicalProcessors, MaxClockSpeed, L2CacheSize, L3CacheSize, SocketDesignation FROM Win32_Processor", - ) { - if let Some(r) = rows.first() { - info.cpu.name = s(r.get("Name")); - info.cpu.cores = u(r.get("NumberOfCores")); - info.cpu.threads = u(r.get("NumberOfLogicalProcessors")); - info.cpu.max_clock_mhz = u(r.get("MaxClockSpeed")); - info.cpu.base_clock_mhz = u(r.get("MaxClockSpeed")); - info.cpu.l2_kb = u(r.get("L2CacheSize")); - info.cpu.l3_kb = u(r.get("L3CacheSize")); - info.cpu.socket = Some(s(r.get("SocketDesignation"))).filter(|x| !x.is_empty()); - } - } - - if let Ok(rows) = wmi.raw_query::("SELECT Product, Manufacturer FROM Win32_BaseBoard") { - if let Some(r) = rows.first() { - info.board.product = s(r.get("Product")); - info.board.manufacturer = s(r.get("Manufacturer")); - } - } - if let Ok(rows) = wmi.raw_query::("SELECT SMBIOSBIOSVersion, ReleaseDate FROM Win32_BIOS") { - if let Some(r) = rows.first() { - info.board.bios_version = s(r.get("SMBIOSBIOSVersion")); - let date = s(r.get("ReleaseDate")); - // WMI CIM_DATETIME: yyyymmddHHMMSS… → mm/dd/yyyy like HWiNFO shows. - if date.len() >= 8 { - info.board.bios_date = format!("{}/{}/{}", &date[4..6], &date[6..8], &date[0..4]); - } - } - } - - if let Ok(rows) = wmi.raw_query::( - "SELECT BankLabel, DeviceLocator, Manufacturer, PartNumber, Capacity, Speed, ConfiguredClockSpeed, ConfiguredVoltage, SMBIOSMemoryType FROM Win32_PhysicalMemory", - ) { - let mut total = 0.0; - for r in &rows { - let capacity_gb = u64v(r.get("Capacity")).map(|b| b as f64 / (1u64 << 30) as f64).unwrap_or(0.0); - total += capacity_gb; - // An absent field is left blank; a present one is decoded, so an - // unrecognised code shows as `Unknown (type 0x??)` rather than - // being flattened into a wrong name. - let mem_type = u(r.get("SMBIOSMemoryType")) - .map(smbios_memory_type) - .unwrap_or_default(); - info.memory_modules.push(MemoryModule { - bank: { - let bank = s(r.get("BankLabel")); - let loc = s(r.get("DeviceLocator")); - if bank.is_empty() { loc } else { format!("{bank}/{loc}") } - }, - manufacturer: s(r.get("Manufacturer")), - part_number: s(r.get("PartNumber")), - capacity_gb, - speed_mts: u(r.get("Speed")), - configured_speed_mts: u(r.get("ConfiguredClockSpeed")), - voltage_mv: u(r.get("ConfiguredVoltage")), - memory_type: mem_type.to_string(), - }); - } - if total > 0.0 { - info.total_memory_gb = Some(total); - } - } - - if let Ok(rows) = wmi.raw_query::("SELECT Name, AdapterRAM, DriverVersion FROM Win32_VideoController") { - for r in &rows { - info.gpus.push(GpuInfo { - name: s(r.get("Name")), - vram_gb: u64v(r.get("AdapterRAM")).map(|b| b as f64 / (1u64 << 30) as f64), - driver_version: s(r.get("DriverVersion")), - }); - } - } - - if let Ok(rows) = wmi.raw_query::("SELECT Model, InterfaceType, Size FROM Win32_DiskDrive") { - for r in &rows { - info.drives.push(DriveInfo { - model: s(r.get("Model")), - interface: s(r.get("InterfaceType")), - size_gb: u64v(r.get("Size")).map(|b| b as f64 / 1_000_000_000.0), - }); - } - } - - if let Ok(rows) = wmi.raw_query::("SELECT Caption, BuildNumber, OSArchitecture FROM Win32_OperatingSystem") { - if let Some(r) = rows.first() { - info.os.caption = s(r.get("Caption")); - info.os.build = s(r.get("BuildNumber")); - info.os.arch = s(r.get("OSArchitecture")); - } - } - - // Secure Boot / UEFI: registry flag (no clean WMI class for it). - info.os.secure_boot = read_secure_boot(); - info.os.uefi_boot = info.os.secure_boot.map(|_| true); - - info -} - -#[cfg(windows)] -fn read_secure_boot() -> Option { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - let out = std::process::Command::new("reg") - .args([ - "query", - r"HKLM\SYSTEM\CurrentControlSet\Control\SecureBoot\State", - "/v", - "UEFISecureBootEnabled", - ]) - .creation_flags(CREATE_NO_WINDOW) - .output() - .ok()?; - let text = String::from_utf8_lossy(&out.stdout); - if text.contains("0x1") { - Some(true) - } else if text.contains("0x0") { - Some(false) - } else { - None - } -} - -#[cfg(target_os = "macos")] -fn query() -> SystemInfo { - let (cpuid, vendor, codename) = cpuid_info(); - - // Apple Silicon is heterogeneous: perflevel0 is the fast cluster (named - // "Performance" through M4, "Super" on M5 — read the name, don't hardcode - // it) and perflevel1 the efficiency cluster. Sum both for the core count, - // and report the layout in `socket` since there is no socket to speak of. - let mut cluster_desc = Vec::new(); - let mut physical = 0u32; - for level in 0..4 { - let Some(count) = sysctl_u64(&format!("hw.perflevel{level}.physicalcpu")) else { - break; - }; - physical += count as u32; - let name = sysctl_string(&format!("hw.perflevel{level}.name")) - .unwrap_or_else(|| format!("level{level}")); - cluster_desc.push(format!("{count} {name}")); - } - // Fall back to the flat count on any Mac that doesn't publish perflevels. - let cores = if physical > 0 { Some(physical) } else { sysctl_u64("hw.physicalcpu").map(|v| v as u32) }; - - let total_memory_gb = sysctl_u64("hw.memsize").map(|b| b as f64 / (1024.0 * 1024.0 * 1024.0)); - - // The SoC is soldered, so there are no per-DIMM SPD entries to enumerate; - // present the unified memory as a single honest module. - let memory_modules = total_memory_gb - .map(|gb| { - vec![MemoryModule { - bank: "Unified Memory".into(), - manufacturer: "Apple".into(), - capacity_gb: gb, - memory_type: "LPDDR (on-package)".into(), - ..Default::default() - }] - }) - .unwrap_or_default(); - - // The integrated GPU shares the SoC; name it after the chip and its core - // count rather than inventing a discrete-adapter identity. - let chip = sysctl_string("machdep.cpu.brand_string").unwrap_or_default(); - let (gpu_cores, metal_driver) = crate::source::macos::sysprofile::gpu_identity(); - let gpus = if chip.is_empty() { - Vec::new() - } else { - let name = match gpu_cores { - Some(cores) => format!("{chip} GPU ({cores} cores)"), - None => format!("{chip} GPU"), - }; - vec![GpuInfo { - name, - // Unified memory — there is no separate VRAM pool to report, and - // quoting total system RAM here would be misleading. - vram_gb: None, - driver_version: metal_driver.unwrap_or_default(), - }] - }; - - // Internal SSD(s), so the Summary's Drives panel isn't blank. - const GB: f64 = 1024.0 * 1024.0 * 1024.0; - let drives = crate::source::macos::sysprofile::drives() - .into_iter() - .map(|(model, bytes)| DriveInfo { - model, - interface: "NVMe".into(), - size_gb: bytes.map(|b| b as f64 / GB), - }) - .collect(); - - // Performance-cluster DVFS states, for the Base/Max Clock rows. - let p_states = - crate::source::macos::dvfs::frequencies_mhz(crate::source::macos::dvfs::Block::Pcpu); - - let os_version = sysctl_string("kern.osproductversion").unwrap_or_default(); - - SystemInfo { - computer_name: sysctl_string("kern.hostname").unwrap_or_default(), - user_name: std::env::var("USER").unwrap_or_default(), - cpu: CpuInfo { - name: chip, - cores, - // Apple Silicon has no SMT: one thread per physical core. - threads: sysctl_u64("hw.logicalcpu").map(|v| v as u32), - // There is no fixed "base clock" on Apple Silicon; the closest - // honest equivalents are the bottom and top of the performance - // cluster's DVFS table. - base_clock_mhz: p_states.first().map(|mhz| *mhz as u32), - max_clock_mhz: p_states.last().map(|mhz| *mhz as u32), - // hw.l2cachesize reports the *efficiency* cluster (6 MB here). - // The headline figure is the performance cluster's L2 - // (hw.perflevel0.l2cachesize, 16 MB), so prefer that. - l2_kb: sysctl_u64("hw.perflevel0.l2cachesize") - .or_else(|| sysctl_u64("hw.l2cachesize")) - .map(|b| (b / 1024) as u32), - // Apple Silicon has no per-core L3; the system-level cache is not - // published anywhere readable, so this stays honestly blank. - l3_kb: None, - socket: (!cluster_desc.is_empty()).then(|| cluster_desc.join(" + ")), - features: cpu_features(), - cpuid, - vendor, - codename, - }, - board: BoardInfo { - // Prefer the marketing name ("MacBook Air (13-inch, M5)") over the - // bare board id ("Mac17,3"), which is already shown as the codename. - product: crate::source::macos::sysprofile::product_name() - .or_else(|| sysctl_string("hw.model")) - .unwrap_or_default(), - manufacturer: "Apple Inc.".into(), - // Apple Silicon boots via iBoot, so the closest thing to a BIOS - // version is the boot firmware revision. - bios_version: crate::source::macos::sysprofile::firmware_version().unwrap_or_default(), - // No firmware build date is published anywhere in IOKit; leave it - // blank rather than guessing from the version string. - bios_date: String::new(), - }, - memory_modules, - total_memory_gb, - gpus, - drives, - os: OsInfo { - caption: if os_version.is_empty() { - "macOS".into() - } else { - format!("macOS {os_version}") - }, - build: sysctl_string("kern.osversion").unwrap_or_default(), - arch: std::env::consts::ARCH.to_string(), - // Apple Silicon boots via iBoot, not UEFI, and Secure Boot state - // lives in a different subsystem — leave both indeterminate rather - // than asserting something false. - uefi_boot: None, - secure_boot: None, - }, - } -} - -#[cfg(target_os = "linux")] -fn query() -> SystemInfo { - use std::fs; - let (cpuid, vendor, codename) = cpuid_info(); - let mut info = SystemInfo { - computer_name: std::env::var("HOSTNAME").unwrap_or_default(), - user_name: std::env::var("USER").unwrap_or_default(), - cpu: CpuInfo { features: cpu_features(), cpuid, vendor, codename, ..Default::default() }, - ..Default::default() - }; - - // Read CPU Model Name and Cores from /proc/cpuinfo - if let Ok(cpuinfo) = fs::read_to_string("/proc/cpuinfo") { - let mut model_name = String::new(); - let mut logical_count = 0u32; - for line in cpuinfo.lines() { - if line.starts_with("model name") { - if let Some(val) = line.split(':').nth(1) { - if model_name.is_empty() { - model_name = val.trim().to_string(); - } - } - } - if line.starts_with("processor") { - logical_count += 1; - } - } - if !model_name.is_empty() { - info.cpu.name = model_name; - } - if logical_count > 0 { - info.cpu.threads = Some(logical_count); - info.cpu.cores = Some(logical_count); // best-effort fallback - } - } - - // Read Motherboard / DMI Info from /sys/class/dmi/id - if let Ok(product) = fs::read_to_string("/sys/class/dmi/id/board_name") { - info.board.product = product.trim().to_string(); - } - if let Ok(vendor) = fs::read_to_string("/sys/class/dmi/id/board_vendor") { - info.board.manufacturer = vendor.trim().to_string(); - } - if let Ok(version) = fs::read_to_string("/sys/class/dmi/id/bios_version") { - info.board.bios_version = version.trim().to_string(); - } - if let Ok(date) = fs::read_to_string("/sys/class/dmi/id/bios_date") { - info.board.bios_date = date.trim().to_string(); - } - - // Read RAM Total from /proc/meminfo - if let Ok(meminfo) = fs::read_to_string("/proc/meminfo") { - for line in meminfo.lines() { - if line.starts_with("MemTotal:") { - if let Some(kb_str) = line.split_whitespace().nth(1) { - if let Ok(kb) = kb_str.parse::() { - info.total_memory_gb = Some(kb / (1024.0 * 1024.0)); - } - } - } - } - } - - // Read OS info from /etc/os-release - if let Ok(os_release) = fs::read_to_string("/etc/os-release") { - for line in os_release.lines() { - if line.starts_with("PRETTY_NAME=") { - let name = line.trim_start_matches("PRETTY_NAME=").trim_matches('"'); - info.os.caption = name.to_string(); - } - } - } - - info -} - -#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] -fn query() -> SystemInfo { - let (cpuid, vendor, codename) = cpuid_info(); - SystemInfo { - computer_name: std::env::var("HOSTNAME").unwrap_or_default(), - user_name: std::env::var("USER").unwrap_or_default(), - cpu: CpuInfo { features: cpu_features(), cpuid, vendor, codename, ..Default::default() }, - ..Default::default() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // Codes are the DMTF SMBIOS values, cross-checked against dmidecode's - // `dmi_memory_device_type` table. These run on every platform, since the - // decoder takes the raw code rather than reading WMI. - - #[test] - fn the_three_codes_that_already_worked_still_decode_the_same_way() { - assert_eq!(smbios_memory_type(24), "DDR3"); - assert_eq!(smbios_memory_type(26), "DDR4"); - assert_eq!(smbios_memory_type(34), "DDR5"); - } - - #[test] - fn soldered_laptop_memory_is_named_rather_than_flattened_to_dram() { - // The reason for the change: every one of these used to fall through - // to "DRAM" and render as "DRAM SDRAM". - assert_eq!(smbios_memory_type(0x1B), "LPDDR"); - assert_eq!(smbios_memory_type(0x1C), "LPDDR2"); - assert_eq!(smbios_memory_type(0x1D), "LPDDR3"); - assert_eq!(smbios_memory_type(0x1E), "LPDDR4"); - assert_eq!(smbios_memory_type(0x23), "LPDDR5"); - } - - #[test] - fn stacked_memory_decodes_too() { - assert_eq!(smbios_memory_type(0x20), "HBM"); - assert_eq!(smbios_memory_type(0x21), "HBM2"); - assert_eq!(smbios_memory_type(0x24), "HBM3"); - } - - #[test] - fn every_assigned_code_from_1_to_0x24_decodes_to_a_name() { - // The claim this table makes: no assigned code falls through. 0x15-0x17 - // are Reserved rather than memory types, so they are the exception. - for code in 0x01..=0x24u32 { - let decoded = smbios_memory_type(code); - if (0x15..=0x17).contains(&code) { - assert_eq!(decoded, format!("Unknown (type {code:#04X})")); - } else { - assert!( - !decoded.starts_with("Unknown (type"), - "assigned code {code:#04X} fell through to the unknown path" - ); - } - } - } - - #[test] - fn an_unassigned_code_reports_the_raw_value_instead_of_guessing() { - // 0x25 is past the last code DMTF has assigned. Showing the number is - // what lets someone look it up. - assert_eq!(smbios_memory_type(0x25), "Unknown (type 0x25)"); - assert_eq!(smbios_memory_type(0xFF), "Unknown (type 0xFF)"); - } - - #[test] - fn smbios_other_and_unknown_are_both_reported_as_unknown() { - assert_eq!(smbios_memory_type(0x01), "Unknown"); - assert_eq!(smbios_memory_type(0x02), "Unknown"); - } - - #[test] - fn the_sdram_suffix_is_only_added_where_it_means_something() { - assert_eq!(memory_type_label("DDR5"), "DDR5 SDRAM"); - assert_eq!(memory_type_label("LPDDR5"), "LPDDR5 SDRAM"); - // These are the ones that used to produce nonsense. - assert_eq!(memory_type_label("HBM3"), "HBM3"); - assert_eq!(memory_type_label("Unknown"), "Unknown"); - assert_eq!(memory_type_label("Unknown (type 0x25)"), "Unknown (type 0x25)"); - assert_eq!(memory_type_label(""), ""); - } -} +//! Static system information for the Main window tree and the System Summary. +//! +//! Queried once at startup on a background thread (WMI/COM on Windows; minimal +//! fallbacks elsewhere). Anything a source can't provide stays `None` and the +//! UI renders "—" — honest placeholders until the native engine (SMBus SPD, +//! CPUID, NVML/ADL) fills them in. + +use std::sync::{Arc, RwLock}; + +#[derive(Debug, Default, Clone, serde::Serialize)] +pub struct CpuInfo { + pub name: String, + pub cores: Option, + pub threads: Option, + pub base_clock_mhz: Option, + pub max_clock_mhz: Option, + pub l2_kb: Option, + pub l3_kb: Option, + pub socket: Option, + /// CPUID(1).EAX signature, HWiNFO-style hex (e.g. "00A60F12"). + pub cpuid: String, + /// Best-effort microarchitecture codename (e.g. "Raphael (Zen 4)"). + pub codename: String, + pub vendor: String, + /// ISA feature names detected at runtime (for the Summary features grid). + pub features: Vec<(&'static str, bool)>, +} + +/// Raw CPUID(1).EAX signature + vendor + codename, computed on x86_64. +fn cpuid_info() -> (String, String, String) { + #[cfg(target_arch = "x86_64")] + { + use core::arch::x86_64::__cpuid; + // __cpuid is safe on x86_64 (CPUID is always available). + let vendor_leaf = __cpuid(0); + let mut vbytes = Vec::new(); + vbytes.extend_from_slice(&vendor_leaf.ebx.to_le_bytes()); + vbytes.extend_from_slice(&vendor_leaf.edx.to_le_bytes()); + vbytes.extend_from_slice(&vendor_leaf.ecx.to_le_bytes()); + let vendor = String::from_utf8_lossy(&vbytes).to_string(); + + let leaf1 = __cpuid(1); + let eax = leaf1.eax; + let base_family = (eax >> 8) & 0xf; + let ext_family = (eax >> 20) & 0xff; + let family = if base_family == 0xf { base_family + ext_family } else { base_family }; + let base_model = (eax >> 4) & 0xf; + let ext_model = (eax >> 16) & 0xf; + let model = (ext_model << 4) | base_model; + + let codename = codename_for(&vendor, family, model); + (format!("{eax:08X}"), vendor, codename) + } + // Apple Silicon has no CPUID. The nearest equivalents are the board id + // (`hw.model`, e.g. "Mac17,3") and the SoC name from the brand string, so + // report those rather than leaving the Summary window blank. + #[cfg(all(target_arch = "aarch64", target_os = "macos"))] + { + let vendor = if sysctl_string("machdep.cpu.brand_string") + .is_some_and(|b| b.starts_with("Apple")) + { + "Apple".to_string() + } else { + String::new() + }; + (String::new(), vendor, sysctl_string("hw.model").unwrap_or_default()) + } + #[cfg(not(any(target_arch = "x86_64", all(target_arch = "aarch64", target_os = "macos"))))] + { + (String::new(), String::new(), String::new()) + } +} + +/// Microarchitecture codename for an x86 CPU, from its CPUID family and model. +/// +/// Sourcing matters here, because a *wrong* codename is worse than none: it is +/// rendered in the System Summary directly beside the CPUID signature a user +/// can check it against. Three sources, none of them guesswork: +/// +/// * Intel up to Tiger Lake / Comet Lake — this repository's own +/// `Hardware/CPU/IntelCPU.cs`, the original OpenHardwareMonitor detection +/// these sources are a port of. +/// * Intel from Rocket Lake onwards, plus the Atom and Xeon lines — the Linux +/// kernel's `arch/x86/include/asm/intel-family.h`. +/// * AMD — libcpuid's `recog_amd.c`. +/// +/// Where a source names a specific part, that name is used. Where none does, +/// the arm falls back to a *generation* label that is true for every member of +/// the family rather than inventing a codename, and an unrecognised family +/// returns `""`, which callers already render as blank. +#[allow(dead_code)] // Not reachable on non-x86_64 targets; see `cpuid_info`. +fn codename_for(vendor: &str, family: u32, model: u32) -> String { + if vendor.contains("AuthenticAMD") { + amd_codename(family, model).to_string() + } else if vendor.contains("GenuineIntel") { + intel_codename(family, model).to_string() + } else { + // Neither vendor — a VM's synthetic CPUID, a Hygon part, or an + // emulated x86. Nothing truthful to say. + String::new() + } +} + +/// AMD, keyed on family then model's high nibble — the grouping AMD itself +/// uses to separate parts within a family. +/// +/// Every family arm ends in a catch-all naming only the *generation*, which is +/// safe because AMD does not mix generations within these families: 17h is +/// Zen through Zen 2, 19h is Zen 3 and Zen 4, 1Ah is Zen 5 throughout. A +/// part released after this table was written therefore still reports its +/// generation correctly instead of falling through to blank. +fn amd_codename(family: u32, model: u32) -> &'static str { + match (family, model) { + // --- Zen 5, family 1Ah --------------------------------------------- + // libcpuid: family 26 model 2 (Turin), 36/0x24 (Strix Point), + // 68/0x44 (Granite Ridge). + (0x1a, 0x00..=0x0f) => "Turin (Zen 5)", + (0x1a, 0x20..=0x2f) => "Strix Point (Zen 5)", + (0x1a, 0x40..=0x4f) => "Granite Ridge (Zen 5)", + (0x1a, _) => "Zen 5", + + // --- Zen 3, Zen 3+ and Zen 4 all share family 19h ------------------ + // libcpuid: family 25 models 1 (Milan), 33/0x21 (Vermeer), + // 68/0x44 (Rembrandt), 80/0x50 (Cezanne), 116/0x74 (Phoenix). + (0x19, 0x00..=0x0f) => "Milan (Zen 3)", + (0x19, 0x20..=0x2f) => "Vermeer (Zen 3)", + (0x19, 0x40..=0x4f) => "Rembrandt (Zen 3+)", + (0x19, 0x50..=0x5f) => "Cezanne (Zen 3)", + (0x19, 0x60..=0x6f) => "Raphael (Zen 4)", + (0x19, 0x70..=0x7f) => "Phoenix (Zen 4)", + (0x19, _) => "Zen 3/Zen 4", + + // --- Zen, Zen+ and Zen 2 share family 17h -------------------------- + // libcpuid: family 23 model 1 (Naples / Whitehaven / Summit Ridge). + // The rest of 17h is deliberately generic — this used to claim + // "Matisse/Renoir (Zen 2)" for the whole family, which mislabelled + // every first-generation Ryzen as a Zen 2 part. + (0x17, 0x00..=0x0f) => "Summit Ridge/Naples (Zen)", + (0x17, _) => "Zen/Zen+/Zen 2", + + _ => "", + } +} + +/// Intel. Family 6 carried everything from the Pentium Pro to Panther Lake, so +/// the model is what identifies a part; Nova Lake (18h) and Diamond Rapids +/// (19h) are the first to move off it, which is why this matches on the family +/// rather than assuming 6. +fn intel_codename(family: u32, model: u32) -> &'static str { + match (family, model) { + // --- Families 12h and 13h: the move off family 6 ------------------- + (0x12, 0x01 | 0x03) => "Nova Lake (Coyote Cove/Arctic Wolf)", + (0x12, _) => "Intel (family 12h)", + (0x13, 0x01) => "Diamond Rapids (Panther Cove)", + (0x13, _) => "Intel (family 13h)", + + // --- Family 6, newest first --------------------------------------- + (0x6, 0xE5 | 0xCC) => "Panther Lake (Cougar Cove/Darkmont)", + (0x6, 0xDD) => "Clearwater Forest (Darkmont)", + (0x6, 0xD7) => "Bartlett Lake (Raptor Cove)", + (0x6, 0xD5) => "Wildcat Lake", + (0x6, 0xC6 | 0xC5 | 0xB5) => "Arrow Lake (Lion Cove/Skymont)", + (0x6, 0xBD) => "Lunar Lake (Lion Cove/Skymont)", + (0x6, 0xCF) => "Emerald Rapids (Raptor Cove)", + (0x6, 0xAD | 0xAE) => "Granite Rapids (Redwood Cove)", + (0x6, 0xAF) => "Sierra Forest (Crestmont)", + (0x6, 0xB6) => "Grand Ridge (Crestmont)", + (0x6, 0xAC | 0xAA) => "Meteor Lake (Redwood Cove/Crestmont)", + (0x6, 0xB7 | 0xBA | 0xBF) => "Raptor Lake (Raptor Cove/Gracemont)", + (0x6, 0xBE) => "Alder Lake-N (Gracemont)", + (0x6, 0x97 | 0x9A) => "Alder Lake (Golden Cove/Gracemont)", + (0x6, 0x8F) => "Sapphire Rapids (Golden Cove)", + (0x6, 0xA7) => "Rocket Lake (Cypress Cove)", + (0x6, 0x8A) => "Lakefield (Sunny Cove/Tremont)", + + // --- Family 6, from Hardware/CPU/IntelCPU.cs ----------------------- + (0x6, 0x8C | 0x8D) => "Tiger Lake", + (0x6, 0xA5 | 0xA6) => "Comet Lake", + (0x6, 0x7D | 0x7E | 0x6A | 0x6C | 0x9D) => "Ice Lake", + (0x6, 0x66) => "Cannon Lake", + (0x6, 0x8E | 0x9E) => "Kaby Lake", + (0x6, 0x4E | 0x5E | 0x55) => "Skylake", + (0x6, 0x3D | 0x47 | 0x4F | 0x56) => "Broadwell", + (0x6, 0x3C | 0x3F | 0x45 | 0x46) => "Haswell", + (0x6, 0x3A | 0x3E) => "Ivy Bridge", + (0x6, 0x2A | 0x2D) => "Sandy Bridge", + (0x6, 0x25 | 0x2C | 0x2F) => "Westmere", + (0x6, 0x1A | 0x1E | 0x1F | 0x2E) => "Nehalem", + (0x6, 0x0F | 0x16 | 0x17 | 0x1D) => "Core 2", + + // Atom. Previously all of these reported "Intel Core", which is not a + // vaguer answer but a wrong one. + (0x6, 0x9C) => "Jasper Lake (Tremont)", + (0x6, 0x96) => "Elkhart Lake (Tremont)", + (0x6, 0x86) => "Jacobsville (Tremont)", + (0x6, 0x7A) => "Gemini Lake (Goldmont Plus)", + (0x6, 0x5C) => "Apollo Lake (Goldmont)", + (0x6, 0x5F) => "Denverton (Goldmont)", + (0x6, 0x4C) => "Cherry Trail (Airmont)", + (0x6, 0x75) => "Lightning Mountain (Airmont)", + (0x6, 0x37 | 0x4A | 0x4D | 0x5A) => "Silvermont", + (0x6, 0x35 | 0x36) => "Saltwell", + (0x6, 0x1C | 0x26 | 0x27) => "Bonnell", + (0x6, 0x57) => "Knights Landing", + (0x6, 0x85) => "Knights Mill", + + // Anything else on family 6 is a part newer than this table. "Intel + // Core" would be a *guess*, not a cautious answer: family 6 also + // carries every Atom, and calling an Atom a Core is the same error + // this table exists to remove. Name only the family, as the 12h and + // 13h arms above do. + (0x6, _) => "Intel (family 6h)", + + // Family 15h — NetBurst (Hardware/CPU/IntelCPU.cs). + (0xf, _) => "NetBurst", + + _ => "", + } +} + +/// SMBIOS *Memory Device* (structure type 17) memory-type code → display name. +/// +/// Codes are the DMTF SMBIOS specification's, cross-checked against +/// dmidecode's `dmi_memory_device_type` table, which runs from `0x01` to +/// `0x24`. Every assigned code in that range is decoded; `0x15`–`0x17` are +/// Reserved rather than memory types, so they fall to the unknown path along +/// with anything DMTF has yet to assign. +/// +/// This used to decode exactly three values — DDR3, DDR4, DDR5 — and answer +/// `"DRAM"` for everything else. The Summary appends " SDRAM" to whatever it +/// gets, so the fallback rendered as the literal string "DRAM SDRAM". Every +/// soldered-memory machine reports an LPDDR code, so that was most current +/// laptop hardware. +/// +/// A code the table does not know is reported as `Unknown (type 0x??)` rather +/// than guessed at: the raw code is what lets someone look it up, and DMTF +/// assigns new ones as memory generations ship. +#[allow(dead_code)] // Only reachable from the Windows WMI path. +fn smbios_memory_type(code: u32) -> String { + let name = match code { + 0x01 | 0x02 => "Unknown", // "Other" and "Unknown" are both non-answers. + 0x03 => "DRAM", + 0x04 => "EDRAM", + 0x05 => "VRAM", + 0x06 => "SRAM", + 0x07 => "RAM", + 0x08 => "ROM", + 0x09 => "Flash", + 0x0A => "EEPROM", + 0x0B => "FEPROM", + 0x0C => "EPROM", + 0x0D => "CDRAM", + 0x0E => "3DRAM", + 0x0F => "SDRAM", + 0x10 => "SGRAM", + 0x11 => "RDRAM", + 0x12 => "DDR", + 0x13 => "DDR2", + 0x14 => "DDR2 FB-DIMM", + 0x18 => "DDR3", + 0x19 => "FBD2", + 0x1A => "DDR4", + 0x1B => "LPDDR", + 0x1C => "LPDDR2", + 0x1D => "LPDDR3", + 0x1E => "LPDDR4", + 0x1F => "Logical non-volatile device", + 0x20 => "HBM", + 0x21 => "HBM2", + 0x22 => "DDR5", + 0x23 => "LPDDR5", + 0x24 => "HBM3", + _ => return format!("Unknown (type {code:#04X})"), + }; + name.to_string() +} + +/// How the Summary labels a module: "DDR5" becomes "DDR5 SDRAM", but "HBM3" +/// and "Unknown" are left alone. +/// +/// The suffix used to be appended unconditionally, which is where "DRAM SDRAM" +/// came from. Only the DDR and LPDDR families are synchronous DRAM in the +/// sense that suffix means. +#[allow(dead_code)] // Only the GUI renders this; headless builds don't link it. +pub fn memory_type_label(memory_type: &str) -> String { + if memory_type.starts_with("DDR") || memory_type.starts_with("LPDDR") { + format!("{memory_type} SDRAM") + } else { + memory_type.to_string() + } +} + +#[derive(Debug, Default, Clone, serde::Serialize)] +pub struct BoardInfo { + pub product: String, + pub manufacturer: String, + pub bios_version: String, + pub bios_date: String, +} + +#[derive(Debug, Default, Clone, serde::Serialize)] +pub struct MemoryModule { + pub bank: String, + pub manufacturer: String, + pub part_number: String, + pub capacity_gb: f64, + pub speed_mts: Option, + pub configured_speed_mts: Option, + pub voltage_mv: Option, + pub memory_type: String, +} + +#[derive(Debug, Default, Clone, serde::Serialize)] +pub struct GpuInfo { + pub name: String, + /// WMI AdapterRAM (u32, capped at 4 GB) — kept for the native engine to + /// replace with NVML/ADL truth; not displayed while unreliable. + #[allow(dead_code)] + pub vram_gb: Option, + pub driver_version: String, +} + +#[derive(Debug, Default, Clone, serde::Serialize)] +pub struct DriveInfo { + pub model: String, + pub interface: String, + pub size_gb: Option, +} + +#[derive(Debug, Default, Clone, serde::Serialize)] +pub struct OsInfo { + pub caption: String, + pub build: String, + pub arch: String, + pub uefi_boot: Option, + pub secure_boot: Option, +} + +#[derive(Debug, Default, Clone, serde::Serialize)] +pub struct SystemInfo { + pub computer_name: String, + pub user_name: String, + pub cpu: CpuInfo, + pub board: BoardInfo, + pub memory_modules: Vec, + pub total_memory_gb: Option, + pub gpus: Vec, + pub drives: Vec, + pub os: OsInfo, +} + +/// Shared handle: `None` until the background query completes. +pub type SystemInfoHandle = Arc>>; + +/// Whether this process is running elevated (`Some(true/false)` on Windows, +/// `None` elsewhere). Reliable and independent of the sidecar — the sidecar is +/// our child, so it inherits our elevation. +// Surfaced as the GUI's "Running as Administrator" badge. +#[allow(dead_code)] +pub fn is_elevated() -> Option { + #[cfg(windows)] + { + #[repr(C)] + struct TokenElevation { + token_is_elevated: u32, + } + const TOKEN_QUERY: u32 = 0x0008; + const TOKEN_ELEVATION_CLASS: i32 = 20; // TokenElevation + + #[link(name = "advapi32")] + extern "system" { + fn OpenProcessToken(process: isize, desired: u32, handle: *mut isize) -> i32; + fn GetTokenInformation( + token: isize, + class: i32, + info: *mut core::ffi::c_void, + len: u32, + ret_len: *mut u32, + ) -> i32; + } + extern "system" { + fn GetCurrentProcess() -> isize; + fn CloseHandle(h: isize) -> i32; + } + + unsafe { + let mut token: isize = 0; + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { + return None; + } + let mut elevation = TokenElevation { token_is_elevated: 0 }; + let mut ret_len = 0u32; + let ok = GetTokenInformation( + token, + TOKEN_ELEVATION_CLASS, + &mut elevation as *mut _ as *mut core::ffi::c_void, + core::mem::size_of::() as u32, + &mut ret_len, + ); + CloseHandle(token); + if ok == 0 { + None + } else { + Some(elevation.token_is_elevated != 0) + } + } + } + // Reported for the status badge only. Nothing on macOS *needs* root: the + // IOKit backend reads every sensor unprivileged, so no feature is gated on + // this (see ui/settings_dialog.rs). + #[cfg(target_os = "macos")] + { + Some(unsafe { libc::geteuid() } == 0) + } + #[cfg(not(any(windows, target_os = "macos")))] + { + None + } +} + +// ---- sysctl helpers (macOS) --------------------------------------------- + +/// Read a string-valued sysctl by name. `None` if the key doesn't exist — +/// keys come and go between macOS releases, so every caller must tolerate it. +#[cfg(target_os = "macos")] +pub(crate) fn sysctl_string(name: &str) -> Option { + let cname = std::ffi::CString::new(name).ok()?; + let mut len = 0usize; + // First call with a null buffer asks for the required size. + if unsafe { + libc::sysctlbyname(cname.as_ptr(), std::ptr::null_mut(), &mut len, std::ptr::null_mut(), 0) + } != 0 + || len == 0 + { + return None; + } + let mut buf = vec![0u8; len]; + if unsafe { + libc::sysctlbyname( + cname.as_ptr(), + buf.as_mut_ptr().cast(), + &mut len, + std::ptr::null_mut(), + 0, + ) + } != 0 + { + return None; + } + buf.truncate(len); + // sysctl strings are NUL-terminated; drop the terminator and anything after. + if let Some(nul) = buf.iter().position(|&b| b == 0) { + buf.truncate(nul); + } + let s = String::from_utf8_lossy(&buf).trim().to_string(); + (!s.is_empty()).then_some(s) +} + +/// Read an integer-valued sysctl. Handles both the 4-byte and 8-byte widths the +/// kernel uses (`hw.ncpu` is 32-bit, `hw.memsize` is 64-bit). +#[cfg(target_os = "macos")] +pub(crate) fn sysctl_u64(name: &str) -> Option { + let cname = std::ffi::CString::new(name).ok()?; + let mut value = 0u64; + let mut len = std::mem::size_of::(); + if unsafe { + libc::sysctlbyname( + cname.as_ptr(), + (&mut value as *mut u64).cast(), + &mut len, + std::ptr::null_mut(), + 0, + ) + } != 0 + { + return None; + } + match len { + 8 => Some(value), + // The kernel wrote only the low 4 bytes; the upper half is our zeroed + // initialiser, so mask rather than trusting the whole u64. + 4 => Some(value & 0xffff_ffff), + _ => None, + } +} + +/// Synchronous `query()` for tests and diagnostics — `spawn_query` returns a +/// handle that only fills in later, which is awkward to assert against. +/// +/// Only the macOS system-profile test consumes it today; `allow(dead_code)` +/// keeps `clippy -D warnings` green on platforms that have no caller yet. +#[cfg(test)] +#[allow(dead_code)] +pub fn query_for_test() -> SystemInfo { + query() +} + +/// Kick off the (slow) WMI enumeration without blocking the UI. +pub fn spawn_query() -> SystemInfoHandle { + let handle: SystemInfoHandle = Arc::new(RwLock::new(None)); + let sink = handle.clone(); + std::thread::spawn(move || { + let info = query(); + if let Ok(mut slot) = sink.write() { + *slot = Some(info); + } + }); + handle +} + +fn cpu_features() -> Vec<(&'static str, bool)> { + #[cfg(target_arch = "x86_64")] + { + vec![ + ("MMX", is_x86_feature_detected!("mmx")), + ("SSE", is_x86_feature_detected!("sse")), + ("SSE2", is_x86_feature_detected!("sse2")), + ("SSE3", is_x86_feature_detected!("sse3")), + ("SSSE3", is_x86_feature_detected!("ssse3")), + ("SSE4.1", is_x86_feature_detected!("sse4.1")), + ("SSE4.2", is_x86_feature_detected!("sse4.2")), + ("SSE4A", is_x86_feature_detected!("sse4a")), + ("AVX", is_x86_feature_detected!("avx")), + ("AVX2", is_x86_feature_detected!("avx2")), + ("AVX-512F", is_x86_feature_detected!("avx512f")), + ("FMA", is_x86_feature_detected!("fma")), + ("BMI1", is_x86_feature_detected!("bmi1")), + ("BMI2", is_x86_feature_detected!("bmi2")), + ("AES-NI", is_x86_feature_detected!("aes")), + ("SHA", is_x86_feature_detected!("sha")), + ("RDRAND", is_x86_feature_detected!("rdrand")), + ("RDSEED", is_x86_feature_detected!("rdseed")), + ("POPCNT", is_x86_feature_detected!("popcnt")), + ("F16C", is_x86_feature_detected!("f16c")), + ] + } + // Apple Silicon: the kernel publishes ~80 `hw.optional.arm.FEAT_*` flags. + // Curated rather than enumerated so the grid stays readable and the labels + // stay `&'static str` — the full list is mostly MTE/SME sub-variants. + #[cfg(all(target_arch = "aarch64", target_os = "macos"))] + { + const FEATURES: &[(&str, &str)] = &[ + ("NEON", "hw.optional.arm.AdvSIMD"), + ("FP16", "hw.optional.arm.FEAT_FP16"), + ("BF16", "hw.optional.arm.FEAT_BF16"), + ("I8MM", "hw.optional.arm.FEAT_I8MM"), + ("DotProd", "hw.optional.arm.FEAT_DotProd"), + ("FHM", "hw.optional.arm.FEAT_FHM"), + ("CRC32", "hw.optional.arm.FEAT_CRC32"), + ("AES", "hw.optional.arm.FEAT_AES"), + ("PMULL", "hw.optional.arm.FEAT_PMULL"), + ("SHA1", "hw.optional.arm.FEAT_SHA1"), + ("SHA256", "hw.optional.arm.FEAT_SHA256"), + ("SHA3", "hw.optional.arm.FEAT_SHA3"), + ("SHA512", "hw.optional.arm.FEAT_SHA512"), + ("LSE", "hw.optional.arm.FEAT_LSE"), + ("LSE2", "hw.optional.arm.FEAT_LSE2"), + ("RDM", "hw.optional.arm.FEAT_RDM"), + ("JSCVT", "hw.optional.arm.FEAT_JSCVT"), + ("FCMA", "hw.optional.arm.FEAT_FCMA"), + ("LRCPC", "hw.optional.arm.FEAT_LRCPC"), + ("PAuth", "hw.optional.arm.FEAT_PAuth"), + ("BTI", "hw.optional.arm.FEAT_BTI"), + ("MTE", "hw.optional.arm.FEAT_MTE"), + ("DIT", "hw.optional.arm.FEAT_DIT"), + ("ECV", "hw.optional.arm.FEAT_ECV"), + ("SME", "hw.optional.arm.FEAT_SME"), + ("SME2", "hw.optional.arm.FEAT_SME2"), + ("SSBS", "hw.optional.arm.FEAT_SSBS"), + ("SPECRES", "hw.optional.arm.FEAT_SPECRES"), + ]; + FEATURES + .iter() + .map(|(label, key)| (*label, sysctl_u64(key).unwrap_or(0) != 0)) + .collect() + } + #[cfg(not(any(target_arch = "x86_64", all(target_arch = "aarch64", target_os = "macos"))))] + { + Vec::new() + } +} + +#[cfg(windows)] +fn query() -> SystemInfo { + use std::collections::HashMap; + use wmi::{Variant, WMIConnection}; + + let mut info = SystemInfo { + computer_name: std::env::var("COMPUTERNAME").unwrap_or_default(), + user_name: std::env::var("USERNAME").unwrap_or_default(), + ..Default::default() + }; + info.cpu.features = cpu_features(); + let (cpuid, vendor, codename) = cpuid_info(); + info.cpu.cpuid = cpuid; + info.cpu.vendor = vendor; + info.cpu.codename = codename; + + let Ok(wmi) = WMIConnection::new() else { return info }; + + type Row = HashMap; + + let s = |v: Option<&Variant>| -> String { + match v { + Some(Variant::String(x)) => x.trim().to_string(), + _ => String::new(), + } + }; + let u = |v: Option<&Variant>| -> Option { + match v { + Some(Variant::UI4(x)) => Some(*x), + Some(Variant::I4(x)) => u32::try_from(*x).ok(), + Some(Variant::UI2(x)) => Some(*x as u32), + Some(Variant::String(x)) => x.parse().ok(), + _ => None, + } + }; + let u64v = |v: Option<&Variant>| -> Option { + match v { + Some(Variant::UI8(x)) => Some(*x), + Some(Variant::I8(x)) => u64::try_from(*x).ok(), + Some(Variant::UI4(x)) => Some(*x as u64), + Some(Variant::String(x)) => x.parse().ok(), + _ => None, + } + }; + + if let Ok(rows) = wmi.raw_query::( + "SELECT Name, NumberOfCores, NumberOfLogicalProcessors, MaxClockSpeed, L2CacheSize, L3CacheSize, SocketDesignation FROM Win32_Processor", + ) { + if let Some(r) = rows.first() { + info.cpu.name = s(r.get("Name")); + info.cpu.cores = u(r.get("NumberOfCores")); + info.cpu.threads = u(r.get("NumberOfLogicalProcessors")); + info.cpu.max_clock_mhz = u(r.get("MaxClockSpeed")); + info.cpu.base_clock_mhz = u(r.get("MaxClockSpeed")); + info.cpu.l2_kb = u(r.get("L2CacheSize")); + info.cpu.l3_kb = u(r.get("L3CacheSize")); + info.cpu.socket = Some(s(r.get("SocketDesignation"))).filter(|x| !x.is_empty()); + } + } + + if let Ok(rows) = wmi.raw_query::("SELECT Product, Manufacturer FROM Win32_BaseBoard") { + if let Some(r) = rows.first() { + info.board.product = s(r.get("Product")); + info.board.manufacturer = s(r.get("Manufacturer")); + } + } + if let Ok(rows) = wmi.raw_query::("SELECT SMBIOSBIOSVersion, ReleaseDate FROM Win32_BIOS") { + if let Some(r) = rows.first() { + info.board.bios_version = s(r.get("SMBIOSBIOSVersion")); + let date = s(r.get("ReleaseDate")); + // WMI CIM_DATETIME: yyyymmddHHMMSS… → mm/dd/yyyy like HWiNFO shows. + if date.len() >= 8 { + info.board.bios_date = format!("{}/{}/{}", &date[4..6], &date[6..8], &date[0..4]); + } + } + } + + if let Ok(rows) = wmi.raw_query::( + "SELECT BankLabel, DeviceLocator, Manufacturer, PartNumber, Capacity, Speed, ConfiguredClockSpeed, ConfiguredVoltage, SMBIOSMemoryType FROM Win32_PhysicalMemory", + ) { + let mut total = 0.0; + for r in &rows { + let capacity_gb = u64v(r.get("Capacity")).map(|b| b as f64 / (1u64 << 30) as f64).unwrap_or(0.0); + total += capacity_gb; + // An absent field is left blank; a present one is decoded, so an + // unrecognised code shows as `Unknown (type 0x??)` rather than + // being flattened into a wrong name. + let mem_type = u(r.get("SMBIOSMemoryType")) + .map(smbios_memory_type) + .unwrap_or_default(); + info.memory_modules.push(MemoryModule { + bank: { + let bank = s(r.get("BankLabel")); + let loc = s(r.get("DeviceLocator")); + if bank.is_empty() { loc } else { format!("{bank}/{loc}") } + }, + manufacturer: s(r.get("Manufacturer")), + part_number: s(r.get("PartNumber")), + capacity_gb, + speed_mts: u(r.get("Speed")), + configured_speed_mts: u(r.get("ConfiguredClockSpeed")), + voltage_mv: u(r.get("ConfiguredVoltage")), + memory_type: mem_type.to_string(), + }); + } + if total > 0.0 { + info.total_memory_gb = Some(total); + } + } + + if let Ok(rows) = wmi.raw_query::("SELECT Name, AdapterRAM, DriverVersion FROM Win32_VideoController") { + for r in &rows { + info.gpus.push(GpuInfo { + name: s(r.get("Name")), + vram_gb: u64v(r.get("AdapterRAM")).map(|b| b as f64 / (1u64 << 30) as f64), + driver_version: s(r.get("DriverVersion")), + }); + } + } + + if let Ok(rows) = wmi.raw_query::("SELECT Model, InterfaceType, Size FROM Win32_DiskDrive") { + for r in &rows { + info.drives.push(DriveInfo { + model: s(r.get("Model")), + interface: s(r.get("InterfaceType")), + size_gb: u64v(r.get("Size")).map(|b| b as f64 / 1_000_000_000.0), + }); + } + } + + if let Ok(rows) = wmi.raw_query::("SELECT Caption, BuildNumber, OSArchitecture FROM Win32_OperatingSystem") { + if let Some(r) = rows.first() { + info.os.caption = s(r.get("Caption")); + info.os.build = s(r.get("BuildNumber")); + info.os.arch = s(r.get("OSArchitecture")); + } + } + + // Secure Boot / UEFI: registry flag (no clean WMI class for it). + info.os.secure_boot = read_secure_boot(); + info.os.uefi_boot = info.os.secure_boot.map(|_| true); + + info +} + +#[cfg(windows)] +fn read_secure_boot() -> Option { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let out = std::process::Command::new("reg") + .args([ + "query", + r"HKLM\SYSTEM\CurrentControlSet\Control\SecureBoot\State", + "/v", + "UEFISecureBootEnabled", + ]) + .creation_flags(CREATE_NO_WINDOW) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout); + if text.contains("0x1") { + Some(true) + } else if text.contains("0x0") { + Some(false) + } else { + None + } +} + +#[cfg(target_os = "macos")] +fn query() -> SystemInfo { + let (cpuid, vendor, codename) = cpuid_info(); + + // Apple Silicon is heterogeneous: perflevel0 is the fast cluster (named + // "Performance" through M4, "Super" on M5 — read the name, don't hardcode + // it) and perflevel1 the efficiency cluster. Sum both for the core count, + // and report the layout in `socket` since there is no socket to speak of. + let mut cluster_desc = Vec::new(); + let mut physical = 0u32; + for level in 0..4 { + let Some(count) = sysctl_u64(&format!("hw.perflevel{level}.physicalcpu")) else { + break; + }; + physical += count as u32; + let name = sysctl_string(&format!("hw.perflevel{level}.name")) + .unwrap_or_else(|| format!("level{level}")); + cluster_desc.push(format!("{count} {name}")); + } + // Fall back to the flat count on any Mac that doesn't publish perflevels. + let cores = if physical > 0 { Some(physical) } else { sysctl_u64("hw.physicalcpu").map(|v| v as u32) }; + + let total_memory_gb = sysctl_u64("hw.memsize").map(|b| b as f64 / (1024.0 * 1024.0 * 1024.0)); + + // The SoC is soldered, so there are no per-DIMM SPD entries to enumerate; + // present the unified memory as a single honest module. + let memory_modules = total_memory_gb + .map(|gb| { + vec![MemoryModule { + bank: "Unified Memory".into(), + manufacturer: "Apple".into(), + capacity_gb: gb, + memory_type: "LPDDR (on-package)".into(), + ..Default::default() + }] + }) + .unwrap_or_default(); + + // The integrated GPU shares the SoC; name it after the chip and its core + // count rather than inventing a discrete-adapter identity. + let chip = sysctl_string("machdep.cpu.brand_string").unwrap_or_default(); + let (gpu_cores, metal_driver) = crate::source::macos::sysprofile::gpu_identity(); + let gpus = if chip.is_empty() { + Vec::new() + } else { + let name = match gpu_cores { + Some(cores) => format!("{chip} GPU ({cores} cores)"), + None => format!("{chip} GPU"), + }; + vec![GpuInfo { + name, + // Unified memory — there is no separate VRAM pool to report, and + // quoting total system RAM here would be misleading. + vram_gb: None, + driver_version: metal_driver.unwrap_or_default(), + }] + }; + + // Internal SSD(s), so the Summary's Drives panel isn't blank. + const GB: f64 = 1024.0 * 1024.0 * 1024.0; + let drives = crate::source::macos::sysprofile::drives() + .into_iter() + .map(|(model, bytes)| DriveInfo { + model, + interface: "NVMe".into(), + size_gb: bytes.map(|b| b as f64 / GB), + }) + .collect(); + + // Performance-cluster DVFS states, for the Base/Max Clock rows. + let p_states = + crate::source::macos::dvfs::frequencies_mhz(crate::source::macos::dvfs::Block::Pcpu); + + let os_version = sysctl_string("kern.osproductversion").unwrap_or_default(); + + SystemInfo { + computer_name: sysctl_string("kern.hostname").unwrap_or_default(), + user_name: std::env::var("USER").unwrap_or_default(), + cpu: CpuInfo { + name: chip, + cores, + // Apple Silicon has no SMT: one thread per physical core. + threads: sysctl_u64("hw.logicalcpu").map(|v| v as u32), + // There is no fixed "base clock" on Apple Silicon; the closest + // honest equivalents are the bottom and top of the performance + // cluster's DVFS table. + base_clock_mhz: p_states.first().map(|mhz| *mhz as u32), + max_clock_mhz: p_states.last().map(|mhz| *mhz as u32), + // hw.l2cachesize reports the *efficiency* cluster (6 MB here). + // The headline figure is the performance cluster's L2 + // (hw.perflevel0.l2cachesize, 16 MB), so prefer that. + l2_kb: sysctl_u64("hw.perflevel0.l2cachesize") + .or_else(|| sysctl_u64("hw.l2cachesize")) + .map(|b| (b / 1024) as u32), + // Apple Silicon has no per-core L3; the system-level cache is not + // published anywhere readable, so this stays honestly blank. + l3_kb: None, + socket: (!cluster_desc.is_empty()).then(|| cluster_desc.join(" + ")), + features: cpu_features(), + cpuid, + vendor, + codename, + }, + board: BoardInfo { + // Prefer the marketing name ("MacBook Air (13-inch, M5)") over the + // bare board id ("Mac17,3"), which is already shown as the codename. + product: crate::source::macos::sysprofile::product_name() + .or_else(|| sysctl_string("hw.model")) + .unwrap_or_default(), + manufacturer: "Apple Inc.".into(), + // Apple Silicon boots via iBoot, so the closest thing to a BIOS + // version is the boot firmware revision. + bios_version: crate::source::macos::sysprofile::firmware_version().unwrap_or_default(), + // No firmware build date is published anywhere in IOKit; leave it + // blank rather than guessing from the version string. + bios_date: String::new(), + }, + memory_modules, + total_memory_gb, + gpus, + drives, + os: OsInfo { + caption: if os_version.is_empty() { + "macOS".into() + } else { + format!("macOS {os_version}") + }, + build: sysctl_string("kern.osversion").unwrap_or_default(), + arch: std::env::consts::ARCH.to_string(), + // Apple Silicon boots via iBoot, not UEFI, and Secure Boot state + // lives in a different subsystem — leave both indeterminate rather + // than asserting something false. + uefi_boot: None, + secure_boot: None, + }, + } +} + +#[cfg(target_os = "linux")] +fn query() -> SystemInfo { + use std::fs; + let (cpuid, vendor, codename) = cpuid_info(); + let mut info = SystemInfo { + computer_name: std::env::var("HOSTNAME").unwrap_or_default(), + user_name: std::env::var("USER").unwrap_or_default(), + cpu: CpuInfo { features: cpu_features(), cpuid, vendor, codename, ..Default::default() }, + ..Default::default() + }; + + // Read CPU Model Name and Cores from /proc/cpuinfo + if let Ok(cpuinfo) = fs::read_to_string("/proc/cpuinfo") { + let mut model_name = String::new(); + let mut logical_count = 0u32; + for line in cpuinfo.lines() { + if line.starts_with("model name") { + if let Some(val) = line.split(':').nth(1) { + if model_name.is_empty() { + model_name = val.trim().to_string(); + } + } + } + if line.starts_with("processor") { + logical_count += 1; + } + } + if !model_name.is_empty() { + info.cpu.name = model_name; + } + if logical_count > 0 { + info.cpu.threads = Some(logical_count); + info.cpu.cores = Some(logical_count); // best-effort fallback + } + } + + // Read Motherboard / DMI Info from /sys/class/dmi/id + if let Ok(product) = fs::read_to_string("/sys/class/dmi/id/board_name") { + info.board.product = product.trim().to_string(); + } + if let Ok(vendor) = fs::read_to_string("/sys/class/dmi/id/board_vendor") { + info.board.manufacturer = vendor.trim().to_string(); + } + if let Ok(version) = fs::read_to_string("/sys/class/dmi/id/bios_version") { + info.board.bios_version = version.trim().to_string(); + } + if let Ok(date) = fs::read_to_string("/sys/class/dmi/id/bios_date") { + info.board.bios_date = date.trim().to_string(); + } + + // Read RAM Total from /proc/meminfo + if let Ok(meminfo) = fs::read_to_string("/proc/meminfo") { + for line in meminfo.lines() { + if line.starts_with("MemTotal:") { + if let Some(kb_str) = line.split_whitespace().nth(1) { + if let Ok(kb) = kb_str.parse::() { + info.total_memory_gb = Some(kb / (1024.0 * 1024.0)); + } + } + } + } + } + + // Read OS info from /etc/os-release + if let Ok(os_release) = fs::read_to_string("/etc/os-release") { + for line in os_release.lines() { + if line.starts_with("PRETTY_NAME=") { + let name = line.trim_start_matches("PRETTY_NAME=").trim_matches('"'); + info.os.caption = name.to_string(); + } + } + } + + info +} + +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] +fn query() -> SystemInfo { + let (cpuid, vendor, codename) = cpuid_info(); + SystemInfo { + computer_name: std::env::var("HOSTNAME").unwrap_or_default(), + user_name: std::env::var("USER").unwrap_or_default(), + cpu: CpuInfo { features: cpu_features(), cpuid, vendor, codename, ..Default::default() }, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Codes are the DMTF SMBIOS values, cross-checked against dmidecode's + // `dmi_memory_device_type` table. These run on every platform, since the + // decoder takes the raw code rather than reading WMI. + + #[test] + fn the_three_codes_that_already_worked_still_decode_the_same_way() { + assert_eq!(smbios_memory_type(24), "DDR3"); + assert_eq!(smbios_memory_type(26), "DDR4"); + assert_eq!(smbios_memory_type(34), "DDR5"); + } + + #[test] + fn soldered_laptop_memory_is_named_rather_than_flattened_to_dram() { + // The reason for the change: every one of these used to fall through + // to "DRAM" and render as "DRAM SDRAM". + assert_eq!(smbios_memory_type(0x1B), "LPDDR"); + assert_eq!(smbios_memory_type(0x1C), "LPDDR2"); + assert_eq!(smbios_memory_type(0x1D), "LPDDR3"); + assert_eq!(smbios_memory_type(0x1E), "LPDDR4"); + assert_eq!(smbios_memory_type(0x23), "LPDDR5"); + } + + #[test] + fn stacked_memory_decodes_too() { + assert_eq!(smbios_memory_type(0x20), "HBM"); + assert_eq!(smbios_memory_type(0x21), "HBM2"); + assert_eq!(smbios_memory_type(0x24), "HBM3"); + } + + #[test] + fn every_assigned_code_from_1_to_0x24_decodes_to_a_name() { + // The claim this table makes: no assigned code falls through. 0x15-0x17 + // are Reserved rather than memory types, so they are the exception. + for code in 0x01..=0x24u32 { + let decoded = smbios_memory_type(code); + if (0x15..=0x17).contains(&code) { + assert_eq!(decoded, format!("Unknown (type {code:#04X})")); + } else { + assert!( + !decoded.starts_with("Unknown (type"), + "assigned code {code:#04X} fell through to the unknown path" + ); + } + } + } + + #[test] + fn an_unassigned_code_reports_the_raw_value_instead_of_guessing() { + // 0x25 is past the last code DMTF has assigned. Showing the number is + // what lets someone look it up. + assert_eq!(smbios_memory_type(0x25), "Unknown (type 0x25)"); + assert_eq!(smbios_memory_type(0xFF), "Unknown (type 0xFF)"); + } + + #[test] + fn smbios_other_and_unknown_are_both_reported_as_unknown() { + assert_eq!(smbios_memory_type(0x01), "Unknown"); + assert_eq!(smbios_memory_type(0x02), "Unknown"); + } + + #[test] + fn the_sdram_suffix_is_only_added_where_it_means_something() { + assert_eq!(memory_type_label("DDR5"), "DDR5 SDRAM"); + assert_eq!(memory_type_label("LPDDR5"), "LPDDR5 SDRAM"); + // These are the ones that used to produce nonsense. + assert_eq!(memory_type_label("HBM3"), "HBM3"); + assert_eq!(memory_type_label("Unknown"), "Unknown"); + assert_eq!(memory_type_label("Unknown (type 0x25)"), "Unknown (type 0x25)"); + assert_eq!(memory_type_label(""), ""); + } + + // These run on every CI leg, including aarch64, because `codename_for` + // takes the family and model as arguments rather than executing CPUID. + + #[test] + fn intel_hybrid_parts_are_named_rather_than_all_reporting_intel_core() { + // The whole point of the change: every one of these used to be + // indistinguishable, because family 6 was matched without the model. + assert_eq!(codename_for("GenuineIntel", 0x6, 0x97), "Alder Lake (Golden Cove/Gracemont)"); + assert_eq!(codename_for("GenuineIntel", 0x6, 0xBF), "Raptor Lake (Raptor Cove/Gracemont)"); + assert_eq!(codename_for("GenuineIntel", 0x6, 0xAA), "Meteor Lake (Redwood Cove/Crestmont)"); + assert_eq!(codename_for("GenuineIntel", 0x6, 0xC6), "Arrow Lake (Lion Cove/Skymont)"); + assert_eq!(codename_for("GenuineIntel", 0x6, 0xBD), "Lunar Lake (Lion Cove/Skymont)"); + assert_eq!(codename_for("GenuineIntel", 0x6, 0xCC), "Panther Lake (Cougar Cove/Darkmont)"); + } + + #[test] + fn intel_parts_off_family_six_are_recognised() { + // Nova Lake and Diamond Rapids are the first Intel parts to leave + // family 6; matching on the family alone reported "" for them. + assert_eq!(codename_for("GenuineIntel", 0x12, 0x01), "Nova Lake (Coyote Cove/Arctic Wolf)"); + assert_eq!(codename_for("GenuineIntel", 0x13, 0x01), "Diamond Rapids (Panther Cove)"); + } + + #[test] + fn an_atom_is_not_called_a_core() { + assert_eq!(codename_for("GenuineIntel", 0x6, 0x9C), "Jasper Lake (Tremont)"); + assert_eq!(codename_for("GenuineIntel", 0x6, 0xBE), "Alder Lake-N (Gracemont)"); + assert_eq!(codename_for("GenuineIntel", 0x6, 0xAF), "Sierra Forest (Crestmont)"); + } + + #[test] + fn historic_intel_parts_still_match_the_c_sharp_table_they_came_from() { + // Hardware/CPU/IntelCPU.cs, which these sources are a port of. + assert_eq!(codename_for("GenuineIntel", 0x6, 0x8D), "Tiger Lake"); + assert_eq!(codename_for("GenuineIntel", 0x6, 0xA5), "Comet Lake"); + assert_eq!(codename_for("GenuineIntel", 0x6, 0x3C), "Haswell"); + assert_eq!(codename_for("GenuineIntel", 0x6, 0x2A), "Sandy Bridge"); + assert_eq!(codename_for("GenuineIntel", 0xf, 0x03), "NetBurst"); + } + + #[test] + fn zen_five_is_split_by_model_instead_of_all_reporting_granite_ridge() { + // The bug this fixes: `(0x1a, _)` labelled every Zen 5 part with the + // desktop codename, so a Strix Point laptop and a Turin server both + // claimed to be a Granite Ridge desktop. + assert_eq!(codename_for("AuthenticAMD", 0x1a, 0x44), "Granite Ridge (Zen 5)"); + assert_eq!(codename_for("AuthenticAMD", 0x1a, 0x24), "Strix Point (Zen 5)"); + assert_eq!(codename_for("AuthenticAMD", 0x1a, 0x02), "Turin (Zen 5)"); + } + + #[test] + fn an_unknown_model_falls_back_to_a_generation_that_is_still_true() { + // A part released after this table was written must degrade to a + // correct generation, never to a wrong codename and never to blank. + assert_eq!(codename_for("AuthenticAMD", 0x1a, 0xF0), "Zen 5"); + assert_eq!(codename_for("AuthenticAMD", 0x19, 0xF0), "Zen 3/Zen 4"); + assert_eq!(codename_for("AuthenticAMD", 0x17, 0xF0), "Zen/Zen+/Zen 2"); + // Not "Intel Core": family 6 carries Atom parts too, so that would + // be a wrong brand rather than a cautious one. + assert_eq!(codename_for("GenuineIntel", 0x6, 0xFE), "Intel (family 6h)"); + } + + #[test] + fn first_generation_ryzen_is_no_longer_labelled_zen_2() { + // `(0x17, _) => "Matisse/Renoir (Zen 2)"` called Summit Ridge a Zen 2 + // part. Family 17h spans Zen through Zen 2. + assert_eq!(codename_for("AuthenticAMD", 0x17, 0x01), "Summit Ridge/Naples (Zen)"); + } + + #[test] + fn zen_three_and_zen_four_share_family_nineteen() { + assert_eq!(codename_for("AuthenticAMD", 0x19, 0x21), "Vermeer (Zen 3)"); + assert_eq!(codename_for("AuthenticAMD", 0x19, 0x50), "Cezanne (Zen 3)"); + assert_eq!(codename_for("AuthenticAMD", 0x19, 0x44), "Rembrandt (Zen 3+)"); + assert_eq!(codename_for("AuthenticAMD", 0x19, 0x61), "Raphael (Zen 4)"); + assert_eq!(codename_for("AuthenticAMD", 0x19, 0x74), "Phoenix (Zen 4)"); + assert_eq!(codename_for("AuthenticAMD", 0x19, 0x01), "Milan (Zen 3)"); + } + + #[test] + fn a_non_x86_vendor_string_says_nothing_rather_than_guessing() { + assert_eq!(codename_for("Apple", 0x0, 0x0), ""); + assert_eq!(codename_for("Qualcomm", 0x0, 0x0), ""); + assert_eq!(codename_for("", 0x6, 0x97), ""); + } + + // --- Cross-check against upstream OpenHardwareMonitor PR #1671 --------- + // + // "New Intel Architectures" (Leckrosh, Jul 2026) is the only open upstream + // PR that overlaps this table. It cannot be merged — the C# tree it edits + // is reference material this build never compiles — so it was treated as a + // second opinion on the model numbers instead, and checked model by model. + // + // Every architecture it adds was already covered here. Two of its model + // numbers were *not* adopted, which is the part worth pinning: 0xAB + // (claimed Meteor Lake) and 0xBC (claimed Lunar Lake) appear in neither + // the Linux kernel's `arch/x86/include/asm/intel-family.h` nor + // LibreHardwareMonitor's `IntelCpu.cs` — the engine that actually reads + // sensors on Windows here. Two independent sources having no such models, + // and the PR citing none, is not enough to name a part. + // + // They are not special-cased. They fall to the family arm and report + // "Intel (family 6h)", which is the correct answer for a model nobody can + // source: honest rather than wrong. + + #[test] + fn every_model_upstream_pr_1671_adds_is_already_covered() { + for (model, expected) in [ + (0x97, "Alder Lake (Golden Cove/Gracemont)"), + (0x9A, "Alder Lake (Golden Cove/Gracemont)"), + (0xA7, "Rocket Lake (Cypress Cove)"), + (0xB7, "Raptor Lake (Raptor Cove/Gracemont)"), + (0xBA, "Raptor Lake (Raptor Cove/Gracemont)"), + (0xBF, "Raptor Lake (Raptor Cove/Gracemont)"), + (0xAA, "Meteor Lake (Redwood Cove/Crestmont)"), + (0xAC, "Meteor Lake (Redwood Cove/Crestmont)"), + (0xB5, "Arrow Lake (Lion Cove/Skymont)"), + (0xC5, "Arrow Lake (Lion Cove/Skymont)"), + (0xC6, "Arrow Lake (Lion Cove/Skymont)"), + (0xBD, "Lunar Lake (Lion Cove/Skymont)"), + (0xCC, "Panther Lake (Cougar Cove/Darkmont)"), + ] { + assert_eq!( + codename_for("GenuineIntel", 0x6, model), + expected, + "model {model:#04X} from upstream PR #1671", + ); + } + } + + #[test] + fn unsourced_models_report_the_family_rather_than_a_guessed_part() { + // Upstream PR #1671 assigns these; no primary source does. If a + // future kernel header or LHM release adds either, this test is the + // thing that should fail and prompt naming them properly. + assert_eq!(codename_for("GenuineIntel", 0x6, 0xAB), "Intel (family 6h)"); + assert_eq!(codename_for("GenuineIntel", 0x6, 0xBC), "Intel (family 6h)"); + } + + #[test] + fn alder_lake_n_is_not_folded_into_raptor_lake() { + // Upstream PR #1671 groups 0xBE with Raptor Lake. Both + // `intel-family.h` (INTEL_ALDERLAKE_N) and LibreHardwareMonitor call + // it Alder Lake-N, so the PR is the outlier and was not followed. + assert_eq!(codename_for("GenuineIntel", 0x6, 0xBE), "Alder Lake-N (Gracemont)"); + } + + #[test] + fn parts_no_upstream_source_covers_yet_are_still_named() { + // Bartlett Lake, Clearwater Forest, Wildcat Lake, Panther Lake-R, + // Nova Lake and Diamond Rapids are absent from both upstream OHM and + // LibreHardwareMonitor's tables; `intel-family.h` carries all six. + assert_eq!(codename_for("GenuineIntel", 0x6, 0xD7), "Bartlett Lake (Raptor Cove)"); + assert_eq!(codename_for("GenuineIntel", 0x6, 0xDD), "Clearwater Forest (Darkmont)"); + assert_eq!(codename_for("GenuineIntel", 0x6, 0xD5), "Wildcat Lake"); + assert_eq!(codename_for("GenuineIntel", 0x6, 0xE5), "Panther Lake (Cougar Cove/Darkmont)"); + assert_eq!(codename_for("GenuineIntel", 0x12, 0x03), "Nova Lake (Coyote Cove/Arctic Wolf)"); + assert_eq!(codename_for("GenuineIntel", 0x13, 0x01), "Diamond Rapids (Panther Cove)"); + } +} diff --git a/app/src/ui/summary_window.rs b/app/src/ui/summary_window.rs index 18e537ab..9c0fd21a 100644 --- a/app/src/ui/summary_window.rs +++ b/app/src/ui/summary_window.rs @@ -1,395 +1,395 @@ -//! HWiNFO-style "System Summary" window: CPU / Motherboard / Memory / GPU / -//! OS / Drives panel grid with the ISA features chip-grid and an Operating -//! Point table fed by live sensors. - -use eframe::egui::{self, RichText}; - -use super::widgets::{chip, info_row, panel}; -use super::{Palette, Shared}; -use crate::model::{Hardware, HardwareType, SensorType}; - -pub fn show(ui: &mut egui::Ui, s: &Shared) { - super::handle_close(ui, &s.windows.summary); - let pal = s.palette(); - let info = s.sysinfo.read().ok().and_then(|i| i.clone()); - let frame = s.frame(); - let tree = &frame.tree; - - egui::CentralPanel::default() - .frame( - egui::Frame::new() - .fill(pal.bg) - .inner_margin(egui::Margin::same(8)), - ) - .show(ui, |ui| { - egui::ScrollArea::vertical().show(ui, |ui| { - let Some(i) = info else { - ui.label(RichText::new("Enumerating system…").color(pal.text_dim)); - return; - }; - - ui.columns(3, |cols| { - // ---- CPU ------------------------------------------------ - cpu_panel(&mut cols[0], &i, tree, &pal); - // ---- Motherboard + Memory ------------------------------ - board_memory_panels(&mut cols[1], &i, &pal); - // ---- GPU + OS + Drives --------------------------------- - gpu_os_drives_panels(&mut cols[2], &i, tree, &pal); - }); - }); - }); -} - -fn cpu_panel(ui: &mut egui::Ui, i: &crate::sysinfo::SystemInfo, tree: &[Hardware], pal: &Palette) { - panel(ui, "CPU", pal, |ui| { - // Vendor text badge (no trademarked logos). - let vendor = if i.cpu.name.to_uppercase().contains("AMD") { - "AMD" - } else if i.cpu.name.to_uppercase().contains("INTEL") { - "INTEL" - } else { - "CPU" - }; - ui.horizontal(|ui| { - egui::Frame::new() - .fill(pal.bg_header) - .corner_radius(3) - .inner_margin(egui::Margin::symmetric(10, 8)) - .show(ui, |ui| { - ui.label(RichText::new(vendor).color(pal.accent).size(15.0).strong()); - }); - ui.vertical(|ui| { - ui.label(RichText::new(&i.cpu.name).color(pal.text).size(12.0).strong()); - ui.label( - RichText::new(i.cpu.socket.as_deref().unwrap_or("—")) - .color(pal.text_dim) - .size(10.5), - ); - }); - }); - ui.add_space(4.0); - - let cores = i - .cpu - .cores - .map(|c| format!("{c} / {}", i.cpu.threads.unwrap_or(c))) - .unwrap_or_default(); - info_row(ui, "Cores / Threads:", &cores, pal); - info_row( - ui, - "L2 Cache:", - &i.cpu.l2_kb.map(|k| format!("{} KB", k)).unwrap_or_default(), - pal, - ); - info_row( - ui, - "L3 Cache:", - &i.cpu.l3_kb.map(|k| format!("{} MB", k / 1024)).unwrap_or_default(), - pal, - ); - info_row(ui, "Codename:", &i.cpu.codename, pal); - info_row(ui, "CPUID:", &i.cpu.cpuid, pal); - info_row( - ui, - "Package Power:", - &cpu_sensor(tree, SensorType::Power, "package") - .map(|v| format!("{v:.1} W")) - .unwrap_or_default(), - pal, - ); - - ui.add_space(4.0); - ui.label(RichText::new("Features").color(pal.text_dim).size(10.5)); - // Fixed rows of 5 — deterministic wrap regardless of column width. - for row in i.cpu.features.chunks(5) { - ui.horizontal(|ui| { - for (name, on) in row { - chip(ui, name, *on, pal); - } - }); - } - - ui.add_space(6.0); - ui.label(RichText::new("Operating Point").color(pal.text_dim).size(10.5)); - operating_point_table(ui, i, tree, pal); - }); -} - -/// Min/Base/Boost/Avg clock table from WMI base clock + live core clocks/VIDs. -fn operating_point_table(ui: &mut egui::Ui, i: &crate::sysinfo::SystemInfo, tree: &[Hardware], pal: &Palette) { - let clocks = collect_cpu(tree, SensorType::Clock, "core"); - let vids = collect_cpu(tree, SensorType::Voltage, "vid"); - let cur_avg = mean(&clocks); - let cur_max = clocks.iter().copied().fold(f32::NAN, f32::max); - let vid = mean(&vids); - - egui::Grid::new("op_table") - .num_columns(3) - .spacing([12.0, 2.0]) - .show(ui, |ui| { - let head = |ui: &mut egui::Ui, t: &str| { - ui.label(RichText::new(t).color(pal.text_dim).size(10.5).strong()); - }; - head(ui, ""); - head(ui, "Clock"); - head(ui, "VID"); - ui.end_row(); - - let row = |ui: &mut egui::Ui, name: &str, clock: Option, vid: Option, pal: &Palette| { - ui.label(RichText::new(name).color(pal.text).size(10.5)); - ui.label( - RichText::new(clock.map(|c| format!("{c:.1} MHz")).unwrap_or("—".into())) - .color(pal.clockc) - .size(10.5) - .monospace(), - ); - ui.label( - RichText::new(vid.map(|v| format!("{v:.4} V")).unwrap_or("—".into())) - .color(pal.volt) - .size(10.5) - .monospace(), - ); - ui.end_row(); - }; - row(ui, "Base Clock", i.cpu.base_clock_mhz.map(|c| c as f32), None, pal); - row(ui, "Max Clock", Some(cur_max).filter(|v| v.is_finite()), None, pal); - row(ui, "Avg. Active Clock", cur_avg, vid, pal); - }); -} - -fn board_memory_panels(ui: &mut egui::Ui, i: &crate::sysinfo::SystemInfo, pal: &Palette) { - panel(ui, "Motherboard", pal, |ui| { - ui.label( - RichText::new(format!("{} {}", i.board.manufacturer, i.board.product)) - .color(pal.text) - .size(12.0) - .strong(), - ); - ui.add_space(2.0); - info_row(ui, "Chipset:", "", pal); // needs PCI enum — native engine - info_row(ui, "BIOS Version:", &i.board.bios_version, pal); - info_row(ui, "BIOS Date:", &i.board.bios_date, pal); - }); - - ui.add_space(6.0); - - panel(ui, "Memory", pal, |ui| { - info_row( - ui, - "Size:", - &i.total_memory_gb.map(|g| format!("{g:.0} GB")).unwrap_or_default(), - pal, - ); - let mem_type = i - .memory_modules - .first() - .map(|m| crate::sysinfo::memory_type_label(&m.memory_type)) - .unwrap_or_default(); - info_row(ui, "Type:", &mem_type, pal); - let clock = i - .memory_modules - .first() - .and_then(|m| m.configured_speed_mts.or(m.speed_mts)) - .map(|v| format!("{v} MT/s")) - .unwrap_or_default(); - info_row(ui, "Clock:", &clock, pal); - // Unified memory is a wide on-package bus, not a DIMM channel count — - // inferring "Single-Channel" from the one synthetic module would be - // plainly wrong. - let unified = i - .memory_modules - .first() - .is_some_and(|m| m.memory_type.contains("on-package")); - let mode = if unified { - "Unified" - } else { - match i.memory_modules.len() { - 2 => "Dual-Channel", - 4 => "Quad-Channel", - 1 => "Single-Channel", - _ => "", - } - }; - info_row(ui, "Mode:", mode, pal); - info_row(ui, "Timings:", "", pal); // needs SPD — native engine - - ui.add_space(4.0); - ui.label(RichText::new("Memory Modules").color(pal.text_dim).size(10.5)); - for m in &i.memory_modules { - egui::Frame::new() - .fill(pal.bg_header) - .corner_radius(2) - .inner_margin(egui::Margin::same(4)) - .show(ui, |ui| { - ui.label( - RichText::new(format!("{}: {} {}", m.bank, m.manufacturer, m.part_number)) - .color(pal.text) - .size(10.5), - ); - ui.label( - RichText::new(format!( - "{:.0} GB {} @ {} MT/s {}", - m.capacity_gb, - m.memory_type, - m.configured_speed_mts.or(m.speed_mts).unwrap_or(0), - m.voltage_mv - .map(|v| format!("{:.2} V", v as f32 / 1000.0)) - .unwrap_or_default(), - )) - .color(pal.text_dim) - .size(10.0), - ); - }); - ui.add_space(2.0); - } - }); -} - -fn gpu_os_drives_panels(ui: &mut egui::Ui, i: &crate::sysinfo::SystemInfo, tree: &[Hardware], pal: &Palette) { - panel(ui, "GPU", pal, |ui| { - for (gi, g) in i.gpus.iter().enumerate() { - let vendor = if g.name.to_uppercase().contains("NVIDIA") { - "NVIDIA" - } else if g.name.to_uppercase().contains("AMD") || g.name.to_uppercase().contains("RADEON") { - "RADEON" - } else { - "GPU" - }; - ui.horizontal(|ui| { - egui::Frame::new() - .fill(pal.bg_header) - .corner_radius(3) - .inner_margin(egui::Margin::symmetric(8, 6)) - .show(ui, |ui| { - ui.label(RichText::new(vendor).color(pal.ok_badge).size(12.0).strong()); - }); - ui.vertical(|ui| { - ui.label(RichText::new(&g.name).color(pal.text).size(11.5).strong()); - // NOTE: WMI AdapterRAM is a u32 capped at 4 GB — showing it - // would be wrong for modern cards. VRAM comes with the - // native GPU engine (NVML/ADL). - ui.label( - RichText::new(format!("Driver {}", g.driver_version)) - .color(pal.text_dim) - .size(10.0), - ); - }); - }); - if gi + 1 < i.gpus.len() { - ui.add_space(3.0); - } - } - ui.add_space(4.0); - // Live GPU clocks from sensors. - let (core, mem) = gpu_live_clocks(tree); - info_row( - ui, - "GPU Clock:", - &core.map(|v| format!("{v:.1} MHz")).unwrap_or_default(), - pal, - ); - info_row( - ui, - "Memory Clock:", - &mem.map(|v| format!("{v:.1} MHz")).unwrap_or_default(), - pal, - ); - info_row(ui, "PCIe Link:", "", pal); // needs native engine - }); - - ui.add_space(6.0); - - panel(ui, "Operating System", pal, |ui| { - ui.label( - RichText::new(format!("{} ({})", i.os.caption, i.os.arch)) - .color(pal.text) - .size(11.0), - ); - info_row(ui, "Build:", &i.os.build, pal); - super::widgets::badge(ui, "UEFI Boot:", i.os.uefi_boot, pal); - super::widgets::badge(ui, "Secure Boot:", i.os.secure_boot, pal); - }); - - ui.add_space(6.0); - - panel(ui, "Drives", pal, |ui| { - for d in &i.drives { - ui.label( - RichText::new(format!( - "• {} [{}] {}", - d.model, - d.interface, - d.size_gb.map(|g| format!("{g:.0} GB")).unwrap_or_default() - )) - .color(pal.text) - .size(10.5), - ); - } - }); -} - -// ---- live-sensor helpers ------------------------------------------------ - -/// First CPU sensor of a type whose name contains `needle` (case-insensitive). -fn cpu_sensor(tree: &[Hardware], t: SensorType, needle: &str) -> Option { - for hw in tree { - if hw.hardware_type == HardwareType::Cpu { - for s in &hw.sensors { - if s.sensor_type == t && s.name.to_lowercase().contains(needle) { - return s.value; - } - } - } - } - None -} - -fn collect_cpu(tree: &[Hardware], t: SensorType, name_contains: &str) -> Vec { - let mut out = Vec::new(); - for hw in tree { - if hw.hardware_type == HardwareType::Cpu { - for s in &hw.sensors { - if s.sensor_type == t && s.name.to_lowercase().contains(name_contains) { - if let Some(v) = s.value { - out.push(v); - } - } - } - } - } - out -} - -fn gpu_live_clocks(tree: &[Hardware]) -> (Option, Option) { - let mut core = None; - let mut mem = None; - for hw in tree { - if matches!( - hw.hardware_type, - HardwareType::GpuNvidia - | HardwareType::GpuAti - | HardwareType::GpuIntel - | HardwareType::GpuApple - ) { - for s in &hw.sensors { - if s.sensor_type == SensorType::Clock { - let n = s.name.to_lowercase(); - if n.contains("core") && core.is_none() { - core = s.value; - } else if n.contains("memory") && mem.is_none() { - mem = s.value; - } - } - } - } - } - (core, mem) -} - -fn mean(v: &[f32]) -> Option { - if v.is_empty() { - None - } else { - Some(v.iter().sum::() / v.len() as f32) - } -} +//! HWiNFO-style "System Summary" window: CPU / Motherboard / Memory / GPU / +//! OS / Drives panel grid with the ISA features chip-grid and an Operating +//! Point table fed by live sensors. + +use eframe::egui::{self, RichText}; + +use super::widgets::{chip, info_row, panel}; +use super::{Palette, Shared}; +use crate::model::{Hardware, HardwareType, SensorType}; + +pub fn show(ui: &mut egui::Ui, s: &Shared) { + super::handle_close(ui, &s.windows.summary); + let pal = s.palette(); + let info = s.sysinfo.read().ok().and_then(|i| i.clone()); + let frame = s.frame(); + let tree = &frame.tree; + + egui::CentralPanel::default() + .frame( + egui::Frame::new() + .fill(pal.bg) + .inner_margin(egui::Margin::same(8)), + ) + .show(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + let Some(i) = info else { + ui.label(RichText::new("Enumerating system…").color(pal.text_dim)); + return; + }; + + ui.columns(3, |cols| { + // ---- CPU ------------------------------------------------ + cpu_panel(&mut cols[0], &i, tree, &pal); + // ---- Motherboard + Memory ------------------------------ + board_memory_panels(&mut cols[1], &i, &pal); + // ---- GPU + OS + Drives --------------------------------- + gpu_os_drives_panels(&mut cols[2], &i, tree, &pal); + }); + }); + }); +} + +fn cpu_panel(ui: &mut egui::Ui, i: &crate::sysinfo::SystemInfo, tree: &[Hardware], pal: &Palette) { + panel(ui, "CPU", pal, |ui| { + // Vendor text badge (no trademarked logos). + let vendor = if i.cpu.name.to_uppercase().contains("AMD") { + "AMD" + } else if i.cpu.name.to_uppercase().contains("INTEL") { + "INTEL" + } else { + "CPU" + }; + ui.horizontal(|ui| { + egui::Frame::new() + .fill(pal.bg_header) + .corner_radius(3) + .inner_margin(egui::Margin::symmetric(10, 8)) + .show(ui, |ui| { + ui.label(RichText::new(vendor).color(pal.accent).size(15.0).strong()); + }); + ui.vertical(|ui| { + ui.label(RichText::new(&i.cpu.name).color(pal.text).size(12.0).strong()); + ui.label( + RichText::new(i.cpu.socket.as_deref().unwrap_or("—")) + .color(pal.text_dim) + .size(10.5), + ); + }); + }); + ui.add_space(4.0); + + let cores = i + .cpu + .cores + .map(|c| format!("{c} / {}", i.cpu.threads.unwrap_or(c))) + .unwrap_or_default(); + info_row(ui, "Cores / Threads:", &cores, pal); + info_row( + ui, + "L2 Cache:", + &i.cpu.l2_kb.map(|k| format!("{} KB", k)).unwrap_or_default(), + pal, + ); + info_row( + ui, + "L3 Cache:", + &i.cpu.l3_kb.map(|k| format!("{} MB", k / 1024)).unwrap_or_default(), + pal, + ); + info_row(ui, "Codename:", &i.cpu.codename, pal); + info_row(ui, "CPUID:", &i.cpu.cpuid, pal); + info_row( + ui, + "Package Power:", + &cpu_sensor(tree, SensorType::Power, "package") + .map(|v| format!("{v:.1} W")) + .unwrap_or_default(), + pal, + ); + + ui.add_space(4.0); + ui.label(RichText::new("Features").color(pal.text_dim).size(10.5)); + // Fixed rows of 5 — deterministic wrap regardless of column width. + for row in i.cpu.features.chunks(5) { + ui.horizontal(|ui| { + for (name, on) in row { + chip(ui, name, *on, pal); + } + }); + } + + ui.add_space(6.0); + ui.label(RichText::new("Operating Point").color(pal.text_dim).size(10.5)); + operating_point_table(ui, i, tree, pal); + }); +} + +/// Min/Base/Boost/Avg clock table from WMI base clock + live core clocks/VIDs. +fn operating_point_table(ui: &mut egui::Ui, i: &crate::sysinfo::SystemInfo, tree: &[Hardware], pal: &Palette) { + let clocks = collect_cpu(tree, SensorType::Clock, "core"); + let vids = collect_cpu(tree, SensorType::Voltage, "vid"); + let cur_avg = mean(&clocks); + let cur_max = clocks.iter().copied().fold(f32::NAN, f32::max); + let vid = mean(&vids); + + egui::Grid::new("op_table") + .num_columns(3) + .spacing([12.0, 2.0]) + .show(ui, |ui| { + let head = |ui: &mut egui::Ui, t: &str| { + ui.label(RichText::new(t).color(pal.text_dim).size(10.5).strong()); + }; + head(ui, ""); + head(ui, "Clock"); + head(ui, "VID"); + ui.end_row(); + + let row = |ui: &mut egui::Ui, name: &str, clock: Option, vid: Option, pal: &Palette| { + ui.label(RichText::new(name).color(pal.text).size(10.5)); + ui.label( + RichText::new(clock.map(|c| format!("{c:.1} MHz")).unwrap_or("—".into())) + .color(pal.clockc) + .size(10.5) + .monospace(), + ); + ui.label( + RichText::new(vid.map(|v| format!("{v:.4} V")).unwrap_or("—".into())) + .color(pal.volt) + .size(10.5) + .monospace(), + ); + ui.end_row(); + }; + row(ui, "Base Clock", i.cpu.base_clock_mhz.map(|c| c as f32), None, pal); + row(ui, "Max Clock", Some(cur_max).filter(|v| v.is_finite()), None, pal); + row(ui, "Avg. Active Clock", cur_avg, vid, pal); + }); +} + +fn board_memory_panels(ui: &mut egui::Ui, i: &crate::sysinfo::SystemInfo, pal: &Palette) { + panel(ui, "Motherboard", pal, |ui| { + ui.label( + RichText::new(format!("{} {}", i.board.manufacturer, i.board.product)) + .color(pal.text) + .size(12.0) + .strong(), + ); + ui.add_space(2.0); + info_row(ui, "Chipset:", "", pal); // needs PCI enum — native engine + info_row(ui, "BIOS Version:", &i.board.bios_version, pal); + info_row(ui, "BIOS Date:", &i.board.bios_date, pal); + }); + + ui.add_space(6.0); + + panel(ui, "Memory", pal, |ui| { + info_row( + ui, + "Size:", + &i.total_memory_gb.map(|g| format!("{g:.0} GB")).unwrap_or_default(), + pal, + ); + let mem_type = i + .memory_modules + .first() + .map(|m| crate::sysinfo::memory_type_label(&m.memory_type)) + .unwrap_or_default(); + info_row(ui, "Type:", &mem_type, pal); + let clock = i + .memory_modules + .first() + .and_then(|m| m.configured_speed_mts.or(m.speed_mts)) + .map(|v| format!("{v} MT/s")) + .unwrap_or_default(); + info_row(ui, "Clock:", &clock, pal); + // Unified memory is a wide on-package bus, not a DIMM channel count — + // inferring "Single-Channel" from the one synthetic module would be + // plainly wrong. + let unified = i + .memory_modules + .first() + .is_some_and(|m| m.memory_type.contains("on-package")); + let mode = if unified { + "Unified" + } else { + match i.memory_modules.len() { + 2 => "Dual-Channel", + 4 => "Quad-Channel", + 1 => "Single-Channel", + _ => "", + } + }; + info_row(ui, "Mode:", mode, pal); + info_row(ui, "Timings:", "", pal); // needs SPD — native engine + + ui.add_space(4.0); + ui.label(RichText::new("Memory Modules").color(pal.text_dim).size(10.5)); + for m in &i.memory_modules { + egui::Frame::new() + .fill(pal.bg_header) + .corner_radius(2) + .inner_margin(egui::Margin::same(4)) + .show(ui, |ui| { + ui.label( + RichText::new(format!("{}: {} {}", m.bank, m.manufacturer, m.part_number)) + .color(pal.text) + .size(10.5), + ); + ui.label( + RichText::new(format!( + "{:.0} GB {} @ {} MT/s {}", + m.capacity_gb, + m.memory_type, + m.configured_speed_mts.or(m.speed_mts).unwrap_or(0), + m.voltage_mv + .map(|v| format!("{:.2} V", v as f32 / 1000.0)) + .unwrap_or_default(), + )) + .color(pal.text_dim) + .size(10.0), + ); + }); + ui.add_space(2.0); + } + }); +} + +fn gpu_os_drives_panels(ui: &mut egui::Ui, i: &crate::sysinfo::SystemInfo, tree: &[Hardware], pal: &Palette) { + panel(ui, "GPU", pal, |ui| { + for (gi, g) in i.gpus.iter().enumerate() { + let vendor = if g.name.to_uppercase().contains("NVIDIA") { + "NVIDIA" + } else if g.name.to_uppercase().contains("AMD") || g.name.to_uppercase().contains("RADEON") { + "RADEON" + } else { + "GPU" + }; + ui.horizontal(|ui| { + egui::Frame::new() + .fill(pal.bg_header) + .corner_radius(3) + .inner_margin(egui::Margin::symmetric(8, 6)) + .show(ui, |ui| { + ui.label(RichText::new(vendor).color(pal.ok_badge).size(12.0).strong()); + }); + ui.vertical(|ui| { + ui.label(RichText::new(&g.name).color(pal.text).size(11.5).strong()); + // NOTE: WMI AdapterRAM is a u32 capped at 4 GB — showing it + // would be wrong for modern cards. VRAM comes with the + // native GPU engine (NVML/ADL). + ui.label( + RichText::new(format!("Driver {}", g.driver_version)) + .color(pal.text_dim) + .size(10.0), + ); + }); + }); + if gi + 1 < i.gpus.len() { + ui.add_space(3.0); + } + } + ui.add_space(4.0); + // Live GPU clocks from sensors. + let (core, mem) = gpu_live_clocks(tree); + info_row( + ui, + "GPU Clock:", + &core.map(|v| format!("{v:.1} MHz")).unwrap_or_default(), + pal, + ); + info_row( + ui, + "Memory Clock:", + &mem.map(|v| format!("{v:.1} MHz")).unwrap_or_default(), + pal, + ); + info_row(ui, "PCIe Link:", "", pal); // needs native engine + }); + + ui.add_space(6.0); + + panel(ui, "Operating System", pal, |ui| { + ui.label( + RichText::new(format!("{} ({})", i.os.caption, i.os.arch)) + .color(pal.text) + .size(11.0), + ); + info_row(ui, "Build:", &i.os.build, pal); + super::widgets::badge(ui, "UEFI Boot:", i.os.uefi_boot, pal); + super::widgets::badge(ui, "Secure Boot:", i.os.secure_boot, pal); + }); + + ui.add_space(6.0); + + panel(ui, "Drives", pal, |ui| { + for d in &i.drives { + ui.label( + RichText::new(format!( + "• {} [{}] {}", + d.model, + d.interface, + d.size_gb.map(|g| format!("{g:.0} GB")).unwrap_or_default() + )) + .color(pal.text) + .size(10.5), + ); + } + }); +} + +// ---- live-sensor helpers ------------------------------------------------ + +/// First CPU sensor of a type whose name contains `needle` (case-insensitive). +fn cpu_sensor(tree: &[Hardware], t: SensorType, needle: &str) -> Option { + for hw in tree { + if hw.hardware_type == HardwareType::Cpu { + for s in &hw.sensors { + if s.sensor_type == t && s.name.to_lowercase().contains(needle) { + return s.value; + } + } + } + } + None +} + +fn collect_cpu(tree: &[Hardware], t: SensorType, name_contains: &str) -> Vec { + let mut out = Vec::new(); + for hw in tree { + if hw.hardware_type == HardwareType::Cpu { + for s in &hw.sensors { + if s.sensor_type == t && s.name.to_lowercase().contains(name_contains) { + if let Some(v) = s.value { + out.push(v); + } + } + } + } + } + out +} + +fn gpu_live_clocks(tree: &[Hardware]) -> (Option, Option) { + let mut core = None; + let mut mem = None; + for hw in tree { + if matches!( + hw.hardware_type, + HardwareType::GpuNvidia + | HardwareType::GpuAti + | HardwareType::GpuIntel + | HardwareType::GpuApple + ) { + for s in &hw.sensors { + if s.sensor_type == SensorType::Clock { + let n = s.name.to_lowercase(); + if n.contains("core") && core.is_none() { + core = s.value; + } else if n.contains("memory") && mem.is_none() { + mem = s.value; + } + } + } + } + } + (core, mem) +} + +fn mean(v: &[f32]) -> Option { + if v.is_empty() { + None + } else { + Some(v.iter().sum::() / v.len() as f32) + } +} diff --git a/app/src/ui/widgets.rs b/app/src/ui/widgets.rs index f2b83a6b..51049981 100644 --- a/app/src/ui/widgets.rs +++ b/app/src/ui/widgets.rs @@ -1,278 +1,278 @@ -//! Shared HWiNFO-style widgets: painted sensor-type icons, group header bands, -//! summary panels, feature "chips" and square checkboxes. - -use eframe::egui::{self, Color32, Pos2, RichText, Stroke, StrokeKind, Vec2}; - -use super::Palette; -use crate::model::SensorType; - -pub const ROW_H: f32 = 17.0; - -/// Icon color per sensor type, HWiNFO-ish (yellow bolts, cyan clocks…). -pub fn type_color(t: SensorType, pal: &Palette) -> Color32 { - match t { - SensorType::Voltage | SensorType::Current | SensorType::Power | SensorType::Energy => pal.volt, - SensorType::Clock | SensorType::Frequency | SensorType::TimeSpan => pal.clockc, - SensorType::Temperature => pal.tempc, - SensorType::Fan | SensorType::Flow | SensorType::Control => pal.fanc, - _ => pal.text_dim, - } -} - -/// Parse a hardware vendor from a device name → (badge text, brand color). -/// Trademark-safe: our own colored text badge, no copied logos. -pub fn vendor_badge(name: &str) -> Option<(&'static str, Color32)> { - let n = name.to_uppercase(); - if n.contains("NVIDIA") || n.contains("GEFORCE") || n.contains("RTX") || n.contains("GTX") { - Some(("NVIDIA", Color32::from_rgb(0x76, 0xb9, 0x00))) - } else if n.contains("RADEON") || n.contains("AMD") || n.contains("RYZEN") { - Some(("AMD", Color32::from_rgb(0xed, 0x1c, 0x24))) - } else if n.contains("INTEL") || n.contains("CORE I") { - Some(("INTEL", Color32::from_rgb(0x00, 0x71, 0xc5))) - } else if n.contains("CORSAIR") { - Some(("CORSAIR", Color32::from_rgb(0xff, 0xd2, 0x00))) - } else if n.contains("SAMSUNG") { - Some(("SAMSUNG", Color32::from_rgb(0x14, 0x28, 0xa0))) - } else if n.contains("MSI") { - Some(("MSI", Color32::from_rgb(0xd4, 0x00, 0x00))) - } else { - None - } -} - -/// Paint a small colored category glyph for a hardware type (group bands/tree). -pub fn hardware_icon(ui: &egui::Ui, rect: egui::Rect, t: crate::model::HardwareType, pal: &Palette) { - use crate::model::HardwareType as H; - let p = ui.painter(); - let c = rect.center(); - let col = match t { - H::Cpu => pal.accent, - H::GpuNvidia | H::GpuAti | H::GpuIntel | H::GpuApple => pal.ok_badge, - H::Ram => pal.clockc, - H::Storage | H::Hdd => pal.warn, - H::Network => pal.fanc, - H::Battery | H::Psu => pal.volt, - _ => pal.text_dim, - }; - match t { - H::Cpu | H::Mainboard | H::SuperIO | H::EmbeddedController => { - // Chip: square with pins. - let r = egui::Rect::from_center_size(c, Vec2::splat(8.0)); - p.rect_stroke(r, 1.0, Stroke::new(1.2, col), StrokeKind::Inside); - let inner = egui::Rect::from_center_size(c, Vec2::splat(3.5)); - p.rect_filled(inner, 0.0, col); - } - H::GpuNvidia | H::GpuAti | H::GpuIntel | H::GpuApple => { - // Card: rectangle + fan circle. - let r = egui::Rect::from_min_size(Pos2::new(c.x - 5.0, c.y - 3.5), Vec2::new(10.0, 7.0)); - p.rect_stroke(r, 1.0, Stroke::new(1.2, col), StrokeKind::Inside); - p.circle_stroke(Pos2::new(c.x + 1.5, c.y), 1.8, Stroke::new(1.0, col)); - } - H::Ram => { - // Memory stick. - let r = egui::Rect::from_min_size(Pos2::new(c.x - 5.0, c.y - 3.0), Vec2::new(10.0, 6.0)); - p.rect_stroke(r, 0.0, Stroke::new(1.2, col), StrokeKind::Inside); - for dx in [-2.5, 0.0, 2.5] { - p.line_segment( - [Pos2::new(c.x + dx, c.y + 3.0), Pos2::new(c.x + dx, c.y + 5.0)], - Stroke::new(1.0, col), - ); - } - } - H::Storage | H::Hdd => { - p.circle_stroke(c, 4.5, Stroke::new(1.2, col)); - p.circle_filled(c, 1.2, col); - } - H::Network => { - p.circle_filled(Pos2::new(c.x - 3.0, c.y + 3.0), 1.5, col); - p.circle_filled(Pos2::new(c.x, c.y - 3.0), 1.5, col); - p.circle_filled(Pos2::new(c.x + 3.0, c.y + 3.0), 1.5, col); - } - _ => { - p.circle_filled(c, 3.0, col); - } - } -} - -/// Paint a small colored dot marker for a sensor type at the cursor (used in -/// the graph header where a full icon would be overkill). -pub fn sensor_icon_at_cursor(ui: &mut egui::Ui, t: SensorType, pal: &Palette) { - let (rect, _) = ui.allocate_exact_size(Vec2::splat(10.0), egui::Sense::hover()); - ui.painter().circle_filled(rect.center(), 4.0, type_color(t, pal)); -} - -/// Allocate a 15×15 slot and paint the category icon inline (device tree rows). -pub fn hardware_icon_inline(ui: &mut egui::Ui, t: crate::model::HardwareType, pal: &Palette) { - let (rect, _) = ui.allocate_exact_size(Vec2::splat(15.0), egui::Sense::hover()); - hardware_icon(ui, rect, t, pal); -} - -/// Collapsible group header band ("CPU [#0]: AMD Ryzen 7 7700"). Returns the -/// new collapsed state (None = unchanged). -pub fn group_header( - ui: &mut egui::Ui, - title: &str, - hw_type: crate::model::HardwareType, - collapsed: bool, - width: f32, - pal: &Palette, -) -> Option { - let (rect, resp) = ui.allocate_exact_size(Vec2::new(width, ROW_H + 1.0), egui::Sense::click()); - let p = ui.painter(); - p.rect_filled(rect, 0.0, pal.bg_header); - p.line_segment( - [rect.left_bottom(), rect.right_bottom()], - Stroke::new(1.0, pal.grid), - ); - let chev = if collapsed { "▸" } else { "▾" }; - p.text( - Pos2::new(rect.left() + 4.0, rect.center().y), - egui::Align2::LEFT_CENTER, - chev, - egui::FontId::proportional(9.0), - pal.text_dim, - ); - // Category icon. - let icon_rect = egui::Rect::from_center_size( - Pos2::new(rect.left() + 20.0, rect.center().y), - Vec2::splat(12.0), - ); - hardware_icon(ui, icon_rect, hw_type, pal); - - // Vendor badge (if recognizable), then title. - let mut x = rect.left() + 30.0; - if let Some((vendor, color)) = vendor_badge(title) { - let galley = ui.painter().layout_no_wrap( - vendor.to_string(), - egui::FontId::proportional(8.5), - Color32::WHITE, - ); - let bw = galley.size().x + 6.0; - let brect = egui::Rect::from_min_size( - Pos2::new(x, rect.center().y - 6.0), - Vec2::new(bw, 12.0), - ); - ui.painter().rect_filled(brect, 2.0, color); - ui.painter().galley(Pos2::new(x + 3.0, rect.center().y - galley.size().y / 2.0), galley, Color32::WHITE); - x += bw + 4.0; - } - ui.painter().text( - Pos2::new(x, rect.center().y), - egui::Align2::LEFT_CENTER, - title, - egui::FontId::proportional(11.0), - pal.text, - ); - if resp.clicked() { - Some(!collapsed) - } else { - None - } -} - -/// Boxed section with a header strip — the System Summary panel look. -pub fn panel( - ui: &mut egui::Ui, - title: &str, - pal: &Palette, - add: impl FnOnce(&mut egui::Ui) -> R, -) -> R { - egui::Frame::new() - .fill(pal.bg_panel) - .stroke(Stroke::new(1.0, pal.grid)) - .inner_margin(egui::Margin::same(6)) - .show(ui, |ui| { - ui.label(RichText::new(title).color(pal.accent).size(11.5).strong()); - ui.separator(); - add(ui) - }) - .inner -} - -/// Small feature "chip" (the green/dark ISA boxes in the Summary CPU panel). -/// The label never wraps — chips flow as whole units in a wrapped row. -pub fn chip(ui: &mut egui::Ui, label: &str, on: bool, pal: &Palette) { - let (bg, fg) = if on { - (pal.ok_badge, Color32::WHITE) - } else { - (pal.bg_header, pal.text_dim) - }; - egui::Frame::new() - .fill(bg) - .corner_radius(2) - .inner_margin(egui::Margin::symmetric(4, 1)) - .show(ui, |ui| { - ui.add( - egui::Label::new(RichText::new(label).color(fg).size(9.5)) - .wrap_mode(egui::TextWrapMode::Extend), - ); - }); -} - -/// Square `[x]` checkbox like HWiNFO's settings dialog. -pub fn square_check(ui: &mut egui::Ui, value: &mut bool, label: &str, pal: &Palette) -> bool { - let resp = ui - .horizontal(|ui| { - let (rect, r) = ui.allocate_exact_size(Vec2::splat(13.0), egui::Sense::click()); - let p = ui.painter(); - p.rect_stroke(rect, 0.0, Stroke::new(1.0, pal.text_dim), StrokeKind::Inside); - if *value { - p.line_segment( - [rect.left_top() + Vec2::splat(2.5), rect.right_bottom() - Vec2::splat(2.5)], - Stroke::new(1.6, pal.text), - ); - p.line_segment( - [ - Pos2::new(rect.right() - 2.5, rect.top() + 2.5), - Pos2::new(rect.left() + 2.5, rect.bottom() - 2.5), - ], - Stroke::new(1.6, pal.text), - ); - } - let lr = ui.label(RichText::new(label).size(11.5).color(pal.text)); - r.union(lr.interact(egui::Sense::click())) - }) - .inner; - if resp.clicked() { - *value = !*value; - true - } else { - false - } -} - -/// `label: value` row for info grids (Feature pane, Summary fields). -pub fn info_row(ui: &mut egui::Ui, label: &str, value: &str, pal: &Palette) { - ui.horizontal(|ui| { - ui.label(RichText::new(label).color(pal.text_dim).size(11.0)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(RichText::new(if value.is_empty() { "—" } else { value }).color(pal.text).size(11.0)); - }); - }); -} - -/// Colored status badge (UEFI Boot / Secure Boot / HVCI rows). -pub fn badge(ui: &mut egui::Ui, label: &str, ok: Option, pal: &Palette) { - let (bg, text) = match ok { - Some(true) => (pal.ok_badge, "Enabled"), - Some(false) => (pal.bg_header, "Disabled"), - None => (pal.bg_header, "—"), - }; - ui.horizontal(|ui| { - ui.label(RichText::new(label).color(pal.text_dim).size(11.0)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - egui::Frame::new() - .fill(bg) - .corner_radius(2) - .inner_margin(egui::Margin::symmetric(6, 1)) - .show(ui, |ui| { - ui.label(RichText::new(text).color(Color32::WHITE).size(10.0)); - }); - }); - }); -} - -// Formatting moved to `crate::format` so the report, CLI and TUI can use it -// without linking the GUI toolkit. Re-exported here because every UI call site -// already says `widgets::format_value`. -pub use crate::format::format_value; +//! Shared HWiNFO-style widgets: painted sensor-type icons, group header bands, +//! summary panels, feature "chips" and square checkboxes. + +use eframe::egui::{self, Color32, Pos2, RichText, Stroke, StrokeKind, Vec2}; + +use super::Palette; +use crate::model::SensorType; + +pub const ROW_H: f32 = 17.0; + +/// Icon color per sensor type, HWiNFO-ish (yellow bolts, cyan clocks…). +pub fn type_color(t: SensorType, pal: &Palette) -> Color32 { + match t { + SensorType::Voltage | SensorType::Current | SensorType::Power | SensorType::Energy => pal.volt, + SensorType::Clock | SensorType::Frequency | SensorType::TimeSpan => pal.clockc, + SensorType::Temperature => pal.tempc, + SensorType::Fan | SensorType::Flow | SensorType::Control => pal.fanc, + _ => pal.text_dim, + } +} + +/// Parse a hardware vendor from a device name → (badge text, brand color). +/// Trademark-safe: our own colored text badge, no copied logos. +pub fn vendor_badge(name: &str) -> Option<(&'static str, Color32)> { + let n = name.to_uppercase(); + if n.contains("NVIDIA") || n.contains("GEFORCE") || n.contains("RTX") || n.contains("GTX") { + Some(("NVIDIA", Color32::from_rgb(0x76, 0xb9, 0x00))) + } else if n.contains("RADEON") || n.contains("AMD") || n.contains("RYZEN") { + Some(("AMD", Color32::from_rgb(0xed, 0x1c, 0x24))) + } else if n.contains("INTEL") || n.contains("CORE I") { + Some(("INTEL", Color32::from_rgb(0x00, 0x71, 0xc5))) + } else if n.contains("CORSAIR") { + Some(("CORSAIR", Color32::from_rgb(0xff, 0xd2, 0x00))) + } else if n.contains("SAMSUNG") { + Some(("SAMSUNG", Color32::from_rgb(0x14, 0x28, 0xa0))) + } else if n.contains("MSI") { + Some(("MSI", Color32::from_rgb(0xd4, 0x00, 0x00))) + } else { + None + } +} + +/// Paint a small colored category glyph for a hardware type (group bands/tree). +pub fn hardware_icon(ui: &egui::Ui, rect: egui::Rect, t: crate::model::HardwareType, pal: &Palette) { + use crate::model::HardwareType as H; + let p = ui.painter(); + let c = rect.center(); + let col = match t { + H::Cpu => pal.accent, + H::GpuNvidia | H::GpuAti | H::GpuIntel | H::GpuApple => pal.ok_badge, + H::Ram => pal.clockc, + H::Storage | H::Hdd => pal.warn, + H::Network => pal.fanc, + H::Battery | H::Psu => pal.volt, + _ => pal.text_dim, + }; + match t { + H::Cpu | H::Mainboard | H::SuperIO | H::EmbeddedController => { + // Chip: square with pins. + let r = egui::Rect::from_center_size(c, Vec2::splat(8.0)); + p.rect_stroke(r, 1.0, Stroke::new(1.2, col), StrokeKind::Inside); + let inner = egui::Rect::from_center_size(c, Vec2::splat(3.5)); + p.rect_filled(inner, 0.0, col); + } + H::GpuNvidia | H::GpuAti | H::GpuIntel | H::GpuApple => { + // Card: rectangle + fan circle. + let r = egui::Rect::from_min_size(Pos2::new(c.x - 5.0, c.y - 3.5), Vec2::new(10.0, 7.0)); + p.rect_stroke(r, 1.0, Stroke::new(1.2, col), StrokeKind::Inside); + p.circle_stroke(Pos2::new(c.x + 1.5, c.y), 1.8, Stroke::new(1.0, col)); + } + H::Ram => { + // Memory stick. + let r = egui::Rect::from_min_size(Pos2::new(c.x - 5.0, c.y - 3.0), Vec2::new(10.0, 6.0)); + p.rect_stroke(r, 0.0, Stroke::new(1.2, col), StrokeKind::Inside); + for dx in [-2.5, 0.0, 2.5] { + p.line_segment( + [Pos2::new(c.x + dx, c.y + 3.0), Pos2::new(c.x + dx, c.y + 5.0)], + Stroke::new(1.0, col), + ); + } + } + H::Storage | H::Hdd => { + p.circle_stroke(c, 4.5, Stroke::new(1.2, col)); + p.circle_filled(c, 1.2, col); + } + H::Network => { + p.circle_filled(Pos2::new(c.x - 3.0, c.y + 3.0), 1.5, col); + p.circle_filled(Pos2::new(c.x, c.y - 3.0), 1.5, col); + p.circle_filled(Pos2::new(c.x + 3.0, c.y + 3.0), 1.5, col); + } + _ => { + p.circle_filled(c, 3.0, col); + } + } +} + +/// Paint a small colored dot marker for a sensor type at the cursor (used in +/// the graph header where a full icon would be overkill). +pub fn sensor_icon_at_cursor(ui: &mut egui::Ui, t: SensorType, pal: &Palette) { + let (rect, _) = ui.allocate_exact_size(Vec2::splat(10.0), egui::Sense::hover()); + ui.painter().circle_filled(rect.center(), 4.0, type_color(t, pal)); +} + +/// Allocate a 15×15 slot and paint the category icon inline (device tree rows). +pub fn hardware_icon_inline(ui: &mut egui::Ui, t: crate::model::HardwareType, pal: &Palette) { + let (rect, _) = ui.allocate_exact_size(Vec2::splat(15.0), egui::Sense::hover()); + hardware_icon(ui, rect, t, pal); +} + +/// Collapsible group header band ("CPU [#0]: AMD Ryzen 7 7700"). Returns the +/// new collapsed state (None = unchanged). +pub fn group_header( + ui: &mut egui::Ui, + title: &str, + hw_type: crate::model::HardwareType, + collapsed: bool, + width: f32, + pal: &Palette, +) -> Option { + let (rect, resp) = ui.allocate_exact_size(Vec2::new(width, ROW_H + 1.0), egui::Sense::click()); + let p = ui.painter(); + p.rect_filled(rect, 0.0, pal.bg_header); + p.line_segment( + [rect.left_bottom(), rect.right_bottom()], + Stroke::new(1.0, pal.grid), + ); + let chev = if collapsed { "▸" } else { "▾" }; + p.text( + Pos2::new(rect.left() + 4.0, rect.center().y), + egui::Align2::LEFT_CENTER, + chev, + egui::FontId::proportional(9.0), + pal.text_dim, + ); + // Category icon. + let icon_rect = egui::Rect::from_center_size( + Pos2::new(rect.left() + 20.0, rect.center().y), + Vec2::splat(12.0), + ); + hardware_icon(ui, icon_rect, hw_type, pal); + + // Vendor badge (if recognizable), then title. + let mut x = rect.left() + 30.0; + if let Some((vendor, color)) = vendor_badge(title) { + let galley = ui.painter().layout_no_wrap( + vendor.to_string(), + egui::FontId::proportional(8.5), + Color32::WHITE, + ); + let bw = galley.size().x + 6.0; + let brect = egui::Rect::from_min_size( + Pos2::new(x, rect.center().y - 6.0), + Vec2::new(bw, 12.0), + ); + ui.painter().rect_filled(brect, 2.0, color); + ui.painter().galley(Pos2::new(x + 3.0, rect.center().y - galley.size().y / 2.0), galley, Color32::WHITE); + x += bw + 4.0; + } + ui.painter().text( + Pos2::new(x, rect.center().y), + egui::Align2::LEFT_CENTER, + title, + egui::FontId::proportional(11.0), + pal.text, + ); + if resp.clicked() { + Some(!collapsed) + } else { + None + } +} + +/// Boxed section with a header strip — the System Summary panel look. +pub fn panel( + ui: &mut egui::Ui, + title: &str, + pal: &Palette, + add: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + egui::Frame::new() + .fill(pal.bg_panel) + .stroke(Stroke::new(1.0, pal.grid)) + .inner_margin(egui::Margin::same(6)) + .show(ui, |ui| { + ui.label(RichText::new(title).color(pal.accent).size(11.5).strong()); + ui.separator(); + add(ui) + }) + .inner +} + +/// Small feature "chip" (the green/dark ISA boxes in the Summary CPU panel). +/// The label never wraps — chips flow as whole units in a wrapped row. +pub fn chip(ui: &mut egui::Ui, label: &str, on: bool, pal: &Palette) { + let (bg, fg) = if on { + (pal.ok_badge, Color32::WHITE) + } else { + (pal.bg_header, pal.text_dim) + }; + egui::Frame::new() + .fill(bg) + .corner_radius(2) + .inner_margin(egui::Margin::symmetric(4, 1)) + .show(ui, |ui| { + ui.add( + egui::Label::new(RichText::new(label).color(fg).size(9.5)) + .wrap_mode(egui::TextWrapMode::Extend), + ); + }); +} + +/// Square `[x]` checkbox like HWiNFO's settings dialog. +pub fn square_check(ui: &mut egui::Ui, value: &mut bool, label: &str, pal: &Palette) -> bool { + let resp = ui + .horizontal(|ui| { + let (rect, r) = ui.allocate_exact_size(Vec2::splat(13.0), egui::Sense::click()); + let p = ui.painter(); + p.rect_stroke(rect, 0.0, Stroke::new(1.0, pal.text_dim), StrokeKind::Inside); + if *value { + p.line_segment( + [rect.left_top() + Vec2::splat(2.5), rect.right_bottom() - Vec2::splat(2.5)], + Stroke::new(1.6, pal.text), + ); + p.line_segment( + [ + Pos2::new(rect.right() - 2.5, rect.top() + 2.5), + Pos2::new(rect.left() + 2.5, rect.bottom() - 2.5), + ], + Stroke::new(1.6, pal.text), + ); + } + let lr = ui.label(RichText::new(label).size(11.5).color(pal.text)); + r.union(lr.interact(egui::Sense::click())) + }) + .inner; + if resp.clicked() { + *value = !*value; + true + } else { + false + } +} + +/// `label: value` row for info grids (Feature pane, Summary fields). +pub fn info_row(ui: &mut egui::Ui, label: &str, value: &str, pal: &Palette) { + ui.horizontal(|ui| { + ui.label(RichText::new(label).color(pal.text_dim).size(11.0)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(if value.is_empty() { "—" } else { value }).color(pal.text).size(11.0)); + }); + }); +} + +/// Colored status badge (UEFI Boot / Secure Boot / HVCI rows). +pub fn badge(ui: &mut egui::Ui, label: &str, ok: Option, pal: &Palette) { + let (bg, text) = match ok { + Some(true) => (pal.ok_badge, "Enabled"), + Some(false) => (pal.bg_header, "Disabled"), + None => (pal.bg_header, "—"), + }; + ui.horizontal(|ui| { + ui.label(RichText::new(label).color(pal.text_dim).size(11.0)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + egui::Frame::new() + .fill(bg) + .corner_radius(2) + .inner_margin(egui::Margin::symmetric(6, 1)) + .show(ui, |ui| { + ui.label(RichText::new(text).color(Color32::WHITE).size(10.0)); + }); + }); + }); +} + +// Formatting moved to `crate::format` so the report, CLI and TUI can use it +// without linking the GUI toolkit. Re-exported here because every UI call site +// already says `widgets::format_value`. +pub use crate::format::format_value;