diff --git a/internal/hal/detect_windows.go b/internal/hal/detect_windows.go index 06c6583..4071f80 100644 --- a/internal/hal/detect_windows.go +++ b/internal/hal/detect_windows.go @@ -10,6 +10,18 @@ import ( "strings" ) +// wmic was removed from recent Windows 11 builds. These PowerShell CIM commands +// are the fallback when wmic is unavailable; each emits the same unquoted +// "header\ndata" CSV the wmic parsers already expect. +const ( + cimCPUCommand = `$c = Get-CimInstance Win32_Processor | Select-Object -First 1; Write-Output 'Name,NumberOfCores,NumberOfLogicalProcessors,MaxClockSpeed'; Write-Output ('{0},{1},{2},{3}' -f $c.Name,$c.NumberOfCores,$c.NumberOfLogicalProcessors,$c.MaxClockSpeed)` + cimRAMCommand = `$o = Get-CimInstance Win32_OperatingSystem; Write-Output 'TotalVisibleMemorySize,FreePhysicalMemory'; Write-Output ('{0},{1}' -f $o.TotalVisibleMemorySize,$o.FreePhysicalMemory)` + cimSwapCommand = `$p = Get-CimInstance Win32_PageFileUsage | Select-Object -First 1; Write-Output 'AllocatedBaseSize'; if ($p) { Write-Output $p.AllocatedBaseSize }` + cimCPULoadCommand = `$c = Get-CimInstance Win32_Processor | Select-Object -First 1; Write-Output 'LoadPercentage'; Write-Output $c.LoadPercentage` +) + +func trimBOM(s string) string { return strings.TrimPrefix(s, "\ufeff") } + func detectCPU(ctx context.Context, runner CommandRunner) CPUInfo { info := CPUInfo{ Arch: runtime.GOARCH, @@ -19,11 +31,14 @@ func detectCPU(ctx context.Context, runner CommandRunner) CPUInfo { out, err := runner.Run(ctx, "wmic", "cpu", "get", "Name,NumberOfCores,NumberOfLogicalProcessors,MaxClockSpeed", "/format:csv") if err != nil { - slog.Warn("wmic cpu detection failed, using defaults", "error", err) - return info + out, err = runner.Run(ctx, "powershell", "-NoProfile", "-Command", cimCPUCommand) + if err != nil { + slog.Warn("cpu detection failed (wmic and CIM unavailable), using defaults", "error", err) + return info + } } - parseWMICCPU(string(out), &info) + parseWMICCPU(trimBOM(string(out)), &info) return info } @@ -45,20 +60,20 @@ func parseWMICCPU(output string, info *CPUInfo) { fields := splitCSV(lines[1]) if idx, ok := colIdx["Name"]; ok && idx < len(fields) { - info.Model = fields[idx] + info.Model = strings.TrimSpace(fields[idx]) } if idx, ok := colIdx["NumberOfCores"]; ok && idx < len(fields) { - if n, err := strconv.Atoi(fields[idx]); err == nil { + if n, err := strconv.Atoi(strings.TrimSpace(fields[idx])); err == nil { info.Cores = n } } if idx, ok := colIdx["NumberOfLogicalProcessors"]; ok && idx < len(fields) { - if n, err := strconv.Atoi(fields[idx]); err == nil { + if n, err := strconv.Atoi(strings.TrimSpace(fields[idx])); err == nil { info.Threads = n } } if idx, ok := colIdx["MaxClockSpeed"]; ok && idx < len(fields) { - if mhz, err := strconv.ParseFloat(fields[idx], 64); err == nil { + if mhz, err := strconv.ParseFloat(strings.TrimSpace(fields[idx]), 64); err == nil { info.FreqGHz = mhz / 1000.0 } } @@ -69,15 +84,22 @@ func detectRAM(ctx context.Context, runner CommandRunner) RAMInfo { out, err := runner.Run(ctx, "wmic", "os", "get", "TotalVisibleMemorySize,FreePhysicalMemory", "/format:csv") if err != nil { - slog.Warn("wmic RAM detection failed, using defaults", "error", err) - return info + out, err = runner.Run(ctx, "powershell", "-NoProfile", "-Command", cimRAMCommand) + if err != nil { + slog.Warn("RAM detection failed (wmic and CIM unavailable), using defaults", "error", err) + return info + } } - parseWMICRAM(string(out), &info) + parseWMICRAM(trimBOM(string(out)), &info) // Detect swap (pagefile) size - if swapOut, err := runner.Run(ctx, "wmic", "pagefile", "get", "AllocatedBaseSize", "/format:csv"); err == nil { - parseWMICSwap(string(swapOut), &info) + swapOut, swapErr := runner.Run(ctx, "wmic", "pagefile", "get", "AllocatedBaseSize", "/format:csv") + if swapErr != nil { + swapOut, swapErr = runner.Run(ctx, "powershell", "-NoProfile", "-Command", cimSwapCommand) + } + if swapErr == nil { + parseWMICSwap(trimBOM(string(swapOut)), &info) } return info @@ -117,12 +139,12 @@ func parseWMICRAM(output string, info *RAMInfo) { fields := splitCSV(lines[1]) if idx, ok := colIdx["TotalVisibleMemorySize"]; ok && idx < len(fields) { - if kb, err := strconv.ParseInt(fields[idx], 10, 64); err == nil { + if kb, err := strconv.ParseInt(strings.TrimSpace(fields[idx]), 10, 64); err == nil { info.TotalMiB = int(kb / 1024) } } if idx, ok := colIdx["FreePhysicalMemory"]; ok && idx < len(fields) { - if kb, err := strconv.ParseInt(fields[idx], 10, 64); err == nil { + if kb, err := strconv.ParseInt(strings.TrimSpace(fields[idx]), 10, 64); err == nil { info.AvailableMiB = int(kb / 1024) } } @@ -131,11 +153,14 @@ func parseWMICRAM(output string, info *RAMInfo) { func collectCPUMetrics(ctx context.Context, runner CommandRunner) CPUMetrics { out, err := runner.Run(ctx, "wmic", "cpu", "get", "LoadPercentage", "/format:csv") if err != nil { - slog.Warn("wmic CPU metrics failed, using defaults", "error", err) - return CPUMetrics{} + out, err = runner.Run(ctx, "powershell", "-NoProfile", "-Command", cimCPULoadCommand) + if err != nil { + slog.Warn("CPU metrics failed (wmic and CIM unavailable), using defaults", "error", err) + return CPUMetrics{} + } } - lines := nonEmptyLines(string(out)) + lines := nonEmptyLines(trimBOM(string(out))) if len(lines) < 2 { return CPUMetrics{} } @@ -148,7 +173,7 @@ func collectCPUMetrics(ctx context.Context, runner CommandRunner) CPUMetrics { fields := splitCSV(lines[1]) if idx, ok := colIdx["LoadPercentage"]; ok && idx < len(fields) { - if pct, err := strconv.ParseFloat(fields[idx], 64); err == nil { + if pct, err := strconv.ParseFloat(strings.TrimSpace(fields[idx]), 64); err == nil { return CPUMetrics{UsagePercent: pct} } } diff --git a/internal/hal/detect_windows_test.go b/internal/hal/detect_windows_test.go index 6e2ae08..5478b0b 100644 --- a/internal/hal/detect_windows_test.go +++ b/internal/hal/detect_windows_test.go @@ -2,7 +2,52 @@ package hal -import "testing" +import ( + "context" + "testing" +) + +// On Windows 11 builds where wmic has been removed, CPU detection must fall +// back to PowerShell CIM. The mock runner returns ErrNotFound for the wmic +// command (not in its map), exercising the fallback path. +func TestDetectCPUFallsBackToCIMWhenWMICMissing(t *testing.T) { + runner := newMockRunner(map[string]mockResult{ + "powershell -NoProfile -Command " + cimCPUCommand: { + output: []byte("Name,NumberOfCores,NumberOfLogicalProcessors,MaxClockSpeed\nAMD Ryzen AI Max+ 395 w/ Radeon 8060S Graphics,16,32,3000\n"), + }, + }) + info := detectCPU(context.Background(), runner) + if info.Model != "AMD Ryzen AI Max+ 395 w/ Radeon 8060S Graphics" { + t.Errorf("Model = %q, want CIM-detected name", info.Model) + } + if info.Cores != 16 || info.Threads != 32 { + t.Errorf("Cores/Threads = %d/%d, want 16/32", info.Cores, info.Threads) + } + if info.FreqGHz != 3.0 { + t.Errorf("FreqGHz = %v, want 3", info.FreqGHz) + } +} + +func TestDetectRAMFallsBackToCIMWhenWMICMissing(t *testing.T) { + runner := newMockRunner(map[string]mockResult{ + "powershell -NoProfile -Command " + cimRAMCommand: { + output: []byte("TotalVisibleMemorySize,FreePhysicalMemory\n134217728,67108864\n"), + }, + "powershell -NoProfile -Command " + cimSwapCommand: { + output: []byte("AllocatedBaseSize\n8192\n"), + }, + }) + info := detectRAM(context.Background(), runner) + if info.TotalMiB != 131072 { + t.Errorf("TotalMiB = %d, want 131072 (128 GiB)", info.TotalMiB) + } + if info.AvailableMiB != 65536 { + t.Errorf("AvailableMiB = %d, want 65536", info.AvailableMiB) + } + if info.SwapTotalMiB != 8192 { + t.Errorf("SwapTotalMiB = %d, want 8192", info.SwapTotalMiB) + } +} func TestParseWMICCPU(t *testing.T) { tests := []struct {