Prechádzať zdrojové kódy

Fix Windows hardware info when wmic.exe is missing (#289)

Prefer Get-CimInstance for Win32_* queries and keep classic wmic as fallback so System Info and SMART still work on Windows 11 hosts that no longer ship wmic.

Co-authored-by: Cursor <cursoragent@cursor.com>
Po-Han Shih 3 týždňov pred
rodič
commit
7a41a535fa

+ 76 - 22
src/mod/disk/smart/helper.go

@@ -3,6 +3,7 @@ package smart
 import (
 	"fmt"
 	"os/exec"
+	"runtime"
 	"strings"
 
 	"imuslab.com/arozos/mod/info/logger"
@@ -19,38 +20,91 @@ func execCommand(executable string, args ...string) string {
 	return string(output)
 }
 
-func wmicGetinfo(wmicName string, itemName string) []string {
-	//get systeminfo
-	var InfoStorage []string
+// wmicClassName maps classic `wmic` aliases to CIM / Win32 class names.
+func wmicClassName(wmicName string) string {
+	if len(wmicName) > 6 && wmicName[0:6] == "Win32_" {
+		return wmicName
+	}
+	switch strings.ToLower(wmicName) {
+	case "cpu":
+		return "Win32_Processor"
+	case "os":
+		return "Win32_OperatingSystem"
+	case "computersystem":
+		return "Win32_ComputerSystem"
+	case "diskdrive":
+		return "Win32_DiskDrive"
+	case "nic":
+		return "Win32_NetworkAdapter"
+	case "logicaldisk":
+		return "Win32_LogicalDisk"
+	case "memorychip":
+		return "Win32_PhysicalMemory"
+	default:
+		return "Win32_" + wmicName
+	}
+}
 
-	cmd := exec.Command("chcp", "65001")
+// cimGetinfo reads a WMI property via PowerShell Get-CimInstance.
+// Modern Windows 11 no longer ships `wmic.exe` by default.
+func cimGetinfo(wmicName string, itemName string) []string {
+	className := wmicClassName(wmicName)
+	psClass := strings.ReplaceAll(className, "'", "''")
+	psItem := strings.ReplaceAll(itemName, "'", "''")
+	script := fmt.Sprintf(
+		"Get-CimInstance -ClassName '%s' | ForEach-Object { $p = $_.PSObject.Properties['%s']; if ($null -ne $p -and $null -ne $p.Value) { [string]$p.Value } }",
+		psClass, psItem,
+	)
+	cmd := exec.Command("powershell.exe",
+		"-NoProfile",
+		"-NonInteractive",
+		"-WindowStyle", "Hidden",
+		"-ExecutionPolicy", "Bypass",
+		"-Command", script,
+	)
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return nil
+	}
+	var info []string
+	for _, line := range strings.Split(string(out), "\n") {
+		line = strings.TrimSpace(strings.ReplaceAll(line, "\r", ""))
+		if line != "" {
+			info = append(info, line)
+		}
+	}
+	return info
+}
 
-	cmd = exec.Command("wmic", wmicName, "list", "full", "/format:list")
+func legacyWmicGetinfo(wmicName string, itemName string) []string {
+	var info []string
+	cmd := exec.Command("wmic", wmicName, "list", "full", "/format:list")
 	if wmicName == "os" {
 		cmd = exec.Command("wmic", wmicName, "get", "*", "/format:list")
 	}
-
-	if len(wmicName) > 6 {
-		if wmicName[0:6] == "Win32_" {
-			cmd = exec.Command("wmic", "path", wmicName, "get", "*", "/format:list")
-		}
+	if len(wmicName) > 6 && wmicName[0:6] == "Win32_" {
+		cmd = exec.Command("wmic", "path", wmicName, "get", "*", "/format:list")
 	}
 	out, _ := cmd.CombinedOutput()
-	strOut := string(out)
-
-	strSplitedOut := strings.Split(strOut, "\n")
-	for _, strConfig := range strSplitedOut {
+	for _, strConfig := range strings.Split(string(out), "\n") {
 		if strings.Contains(strConfig, "=") {
-			strSplitedConfig := strings.SplitN(strConfig, "=", 2)
-			if strSplitedConfig[0] == itemName {
-				strSplitedConfigReplaced := strings.Replace(strSplitedConfig[1], "\r", "", -1)
-				InfoStorage = append(InfoStorage, strSplitedConfigReplaced)
+			parts := strings.SplitN(strConfig, "=", 2)
+			if parts[0] == itemName {
+				info = append(info, strings.Replace(parts[1], "\r", "", -1))
 			}
 		}
-
 	}
-	if len(InfoStorage) == 0 {
-		InfoStorage = append(InfoStorage, "Undefined")
+	return info
+}
+
+func wmicGetinfo(wmicName string, itemName string) []string {
+	if runtime.GOOS == "windows" {
+		if info := cimGetinfo(wmicName, itemName); len(info) > 0 {
+			return info
+		}
+		if info := legacyWmicGetinfo(wmicName, itemName); len(info) > 0 {
+			return info
+		}
 	}
-	return InfoStorage
+	return []string{"Undefined"}
 }

+ 19 - 1
src/mod/disk/smart/smart_test.go

@@ -166,7 +166,7 @@ func TestNewSmartListenerUnsupported(t *testing.T) {
 	}
 }
 
-// TestWmicGetinfoWindowsOnly runs wmic helper only on Windows.
+// TestWmicGetinfoWindowsOnly runs wmic/CIM helper only on Windows.
 func TestWmicGetinfoWindowsOnly(t *testing.T) {
 	if runtime.GOOS != "windows" {
 		t.Skip("wmicGetinfo is Windows-only")
@@ -175,6 +175,24 @@ func TestWmicGetinfoWindowsOnly(t *testing.T) {
 	if len(result) == 0 {
 		t.Error("expected at least one result from wmicGetinfo")
 	}
+	if result[0] == "Undefined" || result[0] == "" {
+		t.Fatalf("wmicGetinfo(os, Caption) = %v; CIM fallback should return a real caption when wmic.exe is missing", result)
+	}
+}
+
+// TestWmicGetinfoWindowsDiskDrive covers the diskdrive Model/Size path used by fillCapacity.
+func TestWmicGetinfoWindowsDiskDrive(t *testing.T) {
+	if runtime.GOOS != "windows" {
+		t.Skip("windows only")
+	}
+	models := wmicGetinfo("diskdrive", "Model")
+	if len(models) == 0 || models[0] == "Undefined" || models[0] == "" {
+		t.Fatalf("wmicGetinfo(diskdrive, Model) = %v", models)
+	}
+	sizes := wmicGetinfo("diskdrive", "Size")
+	if len(sizes) == 0 || sizes[0] == "Undefined" || sizes[0] == "" {
+		t.Fatalf("wmicGetinfo(diskdrive, Size) = %v", sizes)
+	}
 }
 
 // TestFillCapacityNonWindows ensures fillCapacity is a no-op on non-Windows.

+ 81 - 22
src/mod/info/hardwareinfo/hardwareinfo.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"net/http"
 	"os/exec"
+	"runtime"
 	"strings"
 
 	"imuslab.com/arozos/mod/info/logger"
@@ -87,40 +88,98 @@ func (s *Server) GetArOZInfo(w http.ResponseWriter, r *http.Request) {
 	utils.SendJSONResponse(w, string(jsonData))
 }
 
-func wmicGetinfo(wmicName string, itemName string) []string {
-	//get systeminfo
-	var InfoStorage []string
+// wmicClassName maps classic `wmic` aliases to CIM / Win32 class names.
+func wmicClassName(wmicName string) string {
+	if len(wmicName) > 6 && wmicName[0:6] == "Win32_" {
+		return wmicName
+	}
+	switch strings.ToLower(wmicName) {
+	case "cpu":
+		return "Win32_Processor"
+	case "os":
+		return "Win32_OperatingSystem"
+	case "computersystem":
+		return "Win32_ComputerSystem"
+	case "diskdrive":
+		return "Win32_DiskDrive"
+	case "nic":
+		return "Win32_NetworkAdapter"
+	case "logicaldisk":
+		return "Win32_LogicalDisk"
+	case "memorychip":
+		return "Win32_PhysicalMemory"
+	default:
+		return "Win32_" + wmicName
+	}
+}
+
+// cimGetinfo reads a WMI property via PowerShell Get-CimInstance.
+// Modern Windows 11 (24H2+) no longer ships `wmic.exe` by default; CIM is the
+// supported replacement and exposes the same Win32_* properties.
+func cimGetinfo(wmicName string, itemName string) []string {
+	className := wmicClassName(wmicName)
+	psClass := strings.ReplaceAll(className, "'", "''")
+	psItem := strings.ReplaceAll(itemName, "'", "''")
+	script := fmt.Sprintf(
+		"Get-CimInstance -ClassName '%s' | ForEach-Object { $p = $_.PSObject.Properties['%s']; if ($null -ne $p -and $null -ne $p.Value) { [string]$p.Value } }",
+		psClass, psItem,
+	)
+	cmd := exec.Command("powershell.exe",
+		"-NoProfile",
+		"-NonInteractive",
+		"-WindowStyle", "Hidden",
+		"-ExecutionPolicy", "Bypass",
+		"-Command", script,
+	)
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return nil
+	}
+	var info []string
+	for _, line := range strings.Split(string(out), "\n") {
+		line = strings.TrimSpace(strings.ReplaceAll(line, "\r", ""))
+		if line != "" {
+			info = append(info, line)
+		}
+	}
+	return info
+}
 
-	cmd := exec.Command("chcp", "65001")
+// legacyWmicGetinfo keeps the original `wmic` path for older Windows hosts
+// that still ship the binary (pre-removal / optional Feature on Demand).
+func legacyWmicGetinfo(wmicName string, itemName string) []string {
+	var info []string
 
-	cmd = exec.Command("wmic", wmicName, "list", "full", "/format:list")
+	cmd := exec.Command("wmic", wmicName, "list", "full", "/format:list")
 	if wmicName == "os" {
 		cmd = exec.Command("wmic", wmicName, "get", "*", "/format:list")
 	}
-
-	if len(wmicName) > 6 {
-		if wmicName[0:6] == "Win32_" {
-			cmd = exec.Command("wmic", "path", wmicName, "get", "*", "/format:list")
-		}
+	if len(wmicName) > 6 && wmicName[0:6] == "Win32_" {
+		cmd = exec.Command("wmic", "path", wmicName, "get", "*", "/format:list")
 	}
 	out, _ := cmd.CombinedOutput()
-	strOut := string(out)
-
-	strSplitedOut := strings.Split(strOut, "\n")
-	for _, strConfig := range strSplitedOut {
+	for _, strConfig := range strings.Split(string(out), "\n") {
 		if strings.Contains(strConfig, "=") {
-			strSplitedConfig := strings.SplitN(strConfig, "=", 2)
-			if strSplitedConfig[0] == itemName {
-				strSplitedConfigReplaced := strings.Replace(strSplitedConfig[1], "\r", "", -1)
-				InfoStorage = append(InfoStorage, strSplitedConfigReplaced)
+			parts := strings.SplitN(strConfig, "=", 2)
+			if parts[0] == itemName {
+				info = append(info, strings.Replace(parts[1], "\r", "", -1))
 			}
 		}
-
 	}
-	if len(InfoStorage) == 0 {
-		InfoStorage = append(InfoStorage, "Undefined")
+	return info
+}
+
+func wmicGetinfo(wmicName string, itemName string) []string {
+	if runtime.GOOS == "windows" {
+		// Prefer CIM: wmic.exe was removed from many Windows 11 installs.
+		if info := cimGetinfo(wmicName, itemName); len(info) > 0 {
+			return info
+		}
+		if info := legacyWmicGetinfo(wmicName, itemName); len(info) > 0 {
+			return info
+		}
 	}
-	return InfoStorage
+	return []string{"Undefined"}
 }
 
 func filterGrepResults(result string, sep string) string {

+ 44 - 2
src/mod/info/hardwareinfo/hardwareinfo_test.go

@@ -204,14 +204,56 @@ func TestGetRamInfoHandler(t *testing.T) {
 // ---------------------------------------------------------------------------
 
 // TestWmicGetinfoNonWindows verifies that wmicGetinfo returns a default
-// "Undefined" slice when wmic is not available (i.e., non-Windows).
+// "Undefined" slice when wmic/CIM are not available (i.e., non-Windows).
 func TestWmicGetinfoNonWindows(t *testing.T) {
 	if runtime.GOOS == "windows" {
 		t.Skip("skipping non-Windows wmic test on Windows")
 	}
 	result := wmicGetinfo("os", "Caption")
-	// On non-Windows, wmic doesn't exist; the function should return ["Undefined"]
+	// On non-Windows, wmic/CIM don't apply; the function should return ["Undefined"]
 	if len(result) == 0 {
 		t.Error("wmicGetinfo() returned empty slice, expected at least one element")
 	}
+	if result[0] != "Undefined" {
+		t.Errorf("wmicGetinfo() = %v, want [Undefined] on non-Windows", result)
+	}
+}
+
+// TestWmicGetinfoWindowsOsCaption verifies hardware info works on modern
+// Windows 11 where wmic.exe is removed: CIM must still return OS Caption.
+func TestWmicGetinfoWindowsOsCaption(t *testing.T) {
+	if runtime.GOOS != "windows" {
+		t.Skip("windows only")
+	}
+	result := wmicGetinfo("os", "Caption")
+	if len(result) == 0 || result[0] == "Undefined" || result[0] == "" {
+		t.Fatalf("wmicGetinfo(os, Caption) = %v, want a real OS caption (CIM fallback)", result)
+	}
+	if !strings.Contains(strings.ToLower(result[0]), "windows") {
+		t.Errorf("unexpected Caption %q, expected it to mention Windows", result[0])
+	}
+}
+
+// TestWmicGetinfoWindowsCpuAndDisk covers the other aliases used by
+// PrintSystemHardwareDebugMessage / sysinfo_window.go.
+func TestWmicGetinfoWindowsCpuAndDisk(t *testing.T) {
+	if runtime.GOOS != "windows" {
+		t.Skip("windows only")
+	}
+	cpu := wmicGetinfo("cpu", "Name")
+	if len(cpu) == 0 || cpu[0] == "Undefined" || cpu[0] == "" {
+		t.Fatalf("wmicGetinfo(cpu, Name) = %v", cpu)
+	}
+	mem := wmicGetinfo("ComputerSystem", "TotalPhysicalMemory")
+	if len(mem) == 0 || mem[0] == "Undefined" || mem[0] == "" {
+		t.Fatalf("wmicGetinfo(ComputerSystem, TotalPhysicalMemory) = %v", mem)
+	}
+	disk := wmicGetinfo("diskdrive", "Model")
+	if len(disk) == 0 || disk[0] == "Undefined" || disk[0] == "" {
+		t.Fatalf("wmicGetinfo(diskdrive, Model) = %v", disk)
+	}
+	win32 := wmicGetinfo("Win32_USBHub", "Description")
+	if len(win32) == 0 {
+		t.Fatalf("wmicGetinfo(Win32_USBHub, Description) returned empty")
+	}
 }