diff --git a/.github/workflows/windows-msi.yml b/.github/workflows/windows-msi.yml index c82165e..8fcfa91 100644 --- a/.github/workflows/windows-msi.yml +++ b/.github/workflows/windows-msi.yml @@ -41,6 +41,28 @@ jobs: with: disable_ci_label: ${{ inputs.disable_ci_label || false }} + - name: Fetch bundled smartctl from jdm-deps + # Downloads the pre-packaged smartctl 7.5 binary from the public + # 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-core/target/smartctl" + $zip = "$env:TEMP/smartctl-win.zip" + 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)" + & "$dest/smartctl.exe" --version + - name: Build Windows MSI (unsigned) # Builds jdm-core first (-am = also build upstream dependencies), # then jdm-msi. Explicitly activating the windows-msi profile. diff --git a/jdm-core/src/main/java/jdiskmark/Benchmark.java b/jdm-core/src/main/java/jdiskmark/Benchmark.java index 3ce7102..ba2175d 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 8fb73e1..ab11e0c 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,51 @@ public Benchmark execute() throws Exception { GcDetector.triggerAndWait(); // Initial cleanup } - // Fetch SMART data before the benchmark starts (Linux/macOS 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() || App.isMacOs())) { - Gui.runSmart(); + // Fetch SMART data before the benchmark starts (Linux, macOS, and Windows, non-fatal if it fails). + if (Smart.smartEnable) { + Smart smart = null; + try { + Path path = App.locationDir.toPath(); + if (App.isLinux() || App.isMacOs()) { + 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); + } + + // 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(finalSmart); + Gui.smartPanel.onDataLoaded(devName != null ? devName : "unknown"); + }); + } else { + 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 874ae98..d79fdbb 100644 --- a/jdm-core/src/main/java/jdiskmark/Gui.java +++ b/jdm-core/src/main/java/jdiskmark/Gui.java @@ -1188,10 +1188,15 @@ public static void selectMainTab(String tabTitle) { */ static public void runSmart() { - if (!App.isLinux() && !App.isMacOs()) { - App.msg("SMART is only available on Linux and macOS"); + if (!App.isLinux() && !App.isMacOs() && !App.isWindows()) { + App.msg("SMART is only available on Linux, macOS, and Windows"); 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) { App.msg("smartPanel and locationDir must first be initialized"); @@ -1224,10 +1229,22 @@ protected Smart doInBackground() { SMART_LOG.log(Level.WARNING, "runSmart: no device for {0}", locDir); return null; } + } else if (App.isWindows()) { + String driveLetter = UtilOs.getDriveLetterWindows(path); + String driveNum = UtilOs.getPhysicalDriveNumberWindows(driveLetter); + if (driveNum != null) { + deviceRef[0] = "pd" + driveNum; + } } - if (Smart.process == null || !Smart.process.isAlive()) { - Smart.startPrivilegedShell(); - Smart.startHeartbeat(); + if (deviceRef[0] == null) { + SMART_LOG.log(Level.WARNING, "runSmart: no device for {0}", locDir); + return null; + } + if (!App.isWindows()) { + if (Smart.process == null || !Smart.process.isAlive()) { + Smart.startPrivilegedShell(); + Smart.startHeartbeat(); + } } return Smart.getSmart(deviceRef[0]); } catch (IOException ex) { @@ -1251,6 +1268,13 @@ protected void done() { lastSmartData = null; lastSmartDeviceName = null; smartPanel.clear(); + // Give the user a specific hint on Windows + if (App.isWindows()) { + String hint = App.isAdmin + ? "SMART data unavailable — ensure smartctl is installed (smartmontools.org)." + : "SMART access was cancelled or failed — accept the UAC prompt to read SMART data."; + smartPanel.setStatus(hint); + } } } catch (InterruptedException | ExecutionException ex) { SMART_LOG.log(Level.WARNING, "runSmart: panel update failed", ex); diff --git a/jdm-core/src/main/java/jdiskmark/MainFrame.java b/jdm-core/src/main/java/jdiskmark/MainFrame.java index 1e04f5c..88e09f7 100644 --- a/jdm-core/src/main/java/jdiskmark/MainFrame.java +++ b/jdm-core/src/main/java/jdiskmark/MainFrame.java @@ -145,8 +145,8 @@ public MainFrame() { // Start on the Benchmark tab — it's the primary interaction surface. mainTabPane.setSelectedIndex(mainTabPane.getTabCount() - 1); - // SMART tab — Linux and macOS (requires bundled or system smartctl) - if (App.isLinux() || App.isMacOs()) { + // 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 Benchmarks + Events + ... diff --git a/jdm-core/src/main/java/jdiskmark/Smart.java b/jdm-core/src/main/java/jdiskmark/Smart.java index 46d46fc..854f977 100644 --- a/jdm-core/src/main/java/jdiskmark/Smart.java +++ b/jdm-core/src/main/java/jdiskmark/Smart.java @@ -88,6 +88,62 @@ public class Smart { * */ static String resolveSmartctlPath() { + if (App.isWindows()) { + // 1. Bundled copy — derive app dir from the running jar's location. + // jpackage on Windows does NOT set APPDIR (that's Linux-only). + // jar lives at \app\.jar + // → \app\smartctl\smartctl.exe + try { + java.net.URL jarUrl = Smart.class.getProtectionDomain().getCodeSource().getLocation(); + if (jarUrl != null) { + Path jarPath = Path.of(jarUrl.toURI()); + Path appDir = jarPath.getParent(); // …\app\ + Path bundled = appDir.resolve("smartctl/smartctl.exe"); + if (Files.isExecutable(bundled)) { + LOGGER.info("Using bundled smartctl (jar-relative): " + bundled); + return bundled.toString(); + } + } + } catch (Exception e) { + LOGGER.warning("resolveSmartctlPath: jar URL lookup failed: " + e.getMessage()); + } + // 2. Fallback via java.home: runtime\ is sibling of app\ + // \runtime → \app\smartctl\smartctl.exe + try { + Path runtimeDir = Path.of(System.getProperty("java.home")); + Path bundled = runtimeDir.getParent().resolve("app/smartctl/smartctl.exe"); + if (Files.isExecutable(bundled)) { + LOGGER.info("Using bundled smartctl (java.home-relative): " + bundled); + return bundled.toString(); + } + } catch (Exception e) { + LOGGER.warning("resolveSmartctlPath: java.home lookup failed: " + e.getMessage()); + } + // 3. Legacy: APPDIR env var (set by some custom launchers, not standard jpackage on Windows) + String appDirEnv = System.getenv("APPDIR"); + if (appDirEnv != null) { + Path bundled = Path.of(appDirEnv).resolve("smartctl/smartctl.exe"); + if (Files.isExecutable(bundled)) { + LOGGER.info("Using bundled smartctl (APPDIR): " + bundled); + return bundled.toString(); + } + } + // 4. Well-known system 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(); + } + // 5. System PATH fallback + LOGGER.info("Using system smartctl: smartctl.exe"); + return "smartctl.exe"; + } + // 1. Bundled copy via APPDIR (Linux jpackage sets this; macOS does not). String appDir = System.getenv("APPDIR"); if (appDir != null) { @@ -156,6 +212,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 @@ -237,6 +296,10 @@ public static void startPrivilegedShell() throws IOException { * Only one thread is started; subsequent calls are ignored. */ public static void startHeartbeat() { + if (App.isWindows()) { + // TODO: implement persistent process to avoid repeated UAC auth prompt + return; + } if (hbThread != null && hbThread.isAlive()) return; hbThread = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { @@ -262,13 +325,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. + * + * @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. * - *

