Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/windows-msi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <install-dir>\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
Comment thread
IanReyes44 marked this conversation as resolved.

- name: Build Windows MSI (unsigned)
# Builds jdm-core first (-am = also build upstream dependencies),
# then jdm-msi. Explicitly activating the windows-msi profile.
Expand Down
6 changes: 6 additions & 0 deletions jdm-core/src/main/java/jdiskmark/Benchmark.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
51 changes: 46 additions & 5 deletions jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> 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();
Expand Down
34 changes: 29 additions & 5 deletions jdm-core/src/main/java/jdiskmark/Gui.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions jdm-core/src/main/java/jdiskmark/MainFrame.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 + ...
Expand Down
165 changes: 160 additions & 5 deletions jdm-core/src/main/java/jdiskmark/Smart.java
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,62 @@ public class Smart {
* </ol>
*/
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 <install-dir>\app\<jar>.jar
// → <install-dir>\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\
// <install-dir>\runtime → <install-dir>\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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()) {
Expand All @@ -262,20 +325,112 @@ 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/<device>} 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.
*
* <p>{@link #startPrivilegedShell()} must have been called before this.
* <p>On <b>Windows</b>:
* <ul>
* <li>If the process is already elevated ({@link App#isAdmin}), runs
* {@code smartctl} directly via {@link #getSmartDirect}.</li>
* <li>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\}.</li>
* </ul>
*
* @param deviceName bare device name, e.g. {@code nvme0n1}
* <p>On <b>Linux / macOS</b>, 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) {
if (deviceName == null || !deviceName.matches("[A-Za-z0-9._-]+")) {
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) {
Expand Down
Loading
Loading