From 38d29ed5d3ca381d848511b1fe76181fb935467f Mon Sep 17 00:00:00 2001 From: Ian Reyes Date: Tue, 21 Jul 2026 19:09:36 -0700 Subject: [PATCH 1/7] #59 Windows SMART retrieval implementation Implemented Windows-specific SMART data retrieval using smartctl.exe and PowerShell partition mapping. --- .../src/main/java/jdiskmark/Benchmark.java | 6 ++ .../main/java/jdiskmark/BenchmarkRunner.java | 40 +++++++++-- jdm-core/src/main/java/jdiskmark/Gui.java | 34 +++++++--- .../src/main/java/jdiskmark/MainFrame.java | 4 +- jdm-core/src/main/java/jdiskmark/Smart.java | 67 +++++++++++++++++++ jdm-core/src/main/java/jdiskmark/UtilOs.java | 42 ++++++++++++ 6 files changed, 176 insertions(+), 17 deletions(-) diff --git a/jdm-core/src/main/java/jdiskmark/Benchmark.java b/jdm-core/src/main/java/jdiskmark/Benchmark.java index 3ce7102b..ba2175da 100644 --- a/jdm-core/src/main/java/jdiskmark/Benchmark.java +++ b/jdm-core/src/main/java/jdiskmark/Benchmark.java @@ -21,6 +21,7 @@ import jakarta.persistence.NamedQuery; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import jakarta.persistence.Transient; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; import java.io.IOException; @@ -193,6 +194,11 @@ public void setRenderMode(RenderFrequencyMode renderMode) { this.renderMode = renderMode; } + @Transient + private Smart smartData; + public Smart getSmartData() { return smartData; } + public void setSmartData(Smart smartData) { this.smartData = smartData; } + // get the first operation of that type public BenchmarkOperation getOperation(IOMode mode) { for (BenchmarkOperation operation : operations) { diff --git a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java index 3d12cdb0..bd377b2f 100644 --- a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java +++ b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java @@ -5,6 +5,7 @@ import java.util.logging.Level; import java.util.logging.Logger; import java.time.LocalDateTime; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutionException; @@ -129,11 +130,40 @@ public Benchmark execute() throws Exception { GcDetector.triggerAndWait(); // Initial cleanup } - // Fetch SMART data before the benchmark starts (Linux only, non-fatal if it fails). - // Gui.runSmart() handles null/missing locationDir, dead privileged shell, and - // device-resolution failures internally — no risk of crashing the benchmark. - if (Smart.smartEnable && App.isLinux()) { - Gui.runSmart(); + // Fetch SMART data before the benchmark starts (Linux and Windows, non-fatal if it fails). + if (Smart.smartEnable) { + Smart smart = null; + try { + Path path = App.locationDir.toPath(); + if (App.isLinux()) { + String partition = UtilOs.getPartitionFromFilePathLinux(path); + List devices = UtilOs.getDeviceNamesFromPartitionLinux(partition); + if (devices != null && !devices.isEmpty()) { + String device = devices.get(0); + if (Smart.process == null || !Smart.process.isAlive()) { + Smart.startPrivilegedShell(); + Smart.startHeartbeat(); + } + smart = Smart.getSmart(device); + } + } else if (App.isWindows()) { + String driveLetter = UtilOs.getDriveLetterWindows(path); + String driveNum = UtilOs.getPhysicalDriveNumberWindows(driveLetter); + if (driveNum != null) { + smart = Smart.getSmart("pd" + driveNum); + } + } + } catch (Exception ex) { + logger.log(Level.WARNING, "Failed to fetch SMART data for benchmark", ex); + } + if (smart != null) { + benchmark.setSmartData(smart); + } + + // Also trigger UI update if we are in GUI mode + if (Gui.mainFrame != null) { + Gui.runSmart(); + } } benchmark.recordStartTime(); diff --git a/jdm-core/src/main/java/jdiskmark/Gui.java b/jdm-core/src/main/java/jdiskmark/Gui.java index 8821090d..6c1fc13c 100644 --- a/jdm-core/src/main/java/jdiskmark/Gui.java +++ b/jdm-core/src/main/java/jdiskmark/Gui.java @@ -1047,8 +1047,8 @@ public static void selectMainTab(String tabTitle) { */ static public void runSmart() { - if (!App.isLinux()) { - App.msg("SMART is only available in linux"); + if (!App.isLinux() && !App.isWindows()) { + App.msg("SMART is only available on Linux and Windows"); return; } @@ -1067,17 +1067,31 @@ static public void runSmart() { protected Smart doInBackground() { try { Path path = locDir.toPath(); - String partition = UtilOs.getPartitionFromFilePathLinux(path); - List devices = - UtilOs.getDeviceNamesFromPartitionLinux(partition); - if (devices == null || devices.isEmpty()) { + String device = null; + if (App.isLinux()) { + String partition = UtilOs.getPartitionFromFilePathLinux(path); + List devices = + UtilOs.getDeviceNamesFromPartitionLinux(partition); + if (devices != null && !devices.isEmpty()) { + device = devices.get(0); + } + } else if (App.isWindows()) { + String driveLetter = UtilOs.getDriveLetterWindows(path); + String driveNum = UtilOs.getPhysicalDriveNumberWindows(driveLetter); + if (driveNum != null) { + device = "pd" + driveNum; + } + } + if (device == null) { SMART_LOG.log(Level.WARNING, "runSmart: no device for {0}", locDir); return null; } - deviceRef[0] = devices.get(0); - if (Smart.process == null || !Smart.process.isAlive()) { - Smart.startPrivilegedShell(); - Smart.startHeartbeat(); + deviceRef[0] = device; + if (App.isLinux()) { + if (Smart.process == null || !Smart.process.isAlive()) { + Smart.startPrivilegedShell(); + Smart.startHeartbeat(); + } } return Smart.getSmart(deviceRef[0]); } catch (IOException ex) { diff --git a/jdm-core/src/main/java/jdiskmark/MainFrame.java b/jdm-core/src/main/java/jdiskmark/MainFrame.java index 9a50a5af..5471c7d8 100644 --- a/jdm-core/src/main/java/jdiskmark/MainFrame.java +++ b/jdm-core/src/main/java/jdiskmark/MainFrame.java @@ -127,8 +127,8 @@ public MainFrame() { // Start on the Benchmark tab — it's the primary interaction surface. mainTabPane.setSelectedIndex(mainTabPane.getTabCount() - 1); - // SMART tab — Linux only (requires smartctl / NVMe kernel support) - if (App.isLinux()) { + // SMART tab — Linux and Windows (requires smartctl / NVMe kernel support) + if (App.isLinux() || App.isWindows()) { mainTabPane.addTab("SMART", Gui.smartPanel); Gui.smartReportsPanel = new SmartReportsPanel(); // SMART Reports lives in the bottom tabbedPane alongside Benchmark Operations + Events diff --git a/jdm-core/src/main/java/jdiskmark/Smart.java b/jdm-core/src/main/java/jdiskmark/Smart.java index 0a740695..f495b012 100644 --- a/jdm-core/src/main/java/jdiskmark/Smart.java +++ b/jdm-core/src/main/java/jdiskmark/Smart.java @@ -84,6 +84,37 @@ public class Smart { * */ static String resolveSmartctlPath() { + if (App.isWindows()) { + // 1. Bundled copy: jpackage sets APPDIR → …\JDiskMark\app + String appDir = System.getenv("APPDIR"); + if (appDir != null) { + Path bundled = Path.of(appDir).getParent().resolve("smartctl/smartctl.exe"); + if (Files.isExecutable(bundled)) { + LOGGER.info("Using bundled smartctl (APPDIR): " + bundled); + return bundled.toString(); + } + bundled = Path.of(appDir).resolve("smartctl.exe"); + if (Files.isExecutable(bundled)) { + LOGGER.info("Using bundled smartctl: " + bundled); + return bundled.toString(); + } + } + // 2. Check well-known installation paths + Path installed = Path.of("C:\\Program Files\\smartmontools\\bin\\smartctl.exe"); + if (Files.isExecutable(installed)) { + LOGGER.info("Using installed smartctl: " + installed); + return installed.toString(); + } + installed = Path.of("C:\\Program Files (x86)\\smartmontools\\bin\\smartctl.exe"); + if (Files.isExecutable(installed)) { + LOGGER.info("Using installed smartctl: " + installed); + return installed.toString(); + } + // 3. System fallback + LOGGER.info("Using system smartctl: smartctl.exe"); + return "smartctl.exe"; + } + // 1. Bundled copy: jpackage sets APPDIR → …/opt/jdiskmark/app String appDir = System.getenv("APPDIR"); if (appDir != null) { @@ -115,6 +146,9 @@ static String resolveSmartctlPath() { * @throws IOException if the process cannot be started */ public static void startPrivilegedShell() throws IOException { + if (App.isWindows()) { + return; + } synchronized (pLock) { if (process != null && process.isAlive()) { return; // already running @@ -154,6 +188,9 @@ public static void startPrivilegedShell() throws IOException { * Only one thread is started; subsequent calls are ignored. */ public static void startHeartbeat() { + if (App.isWindows()) { + return; + } if (hbThread != null && hbThread.isAlive()) return; hbThread = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { @@ -193,6 +230,36 @@ public static Smart getSmart(String deviceName) { LOGGER.severe("getSmart: invalid device name: " + deviceName); return null; } + if (App.isWindows()) { + try { + String smartctlPath = resolveSmartctlPath(); + ProcessBuilder pb = new ProcessBuilder(smartctlPath, "--json", "-a", "/dev/" + deviceName); + pb.redirectErrorStream(false); + Process p = pb.start(); + + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append('\n'); + } + } + p.waitFor(); + + String result = sb.toString().trim(); + if (result.isEmpty()) { + LOGGER.severe("getSmart: empty response from smartctl for device " + deviceName); + return null; + } + + Smart smart = fromJson(result); + logSmart(smart); + return smart; + } catch (IOException | InterruptedException ex) { + LOGGER.log(Level.SEVERE, "getSmart failed for Windows device: " + deviceName, ex); + } + return null; + } final String sentinel = "---SMART_DONE---"; try { synchronized (pLock) { diff --git a/jdm-core/src/main/java/jdiskmark/UtilOs.java b/jdm-core/src/main/java/jdiskmark/UtilOs.java index ba7d718d..203b057b 100644 --- a/jdm-core/src/main/java/jdiskmark/UtilOs.java +++ b/jdm-core/src/main/java/jdiskmark/UtilOs.java @@ -1048,6 +1048,48 @@ static String getSectorSizeWindows(String driveLetter) { } } + /** + * Returns the physical drive number for the given drive letter (e.g. "1" or "0"). + * Uses PowerShell {@code Get-Partition -DriveLetter }. Falls back to WMI if needed. + * + * @param driveLetter single letter, e.g. "C" + * @return physical drive number string or {@code null} on failure + */ + public static String getPhysicalDriveNumberWindows(String driveLetter) { + if (driveLetter == null || driveLetter.trim().isEmpty()) { + return null; + } + String letter = driveLetter.trim().substring(0, 1).toUpperCase(); + + // 1. Try using Get-Partition + String diskNum = runPowerShellOneLiner( + "Get-Partition -DriveLetter '" + letter + "' | Select-Object -ExpandProperty DiskNumber"); + if (diskNum != null) { + diskNum = diskNum.trim(); + if (diskNum.matches("\\d+")) { + return diskNum; + } + } + + // 2. Fallback using WMI / Get-CimInstance + String fallbackNum = runPowerShellOneLiner( + "Get-CimInstance Win32_LogicalDiskToPartition | " + + "Where-Object { `$_.Dependent.DeviceId -eq '" + letter + ":' } | " + + "ForEach-Object { `$_.Antecedent.DeviceId }"); + if (fallbackNum != null && fallbackNum.contains("Disk #")) { + String parts[] = fallbackNum.split("Disk #"); + if (parts.length > 1) { + String diskNumPart = parts[1].split(",")[0].trim(); + if (diskNumPart.matches("\\d+")) { + return diskNumPart; + } + } + } + + return null; + } + + /** * Runs a single PowerShell command and returns the first non-blank line of * output, or {@code null} on any error. Timeout: 15 seconds. From 23eae9e648c49f53f88928604c63bd0debfeec54 Mon Sep 17 00:00:00 2001 From: Ian Reyes Date: Sat, 25 Jul 2026 13:56:51 -0700 Subject: [PATCH 2/7] Apply Copilot PR review suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MainFrame: add macOS to SMART tab condition (was Linux+Windows only, but macOS SMART is fully implemented in Gui.runSmart() and Smart.java) - Smart.getSmart() Windows: replace unbounded p.waitFor() with 15-second timeout; forcibly destroy hung smartctl process and continue to next device arg - Smart.getSmart() Windows: split IOException|InterruptedException multi-catch; restore thread interrupt flag on InterruptedException - BenchmarkRunner: guard Windows SMART fetch with App.isAdmin check, consistent with Gui.runSmart() — avoids noisy log failures when not elevated - windows-msi.yml: correct Fetch bundled smartctl comment and $dest path from old app-content location to jdm-core/target/smartctl/ (actual --input path) - jdm-msi/pom.xml: add Ant fail task after smartctl staging check so MSI build fails fast instead of silently producing a bundle without the expected binary --- .github/workflows/windows-msi.yml | 16 ++++++++-------- .../src/main/java/jdiskmark/BenchmarkRunner.java | 2 +- jdm-core/src/main/java/jdiskmark/MainFrame.java | 4 ++-- jdm-core/src/main/java/jdiskmark/Smart.java | 11 +++++++++-- jdm-dist/jdm-msi/pom.xml | 3 +++ 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/.github/workflows/windows-msi.yml b/.github/workflows/windows-msi.yml index afef407e..f5e6aae9 100644 --- a/.github/workflows/windows-msi.yml +++ b/.github/workflows/windows-msi.yml @@ -43,20 +43,20 @@ jobs: - name: Fetch bundled smartctl from jdm-deps # Downloads the pre-packaged smartctl 7.5 binary from the public - # JDiskMark/jdm-deps release asset. The staging directory is picked - # up by jpackage --app-content (configured in jdm-msi/pom.xml) and - # lands at \app\smartctl\smartctl.exe in the installed image. + # JDiskMark/jdm-deps release asset into jdm-core/target/smartctl/. + # The windows-msi Maven profile passes jdm-core/target as jpackage --input, + # so smartctl.exe is included at \app\smartctl\smartctl.exe in the installed image. shell: pwsh run: | - $dest = "jdm-dist/jdm-msi/src/main/app-content" + $dest = "jdm-core/target/smartctl" $zip = "$env:TEMP/smartctl-win.zip" - New-Item -ItemType Directory -Force -Path "$dest/smartctl" | Out-Null + New-Item -ItemType Directory -Force -Path $dest | Out-Null Write-Host "[smartctl] Downloading smartctl-7.5-windows-x86_64.zip from jdm-deps..." Invoke-WebRequest -Uri "https://github.com/JDiskMark/jdm-deps/releases/download/tools%2Fsmartctl-7.5-win64/smartctl-7.5-windows-x86_64.zip" -OutFile $zip - Expand-Archive -Path $zip -DestinationPath $dest -Force - $exe = Get-Item "$dest/smartctl/smartctl.exe" -ErrorAction Stop + Expand-Archive -Path $zip -DestinationPath (Split-Path $dest) -Force + $exe = Get-Item "$dest/smartctl.exe" -ErrorAction Stop Write-Host "[smartctl] Staged: $($exe.FullName) ($($exe.Length) bytes)" - & "$dest/smartctl/smartctl.exe" --version + & "$dest/smartctl.exe" --version - name: Build Windows MSI (unsigned) # Builds jdm-core first (-am = also build upstream dependencies), diff --git a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java index 5eb75858..81223eb1 100644 --- a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java +++ b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java @@ -146,7 +146,7 @@ public Benchmark execute() throws Exception { } smart = Smart.getSmart(device); } - } else if (App.isWindows()) { + } else if (App.isWindows() && App.isAdmin) { String driveLetter = UtilOs.getDriveLetterWindows(path); String driveNum = UtilOs.getPhysicalDriveNumberWindows(driveLetter); if (driveNum != null) { diff --git a/jdm-core/src/main/java/jdiskmark/MainFrame.java b/jdm-core/src/main/java/jdiskmark/MainFrame.java index b1d6f77b..fb17db59 100644 --- a/jdm-core/src/main/java/jdiskmark/MainFrame.java +++ b/jdm-core/src/main/java/jdiskmark/MainFrame.java @@ -152,8 +152,8 @@ public MainFrame() { // Start on the Benchmark tab — it's the primary interaction surface. mainTabPane.setSelectedIndex(mainTabPane.getTabCount() - 1); - // SMART tab — Linux and Windows (requires smartctl / NVMe kernel support) - if (App.isLinux() || App.isWindows()) { + // SMART tab — Linux, macOS, and Windows (requires smartctl / NVMe kernel support) + if (App.isLinux() || App.isMacOs() || App.isWindows()) { mainTabPane.addTab("SMART", Gui.smartPanel); Gui.smartReportsPanel = new SmartReportsPanel(); // SMART Reports lives in the bottom tabbedPane alongside Benchmark Operations + Events diff --git a/jdm-core/src/main/java/jdiskmark/Smart.java b/jdm-core/src/main/java/jdiskmark/Smart.java index 11cace84..f3b1c93e 100644 --- a/jdm-core/src/main/java/jdiskmark/Smart.java +++ b/jdm-core/src/main/java/jdiskmark/Smart.java @@ -355,7 +355,11 @@ public static Smart getSmart(String deviceName) { sb.append(line).append('\n'); } } - p.waitFor(); + if (!p.waitFor(15, TimeUnit.SECONDS)) { + p.destroyForcibly(); + LOGGER.warning("getSmart: smartctl timed out for device arg: " + devArg); + continue; + } String result = sb.toString().trim(); if (result.isEmpty()) { @@ -372,7 +376,10 @@ public static Smart getSmart(String deviceName) { return smart; } LOGGER.severe("getSmart: all device arg attempts failed for: " + deviceName); - } catch (IOException | InterruptedException ex) { + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + LOGGER.log(Level.SEVERE, "getSmart interrupted for Windows device: " + deviceName, ex); + } catch (IOException ex) { LOGGER.log(Level.SEVERE, "getSmart failed for Windows device: " + deviceName, ex); } return null; diff --git a/jdm-dist/jdm-msi/pom.xml b/jdm-dist/jdm-msi/pom.xml index 646e3aa7..62cddb6b 100644 --- a/jdm-dist/jdm-msi/pom.xml +++ b/jdm-dist/jdm-msi/pom.xml @@ -71,6 +71,9 @@ + + + From c63c855decd38fe344fd3d866a7a68ffffaee85c Mon Sep 17 00:00:00 2001 From: Ian Reyes Date: Sun, 26 Jul 2026 00:12:03 -0700 Subject: [PATCH 3/7] Windows SMART: UAC privilege escalation (no longer requires running as admin) Previously, SMART data on Windows required launching the entire application as Administrator. This commit introduces on-demand UAC escalation so the app can run as a normal user and still read SMART data. How it works: - When the user clicks Run SMART (and is not already admin), SmartEscalation writes a PowerShell helper script to %LOCALAPPDATA%\JDiskMark\smart-helper.ps1 - The script is launched elevated via Start-Process -Verb RunAs -Wait, which triggers a standard Windows UAC prompt (shows "Windows PowerShell") - The elevated helper runs smartctl and writes the JSON result to %LOCALAPPDATA%\JDiskMark\smart-ipc-.json - Both processes share %LOCALAPPDATA% because they run as the same Windows user (just different privilege tokens), so the file is readable by the non-elevated app - The main process reads, parses and displays the result; UAC cancellation is handled gracefully with a clear status message New file: SmartEscalation.java: IPC helper (write script, launch elevated, read result) Modified files: Smart.java: getSmart() splits into fast path (already admin -> getSmartDirect) and escalation path (not admin -> SmartEscalation.runElevated) Gui.java: remove early-return admin guard; show UAC hint in status bar; update failure message to mention UAC cancellation BenchmarkRunner.java: remove App.isAdmin guard (escalation is now transparent) Future improvement: a native jdm-smart-helper.exe with requireAdministrator manifest would show "JDiskMark" in the UAC dialog instead of "Windows PowerShell" --- .../main/java/jdiskmark/BenchmarkRunner.java | 2 +- jdm-core/src/main/java/jdiskmark/Gui.java | 11 +- jdm-core/src/main/java/jdiskmark/Smart.java | 131 +++++++++++----- .../main/java/jdiskmark/SmartEscalation.java | 148 ++++++++++++++++++ 4 files changed, 241 insertions(+), 51 deletions(-) create mode 100644 jdm-core/src/main/java/jdiskmark/SmartEscalation.java diff --git a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java index 81223eb1..5eb75858 100644 --- a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java +++ b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java @@ -146,7 +146,7 @@ public Benchmark execute() throws Exception { } smart = Smart.getSmart(device); } - } else if (App.isWindows() && App.isAdmin) { + } else if (App.isWindows()) { String driveLetter = UtilOs.getDriveLetterWindows(path); String driveNum = UtilOs.getPhysicalDriveNumberWindows(driveLetter); if (driveNum != null) { diff --git a/jdm-core/src/main/java/jdiskmark/Gui.java b/jdm-core/src/main/java/jdiskmark/Gui.java index 7787d24b..26de07aa 100644 --- a/jdm-core/src/main/java/jdiskmark/Gui.java +++ b/jdm-core/src/main/java/jdiskmark/Gui.java @@ -1193,11 +1193,9 @@ static public void runSmart() { return; } - // On Windows, smartctl requires Administrator privileges to access raw SMART data. - if (App.isWindows() && !App.isAdmin) { - App.msg("SMART requires Administrator privileges on Windows. Please restart JDiskMark as Administrator."); - if (smartPanel != null) smartPanel.setStatus("Run as Administrator to read SMART data."); - return; + // On Windows, if not already admin, escalation will trigger a UAC prompt. + if (App.isWindows() && !App.isAdmin && smartPanel != null) { + smartPanel.setStatus("A Windows security (UAC) prompt will appear to authorise SMART access..."); } if (smartPanel == null || App.locationDir == null) { @@ -1274,8 +1272,7 @@ protected void done() { if (App.isWindows()) { String hint = App.isAdmin ? "SMART data unavailable — ensure smartctl is installed (smartmontools.org)." - : "SMART requires Administrator privileges — restart as Administrator."; - App.msg(hint); + : "SMART access was cancelled or failed — accept the UAC prompt to read SMART data."; smartPanel.setStatus(hint); } } diff --git a/jdm-core/src/main/java/jdiskmark/Smart.java b/jdm-core/src/main/java/jdiskmark/Smart.java index f3b1c93e..aeaa2ea9 100644 --- a/jdm-core/src/main/java/jdiskmark/Smart.java +++ b/jdm-core/src/main/java/jdiskmark/Smart.java @@ -323,13 +323,73 @@ public static void startHeartbeat() { } /** - * Queries SMART data for the given device by writing a {@code smartctl} - * command to the persistent privileged shell and reading back its output - * up to a unique sentinel line. Logs the key fields at INFO level. + * Runs {@code smartctl} directly in-process (Windows elevated / fast path). + * Tries {@code /dev/} first, then the bare device name as a fallback, + * since some Windows controller drivers require one form or the other. * - *