{@link #startPrivilegedShell()} must have been called before this. + *

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\}.
  • + *
* - * @param deviceName bare device name, e.g. {@code nvme0n1} + *

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) { @@ -276,6 +399,38 @@ public static Smart getSmart(String deviceName) { LOGGER.severe("getSmart: invalid device name: " + deviceName); return null; } + if (App.isWindows()) { + String smartctlPath = resolveSmartctlPath(); + LOGGER.info("getSmart: using smartctl at: " + smartctlPath + " for device: " + deviceName); + + 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; + } + if (!json.startsWith("{")) { + LOGGER.warning("getSmart: escalation returned non-JSON for " + deviceName + ": " + json); + return null; + } + 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); + } + return null; + } + } final String sentinel = "---SMART_DONE---"; try { synchronized (pLock) { 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 0000000..d068dbc --- /dev/null +++ b/jdm-core/src/main/java/jdiskmark/SmartEscalation.java @@ -0,0 +1,174 @@ +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.Base64; +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\}. + * + *

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 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 + * 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. Builds a PowerShell script inline and encodes it as UTF-16LE base64.
  2. + *
  3. Launches an elevated {@code powershell.exe} with {@code -EncodedCommand} + * 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 cannot be created + * @throws InterruptedException if the calling thread is interrupted while waiting + */ + 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); + + Path outputFile = ipcDir.resolve("smart-ipc-" + device + ".json"); + Path statusFile = ipcDir.resolve("smart-ipc-" + device + ".status"); + + // Remove stale artifacts from any previous run + Files.deleteIfExists(outputFile); + Files.deleteIfExists(statusFile); + + // ── 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)", + "}" + ); + + // 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", "-Command", outerCmd); + pb.redirectErrorStream(true); + Process launcher = pb.start(); + + // 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(); + LOGGER.warning("SmartEscalation: launcher timed out for device: " + device); + return null; + } + + 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 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 (some writers may include a BOM). + if (json.startsWith("\uFEFF")) { + json = json.substring(1).trim(); + } + + if (json.isEmpty() || !json.startsWith("{")) { + LOGGER.warning("SmartEscalation: unexpected output (not JSON): " + + json.substring(0, Math.min(200, json.length()))); + return null; + } + + LOGGER.info("SmartEscalation: received " + json.length() + " bytes for device: " + device); + return json; + } + + /** Returns the IPC directory: {@code %LOCALAPPDATA%\JDiskMark}. */ + private static Path resolveIpcDir() { + String base = System.getenv("LOCALAPPDATA"); + if (base == null) base = System.getProperty("java.io.tmpdir"); + return Path.of(base, "JDiskMark"); + } + + private SmartEscalation() {} +} diff --git a/jdm-core/src/main/java/jdiskmark/SmartPanel.java b/jdm-core/src/main/java/jdiskmark/SmartPanel.java index 7df40f5..17383e2 100644 --- a/jdm-core/src/main/java/jdiskmark/SmartPanel.java +++ b/jdm-core/src/main/java/jdiskmark/SmartPanel.java @@ -383,6 +383,17 @@ public void onDataSaved() { }); } + /** + * Sets an arbitrary message in the toolbar status label. Safe to call + * from any thread. Use this to show error or warning messages when SMART + * data retrieval fails (e.g. missing admin privileges or smartctl not found). + * + * @param message the text to display in the status label + */ + public void setStatus(String message) { + SwingUtilities.invokeLater(() -> statusLabel.setText(message)); + } + /** * Called after the SMART tab has been populated from a stored * {@link SmartSnapshot} (via the SMART Reports table). diff --git a/jdm-core/src/main/java/jdiskmark/UtilOs.java b/jdm-core/src/main/java/jdiskmark/UtilOs.java index 9840abd..9903d3c 100644 --- a/jdm-core/src/main/java/jdiskmark/UtilOs.java +++ b/jdm-core/src/main/java/jdiskmark/UtilOs.java @@ -1068,6 +1068,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. diff --git a/jdm-dist/jdm-msi/pom.xml b/jdm-dist/jdm-msi/pom.xml index e1069ab..e20669e 100644 --- a/jdm-dist/jdm-msi/pom.xml +++ b/jdm-dist/jdm-msi/pom.xml @@ -44,6 +44,47 @@ + + + stage-smartctl-windows + package + + run + + + + + + + + + + + + + + + + + + + + + + + + + + --icon ${project.basedir}/${app.icon.win} + diff --git a/jdm-dist/jdm-msi/src/main/app-content/smartctl/smartctl.exe b/jdm-dist/jdm-msi/src/main/app-content/smartctl/smartctl.exe new file mode 100644 index 0000000..615626c Binary files /dev/null and b/jdm-dist/jdm-msi/src/main/app-content/smartctl/smartctl.exe differ