/smart-ipc/}).
*
*
* On Linux / macOS, writes the command to the persistent privileged
diff --git a/jdm-core/src/main/java/jdiskmark/SmartEscalation.java b/jdm-core/src/main/java/jdiskmark/SmartEscalation.java
index d068dbc..c34a706 100644
--- a/jdm-core/src/main/java/jdiskmark/SmartEscalation.java
+++ b/jdm-core/src/main/java/jdiskmark/SmartEscalation.java
@@ -10,45 +10,58 @@
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 the static {@code smart-agent.ps1} installed alongside
+ * {@code smartctl.exe} (in {@code Program Files}, admin-write-only) is 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:
+ *
+ * - Simple paths: {@code /dev/} and bare {@code }.
+ * - Win32 path {@code \\.\PhysicalDriveN} with plain, {@code -d nvme},
+ * and {@code -d sat} type hints (covers Windows 11 NVMe controllers).
+ *
+ * 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
+ * IPC directory because they run under the same Windows user account (just
+ * different privilege tokens).
+ *
+ *
The IPC directory is version-scoped: {@code ~\.jdm\\smart-ipc}
+ * ({@link App#APP_CACHE_DIR_NAME} + {@code /smart-ipc}), so side-by-side
+ * installs of different versions do not interfere with each other.
*/
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.
- *
- *
- * - Builds a PowerShell script inline and encodes it as UTF-16LE base64.
- * - Launches an elevated {@code powershell.exe} with {@code -EncodedCommand}
- * via {@code Start-Process -Verb RunAs -Wait}.
- * - Reads the JSON result written by the elevated helper.
- *
+ * 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,113 +74,170 @@ 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);
-
- // ── 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;
+ Files.writeString(reqFile, device, StandardCharsets.UTF_8);
+
+ LOGGER.info("SmartEscalation: submitted request for device: " + device);
+
+ // 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;
+
+ synchronized (agentLock) {
+ if (agentReady) return true;
+
+ Path readyFile = ipcDir.resolve("smart-agent-ready.txt");
+ Files.deleteIfExists(readyFile);
+ Path stopFile = ipcDir.resolve("smart-agent-stop.txt");
+ Files.deleteIfExists(stopFile);
+
+ Path agentScript = resolveAgentScript(smartctlPath);
+ if (agentScript == null) {
+ LOGGER.warning("SmartEscalation: smart-agent.ps1 not found alongside smartctl.exe — cannot elevate");
+ return false;
+ }
+
+ // Build the inner PS invocation and Base64-encode it (UTF-16LE is
+ // required by PowerShell -EncodedCommand). Encoding embeds all paths
+ // inside the Base64 blob so spaces and special chars in any path never
+ // reach Start-Process argument-list parsing.
+ String innerCmd = "& '"
+ + agentScript.toString().replace("'", "''")
+ + "' -SmartctlPath '"
+ + smartctlPath.replace("'", "''")
+ + "' -IpcDir '"
+ + ipcDir.toString().replace("'", "''")
+ + "'";
+ String b64 = Base64.getEncoder().encodeToString(
+ innerCmd.getBytes(StandardCharsets.UTF_16LE));
+
+ String outerCmd = "Start-Process powershell"
+ + " -Verb RunAs"
+ + " -WindowStyle Hidden"
+ + " -ArgumentList @('-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-EncodedCommand','" + b64 + "')";
+
+ 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;
}
+ }
- 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();
+ /**
+ * Locates the static {@code smart-agent.ps1} installed alongside
+ * {@code smartctl.exe}. Returns {@code null} if not found (e.g. running
+ * from the IDE without a packaged install).
+ */
+ private static Path resolveAgentScript(String smartctlPath) {
+ try {
+ Path script = Path.of(smartctlPath).getParent().resolve("smart-agent.ps1");
+ if (Files.isReadable(script)) return script;
+ } catch (Exception e) {
+ LOGGER.warning("SmartEscalation: resolveAgentScript failed: " + e.getMessage());
}
+ return null;
+ }
- 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;
+ /** 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}. */
+ /** Returns the version-scoped IPC directory: {@code ~/.jdm//smart-ipc}. */
private static Path resolveIpcDir() {
- String base = System.getenv("LOCALAPPDATA");
- if (base == null) base = System.getProperty("java.io.tmpdir");
- return Path.of(base, "JDiskMark");
+ return Path.of(App.APP_CACHE_DIR_NAME, "smart-ipc");
}
private SmartEscalation() {}
diff --git a/jdm-core/src/main/resources/smartctl/smart-agent.ps1 b/jdm-core/src/main/resources/smartctl/smart-agent.ps1
new file mode 100644
index 0000000..fe81da0
--- /dev/null
+++ b/jdm-core/src/main/resources/smartctl/smart-agent.ps1
@@ -0,0 +1,61 @@
+param(
+ [Parameter(Mandatory)][string]$SmartctlPath,
+ [Parameter(Mandatory)][string]$IpcDir
+)
+
+$ErrorActionPreference = 'Continue'
+$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
+
+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
+}