{@link #startPrivilegedShell()} must have been called before this. + * @param deviceName bare device name, e.g. {@code pd0} + * @param smartctlPath absolute path to {@code smartctl.exe} + * @return a populated {@link Smart} instance, or {@code null} on error + */ + private static Smart getSmartDirect(String deviceName, String smartctlPath) { + try { + for (String devArg : new String[]{"/dev/" + deviceName, deviceName}) { + ProcessBuilder pb = new ProcessBuilder(smartctlPath, "--json", "-a", devArg); + pb.redirectErrorStream(true); + Process p = pb.start(); + + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append('\n'); + } + } + if (!p.waitFor(15, TimeUnit.SECONDS)) { + p.destroyForcibly(); + LOGGER.warning("getSmartDirect: smartctl timed out for: " + devArg); + continue; + } + String result = sb.toString().trim(); + if (result.isEmpty()) { + LOGGER.warning("getSmartDirect: empty response for: " + devArg); + continue; + } + if (!result.startsWith("{")) { + LOGGER.warning("getSmartDirect: non-JSON response for " + devArg + ": " + result); + continue; + } + Smart smart = fromJson(result); + logSmart(smart); + return smart; + } + LOGGER.severe("getSmartDirect: all attempts failed for: " + deviceName); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + LOGGER.log(Level.SEVERE, "getSmartDirect interrupted for: " + deviceName, ex); + } catch (IOException ex) { + LOGGER.log(Level.SEVERE, "getSmartDirect failed for: " + deviceName, ex); + } + return null; + } + + /** + * Queries SMART data for the given device. * - * @param deviceName bare device name, e.g. {@code nvme0n1} + *

On Windows: + *

    + *
  • If the process is already elevated ({@link App#isAdmin}), runs + * {@code smartctl} directly via {@link #getSmartDirect}.
  • + *
  • Otherwise, delegates to {@link SmartEscalation#runElevated} which + * triggers a UAC prompt and runs an elevated helper, returning the + * JSON via a temp file in {@code %LOCALAPPDATA%\JDiskMark\}.
  • + *
+ * + *

On Linux / macOS, writes the command to the persistent privileged + * shell started by {@link #startPrivilegedShell()} and reads back the output. + * + * @param deviceName bare device name, e.g. {@code nvme0n1} or {@code pd0} * @return a populated {@link Smart} instance, or {@code null} on error */ public static Smart getSmart(String deviceName) { @@ -338,51 +398,36 @@ public static Smart getSmart(String deviceName) { return null; } if (App.isWindows()) { - try { - String smartctlPath = resolveSmartctlPath(); - LOGGER.info("getSmart: using smartctl at: " + smartctlPath + " for device: " + deviceName); - // Try /dev/ first; fall back to bare if output is empty. - for (String devArg : new String[]{"/dev/" + deviceName, deviceName}) { - ProcessBuilder pb = new ProcessBuilder(smartctlPath, "--json", "-a", devArg); - pb.redirectErrorStream(true); // merge stderr into stdout so we can log it - Process p = pb.start(); - - StringBuilder sb = new StringBuilder(); - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { - String line; - while ((line = reader.readLine()) != null) { - sb.append(line).append('\n'); - } - } - if (!p.waitFor(15, TimeUnit.SECONDS)) { - p.destroyForcibly(); - LOGGER.warning("getSmart: smartctl timed out for device arg: " + devArg); - continue; - } + String smartctlPath = resolveSmartctlPath(); + LOGGER.info("getSmart: using smartctl at: " + smartctlPath + " for device: " + deviceName); - String result = sb.toString().trim(); - if (result.isEmpty()) { - LOGGER.warning("getSmart: empty response from smartctl for device arg: " + devArg); - continue; + if (App.isAdmin) { + // ── Fast path: already elevated, run smartctl directly ────────────── + return getSmartDirect(deviceName, smartctlPath); + } else { + // ── Escalation path: request UAC elevation for the helper ──────────── + try { + LOGGER.info("getSmart: not admin — requesting UAC elevation for device: " + deviceName); + String json = SmartEscalation.runElevated(deviceName, smartctlPath); + if (json == null) { + LOGGER.warning("getSmart: escalation returned null (UAC cancelled?) for: " + deviceName); + return null; } - // smartctl may return non-JSON error text if it can't open the device - if (!result.startsWith("{")) { - LOGGER.warning("getSmart: non-JSON response for " + devArg + ": " + result); - continue; + if (!json.startsWith("{")) { + LOGGER.warning("getSmart: escalation returned non-JSON for " + deviceName + ": " + json); + return null; } - Smart smart = fromJson(result); + Smart smart = fromJson(json); logSmart(smart); return smart; + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + LOGGER.log(Level.SEVERE, "getSmart escalation interrupted for: " + deviceName, ex); + } catch (IOException ex) { + LOGGER.log(Level.SEVERE, "getSmart escalation failed for: " + deviceName, ex); } - LOGGER.severe("getSmart: all device arg attempts failed for: " + deviceName); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - LOGGER.log(Level.SEVERE, "getSmart interrupted for Windows device: " + deviceName, ex); - } catch (IOException ex) { - LOGGER.log(Level.SEVERE, "getSmart failed for Windows device: " + deviceName, ex); + return null; } - return null; } final String sentinel = "---SMART_DONE---"; try { diff --git a/jdm-core/src/main/java/jdiskmark/SmartEscalation.java b/jdm-core/src/main/java/jdiskmark/SmartEscalation.java new file mode 100644 index 00000000..a2dc801a --- /dev/null +++ b/jdm-core/src/main/java/jdiskmark/SmartEscalation.java @@ -0,0 +1,148 @@ +package jdiskmark; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +/** + * Runs {@code smartctl} in an elevated child process on Windows via a UAC prompt, + * passing the JSON result back to the non-elevated caller through a temp file in + * {@code %LOCALAPPDATA%\JDiskMark\}. + * + *

Both the elevated helper and the non-elevated main process share the same + * {@code %LOCALAPPDATA%} path because they run under the same Windows user account + * (just different privilege tokens), so the IPC file is readable by both. + * + *

The UAC dialog will show "Windows PowerShell" as the requesting application. + * A future native helper exe with an embedded {@code requireAdministrator} manifest + * would display "JDiskMark" instead. + */ +public class SmartEscalation { + + private static final Logger LOGGER = Logger.getLogger(SmartEscalation.class.getName()); + + /** Maximum time to wait for the elevated helper to complete. */ + private static final int TIMEOUT_SECONDS = 45; + + /** + * Runs {@code smartctl} for the given Windows device using UAC elevation. + * + *

    + *
  1. Writes a PowerShell helper script to {@code %LOCALAPPDATA%\JDiskMark\}.
  2. + *
  3. Launches the script elevated via {@code Start-Process -Verb RunAs -Wait}.
  4. + *
  5. Reads the JSON result written by the elevated helper.
  6. + *
+ * + * @param device Windows device name, e.g. {@code pd0} + * @param smartctlPath absolute path to {@code smartctl.exe} + * @return raw JSON string from smartctl, or {@code null} if the UAC prompt was + * cancelled or the elevated helper failed + * @throws IOException if the IPC directory or script file cannot be created + * @throws InterruptedException if the calling thread is interrupted while waiting + */ + public static String runElevated(String device, String smartctlPath) + throws IOException, InterruptedException { + + Path ipcDir = resolveIpcDir(); + Files.createDirectories(ipcDir); + + Path scriptFile = ipcDir.resolve("smart-helper.ps1"); + Path outputFile = ipcDir.resolve("smart-ipc-" + device + ".json"); + Path cancelFile = ipcDir.resolve("smart-ipc-" + device + ".cancelled"); + + // Remove stale artifacts from any previous run + Files.deleteIfExists(outputFile); + Files.deleteIfExists(cancelFile); + + writeHelperScript(scriptFile, smartctlPath, device, outputFile, cancelFile); + + // Launch script elevated. Start-Process -Verb RunAs triggers UAC. + // -Wait blocks the launcher until the elevated powershell exits. + String launchCmd = String.format( + "Start-Process powershell -Verb RunAs -Wait -WindowStyle Hidden " + + "-ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"%s\"'", + scriptFile.toString().replace("\"", "`\"")); + + LOGGER.info("SmartEscalation: launching elevated helper for device: " + device); + ProcessBuilder pb = new ProcessBuilder( + "powershell", "-NoProfile", "-NonInteractive", "-Command", launchCmd); + pb.redirectErrorStream(true); + Process launcher = pb.start(); + + // Drain launcher stdout/stderr to prevent pipe-full stalls + launcher.getInputStream().transferTo(OutputStream.nullOutputStream()); + + if (!launcher.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + launcher.destroyForcibly(); + LOGGER.warning("SmartEscalation: launcher timed out for device: " + device); + return null; + } + + if (Files.exists(cancelFile)) { + LOGGER.info("SmartEscalation: UAC cancelled by user for device: " + device); + Files.deleteIfExists(cancelFile); + return null; + } + + if (!Files.exists(outputFile)) { + LOGGER.warning("SmartEscalation: output file not created for device: " + device + + " (UAC may have been cancelled or helper failed)"); + return null; + } + + String json = Files.readString(outputFile, StandardCharsets.UTF_8).trim(); + Files.deleteIfExists(outputFile); + + if (json.isEmpty()) { + LOGGER.warning("SmartEscalation: empty output for device: " + device); + return null; + } + + LOGGER.info("SmartEscalation: received " + json.length() + " bytes for device: " + device); + return json; + } + + /** + * Writes the PowerShell helper script that will run inside the elevated process. + * The script runs smartctl and writes the JSON to {@code outputFile}, or writes + * a sentinel {@code cancelFile} if smartctl cannot be executed. + */ + private static void writeHelperScript(Path scriptFile, String smartctlPath, + String device, Path outputFile, Path cancelFile) throws IOException { + + // Single-quote PS strings; escape embedded single-quotes by doubling them. + String smartctlPs = smartctlPath.replace("'", "''"); + String outputPs = outputFile.toString().replace("'", "''"); + String cancelPs = cancelFile.toString().replace("'", "''"); + + String script = String.join("\r\n", + "# JDiskMark SMART elevation helper -- auto-generated, do not edit", + "param()", + "Set-StrictMode -Off", + "try {", + " $out = & '" + smartctlPs + "' --json -a '/dev/" + device + "' 2>&1", + " $out | Out-File -FilePath '" + outputPs + "' -Encoding utf8 -NoNewline", + "} catch {", + " # smartctl not found or access denied -- write cancel sentinel", + " $_.Exception.Message | Out-File -FilePath '" + cancelPs + + "' -Encoding utf8 -NoNewline", + "}" + ); + Files.writeString(scriptFile, script, StandardCharsets.UTF_8); + } + + /** Returns the IPC directory path: {@code %LOCALAPPDATA%\JDiskMark}. */ + private static Path resolveIpcDir() { + String localAppData = System.getenv("LOCALAPPDATA"); + if (localAppData == null) { + localAppData = System.getProperty("java.io.tmpdir"); + } + return Path.of(localAppData, "JDiskMark"); + } + + private SmartEscalation() {} +} From ab56d93615162ce6a9dda484c9297cc73ab41dba Mon Sep 17 00:00:00 2001 From: Ian Reyes Date: Sun, 26 Jul 2026 00:31:47 -0700 Subject: [PATCH 4/7] Fix SmartEscalation: switch to -EncodedCommand and strip UTF-8 BOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs fixed: 1. Script file path with spaces (e.g. 'Ian Reyes' in %LOCALAPPDATA%) broke the -File argument when passed through Start-Process -ArgumentList, causing the elevated PowerShell to silently not run the script. Fixed by switching to -EncodedCommand (UTF-16LE base64) — no script file path needed at all. 2. .NET's [System.Text.Encoding]::UTF8 writes a UTF-8 BOM (EF BB BF) by default, causing the JSON check startsWith('{') to fail (the string actually started with U+FEFF). Fixed by using New-Object System.Text.UTF8Encoding(\False) in the PowerShell script, and adding a defensive BOM-strip in Java before parsing. --- .../main/java/jdiskmark/SmartEscalation.java | 135 ++++++++++-------- 1 file changed, 77 insertions(+), 58 deletions(-) diff --git a/jdm-core/src/main/java/jdiskmark/SmartEscalation.java b/jdm-core/src/main/java/jdiskmark/SmartEscalation.java index a2dc801a..06ac59cc 100644 --- a/jdm-core/src/main/java/jdiskmark/SmartEscalation.java +++ b/jdm-core/src/main/java/jdiskmark/SmartEscalation.java @@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Base64; import java.util.concurrent.TimeUnit; import java.util.logging.Logger; @@ -13,9 +14,13 @@ * passing the JSON result back to the non-elevated caller through a temp file in * {@code %LOCALAPPDATA%\JDiskMark\}. * + *

The elevated script is delivered via PowerShell's {@code -EncodedCommand} + * (UTF-16LE base64), which avoids all script-file-path / space-in-username quoting + * issues that arise when using {@code -File}. + * *

Both the elevated helper and the non-elevated main process share the same * {@code %LOCALAPPDATA%} path because they run under the same Windows user account - * (just different privilege tokens), so the IPC file is readable by both. + * (just different privilege tokens), so the IPC file is accessible to both. * *

The UAC dialog will show "Windows PowerShell" as the requesting application. * A future native helper exe with an embedded {@code requireAdministrator} manifest @@ -32,8 +37,9 @@ public class SmartEscalation { * Runs {@code smartctl} for the given Windows device using UAC elevation. * *

    - *
  1. Writes a PowerShell helper script to {@code %LOCALAPPDATA%\JDiskMark\}.
  2. - *
  3. Launches the script elevated via {@code Start-Process -Verb RunAs -Wait}.
  4. + *
  5. Builds a PowerShell script inline and encodes it as UTF-16LE base64.
  6. + *
  7. Launches an elevated {@code powershell.exe} with {@code -EncodedCommand} + * via {@code Start-Process -Verb RunAs -Wait}.
  8. *
  9. Reads the JSON result written by the elevated helper.
  10. *
* @@ -41,7 +47,7 @@ public class SmartEscalation { * @param smartctlPath absolute path to {@code smartctl.exe} * @return raw JSON string from smartctl, or {@code null} if the UAC prompt was * cancelled or the elevated helper failed - * @throws IOException if the IPC directory or script file cannot be created + * @throws IOException if the IPC directory cannot be created * @throws InterruptedException if the calling thread is interrupted while waiting */ public static String runElevated(String device, String smartctlPath) @@ -50,30 +56,65 @@ public static String runElevated(String device, String smartctlPath) Path ipcDir = resolveIpcDir(); Files.createDirectories(ipcDir); - Path scriptFile = ipcDir.resolve("smart-helper.ps1"); Path outputFile = ipcDir.resolve("smart-ipc-" + device + ".json"); - Path cancelFile = ipcDir.resolve("smart-ipc-" + device + ".cancelled"); + Path statusFile = ipcDir.resolve("smart-ipc-" + device + ".status"); // Remove stale artifacts from any previous run Files.deleteIfExists(outputFile); - Files.deleteIfExists(cancelFile); + Files.deleteIfExists(statusFile); - writeHelperScript(scriptFile, smartctlPath, device, outputFile, cancelFile); + // ── Build the elevated script ───────────────────────────────────────── + // Single-quote PS string escaping (double any embedded single-quotes). + String smartctlPs = smartctlPath.replace("'", "''"); + String outputPs = outputFile.toString().replace("'", "''"); + String statusPs = statusFile.toString().replace("'", "''"); + + // The script tries /dev/ first, then the bare device name. + // Uses [System.IO.File]::WriteAllText which handles paths with spaces. + // Writes a status file if smartctl doesn't produce JSON (for diagnostics). + String innerScript = String.join("\r\n", + "$ErrorActionPreference = 'Continue'", + "$written = $false", + "foreach ($d in @('/dev/" + device + "', '" + device + "')) {", + " $out = & '" + smartctlPs + "' --json -a $d 2>&1", + " $text = ($out | ForEach-Object { $_.ToString() }) -join \"`n\"", + " if ($text.TrimStart().StartsWith('{')) {", + " $utf8NoBom = New-Object System.Text.UTF8Encoding($false)", + " [System.IO.File]::WriteAllText('" + outputPs + "', $text, $utf8NoBom)", + " $written = $true", + " break", + " }", + "}", + "if (-not $written) {", + " $msg = 'no-json: ' + ($out -join '; ')", + " $utf8NoBom = New-Object System.Text.UTF8Encoding($false)", + " [System.IO.File]::WriteAllText('" + statusPs + "', $msg, $utf8NoBom)", + "}" + ); - // Launch script elevated. Start-Process -Verb RunAs triggers UAC. - // -Wait blocks the launcher until the elevated powershell exits. - String launchCmd = String.format( - "Start-Process powershell -Verb RunAs -Wait -WindowStyle Hidden " - + "-ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"%s\"'", - scriptFile.toString().replace("\"", "`\"")); + // Encode script as UTF-16LE for PowerShell -EncodedCommand + byte[] utf16le = innerScript.getBytes(StandardCharsets.UTF_16LE); + String b64 = Base64.getEncoder().encodeToString(utf16le); LOGGER.info("SmartEscalation: launching elevated helper for device: " + device); + LOGGER.info("SmartEscalation: smartctlPath=" + smartctlPath); + LOGGER.info("SmartEscalation: outputFile=" + outputFile); + + // ── Launch elevated helper ──────────────────────────────────────────── + // The outer (non-elevated) PS starts an elevated PS with the encoded command. + // -EncodedCommand has no spaces / path quoting issues. + String outerCmd = "Start-Process powershell" + + " -Verb RunAs" + + " -Wait" + + " -WindowStyle Hidden" + + " -ArgumentList '-NoProfile -NonInteractive -EncodedCommand " + b64 + "'"; + ProcessBuilder pb = new ProcessBuilder( - "powershell", "-NoProfile", "-NonInteractive", "-Command", launchCmd); + "powershell", "-NoProfile", "-Command", outerCmd); pb.redirectErrorStream(true); Process launcher = pb.start(); - // Drain launcher stdout/stderr to prevent pipe-full stalls + // Drain stdout/stderr to prevent pipe-full stalls launcher.getInputStream().transferTo(OutputStream.nullOutputStream()); if (!launcher.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { @@ -82,23 +123,32 @@ public static String runElevated(String device, String smartctlPath) return null; } - if (Files.exists(cancelFile)) { - LOGGER.info("SmartEscalation: UAC cancelled by user for device: " + device); - Files.deleteIfExists(cancelFile); + int exitCode = launcher.exitValue(); + LOGGER.info("SmartEscalation: launcher exited with code: " + exitCode); + + // ── Read result ─────────────────────────────────────────────────────── + if (Files.exists(statusFile)) { + String status = Files.readString(statusFile, StandardCharsets.UTF_8).trim(); + LOGGER.warning("SmartEscalation: helper status (no JSON produced): " + status); + Files.deleteIfExists(statusFile); return null; } if (!Files.exists(outputFile)) { - LOGGER.warning("SmartEscalation: output file not created for device: " + device - + " (UAC may have been cancelled or helper failed)"); + LOGGER.warning("SmartEscalation: output file missing — UAC likely cancelled for device: " + device); return null; } String json = Files.readString(outputFile, StandardCharsets.UTF_8).trim(); Files.deleteIfExists(outputFile); + // Strip UTF-8 BOM (U+FEFF) if present — .NET's Encoding.UTF8 includes a BOM by default + if (json.startsWith("\uFEFF")) { + json = json.substring(1).trim(); + } - if (json.isEmpty()) { - LOGGER.warning("SmartEscalation: empty output for device: " + device); + if (json.isEmpty() || !json.startsWith("{")) { + LOGGER.warning("SmartEscalation: unexpected output (not JSON): " + + json.substring(0, Math.min(200, json.length()))); return null; } @@ -106,42 +156,11 @@ public static String runElevated(String device, String smartctlPath) return json; } - /** - * Writes the PowerShell helper script that will run inside the elevated process. - * The script runs smartctl and writes the JSON to {@code outputFile}, or writes - * a sentinel {@code cancelFile} if smartctl cannot be executed. - */ - private static void writeHelperScript(Path scriptFile, String smartctlPath, - String device, Path outputFile, Path cancelFile) throws IOException { - - // Single-quote PS strings; escape embedded single-quotes by doubling them. - String smartctlPs = smartctlPath.replace("'", "''"); - String outputPs = outputFile.toString().replace("'", "''"); - String cancelPs = cancelFile.toString().replace("'", "''"); - - String script = String.join("\r\n", - "# JDiskMark SMART elevation helper -- auto-generated, do not edit", - "param()", - "Set-StrictMode -Off", - "try {", - " $out = & '" + smartctlPs + "' --json -a '/dev/" + device + "' 2>&1", - " $out | Out-File -FilePath '" + outputPs + "' -Encoding utf8 -NoNewline", - "} catch {", - " # smartctl not found or access denied -- write cancel sentinel", - " $_.Exception.Message | Out-File -FilePath '" + cancelPs - + "' -Encoding utf8 -NoNewline", - "}" - ); - Files.writeString(scriptFile, script, StandardCharsets.UTF_8); - } - - /** Returns the IPC directory path: {@code %LOCALAPPDATA%\JDiskMark}. */ + /** Returns the IPC directory: {@code %LOCALAPPDATA%\JDiskMark}. */ private static Path resolveIpcDir() { - String localAppData = System.getenv("LOCALAPPDATA"); - if (localAppData == null) { - localAppData = System.getProperty("java.io.tmpdir"); - } - return Path.of(localAppData, "JDiskMark"); + String base = System.getenv("LOCALAPPDATA"); + if (base == null) base = System.getProperty("java.io.tmpdir"); + return Path.of(base, "JDiskMark"); } private SmartEscalation() {} From 745d212a25d46956cf509af9b90ffa95a7cd4772 Mon Sep 17 00:00:00 2001 From: Ian Reyes Date: Sun, 26 Jul 2026 00:54:04 -0700 Subject: [PATCH 5/7] Address Copilot suggestions for Windows SMART escalation and packaging - SmartEscalation: drain process input stream asynchronously to prevent deadlock during timeouts - SmartEscalation: validate device name format to prevent injection/path traversal - SmartEscalation: generalize BOM stripping rationale comment - BenchmarkRunner: populate SMART UI using already-fetched data instead of triggering duplicate SMART query/UAC prompt - jdm-msi/pom.xml: verify SHA-256 checksum of downloaded smartctl.zip in AntRun step - windows-msi.yml: verify SHA-256 checksum of downloaded smartctl.zip in CI workflow --- .github/workflows/windows-msi.yml | 5 +++++ .../src/main/java/jdiskmark/BenchmarkRunner.java | 16 +++++++++++++--- .../src/main/java/jdiskmark/SmartEscalation.java | 13 ++++++++++--- jdm-dist/jdm-msi/pom.xml | 7 +++++++ 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/.github/workflows/windows-msi.yml b/.github/workflows/windows-msi.yml index f5e6aae9..8fcfa916 100644 --- a/.github/workflows/windows-msi.yml +++ b/.github/workflows/windows-msi.yml @@ -53,6 +53,11 @@ jobs: New-Item -ItemType Directory -Force -Path $dest | Out-Null Write-Host "[smartctl] Downloading smartctl-7.5-windows-x86_64.zip from jdm-deps..." Invoke-WebRequest -Uri "https://github.com/JDiskMark/jdm-deps/releases/download/tools%2Fsmartctl-7.5-win64/smartctl-7.5-windows-x86_64.zip" -OutFile $zip + $expectedHash = "CC32CC0CA24258933953182F0FCACD964C8762B947E96007FF69210A215ECCF3" + $actualHash = (Get-FileHash -Path $zip -Algorithm SHA256).Hash + if ($actualHash -ne $expectedHash) { + throw "Checksum mismatch for smartctl zip! Expected $expectedHash, got $actualHash" + } Expand-Archive -Path $zip -DestinationPath (Split-Path $dest) -Force $exe = Get-Item "$dest/smartctl.exe" -ErrorAction Stop Write-Host "[smartctl] Staged: $($exe.FullName) ($($exe.Length) bytes)" diff --git a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java index 5eb75858..aa287ba5 100644 --- a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java +++ b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java @@ -160,9 +160,19 @@ public Benchmark execute() throws Exception { benchmark.setSmartData(smart); } - // Also trigger UI update if we are in GUI mode - if (Gui.mainFrame != null) { - Gui.runSmart(); + // Update the SMART tab if running in GUI mode, without re-triggering SMART retrieval. + if (Gui.mainFrame != null && Gui.smartPanel != null) { + if (smart != null) { + String devName = (smart.getDevice() != null) ? smart.getDevice().getName() : null; + Gui.lastSmartData = smart; + Gui.lastSmartDeviceName = devName; + javax.swing.SwingUtilities.invokeLater(() -> { + Gui.smartPanel.populate(smart); + Gui.smartPanel.onDataLoaded(devName != null ? devName : "unknown"); + }); + } else { + Gui.runSmart(); + } } } diff --git a/jdm-core/src/main/java/jdiskmark/SmartEscalation.java b/jdm-core/src/main/java/jdiskmark/SmartEscalation.java index 06ac59cc..d068dbcf 100644 --- a/jdm-core/src/main/java/jdiskmark/SmartEscalation.java +++ b/jdm-core/src/main/java/jdiskmark/SmartEscalation.java @@ -53,6 +53,11 @@ public class SmartEscalation { public static String runElevated(String device, String smartctlPath) throws IOException, InterruptedException { + if (device == null || !device.matches("[A-Za-z0-9._-]+")) { + LOGGER.warning("SmartEscalation: invalid device name: " + device); + return null; + } + Path ipcDir = resolveIpcDir(); Files.createDirectories(ipcDir); @@ -114,8 +119,10 @@ public static String runElevated(String device, String smartctlPath) pb.redirectErrorStream(true); Process launcher = pb.start(); - // Drain stdout/stderr to prevent pipe-full stalls - launcher.getInputStream().transferTo(OutputStream.nullOutputStream()); + // Drain stdout/stderr to prevent pipe-full stalls (async so timeout still works) + Thread.startVirtualThread(() -> { + try { launcher.getInputStream().transferTo(OutputStream.nullOutputStream()); } catch (IOException ignored) {} + }); if (!launcher.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { launcher.destroyForcibly(); @@ -141,7 +148,7 @@ public static String runElevated(String device, String smartctlPath) String json = Files.readString(outputFile, StandardCharsets.UTF_8).trim(); Files.deleteIfExists(outputFile); - // Strip UTF-8 BOM (U+FEFF) if present — .NET's Encoding.UTF8 includes a BOM by default + // Strip UTF-8 BOM (U+FEFF) if present (some writers may include a BOM). if (json.startsWith("\uFEFF")) { json = json.substring(1).trim(); } diff --git a/jdm-dist/jdm-msi/pom.xml b/jdm-dist/jdm-msi/pom.xml index 62cddb6b..e20669e9 100644 --- a/jdm-dist/jdm-msi/pom.xml +++ b/jdm-dist/jdm-msi/pom.xml @@ -64,6 +64,13 @@ dest="${project.build.directory}/smartctl-win.zip" verbose="true" retries="3"/> + + + + + + + From 03078e96d7934ed826f98713372d88e23608ff29 Mon Sep 17 00:00:00 2001 From: Ian Reyes Date: Sun, 26 Jul 2026 00:55:45 -0700 Subject: [PATCH 6/7] Fix lambda effectively final compilation error in BenchmarkRunner --- jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java index aa287ba5..ab11e0ca 100644 --- a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java +++ b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java @@ -163,11 +163,12 @@ public Benchmark execute() throws Exception { // Update the SMART tab if running in GUI mode, without re-triggering SMART retrieval. if (Gui.mainFrame != null && Gui.smartPanel != null) { if (smart != null) { + final Smart finalSmart = smart; String devName = (smart.getDevice() != null) ? smart.getDevice().getName() : null; Gui.lastSmartData = smart; Gui.lastSmartDeviceName = devName; javax.swing.SwingUtilities.invokeLater(() -> { - Gui.smartPanel.populate(smart); + Gui.smartPanel.populate(finalSmart); Gui.smartPanel.onDataLoaded(devName != null ? devName : "unknown"); }); } else { From 030b447b5ae4fb9b02ffe8840f3723cb37e0d308 Mon Sep 17 00:00:00 2001 From: Ian Reyes Date: Sun, 26 Jul 2026 11:21:19 -0700 Subject: [PATCH 7/7] Add TODO in startHeartbeat for persistent Windows elevated process --- jdm-core/src/main/java/jdiskmark/Smart.java | 1 + 1 file changed, 1 insertion(+) diff --git a/jdm-core/src/main/java/jdiskmark/Smart.java b/jdm-core/src/main/java/jdiskmark/Smart.java index aeaa2ea9..5453c9cf 100644 --- a/jdm-core/src/main/java/jdiskmark/Smart.java +++ b/jdm-core/src/main/java/jdiskmark/Smart.java @@ -296,6 +296,7 @@ public static void startPrivilegedShell() throws IOException { */ public static void startHeartbeat() { if (App.isWindows()) { + // TODO: implement persistent process to avoid repeated UAC auth prompt return; } if (hbThread != null && hbThread.isAlive()) return;