diff --git a/jdm-core/src/main/java/jdiskmark/Smart.java b/jdm-core/src/main/java/jdiskmark/Smart.java index 9847210..0b526e3 100644 --- a/jdm-core/src/main/java/jdiskmark/Smart.java +++ b/jdm-core/src/main/java/jdiskmark/Smart.java @@ -11,6 +11,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.logging.Level; @@ -356,41 +357,51 @@ public static void startHeartbeat() { * @return a populated {@link Smart} instance, or {@code null} on error */ private static Smart getSmartDirect(String deviceName, String smartctlPath) { + List> candidates = new ArrayList<>(); + candidates.add(List.of("--json", "-a", "/dev/" + deviceName)); + candidates.add(List.of("--json", "-a", deviceName)); + if (deviceName.startsWith("pd")) { + String win32 = "\\\\.\\PhysicalDrive" + deviceName.substring(2); + candidates.add(List.of("--json", "-a", win32)); + candidates.add(List.of("--json", "-a", win32, "-d", "nvme")); + candidates.add(List.of("--json", "-a", win32, "-d", "sat")); + } + Smart fallback = null; try { - for (String devArg : new String[]{"/dev/" + deviceName, deviceName}) { - ProcessBuilder pb = new ProcessBuilder(smartctlPath, "--json", "-a", devArg); + for (List args : candidates) { + List cmd = new ArrayList<>(); + cmd.add(smartctlPath); + cmd.addAll(args); + ProcessBuilder pb = new ProcessBuilder(cmd); 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'); - } + 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); - App.err("Smart - smartctl timed out for: " + devArg); + LOGGER.warning("getSmartDirect: smartctl timed out for: " + args); continue; } String result = sb.toString().trim(); - if (result.isEmpty()) { - LOGGER.warning("getSmartDirect: empty response for: " + devArg); - App.err("Smart - empty response for: " + devArg); - continue; - } - if (!result.startsWith("{")) { - LOGGER.warning("getSmartDirect: non-JSON response for " + devArg + ": " + result); - App.err("Smart - non-JSON response for: " + devArg); + if (result.isEmpty() || !result.startsWith("{")) continue; + if ((p.exitValue() & 2) != 0) { + LOGGER.info("getSmartDirect: device open failed (exit " + p.exitValue() + ") for: " + args); + if (fallback == null) fallback = fromJson(result); continue; } Smart smart = fromJson(result); logSmart(smart); return smart; } + if (fallback != null) { + LOGGER.warning("getSmartDirect: all candidates failed; using error response for: " + deviceName); + logSmart(fallback); + return fallback; + } LOGGER.severe("getSmartDirect: all attempts failed for: " + deviceName); App.err("Smart - all attempts failed for: " + deviceName); } catch (InterruptedException ex) { @@ -399,7 +410,7 @@ private static Smart getSmartDirect(String deviceName, String smartctlPath) { App.err("Smart - interrupted for: " + deviceName); } catch (IOException ex) { LOGGER.log(Level.SEVERE, "getSmartDirect failed for: " + deviceName, ex); - App.err("Smart - failed for: " + deviceName + " — " + ex.getMessage()); + App.err("Smart - failed for: " + deviceName + " \u2014 " + ex.getMessage()); } return null; } diff --git a/jdm-core/src/main/java/jdiskmark/SmartEscalation.java b/jdm-core/src/main/java/jdiskmark/SmartEscalation.java index d068dbc..e914922 100644 --- a/jdm-core/src/main/java/jdiskmark/SmartEscalation.java +++ b/jdm-core/src/main/java/jdiskmark/SmartEscalation.java @@ -5,50 +5,58 @@ 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\}. + * Runs {@code smartctl} in a persistent elevated PowerShell agent on Windows, + * prompting for UAC elevation only once per session. * - *

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}. + *

On the first call a {@code -File} PowerShell script is written to + * {@code %LOCALAPPDATA%\JDiskMark\smart-agent.ps1} and launched elevated via + * {@code Start-Process -Verb RunAs}. Subsequent calls reuse the running agent + * by dropping a {@code smart-req-.txt} request file and polling for + * the corresponding {@code smart-ipc-.json} result 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 agent probes device paths in two passes: + *

    + *
  1. Simple paths: {@code /dev/} and bare {@code }.
  2. + *
  3. Win32 path {@code \\.\PhysicalDriveN} with plain, {@code -d nvme}, + * and {@code -d sat} type hints (covers Windows 11 NVMe controllers).
  4. + *
+ * If all paths fail to open the device (smartctl exit code bit 1 set) the + * best available error-JSON is returned as a fallback so the UI can still show + * drive identity, firmware, and serial number. * - *

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. + *

Both the elevated agent 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). */ 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; + /** Seconds to wait for the outer UAC launcher to exit. */ + private static final int UAC_TIMEOUT_SECONDS = 45; + /** Seconds to poll for the agent-ready file after launching. */ + private static final int AGENT_READY_TIMEOUT_SECONDS = 20; + /** Seconds to wait for a single SMART query result from the running agent. */ + private static final int QUERY_TIMEOUT_SECONDS = 30; + + private static volatile boolean agentReady = false; + private static volatile boolean shutdownHookRegistered = false; + private static final Object agentLock = new Object(); /** - * 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. - *
+ * Runs {@code smartctl} for the given Windows device using a persistent + * elevated agent, prompting for UAC elevation only on the first call. * * @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 + * @return raw JSON string from smartctl, or {@code null} if UAC was + * cancelled or the query failed * @throws IOException if the IPC directory cannot be created - * @throws InterruptedException if the calling thread is interrupted while waiting + * @throws InterruptedException if the calling thread is interrupted */ public static String runElevated(String device, String smartctlPath) throws IOException, InterruptedException { @@ -61,106 +69,205 @@ public static String runElevated(String device, String smartctlPath) Path ipcDir = resolveIpcDir(); Files.createDirectories(ipcDir); + if (!ensureAgentRunning(smartctlPath, ipcDir)) { + LOGGER.warning("SmartEscalation: agent not ready — UAC may have been cancelled"); + return null; + } + + // Drop a request file; the agent picks it up and writes the result. + Path reqFile = ipcDir.resolve("smart-req-" + device + ".txt"); 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); + Files.writeString(reqFile, device, StandardCharsets.UTF_8); - // ── 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("'", "''"); + LOGGER.info("SmartEscalation: submitted request for device: " + device); - // 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; + // Poll for the result or status file. + long deadline = System.currentTimeMillis() + QUERY_TIMEOUT_SECONDS * 1000L; + while (System.currentTimeMillis() < deadline) { + if (Files.exists(statusFile)) { + String status = Files.readString(statusFile, StandardCharsets.UTF_8).trim(); + LOGGER.warning("SmartEscalation: agent status (no JSON): " + status); + Files.deleteIfExists(statusFile); + return null; + } + if (Files.exists(outputFile)) { + String json = Files.readString(outputFile, StandardCharsets.UTF_8).trim(); + Files.deleteIfExists(outputFile); + 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; + } + Thread.sleep(200); } - int exitCode = launcher.exitValue(); - LOGGER.info("SmartEscalation: launcher exited with code: " + exitCode); + // Timed out — agent may have died; force a restart on the next call. + LOGGER.warning("SmartEscalation: query timed out for device: " + device + " — resetting agent state"); + Files.deleteIfExists(reqFile); + agentReady = false; + return null; + } - // ── 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; - } + /** + * Ensures the persistent elevated agent is running. On the first call + * this triggers a single UAC prompt; subsequent calls return immediately. + */ + private static boolean ensureAgentRunning(String smartctlPath, Path ipcDir) + throws IOException, InterruptedException { - if (!Files.exists(outputFile)) { - LOGGER.warning("SmartEscalation: output file missing — UAC likely cancelled for device: " + device); - return null; - } + if (agentReady) return true; - 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(); - } + synchronized (agentLock) { + if (agentReady) return true; - if (json.isEmpty() || !json.startsWith("{")) { - LOGGER.warning("SmartEscalation: unexpected output (not JSON): " - + json.substring(0, Math.min(200, json.length()))); - return null; + Path readyFile = ipcDir.resolve("smart-agent-ready.txt"); + Files.deleteIfExists(readyFile); + Path stopFile = ipcDir.resolve("smart-agent-stop.txt"); + Files.deleteIfExists(stopFile); + + String ipcPs = ipcDir.toString().replace("'", "''"); + String sctlPs = smartctlPath.replace("'", "''"); + String script = buildAgentScript(sctlPs, ipcPs); + + Path scriptFile = ipcDir.resolve("smart-agent.ps1"); + Files.writeString(scriptFile, script, StandardCharsets.UTF_8); + String scriptPs = scriptFile.toString().replace("'", "''"); + + String outerCmd = "Start-Process powershell" + + " -Verb RunAs" + + " -WindowStyle Hidden" + + " -ArgumentList '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File \\\"" + scriptPs + "\\\"'"; + + LOGGER.info("SmartEscalation: launching persistent elevated agent via UAC..."); + ProcessBuilder pb = new ProcessBuilder("powershell", "-NoProfile", "-Command", outerCmd); + pb.redirectErrorStream(true); + Process launcher = pb.start(); + + Thread.startVirtualThread(() -> { + try { launcher.getInputStream().transferTo(OutputStream.nullOutputStream()); } catch (IOException ignored) {} + }); + + if (!launcher.waitFor(UAC_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + launcher.destroyForcibly(); + LOGGER.warning("SmartEscalation: UAC launcher timed out"); + return false; + } + + int launcherExit = launcher.exitValue(); + LOGGER.info("SmartEscalation: UAC launcher exited with code: " + launcherExit); + if (launcherExit != 0) { + LOGGER.warning("SmartEscalation: UAC likely cancelled (launcher exit code: " + launcherExit + ")"); + return false; + } + + long deadline = System.currentTimeMillis() + AGENT_READY_TIMEOUT_SECONDS * 1000L; + while (System.currentTimeMillis() < deadline) { + if (Files.exists(readyFile)) { + Files.deleteIfExists(readyFile); + agentReady = true; + LOGGER.info("SmartEscalation: persistent elevated agent is ready"); + registerShutdownHook(ipcDir); + return true; + } + Thread.sleep(200); + } + + LOGGER.warning("SmartEscalation: agent did not signal ready within " + + AGENT_READY_TIMEOUT_SECONDS + "s"); + return false; } + } + + /** + * Builds the PowerShell agent script. + * Pass 1: /dev/pdN and pdN. Pass 2: Win32 path with plain, -d nvme, -d sat. + * Falls back to best error-JSON if all paths fail to open the device. + */ + private static String buildAgentScript(String smartctlPs, String ipcPs) { + return String.join("\r\n", + "$ErrorActionPreference = 'Continue'", + "$utf8NoBom = New-Object System.Text.UTF8Encoding($false)", + "$ipcDir = '" + ipcPs + "'", + "$smartctlPath = '" + smartctlPs + "'", + "", + "Remove-Item (Join-Path $ipcDir 'smart-agent-stop.txt') -Force -ErrorAction SilentlyContinue", + "[System.IO.File]::WriteAllText((Join-Path $ipcDir 'smart-agent-ready.txt'), 'ready', $utf8NoBom)", + "", + "while ($true) {", + " if (Test-Path (Join-Path $ipcDir 'smart-agent-stop.txt')) { break }", + " $reqs = Get-ChildItem (Join-Path $ipcDir 'smart-req-*.txt') -ErrorAction SilentlyContinue", + " foreach ($req in $reqs) {", + " $device = (Get-Content $req.FullName -Raw -ErrorAction SilentlyContinue).Trim()", + " Remove-Item $req.FullName -Force -ErrorAction SilentlyContinue", + " if (-not $device) { continue }", + " $outFile = Join-Path $ipcDir \"smart-ipc-$device.json\"", + " $statFile = Join-Path $ipcDir \"smart-ipc-$device.status\"", + " $written = $false", + " $fallbackOut = $null", + "", + " # Pass 1 - simple paths", + " foreach ($d in @(\"/dev/$device\", $device)) {", + " $out = & $smartctlPath --json -a $d 2>&1", + " $code = $LASTEXITCODE", + " $text = ($out | ForEach-Object { $_.ToString() }) -join \"`n\"", + " if (-not $text.TrimStart().StartsWith('{')) { continue }", + " if (($code -band 2) -ne 0) { if ($null -eq $fallbackOut) { $fallbackOut = $out }; continue }", + " [System.IO.File]::WriteAllText($outFile, $text, $utf8NoBom)", + " $written = $true; break", + " }", + "", + " # Pass 2 - Win32 path with NVMe/SAT hints", + " if (-not $written -and $device -match '^pd(\\d+)$') {", + " $win32 = \"\\\\.\\PhysicalDrive$($Matches[1])\"", + " foreach ($hint in @('', '-d nvme', '-d sat')) {", + " $args2 = @('--json', '-a', $win32)", + " if ($hint) { $args2 += $hint.Split(' ') }", + " $out = & $smartctlPath @args2 2>&1", + " $code = $LASTEXITCODE", + " $text = ($out | ForEach-Object { $_.ToString() }) -join \"`n\"", + " if (-not $text.TrimStart().StartsWith('{')) { continue }", + " if (($code -band 2) -ne 0) { if ($null -eq $fallbackOut) { $fallbackOut = $out }; continue }", + " [System.IO.File]::WriteAllText($outFile, $text, $utf8NoBom)", + " $written = $true; break", + " }", + " }", + "", + " # Fallback - use first error-JSON so UI has drive identity", + " if (-not $written -and ($null -ne $fallbackOut)) {", + " $text = ($fallbackOut | ForEach-Object { $_.ToString() }) -join \"`n\"", + " [System.IO.File]::WriteAllText($outFile, $text, $utf8NoBom); $written = $true", + " }", + " if (-not $written) {", + " [System.IO.File]::WriteAllText($statFile, 'no-json: all candidates failed', $utf8NoBom)", + " }", + " }", + " Start-Sleep -Milliseconds 100", + "}" + ); + } - LOGGER.info("SmartEscalation: received " + json.length() + " bytes for device: " + device); - return json; + /** Registers a JVM shutdown hook that writes the stop file to cleanly exit the agent. */ + private static void registerShutdownHook(Path ipcDir) { + synchronized (agentLock) { + if (shutdownHookRegistered) return; + shutdownHookRegistered = true; + } + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { + Files.writeString(ipcDir.resolve("smart-agent-stop.txt"), "stop", StandardCharsets.UTF_8); + LOGGER.info("SmartEscalation: shutdown hook wrote stop file"); + } catch (IOException ex) { + LOGGER.warning("SmartEscalation: shutdown hook failed to write stop file: " + ex.getMessage()); + } + }, "smart-agent-stopper")); } /** Returns the IPC directory: {@code %LOCALAPPDATA%\JDiskMark}. */