Line | Count | Source |
1 | | // SPDX-License-Identifier: Apache-2.0 |
2 | | //! Apple/macOS detection via `system_profiler SPDisplaysDataType` and `sysctl |
3 | | //! hw.memsize`. No Metal framework linkage required. Apple Silicon uses a |
4 | | //! unified memory architecture (no dedicated VRAM), so `total_bytes` falls |
5 | | //! back to total physical memory. |
6 | | |
7 | | /// Map a vendor id as printed by `system_profiler` (e.g. `0x106b`) to a |
8 | | /// [`Vendor`](crate::Vendor). |
9 | | #[allow(dead_code)] // used on macOS + in tests; unused on other targets |
10 | 10 | fn vendor_from_id(id: &str) -> crate::Vendor { |
11 | | use crate::Vendor; |
12 | 10 | match id.trim().to_ascii_lowercase().as_str() { |
13 | 10 | "0x106b" => Vendor::Apple3 , |
14 | 7 | "0x1002" => Vendor::Amd3 , |
15 | 4 | "0x10de" => Vendor::Nvidia1 , |
16 | 3 | "0x8086" => Vendor::Intel2 , |
17 | 1 | _ => Vendor::Unknown, |
18 | | } |
19 | 10 | } |
20 | | |
21 | | /// Parse a `system_profiler` VRAM value like `"8 GB"` or `"1536 MB"` into bytes |
22 | | /// (binary units). |
23 | | #[allow(dead_code)] // used on macOS + in tests; unused on other targets |
24 | 12 | fn parse_vram(value: &str) -> Option<u64> { |
25 | 12 | let (num9 , unit9 ) = value.trim().split_once(' ')?3 ; |
26 | 9 | let amount8 : u648 = num.trim().parse().ok()?1 ; |
27 | 8 | let mult7 : u647 = match unit.trim().to_ascii_uppercase().as_str() { |
28 | 8 | "GB" => 1024 * 1024 * 10243 , |
29 | 5 | "MB" => 1024 * 10243 , |
30 | 2 | "KB" => 10241 , |
31 | 1 | _ => return None, |
32 | | }; |
33 | 7 | Some(amount * mult) |
34 | 12 | } |
35 | | |
36 | | /// Parse `sysctl -n hw.memsize` output (a decimal byte count). |
37 | | #[allow(dead_code)] // used on macOS + in tests; unused on other targets |
38 | 4 | fn parse_memsize(content: &str) -> Option<u64> { |
39 | 4 | content.trim().parse().ok() |
40 | 4 | } |
41 | | |
42 | | /// Parse plain-text `system_profiler SPDisplaysDataType` output into one |
43 | | /// [`GpuInfo`](crate::GpuInfo) per "Chipset Model:" block. `total_bytes` is `0` |
44 | | /// when no VRAM line is present (Apple Silicon unified memory); callers |
45 | | /// backfill it from physical memory. |
46 | | #[allow(dead_code)] // used on macOS + in tests; unused on other targets |
47 | 8 | fn parse_system_profiler(text: &str) -> Vec<crate::GpuInfo> { |
48 | | use crate::{GpuInfo, Vendor}; |
49 | | |
50 | 8 | let mut gpus: Vec<GpuInfo> = Vec::new(); |
51 | 29 | for raw in text8 .lines8 () { |
52 | 29 | let line = raw.trim(); |
53 | 29 | if let Some(name7 ) = line.strip_prefix("Chipset Model:") { |
54 | 7 | gpus.push(GpuInfo { |
55 | 7 | name: name.trim().to_string(), |
56 | 7 | vendor: Vendor::Apple, |
57 | 7 | total_bytes: 0, |
58 | 7 | free_bytes: None, |
59 | 7 | used_bytes: None, |
60 | 7 | }); |
61 | 22 | } else if let Some(gpu16 ) = gpus.last_mut() { |
62 | 16 | if let Some(v7 ) = line.strip_prefix("Vendor:") { |
63 | | // e.g. "Apple (0x106b)" — pull out the parenthesized id. |
64 | 7 | if let Some(id6 ) = v.split('(').nth(1).and_then(|s| s.split(')')6 .next6 ()) { |
65 | 6 | gpu.vendor = vendor_from_id(id); |
66 | 6 | }1 |
67 | 9 | } else if let Some(v5 ) = line |
68 | 9 | .strip_prefix("VRAM (Total):") |
69 | 9 | .or_else(|| line6 .strip_prefix6 ("VRAM (Dynamic, Max):")) |
70 | 5 | && let Some(bytes4 ) = parse_vram(v) |
71 | 4 | { |
72 | 4 | gpu.total_bytes = bytes; |
73 | 5 | } |
74 | 6 | } |
75 | | } |
76 | 8 | gpus |
77 | 8 | } |
78 | | |
79 | | #[cfg(target_os = "macos")] |
80 | | pub(crate) fn detect() -> Vec<crate::GpuInfo> { |
81 | | use crate::{GpuInfo, Vendor}; |
82 | | |
83 | | let mut gpus = std::process::Command::new("system_profiler") |
84 | | .arg("SPDisplaysDataType") |
85 | | .output() |
86 | | .ok() |
87 | | .filter(|o| o.status.success()) |
88 | | .map(|o| parse_system_profiler(&String::from_utf8_lossy(&o.stdout))) |
89 | | .unwrap_or_default(); |
90 | | |
91 | | // Apple Silicon reports no VRAM line — backfill from physical memory. |
92 | | let memsize = sysctl_memsize(); |
93 | | for gpu in &mut gpus { |
94 | | if gpu.total_bytes == 0 |
95 | | && let Some(mem) = memsize |
96 | | { |
97 | | gpu.total_bytes = mem; |
98 | | } |
99 | | } |
100 | | if gpus.is_empty() |
101 | | && let Some(mem) = memsize |
102 | | { |
103 | | gpus.push(GpuInfo { |
104 | | name: "Apple GPU".to_string(), |
105 | | vendor: Vendor::Apple, |
106 | | total_bytes: mem, |
107 | | free_bytes: None, |
108 | | used_bytes: None, |
109 | | }); |
110 | | } |
111 | | gpus |
112 | | } |
113 | | |
114 | | #[cfg(target_os = "macos")] |
115 | | fn sysctl_memsize() -> Option<u64> { |
116 | | let output = std::process::Command::new("sysctl") |
117 | | .args(["-n", "hw.memsize"]) |
118 | | .output() |
119 | | .ok()?; |
120 | | output |
121 | | .status |
122 | | .success() |
123 | | .then(|| parse_memsize(&String::from_utf8_lossy(&output.stdout))) |
124 | | .flatten() |
125 | | } |
126 | | |
127 | | #[cfg(not(target_os = "macos"))] |
128 | 215 | pub(crate) fn detect() -> Vec<crate::GpuInfo> { |
129 | 215 | Vec::new() |
130 | 215 | } |
131 | | |
132 | | #[cfg(test)] |
133 | | mod tests { |
134 | | use super::*; |
135 | | use crate::Vendor; |
136 | | |
137 | | #[test] |
138 | 1 | fn apple_silicon_block_has_no_vram() { |
139 | 1 | let text = "Graphics/Displays:\n\n Apple M2 Pro:\n\n Chipset Model: Apple M2 Pro\n Type: GPU\n Vendor: Apple (0x106b)\n Metal Support: Metal 3\n"; |
140 | 1 | let gpus = parse_system_profiler(text); |
141 | 1 | assert_eq!(gpus.len(), 1); |
142 | 1 | assert_eq!(gpus[0].name, "Apple M2 Pro"); |
143 | 1 | assert_eq!(gpus[0].vendor, Vendor::Apple); |
144 | 1 | assert_eq!( |
145 | 1 | gpus[0].total_bytes, 0, |
146 | | "unified memory; backfilled in detect()" |
147 | | ); |
148 | 1 | } |
149 | | |
150 | | #[test] |
151 | 1 | fn intel_mac_discrete_gpu_reports_vram() { |
152 | 1 | let text = " Chipset Model: AMD Radeon Pro 5500M\n Type: GPU\n Bus: PCIe\n VRAM (Total): 8 GB\n Vendor: AMD (0x1002)\n"; |
153 | 1 | let gpus = parse_system_profiler(text); |
154 | 1 | assert_eq!(gpus.len(), 1); |
155 | 1 | assert_eq!(gpus[0].vendor, Vendor::Amd); |
156 | 1 | assert_eq!(gpus[0].total_bytes, 8 * 1024 * 1024 * 1024); |
157 | 1 | } |
158 | | |
159 | | #[test] |
160 | 1 | fn dynamic_vram_line_is_parsed() { |
161 | 1 | let text = " Chipset Model: Intel Iris Pro\n VRAM (Dynamic, Max): 1536 MB\n Vendor: Intel (0x8086)\n"; |
162 | 1 | let gpus = parse_system_profiler(text); |
163 | 1 | assert_eq!(gpus[0].vendor, Vendor::Intel); |
164 | 1 | assert_eq!(gpus[0].total_bytes, 1536 * 1024 * 1024); |
165 | 1 | } |
166 | | |
167 | | #[test] |
168 | 1 | fn vram_and_memsize_parsers() { |
169 | 1 | assert_eq!(parse_vram("8 GB"), Some(8 * 1024 * 1024 * 1024)); |
170 | 1 | assert_eq!(parse_vram("1536 MB"), Some(1536 * 1024 * 1024)); |
171 | 1 | assert_eq!(parse_vram("weird"), None); |
172 | 1 | assert_eq!(parse_memsize("17179869184\n"), Some(17_179_869_184)); |
173 | 1 | } |
174 | | |
175 | | #[test] |
176 | 1 | fn vendor_id_mapping() { |
177 | 1 | assert_eq!(vendor_from_id("0x106b"), Vendor::Apple); |
178 | 1 | assert_eq!(vendor_from_id("0x10DE"), Vendor::Nvidia); |
179 | 1 | assert_eq!(vendor_from_id("0x1002"), Vendor::Amd); |
180 | 1 | assert_eq!(vendor_from_id("0xbeef"), Vendor::Unknown); |
181 | 1 | } |
182 | | |
183 | | #[test] |
184 | 1 | fn parses_multiple_gpu_blocks() { |
185 | 1 | let text = " Chipset Model: AMD Radeon Pro 5500M\n VRAM (Total): 8 GB\n Vendor: AMD (0x1002)\n Chipset Model: Intel UHD Graphics 630\n VRAM (Dynamic, Max): 1536 MB\n Vendor: Intel (0x8086)\n"; |
186 | 1 | let gpus = parse_system_profiler(text); |
187 | 1 | assert_eq!(gpus.len(), 2); |
188 | 1 | assert_eq!(gpus[0].vendor, Vendor::Amd); |
189 | 1 | assert_eq!(gpus[0].total_bytes, 8 * 1024 * 1024 * 1024); |
190 | 1 | assert_eq!(gpus[1].vendor, Vendor::Intel); |
191 | 1 | assert_eq!(gpus[1].total_bytes, 1536 * 1024 * 1024); |
192 | 1 | } |
193 | | |
194 | | #[test] |
195 | 1 | fn empty_output_yields_no_gpus() { |
196 | 1 | assert!(parse_system_profiler("").is_empty()); |
197 | | // Lines before any "Chipset Model:" have no GPU to attach to. |
198 | 1 | assert!(parse_system_profiler("Graphics/Displays:\n VRAM (Total): 8 GB\n").is_empty()); |
199 | 1 | } |
200 | | |
201 | | #[test] |
202 | 1 | fn vendor_line_without_parens_keeps_default() { |
203 | 1 | let text = " Chipset Model: Mystery GPU\n Vendor: sieve\n"; |
204 | 1 | let gpus = parse_system_profiler(text); |
205 | 1 | assert_eq!(gpus.len(), 1); |
206 | | // No "(id)" to parse, so the Apple default placed at block start stands. |
207 | 1 | assert_eq!(gpus[0].vendor, Vendor::Apple); |
208 | 1 | } |
209 | | |
210 | | #[test] |
211 | 1 | fn malformed_vram_leaves_total_at_zero() { |
212 | 1 | let text = |
213 | 1 | " Chipset Model: Broken\n VRAM (Total): lots\n Vendor: Apple (0x106b)\n"; |
214 | 1 | let gpus = parse_system_profiler(text); |
215 | 1 | assert_eq!(gpus[0].total_bytes, 0); |
216 | 1 | } |
217 | | |
218 | | #[test] |
219 | 1 | fn parse_vram_rejects_unknown_units_and_bad_numbers() { |
220 | 1 | assert_eq!(parse_vram("8 TB"), None); |
221 | 1 | assert_eq!(parse_vram("8"), None); |
222 | 1 | assert_eq!(parse_vram("eight GB"), None); |
223 | 1 | assert_eq!( |
224 | 1 | parse_vram("512 kb"), |
225 | 1 | Some(512 * 1024), |
226 | | "unit is case-insensitive" |
227 | | ); |
228 | 1 | } |
229 | | |
230 | | #[test] |
231 | 1 | fn parse_memsize_rejects_non_numeric() { |
232 | 1 | assert_eq!(parse_memsize("nope"), None); |
233 | 1 | assert_eq!(parse_memsize(""), None); |
234 | 1 | assert_eq!(parse_memsize("0"), Some(0)); |
235 | 1 | } |
236 | | } |