From e51ed57e10a866cc280bd39737ca5c21227160f1 Mon Sep 17 00:00:00 2001 From: James Mark Chan Date: Sun, 14 Jun 2026 17:22:39 -0700 Subject: [PATCH 1/6] #117 initial sharing acknowledgement --- jdm-core/src/main/java/jdiskmark/App.java | 167 +- .../src/main/java/jdiskmark/Benchmark.java | 18 +- .../main/java/jdiskmark/BenchmarkRunner.java | 4 +- jdm-core/src/main/java/jdiskmark/Gui.java | 28 +- .../src/main/java/jdiskmark/MainFrame.java | 57 +- .../src/main/java/jdiskmark/SharingPanel.java | 211 ++ jdm-core/src/main/java/jdiskmark/UtilOs.java | 1866 +++++++++-------- jdm-core/src/test/java/jdiskmark/AppTest.java | 10 + 8 files changed, 1411 insertions(+), 950 deletions(-) create mode 100644 jdm-core/src/main/java/jdiskmark/SharingPanel.java diff --git a/jdm-core/src/main/java/jdiskmark/App.java b/jdm-core/src/main/java/jdiskmark/App.java index 6ba2402..9bb7aa2 100644 --- a/jdm-core/src/main/java/jdiskmark/App.java +++ b/jdm-core/src/main/java/jdiskmark/App.java @@ -113,24 +113,24 @@ public String toString() { */ public enum AppIcon { /** Blue/orange circle — the beta brand. Single resolution. */ - BETA(new String[]{"/icons/icon-jdm-beta.png"}), + BETA(new String[] { "/icons/icon-jdm-beta.png" }), /** Custom JDiskMark turtle logo — the default project brand. */ - TURTLE(new String[]{ - "/icons/jdm-turtle-logo-16x16.png", - "/icons/jdm-turtle-logo-20x20.png", - "/icons/jdm-turtle-logo-24x24.png", - "/icons/jdm-turtle-logo-32x32.png", - "/icons/jdm-turtle-logo-40x40.png", - "/icons/jdm-turtle-logo-48x48.png", - "/icons/jdm-turtle-logo-64x64.png", - "/icons/jdm-turtle-logo-96x96.png", - "/icons/jdm-turtle-logo-128x128.png", - "/icons/jdm-turtle-logo-256x256.png", - "/icons/jdm-turtle-logo-512x512.png", - "/icons/jdm-turtle-logo-1024x1024.png" + TURTLE(new String[] { + "/icons/jdm-turtle-logo-16x16.png", + "/icons/jdm-turtle-logo-20x20.png", + "/icons/jdm-turtle-logo-24x24.png", + "/icons/jdm-turtle-logo-32x32.png", + "/icons/jdm-turtle-logo-40x40.png", + "/icons/jdm-turtle-logo-48x48.png", + "/icons/jdm-turtle-logo-64x64.png", + "/icons/jdm-turtle-logo-96x96.png", + "/icons/jdm-turtle-logo-128x128.png", + "/icons/jdm-turtle-logo-256x256.png", + "/icons/jdm-turtle-logo-512x512.png", + "/icons/jdm-turtle-logo-1024x1024.png" }), /** Duke, the BSD-licensed Java mascot from the OpenJDK project. */ - DUKE(new String[]{"/icons/icon-duke.png"}); + DUKE(new String[] { "/icons/icon-duke.png" }); /** All resource paths for this icon variant, from smallest to largest. */ public final String[] resourcePaths; @@ -243,23 +243,44 @@ public javax.swing.ImageIcon loadSize(int targetSize) { public static String arch; public static String processorName; public static String jdk; - public static String username; + // PII: OS username collection removed (#117 — use anonymous or a non-PII system id instead). + // public static String username; + + /** + * Stable, non-PII system identifier (32-char SHA-256 hex derived from the + * OS machine GUID / machine-id). Persisted in {@code jdm.properties} so + * it survives app restarts. See {@link UtilOs#getMachineSystemId}. + */ + public static String systemId; // --- OS convenience helpers --- // Delegate to UtilOs primitives. Safe to call before init() (e.g. early in // main() or in CLI mode where App.os is never populated). /** Returns {@code true} when running on macOS. */ - public static boolean isMacOs() { return UtilOs.isMacOs(osName()); } + public static boolean isMacOs() { + return UtilOs.isMacOs(osName()); + } + /** Returns {@code true} when running on Windows. */ - public static boolean isWindows() { return UtilOs.isWindows(osName()); } + public static boolean isWindows() { + return UtilOs.isWindows(osName()); + } + /** Returns {@code true} when running on Linux. */ - public static boolean isLinux() { return UtilOs.isLinux(osName()); } - /** Resolves the OS name, falling back to the system property when {@link #os} is not yet set. - * Safe to call before {@link #init()} and in CLI mode. */ + public static boolean isLinux() { + return UtilOs.isLinux(osName()); + } + + /** + * Resolves the OS name, falling back to the system property when {@link #os} is + * not yet set. + * Safe to call before {@link #init()} and in CLI mode. + */ public static String osName() { return (os != null) ? os : System.getProperty("os.name", ""); } + // benchmark options public static Properties p; public static File locationDir = null; @@ -269,9 +290,11 @@ public static String osName() { public static boolean autoSave = false; public static boolean sharePortal = false; // True if sharePortal was enabled in the last session; used to offer a - // one-click - // re-enable prompt at startup rather than silently resuming network activity. + // one-click re-enable prompt at startup rather than silently resuming network activity. public static boolean sharePortalPreviouslyEnabled = false; + // True once the user has answered the first-run portal-consent prompt. + // Persisted so the prompt is shown exactly once (issue #117). + public static boolean portalConsentAsked = false; public static boolean verbose = false; // affects cli output public static boolean multiFile = true; public static boolean autoRemoveData = true; @@ -393,13 +416,11 @@ public static void init() { GcDetector.printActive(); - username = System.getProperty("user.name"); - os = System.getProperty("os.name"); arch = System.getProperty("os.arch"); processorName = Util.getProcessorName(); jdk = Util.getJvmInfo(); - + checkPermission(); if (!APP_CACHE_DIR.exists()) { APP_CACHE_DIR.mkdirs(); @@ -409,6 +430,14 @@ public static void init() { loadConfig(); } + // Derive the stable, non-PII machine identifier now that loadConfig() has + // populated the persisted fallback value (if any). Resolved after loadConfig + // so we never clobber portalConsentAsked or other flags with a premature + // saveConfig() call. + String fallbackSystemId = (systemId != null && !systemId.isBlank()) ? systemId : ""; + systemId = UtilOs.getMachineSystemId(os, fallbackSystemId); + // systemId persisted by the shutdown-hook saveConfig() and other normal save paths. + // initialize data dir if necessary if (locationDir == null) { locationDir = new File(System.getProperty("user.home")); @@ -437,11 +466,23 @@ public void run() { App.saveConfig(); } }); - // If portal upload was active last session, offer a one-click re-enable. - // This avoids silent outbound network activity while keeping dev workflow - // smooth. - if (sharePortalPreviouslyEnabled) { - javax.swing.SwingUtilities.invokeLater(App::promptResumePortalUpload); + // #117 First-run consent: ask once if the user has never been asked. + // Fires for both test and production endpoints so the dialog can be + // exercised from the IDE without any config changes. + // This runs before the re-enable check so a brand-new install shows + // the consent dialog rather than nothing. + if (!portalConsentAsked) { + javax.swing.SwingUtilities.invokeLater(App::promptFirstRunPortalConsent); + } else if (sharePortalPreviouslyEnabled) { + // Consent was already given and upload was active last session — + // silently restore it. No need to ask again once consent is on record. + sharePortal = true; + javax.swing.SwingUtilities.invokeLater(() -> { + msg("Portal upload active — disable via the Sharing tab."); + if (Gui.mainFrame != null) { + Gui.mainFrame.loadPropertiesConfig(); + } + }); } } } @@ -450,13 +491,15 @@ public void run() { * Attempts to acquire an OS-level advisory lock on a file in the per-version * cache directory. Called once at startup in GUI mode, before {@link #init()}. * - *

The lock is held by a {@link java.nio.channels.FileLock} whose lifecycle + *

+ * The lock is held by a {@link java.nio.channels.FileLock} whose lifecycle * is tied to the JVM process: the OS kernel releases it automatically when the * process exits by any means (normal exit, uncaught exception, * {@code SIGKILL}, OOM crash). Stale lock files left behind after a crash are * therefore impossible — the next launch will always succeed. * - *

If another instance already holds the lock a user-friendly dialog is shown + *

+ * If another instance already holds the lock a user-friendly dialog is shown * and the method returns {@code false}, allowing {@code main()} to exit cleanly * without opening any window or touching the Derby database. * @@ -498,7 +541,7 @@ public static boolean acquireInstanceLock() { javax.swing.JOptionPane.showMessageDialog( null, "JDiskMark is already running.\n" - + "Only one instance can be open at a time.", + + "Only one instance can be open at a time.", "JDiskMark — Already Running", javax.swing.JOptionPane.WARNING_MESSAGE); System.exit(0); @@ -521,6 +564,43 @@ public static void checkPermission() { } } + /** + * Shows the one-time first-run consent dialog for anonymous portal upload + * (issue #117). Fires only when the production endpoint is configured and + * {@link #portalConsentAsked} is {@code false}. After the user responds the + * flag is set to {@code true} and persisted so the dialog never appears again. + */ + public static void promptFirstRunPortalConsent() { + String message = "" + + "Help improve JDiskMark by sharing your results!

" + + "Would you like to share your benchmark results with " + + "the JDiskMark community portal?

" + + "

" + + ""; + int choice = javax.swing.JOptionPane.showConfirmDialog( + Gui.mainFrame, + new javax.swing.JLabel(message), + "Share Benchmark Results?", + javax.swing.JOptionPane.YES_NO_OPTION, + javax.swing.JOptionPane.QUESTION_MESSAGE); + portalConsentAsked = true; // mark as answered regardless of choice + if (choice == javax.swing.JOptionPane.YES_OPTION) { + sharePortal = true; + msg("Portal upload enabled — thank you for sharing!"); + } else { + sharePortal = false; + msg("Portal upload declined. You can enable it later via Help \u203a Portal Upload."); + } + saveConfig(); // persist consent flag and choice immediately + // sync the Sharing tab to reflect the resolved state + if (Gui.mainFrame != null) { + Gui.mainFrame.loadPropertiesConfig(); + } + } + /** * Offers a one-click prompt to re-enable portal upload when it was active * in the previous session. Called after the main window is visible so the @@ -602,6 +682,13 @@ public static void loadConfig() { sharePortalPreviouslyEnabled = Boolean.parseBoolean(value); sharePortal = false; // always start disabled; prompt offered after window visible + // #117 one-time first-run consent flag + value = p.getProperty("portalConsentAsked", "false"); + portalConsentAsked = Boolean.parseBoolean(value); + + // Non-PII system identifier (blank on very first run; resolved in init()) + systemId = p.getProperty("systemId", ""); + Portal.uploadResourceLocator = p.getProperty("uploadResourceLocator", Portal.uploadResourceLocator); Portal.uploadProtocol = p.getProperty("uploadProtocol", Portal.uploadProtocol); @@ -709,8 +796,16 @@ public static void saveConfig() { // configure properties p.setProperty("sharePortal", String.valueOf(sharePortal)); - p.setProperty("uploadResourceLocator", Portal.uploadResourceLocator); - p.setProperty("uploadProtocol", Portal.uploadProtocol); + p.setProperty("portalConsentAsked", String.valueOf(portalConsentAsked)); // #117 + if (systemId != null && !systemId.isBlank()) { + p.setProperty("systemId", systemId); + } + if (Portal.uploadResourceLocator != null) { + p.setProperty("uploadResourceLocator", Portal.uploadResourceLocator); + } + if (Portal.uploadProtocol != null) { + p.setProperty("uploadProtocol", Portal.uploadProtocol); + } p.setProperty("activeProfile", activeProfile.name()); p.setProperty("profileModified", String.valueOf(profileModified)); p.setProperty("benchmarkType", benchmarkType.name()); diff --git a/jdm-core/src/main/java/jdiskmark/Benchmark.java b/jdm-core/src/main/java/jdiskmark/Benchmark.java index 83b60ca..7ebed9c 100644 --- a/jdm-core/src/main/java/jdiskmark/Benchmark.java +++ b/jdm-core/src/main/java/jdiskmark/Benchmark.java @@ -126,10 +126,22 @@ public void serialize(UUID value, JsonGenerator gen, SerializerProvider serializ @JsonSerialize(using = UuidToMongoIdSerializer.class) private UUID id; - // user account - @Column + // PII: username field disabled (#117). Defaults to "anonymous" for portal upload. + // Restore (or replace with a non-PII device/machine id) when needed. + // @Column String username = "anonymous"; // "user" is reserved in Derby - public String getUsername() { return username; } + // public String getUsername() { return username; } + public String getUsername() { return "anonymous"; } + + /** + * Non-PII stable system identifier derived from the OS machine GUID / machine-id. + * 32-char lowercase SHA-256 hex. Set from {@link App#systemId} at benchmark + * creation time and included in every portal upload payload. + * See {@link UtilOs#getMachineSystemId} for the derivation strategy. + */ + @Column + String systemId = ""; + public String getSystemId() { return systemId; } // system info @Embedded diff --git a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java index e3caa15..c9d0483 100644 --- a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java +++ b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java @@ -316,8 +316,8 @@ private BenchmarkOperation createOp(Benchmark b, IOMode mode) { } private void mapEnvironment(Benchmark b, String model, String partId, DiskUsageInfo u) { - b.username = App.username; - + b.systemId = (App.systemId != null) ? App.systemId : ""; + b.systemInfo.processorName = App.processorName; b.systemInfo.os = App.os; b.systemInfo.arch = App.arch; diff --git a/jdm-core/src/main/java/jdiskmark/Gui.java b/jdm-core/src/main/java/jdiskmark/Gui.java index 648060b..0e6d65a 100644 --- a/jdm-core/src/main/java/jdiskmark/Gui.java +++ b/jdm-core/src/main/java/jdiskmark/Gui.java @@ -286,11 +286,31 @@ public static void init() { */ public static void showAboutDialog() { javax.swing.ImageIcon icon = App.activeIcon.loadSize(128); - String message = App.APP_NAME + " " + App.VERSION + "\n" + - "JVM: " + App.jdk + "\n" + - "OS: " + App.os; + + // Build an HTML panel so the website URL is a clickable hyperlink. + String url = "https://www.jdiskmark.net"; + String html = "" + + "" + App.APP_NAME + " " + App.VERSION + "
" + + "JVM: " + App.jdk + "
" + + "OS:  " + App.os + "

" + + "" + url + "" + + ""; + + javax.swing.JEditorPane msgPane = new javax.swing.JEditorPane("text/html", html); + msgPane.setEditable(false); + msgPane.setOpaque(false); + msgPane.addHyperlinkListener(e -> { + if (e.getEventType() == javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) { + try { + java.awt.Desktop.getDesktop().browse(new java.net.URI(url)); + } catch (Exception ex) { + App.msg("Could not open browser: " + ex.getMessage()); + } + } + }); + javax.swing.JOptionPane.showMessageDialog( - mainFrame, message, "About " + App.APP_NAME, + mainFrame, msgPane, "About " + App.APP_NAME, javax.swing.JOptionPane.PLAIN_MESSAGE, icon); } diff --git a/jdm-core/src/main/java/jdiskmark/MainFrame.java b/jdm-core/src/main/java/jdiskmark/MainFrame.java index 7dc127e..370545c 100644 --- a/jdm-core/src/main/java/jdiskmark/MainFrame.java +++ b/jdm-core/src/main/java/jdiskmark/MainFrame.java @@ -21,6 +21,12 @@ public final class MainFrame extends javax.swing.JFrame { public static final DecimalFormat DF = new DecimalFormat("###.##"); + + /** + * Sharing tab panel — built programmatically, added to tabbedPane in the + * constructor. + */ + public SharingPanel sharingPanel; /** * Creates new form MainFrame @@ -45,8 +51,8 @@ public MainFrame() { totalTxProgBar.setString(""); StringBuilder titleSb = new StringBuilder(); - titleSb.append(getTitle()).append(" ").append(App.VERSION); - + titleSb.append(getTitle()).append(" ").append(App.VERSION); + refreshConfig(); bcPanel.configChangeDetection(); @@ -69,12 +75,21 @@ public MainFrame() { // auto scroll the text area. DefaultCaret caret = (DefaultCaret)msgTextArea.getCaret(); caret.setUpdatePolicy(DefaultCaret.ALWAYS_UPDATE); + + // #117 Sharing tab — added programmatically so the NetBeans form is untouched. + sharingPanel = new SharingPanel(); + tabbedPane.addTab("Sharing", sharingPanel); + + // Hide the now-redundant Help-menu portal items; all controls live in the tab. + portalUploadMenuItem.setVisible(false); + portalEndpointMenu.setVisible(false); + portalProtocolMenu.setVisible(false); } public JPanel getMountPanel() { return cResultMountPanel; } - + /** * This method is called when the gui needs to be updated after a new config * has been loaded. @@ -85,24 +100,9 @@ public void loadPropertiesConfig() { setLocation(App.locationDir.getAbsolutePath()); } - // test portal settings - portalUploadMenuItem.setSelected(App.sharePortal); - portalEndpointMenu.setEnabled(App.sharePortal); - if (Portal.uploadResourceLocator.equalsIgnoreCase(Portal.LOCAL_UPLOAD_LOCATOR)) { - localEndpointRbMenuItem.setSelected(true); - } - if (Portal.uploadResourceLocator.equalsIgnoreCase(Portal.TEST_UPLOAD_LOCATOR)) { - testEndpointRbMenuItem.setSelected(true); - } - if (Portal.uploadResourceLocator.equalsIgnoreCase(Portal.PRODUCTION_UPLOAD_LOCATOR)) { - prodEndpointRbMenuItem.setSelected(true); - } - portalProtocolMenu.setEnabled(App.sharePortal); - if (Portal.uploadProtocol.equalsIgnoreCase(Portal.HTTP)) { - httpProtoRbMenuItem.setSelected(true); - } - if (Portal.uploadProtocol.equalsIgnoreCase(Portal.HTTPS)) { - httpsProtoRbMenuItem.setSelected(true); + // Sharing tab reflects the current portal state. + if (sharingPanel != null) { + sharingPanel.refresh(); } multiFileCheckBoxMenuItem.setSelected(App.multiFile); @@ -946,15 +946,6 @@ private void resetBenchmarkItemActionPerformed(java.awt.event.ActionEvent evt) { }//GEN-LAST:event_resetBenchmarkItemActionPerformed private void portalUploadMenuItemActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_portalUploadMenuItemActionPerformed - if (portalUploadMenuItem.getState() == true) { - PortalEnableDialog dialog = new PortalEnableDialog(this); - dialog.setVisible(true); // Execution pauses here because it's modal - if (!dialog.isAuthorized()) { - App.msg("test passcode required to upload benchmarks"); - portalUploadMenuItem.setSelected(false); - return; - } - } App.sharePortal = portalUploadMenuItem.getState(); App.saveConfig(); if (App.sharePortal) { @@ -1167,14 +1158,14 @@ private void httpsProtoRbMenuItemActionPerformed(java.awt.event.ActionEvent evt) private javax.swing.JCheckBoxMenuItem writeSyncCheckBoxMenuItem; // End of variables declaration//GEN-END:variables - public void setLocation(String path ) { + public void setLocation(String path) { locationText.setText(path); } public void msg(String message) { - msgTextArea.append(message+'\n'); + msgTextArea.append(message + '\n'); } - + public void applyTestParams() { if (Gui.controlPanel != null) { Gui.controlPanel.applySettings(); diff --git a/jdm-core/src/main/java/jdiskmark/SharingPanel.java b/jdm-core/src/main/java/jdiskmark/SharingPanel.java new file mode 100644 index 0000000..c12ac57 --- /dev/null +++ b/jdm-core/src/main/java/jdiskmark/SharingPanel.java @@ -0,0 +1,211 @@ +package jdiskmark; + +import javax.swing.*; +import javax.swing.border.TitledBorder; +import java.awt.*; + +/** + * Sharing tab panel (issue #117). + * + * Layout: + * + * ┌──────────────────────────┬───────────────────────────────────────────────────────────────────────┐ + * │ ☑ Share benchmark │ Endpoint — https://test.jdiskmark.net:5000/api/benchmarks/upload │ + * │ results with the │ ○ Production ○ Test (test.jdiskmark.net) ○ Localhost │ + * │ JDiskMark community │ Protocol: ○ HTTPS ○ HTTP │ + * │ portal │ │ + * │ │ │ + * │ ● Enabled │ │ + * └──────────────────────────┴───────────────────────────────────────────────────────────────────────┘ + * + * Radios are laid out horizontally to keep the vertical footprint minimal — no + * scroll bar is triggered at the default window height. + * + * The titled border on the right panel doubles as a URL preview; it is updated + * live whenever the endpoint or protocol changes. + * + * Developer controls (endpoint / protocol) will be hidden in a future + * production build; they remain here for testing. + */ +public class SharingPanel extends JPanel { + + // ── Controls ────────────────────────────────────────────────────────────── + private final JCheckBox enableCheckBox; + private final JLabel statusLabel; + + private final JRadioButton localRb; + private final JRadioButton testRb; + private final JRadioButton prodRb; + + private final JRadioButton httpRb; + private final JRadioButton httpsRb; + + /** The right dev-controls panel whose titled border shows the live URL. */ + private final JPanel devPanel; + + + public SharingPanel() { + setLayout(new BorderLayout()); + + // ── Outer split: left (toggle) | right (dev controls) ───────────────── + JPanel grid = new JPanel(new GridBagLayout()); + grid.setBorder(BorderFactory.createEmptyBorder(8, 6, 8, 6)); + + // ── LEFT ────────────────────────────────────────────────────────────── + JPanel leftPanel = new JPanel(new GridBagLayout()); + leftPanel.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 8)); + + enableCheckBox = new JCheckBox( + "Share benchmark results with
the JDiskMark community portal
"); + + statusLabel = new JLabel("○ Disabled"); + statusLabel.setFont(statusLabel.getFont().deriveFont(Font.PLAIN, 11f)); + statusLabel.setForeground(Color.GRAY); + + GridBagConstraints lc = new GridBagConstraints(); + lc.gridx = 0; lc.fill = GridBagConstraints.HORIZONTAL; lc.weightx = 1; + lc.anchor = GridBagConstraints.NORTHWEST; lc.insets = new Insets(0, 0, 4, 0); + + lc.gridy = 0; leftPanel.add(enableCheckBox, lc); + lc.gridy = 1; lc.insets = new Insets(0, 4, 0, 0); + leftPanel.add(statusLabel, lc); + lc.gridy = 2; lc.weighty = 1; lc.fill = GridBagConstraints.BOTH; + leftPanel.add(Box.createVerticalGlue(), lc); + + // ── RIGHT: developer controls ───────────────────────────────────────── + // Endpoint radios + ButtonGroup endpointGroup = new ButtonGroup(); + prodRb = new JRadioButton("Production (www.jdiskmark.net)"); + testRb = new JRadioButton("Test (test.jdiskmark.net)"); + localRb = new JRadioButton("Localhost"); + endpointGroup.add(prodRb); + endpointGroup.add(testRb); + endpointGroup.add(localRb); + + // Protocol radios + ButtonGroup protocolGroup = new ButtonGroup(); + httpsRb = new JRadioButton("HTTPS"); + httpRb = new JRadioButton("HTTP"); + protocolGroup.add(httpsRb); + protocolGroup.add(httpRb); + + // Endpoint row + JPanel endpointRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 2)); + endpointRow.add(prodRb); + endpointRow.add(testRb); + endpointRow.add(localRb); + + // Protocol row + JPanel protocolRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 2)); + protocolRow.add(new JLabel("Protocol:")); + protocolRow.add(httpsRb); + protocolRow.add(httpRb); + + // Assemble with GridBagLayout so rows stretch horizontally + devPanel = new JPanel(new GridBagLayout()); + // border title is set in refreshBorderTitle() + GridBagConstraints dc = new GridBagConstraints(); + dc.gridx = 0; dc.fill = GridBagConstraints.HORIZONTAL; dc.weightx = 1; + dc.anchor = GridBagConstraints.NORTHWEST; dc.insets = new Insets(0, 0, 2, 0); + + dc.gridy = 0; devPanel.add(endpointRow, dc); + dc.gridy = 1; devPanel.add(protocolRow, dc); + dc.gridy = 2; dc.weighty = 1; dc.fill = GridBagConstraints.BOTH; + devPanel.add(Box.createVerticalGlue(), dc); + + // ── Outer grid assembly ──────────────────────────────────────────────── + GridBagConstraints oc = new GridBagConstraints(); + oc.gridy = 0; oc.fill = GridBagConstraints.BOTH; oc.weighty = 1; + oc.anchor = GridBagConstraints.NORTHWEST; + + oc.gridx = 0; oc.weightx = 0.35; oc.insets = new Insets(0, 0, 0, 4); + grid.add(leftPanel, oc); + + oc.gridx = 1; oc.weightx = 0.65; oc.insets = new Insets(0, 0, 0, 0); + grid.add(devPanel, oc); + + // ── Scroll pane: vertical only ───────────────────────────────────────── + JPanel northWrapper = new JPanel(new BorderLayout()); + northWrapper.add(grid, BorderLayout.NORTH); + + JScrollPane scrollPane = new JScrollPane(northWrapper, + JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, + JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); + scrollPane.setBorder(BorderFactory.createEmptyBorder()); + add(scrollPane, BorderLayout.CENTER); + + // ── Listeners ────────────────────────────────────────────────────────── + enableCheckBox.addActionListener(e -> onToggleSharing()); + prodRb .addActionListener(e -> onEndpointChanged(Portal.PRODUCTION_UPLOAD_LOCATOR)); + testRb .addActionListener(e -> onEndpointChanged(Portal.TEST_UPLOAD_LOCATOR)); + localRb.addActionListener(e -> onEndpointChanged(Portal.LOCAL_UPLOAD_LOCATOR)); + httpsRb.addActionListener(e -> onProtocolChanged(Portal.HTTPS)); + httpRb .addActionListener(e -> onProtocolChanged(Portal.HTTP)); + + refresh(); + } + + // ── Event handlers ───────────────────────────────────────────────────────── + + private void onToggleSharing() { + App.sharePortal = enableCheckBox.isSelected(); + App.saveConfig(); + App.msg(App.sharePortal ? "Portal upload enabled." : "Portal upload disabled."); + refresh(); + } + + private void onEndpointChanged(String locator) { + Portal.uploadResourceLocator = locator; + App.saveConfig(); + refreshBorderTitle(); + } + + private void onProtocolChanged(String protocol) { + Portal.uploadProtocol = protocol; + App.saveConfig(); + refreshBorderTitle(); + } + + // ── Sync ─────────────────────────────────────────────────────────────────── + + /** Synchronises all controls to the current {@link App} / {@link Portal} state. */ + public void refresh() { + enableCheckBox.setSelected(App.sharePortal); + + if (App.sharePortal) { + statusLabel.setText("● Enabled"); + statusLabel.setForeground(new Color(0, 150, 60)); + } else { + statusLabel.setText("○ Disabled"); + statusLabel.setForeground(Color.GRAY); + } + + // Endpoint + if (Portal.uploadResourceLocator.equalsIgnoreCase(Portal.LOCAL_UPLOAD_LOCATOR)) { + localRb.setSelected(true); + } else if (Portal.uploadResourceLocator.equalsIgnoreCase(Portal.TEST_UPLOAD_LOCATOR)) { + testRb.setSelected(true); + } else { + prodRb.setSelected(true); + } + + // Protocol + if (Portal.uploadProtocol.equalsIgnoreCase(Portal.HTTPS)) { + httpsRb.setSelected(true); + } else { + httpRb.setSelected(true); + } + + refreshBorderTitle(); + } + + /** + * Updates the titled border of the developer panel to reflect the currently + * constructed upload URL. Called whenever endpoint or protocol changes. + */ + private void refreshBorderTitle() { + String title = "Endpoint \u2014 " + Portal.getUploadUrl(); + devPanel.setBorder(BorderFactory.createTitledBorder(title)); + devPanel.repaint(); + } +} diff --git a/jdm-core/src/main/java/jdiskmark/UtilOs.java b/jdm-core/src/main/java/jdiskmark/UtilOs.java index 06baf0f..28751e5 100644 --- a/jdm-core/src/main/java/jdiskmark/UtilOs.java +++ b/jdm-core/src/main/java/jdiskmark/UtilOs.java @@ -1,872 +1,994 @@ -package jdiskmark; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.BufferedReader; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.RandomAccessFile; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.logging.Level; -import java.util.logging.Logger; - -/** - * OS Specific Utility methods for JDiskMark - */ -public class UtilOs { - - public static final Logger LOGGER = Logger.getLogger(UtilOs.class.getName()); - - // --- OS detection primitives --- - // Accept an explicit osName string so these can be used before App.os is - // populated (e.g. in CLI mode or very early in main()). - - /** Returns {@code true} when {@code osName} identifies macOS. */ - public static boolean isMacOs(String osName) { - return osName != null && osName.contains("Mac OS"); - } - - /** Returns {@code true} when {@code osName} identifies Windows. */ - public static boolean isWindows(String osName) { - return osName != null && osName.startsWith("Windows"); - } - - /** Returns {@code true} when {@code osName} identifies Linux. */ - public static boolean isLinux(String osName) { - return osName != null && osName.contains("Linux"); - } - - /** The disk model power shell utility. */ - public static final String DISK_MODEL_PS_FILENAME = "disk-model.ps1"; - - /** The capacity power shell utility. */ - public static final String CAPACITY_PS_FILENAME = "capacity.ps1"; - - /* Not used kept here for reference. */ - static public void readPhysicalDriveWindows() throws FileNotFoundException, IOException { - File diskRoot = new File ("\\\\.\\PhysicalDrive0"); - RandomAccessFile diskAccess = new RandomAccessFile (diskRoot, "r"); - byte[] content = new byte[1024]; - diskAccess.readFully (content); - System.out.println("done reading fully"); - System.out.println("content " + Arrays.toString(content)); - } - - /** - * This method became obsolete with an updated version of windows 10. - * A newer version of the method is used. - * * Get the drive model description based on the windows drive letter. - * Uses the powershell script disk-model.ps1 - * * This appears to be the output of the original ps script before the update: - * * d:\>powershell -ExecutionPolicy ByPass -File tmp.ps1 - - DiskSize : 128034708480 - RawSize : 117894545408 - FreeSpace : 44036825088 - Disk : \\.\PHYSICALDRIVE1 - DriveLetter : C: - DiskModel : SanDisk SD6SF1M128G - VolumeName : OS_Install - Size : 117894541312 - Partition : Disk #1, Partition #2 - - DiskSize : 320070320640 - RawSize : 320070836224 - FreeSpace : 29038071808 - Disk : \\.\PHYSICALDRIVE2 - DriveLetter : E: - DiskModel : TOSHIBA External USB 3.0 USB Device - VolumeName : TOSHIBA EXT - Size : 320070832128 - Partition : Disk #2, Partition #0 - - * We should be able to modify the new parser to detect the - * output type and adjust parsing as needed. - * * @param driveLetter The single character drive letter. - * @return Disk Drive Model description or empty string if not found. - */ - @Deprecated - public static String getDriveModelLegacyWindows(String driveLetter) { - try { - Process p = Runtime.getRuntime().exec("powershell -ExecutionPolicy ByPass -File disk-model.ps1"); - p.waitFor(); - BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream())); - String line = reader.readLine(); - - String curDriveLetter = null; - String curDiskModel = null; - while (line != null) { - System.out.println(line); - if (line.trim().isEmpty()) { - if (curDriveLetter != null && curDiskModel != null && - curDriveLetter.equalsIgnoreCase(driveLetter)) { - return curDiskModel; - } - } - if (line.contains("DriveLetter : ")) { - curDriveLetter = line.split(" : ")[1].substring(0, 1); - System.out.println("current letter=" + curDriveLetter); - } - if (line.contains("DiskModel : ")) { - curDiskModel = line.split(" : ")[1]; - System.out.println("current model=" + curDiskModel); - } - line = reader.readLine(); - } - } - catch(IOException | InterruptedException e) { - Logger.getLogger(UtilOs.class.getName()).log(Level.SEVERE, null, e); - } - return null; - } - - public static String getDriveLetterWindows(Path dataDirPath) { - // get disk info for windows - String driveLetter = dataDirPath.getRoot().toFile().toString().split(":")[0]; - if (driveLetter.length() == 1 && Character.isLetter(driveLetter.charAt(0))) { - // Only proceed if the driveLetter is a single character and a letter - return driveLetter; - } - return "unknown"; - } - - /** - * Get the drive model description based on the windows drive letter. - * Uses the powershell script disk-model.ps1 - * * Parses output such as the following: - * * DiskModel DriveLetter - * --------- ----------- - * ST31500341AS ATA Device D: - * Samsung SSD 850 EVO 1TB ATA Device C: - * * Tested on Windows 10 on 3/6/2017 - * * @param driveLetter as a string - * @return the model as a string - */ - public static String getDriveModelWindows(String driveLetter) { - // match powershell uppercase output - driveLetter = driveLetter.toUpperCase(); - File diskModelPsFile = new File(DISK_MODEL_PS_FILENAME); - if (!diskModelPsFile.exists()) { - diskModelPsFile = new File(".//app//" + DISK_MODEL_PS_FILENAME); - } - - try { - ProcessBuilder pb = new ProcessBuilder("powershell", "-ExecutionPolicy", - "ByPass", "-File", diskModelPsFile.getAbsolutePath()); - pb.redirectErrorStream(true); - Process process = pb.start(); - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - String line; - while ((line = reader.readLine()) != null) { - if (App.verbose) { - System.out.println(line); - } - if (line.trim().endsWith(driveLetter + ":")) { - String model = line.split(driveLetter + ":")[0].trim(); - return model; - } - } - } catch (IOException e) { - LOGGER.log(Level.SEVERE, "IO exception getting model", e); - } - return null; - } - - public static DiskUsageInfo getCapacityWindows(String driveLetter) { - File capacityPsFile = new File(CAPACITY_PS_FILENAME); - if (!capacityPsFile.exists()) { - capacityPsFile = new File(".//app//" + CAPACITY_PS_FILENAME); - } - - DiskUsageInfo usageInfo = new DiskUsageInfo(); - - try { - ProcessBuilder pb = new ProcessBuilder("powershell", "-ExecutionPolicy", - "ByPass", "-File", capacityPsFile.getAbsolutePath(), driveLetter); - - // FIX: Set to false so error messages don't get mixed into the JSON output - pb.redirectErrorStream(false); - - final Process process = pb.start(); - - // Create a thread to handle the error stream - Thread errorThread = new Thread(() -> { - try (BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { - String errorLine; - while ((errorLine = errorReader.readLine()) != null) { - LOGGER.log(Level.SEVERE, "PowerShell script error: {0}", errorLine); - } - } catch (IOException e) { - LOGGER.log(Level.SEVERE, "Error reading from error stream", e); - } - }); - errorThread.start(); - - try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - StringBuilder jsonBuilder = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - jsonBuilder.append(line); - } - - String jsonOutput = jsonBuilder.toString().trim(); - - // FIX: Guard clause. Only parse if it actually looks like JSON. - if (jsonOutput.isEmpty() || !jsonOutput.startsWith("{")) { - LOGGER.log(Level.WARNING, "PowerShell returned non-JSON data: {0}", jsonOutput); - return usageInfo; // Returns default 0 values to prevent a UI crash - } - - ObjectMapper objectMapper = new ObjectMapper(); - JsonNode rootNode = objectMapper.readTree(jsonOutput); - usageInfo.totalGb = rootNode.get("TotalSpaceGb").asDouble(); - usageInfo.freeGb = rootNode.get("FreeSpaceGb").asDouble(); - usageInfo.usedGb = rootNode.get("UsedSpaceGb").asDouble(); - usageInfo.calcPercentageUsed(); - } - } catch (Exception e) { - LOGGER.log(Level.SEVERE, "Exception retrieving disk capacity: " + e.getLocalizedMessage(), e); - } - return usageInfo; - } - - /** - * On Linux OS get the device path when given a file path. - * eg. filePath = /home/james/Desktop/jdm-data - * devicePath = /dev/sda - * * Example command and output: - * $ df /home/james/jdm-data - * Filesystem 1K-blocks Used Available Use% Mounted on - * /dev/sda2 238737052 54179492 172357524 24% / - * * @param path the file path - * @return the device path - */ - static public String getPartitionFromFilePathLinux(Path path) { - if (App.verbose) { - System.out.println("filePath=" + path.toString()); - } - try { - ProcessBuilder pb = new ProcessBuilder("df", "-k", path.toString()); - Map env = pb.environment(); - env.put("LC_ALL", "C"); // set language to english - pb.redirectErrorStream(true); - Process process = pb.start(); - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - String line; - String curPartition; - while ((line = reader.readLine()) != null) { - if (App.verbose) { - System.out.println("curLine=" + line); - } - if (line.contains("/dev/")) { - curPartition = line.split(" ")[0]; - return curPartition; - } - } - } catch (IOException e) { - LOGGER.log(Level.SEVERE, null, e); - } - return null; - } - - /** - * This method returns a list to handle multiple physical drives - * in case the partition is part of an LVM or RAID in Linux - * @param partition the partition to look up - * @return list of physical drives - */ - static public List getDeviceNamesFromPartitionLinux(String partition) { - List deviceNames = new ArrayList<>(); - try { - ProcessBuilder pb = new ProcessBuilder("lsblk", "-no", "pkname", partition); - Map env = pb.environment(); - env.put("LC_ALL", "C"); // set language to english - pb.redirectErrorStream(true); - Process process = pb.start(); - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - // detect multiple lines and if so indicate it is an LVM - String line; - while ((line = reader.readLine()) != null) { - if (App.verbose) { - System.err.println("devName=" + line); - } - deviceNames.add(line); - } - } catch (IOException e) { - LOGGER.log(Level.SEVERE, null, e); - } - return deviceNames; - } - - /** - * On Linux OS use the lsblk command to get the disk model number for a - * specific Device ie. /dev/sda - * * Example output of command: - * ~$ lsblk /dev/sda --output MODEL - * MODEL - * Samsung SSD 860 EVO M.2 250GB - * * @param devicePath path of the device - * @return the disk model number - */ - static public String getDeviceModelLinux(String devicePath) { - try { - ProcessBuilder pb = new ProcessBuilder("lsblk", devicePath, "--output", "MODEL"); - Map env = pb.environment(); - env.put("LC_ALL", "C"); // set language to english - pb.redirectErrorStream(true); - Process process = pb.start(); - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - String line; - while ((line = reader.readLine()) != null) { - // return the first line that does not contain the header - if (!line.equals("MODEL") && !line.trim().isEmpty()) { - return line.trim(); - } - } - } catch (IOException e) { - LOGGER.log(Level.SEVERE, null, e); - } - return null; - } - - /** - * On Linux OS use the lsblk command to get the disk size for a - * specific Device ie. /dev/sda - * * The full command is: - * * $ lsblk /dev/sda - * NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS - * sda 8:0 0 232.9G 0 disk - * ├─sda1 8:1 0 512M 0 part /boot/efi - * └─sda2 8:2 0 232.4G 0 part /var/snap/firefox/common/host-hunspell - * * Retrieving just the size column is: - * * $ lsblk /dev/sda --output SIZE - * SIZE - * 232.9G - * 512M - * 232.4G - * * @param devicePath path of the device - * @return the size of the device - */ - static public String getDeviceSizeLinux(String devicePath) { - System.out.println("getting size of " + devicePath); - try { - ProcessBuilder pb = new ProcessBuilder("lsblk", devicePath, "--output", "SIZE"); - Map env = pb.environment(); - env.put("LC_ALL", "C"); // set language to english - pb.redirectErrorStream(true); - Process process = pb.start(); - - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - String line; - // return the first entry which is not the column header - while ((line = reader.readLine()) != null) { - if (!line.contains("SIZE") && !line.trim().isEmpty()) { - return line; - } - } - } catch (IOException e) { - LOGGER.log(Level.SEVERE, null, e); - } - return null; - } - - static public String getDeviceFromPathMacOs(Path path) { - try { - ProcessBuilder pb = new ProcessBuilder("df", "-k", path.toString()); - Map env = pb.environment(); - env.put("LC_ALL", "C"); // set language to english - pb.redirectErrorStream(true); - Process process = pb.start(); - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - String line; - while ((line = reader.readLine()) != null) { - System.out.println(line); - if (line.contains("/dev/")) { - return line.split(" ")[0]; - } - } - } catch(IOException e) { - LOGGER.log(Level.SEVERE, null, e); - } - return null; - } - - static public String getDeviceModelMacOs(String devicePath) { - - if (devicePath == null || devicePath.isEmpty()) { - throw new IllegalArgumentException("Invalid device path"); - } - - try { - ProcessBuilder pb = new ProcessBuilder("diskutil", "info", devicePath); - Map env = pb.environment(); - env.put("LC_ALL", "C"); // set language to english - pb.redirectErrorStream(true); - Process process = pb.start(); - - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - String line; - while ((line = reader.readLine()) != null) { - if (line.contains("Device / Media Name:")) { - return line.split("Device / Media Name:")[1].trim(); - } - } - } catch (IOException e) { - LOGGER.log(Level.SEVERE, null, e); - } - - String deviceId = devicePath; - if (deviceId.contains("/dev/")) { - deviceId = deviceId.split("/dev/")[1]; - } - - try { - ProcessBuilder pb = new ProcessBuilder("system_profiler", "SPStorageDataType"); - pb.redirectErrorStream(true); - Process process = pb.start(); - - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - String line; - while ((line = reader.readLine()) != null) { - if (line.contains(deviceId)) { - // Lines after deviceId - String lineAfterId; - while ((lineAfterId = reader.readLine()) != null) { - if (lineAfterId.contains("Device Name: ")) { - return lineAfterId.split("Device Name: ")[1]; - } - } - } - } - } catch (IOException e) { - LOGGER.log(Level.SEVERE, null, e); - } - - return "Model unavailable for " + deviceId; - } - - static public void flushDataToDriveMacOs() { - flushDataToDriveLinux(); - } - - /** - * GH-2 flush data to disk - */ - static public void flushDataToDriveLinux() { - String[] command = {"sync"}; - System.out.println("running: " + Arrays.toString(command)); - - try { - ProcessBuilder builder = new ProcessBuilder(command); - Process process = builder.start(); - boolean interrupted = false; - boolean finished = false; - // prevent interruption from interfering with flush - while (!finished) { - try { - int exitValue = process.waitFor(); - - try (BufferedReader outputReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - String line; - while ((line = outputReader.readLine()) != null) { - System.out.println(line); - } - } - - StringBuilder stderr = new StringBuilder(); - try (BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { - String line; - while ((line = errorReader.readLine()) != null) { - stderr.append(line).append(System.lineSeparator()); - } - } - - if (!stderr.isEmpty() || exitValue != 0) { - String errMsg = "sync failed (exit=" + exitValue + ")" - + (stderr.isEmpty() ? "" : ": " + stderr.toString().trim()); - LOGGER.log(Level.WARNING, errMsg); - App.err(errMsg); - } - System.out.println("EXIT VALUE: " + exitValue); - finished = true; - } catch (InterruptedException e) { - interrupted = true; - LOGGER.log(Level.WARNING, "Interrupted while waiting for sync, retrying", e); - } - } - if (interrupted) { - Thread.currentThread().interrupt(); - } - } catch (IOException e) { - LOGGER.log(Level.SEVERE, null, e); - App.err("sync command failed: " + e.getMessage()); - } - } - - static public void dropWriteCacheMacOs() { - - String[] command = {"purge"}; - System.out.println("running: " + Arrays.toString(command)); - - try { - ProcessBuilder builder = new ProcessBuilder(command); - Process process = builder.start(); - int exitValue = process.waitFor(); - - try (BufferedReader outputReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - String line; - System.out.println("Standard Output:"); - while ((line = outputReader.readLine()) != null) { - System.out.println(line); - } - } - - try (BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { - String line; - System.err.println("Standard Error:"); - while ((line = errorReader.readLine()) != null) { - System.err.println(line); - } - } - - System.out.println("EXIT VALUE: " + exitValue); - - } catch (IOException | InterruptedException e) { - LOGGER.log(Level.SEVERE, "Error executing command", e); - } - } - - /** - * GH-2 Drop the write cache, used to prevent invalid read measurement - */ - static public void dropWriteCacheLinux() { - - String[] command = {"/bin/sh", "-c", "echo 1 > /proc/sys/vm/drop_caches"}; - System.out.println("running: " + Arrays.toString(command)); - - try { - ProcessBuilder builder = new ProcessBuilder(command); - Process process = builder.start(); - boolean interrupted = false; - boolean finished = false; - // prevent interruption from interfering with cleaning cache - while (!finished) { - try { - int exitValue = process.waitFor(); - - try (BufferedReader outputReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - String line; - while ((line = outputReader.readLine()) != null) { - System.out.println(line); - } - } - - StringBuilder stderr = new StringBuilder(); - try (BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { - String line; - while ((line = errorReader.readLine()) != null) { - stderr.append(line).append(System.lineSeparator()); - } - } - - if (!stderr.isEmpty() || exitValue != 0) { - String errMsg = "drop_caches failed (exit=" + exitValue + ")" - + (stderr.isEmpty() ? "" : ": " + stderr.toString().trim()); - LOGGER.log(Level.WARNING, errMsg); - App.err(errMsg); - } - System.out.println("EXIT VALUE: " + exitValue); - finished = true; - } catch (InterruptedException e) { - interrupted = true; - LOGGER.log(Level.WARNING, "Interrupted while waiting for drop_caches, retrying", e); - } - } - if (interrupted) { - Thread.currentThread().interrupt(); - } - } catch (IOException e) { - LOGGER.log(Level.SEVERE, "Error executing command", e); - App.err("drop_caches command failed: " + e.getMessage()); - } - } - - public static boolean isRunningAsRootMacOs() { - return isRunningAsRootLinux(); - } - - public static boolean isRunningAsRootLinux() { - try { - ProcessBuilder pb = new ProcessBuilder("id", "-u"); - Process process = pb.start(); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - String line = reader.readLine(); - if (line != null) { - int uid = Integer.parseInt(line); - return uid == 0; - } - } - } catch (IOException e) { - LOGGER.log(Level.SEVERE, "Error executing command", e); - return false; - } - return false; - } - - static boolean isRunningAsAdminWindows() { - try { - ProcessBuilder pb = new ProcessBuilder("cmd", "/c", "net session"); - // Redirect output and error streams to avoid hanging if admin privileges are missing - pb.redirectOutput(ProcessBuilder.Redirect.DISCARD); - pb.redirectError(ProcessBuilder.Redirect.DISCARD); - Process process = pb.start(); - int exitCode = process.waitFor(); - return exitCode == 0; - } catch (IOException | InterruptedException e) { - LOGGER.log(Level.SEVERE, "Error executing command", e); - return false; - } - } - - static public void emptyStandbyListWindows(File esblExe) { - - // there seem to be some testing issues with only doing the standbylist - //String[] command = { ".\\EmptyStandbyList.exe", "standbylist" }; - - //String[] command = {".\\EmptyStandbyList.exe"}; - String[] command = { esblExe.getAbsolutePath() }; - System.out.println("running: " + Arrays.toString(command)); - - try { - ProcessBuilder builder = new ProcessBuilder(command); - Process process = builder.start(); - int exitValue = process.waitFor(); - - try (BufferedReader outputReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - String line; - System.out.println("Standard Output:"); - while ((line = outputReader.readLine()) != null) { - System.out.println(line); - } - } - - try (BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { - String line; - System.err.println("Standard Error:"); - while ((line = errorReader.readLine()) != null) { - System.err.println(line); - } - } - System.out.println("EXIT VALUE: " + exitValue); - - } catch (IOException | InterruptedException e) { - LOGGER.log(Level.SEVERE, "Error executing command", e); - } - } - - /** - * $ df -h /home/james - * Filesystem Size Used Avail Use% Mounted on - * /dev/sda2 228G 52G 165G 24% / - * * @param outputLines - * @return usage object - */ - static DiskUsageInfo parseDiskUsageInfoLinux(List outputLines) { - String usageLine = outputLines.get(1); // Assuming the relevant information is on the second line - String[] parts = usageLine.trim().split("\\s+"); - - /* Grab relevant bits from df output and convert from kilobytes to gigabytes. - JSL 2024-01-06 */ - double usedGb = Double.parseDouble(parts[2])/Math.pow(2,20); - double totalGb = Double.parseDouble(parts[1])/Math.pow(2,20); - double percentUsed = usedGb / totalGb * 100; - - return new DiskUsageInfo(percentUsed, usedGb, totalGb); - } - - /** - * $ df -h /Users/james - * Filesystem Size Used Avail Capacity iused ifree %iused Mounted on - * /dev/disk1s1 466Gi 191Gi 273Gi 42% 947563 9223372036853828244 0% / - * * @param outputLines - * @return usage object - */ - static DiskUsageInfo parseDiskUsageInfoMacOs(List outputLines) { - String usageLine = outputLines.get(1); // Assuming the relevant information is on the second line - String[] parts = usageLine.trim().split("\\s+"); - - /* Grab relevant bits from df output and convert from kilobytes to gigabytes. - JSL 2024-01-06 */ - double usedGb = Double.parseDouble(parts[2])/Math.pow(2,20); - double totalGb = Double.parseDouble(parts[1])/Math.pow(2,20); - double percentUsed = usedGb / totalGb * 100; - - return new DiskUsageInfo(percentUsed, usedGb, totalGb); - } - - /** - * This parses disk usage on windows, tested on w11. - * * >cmd.exe /c fsutil volume diskfree c:\Users\james - * Total free bytes : 35,466,014,720 ( 33.0 GB) - * Total bytes : 511,324,794,880 (476.2 GB) - * Total quota free bytes : 35,466,014,720 ( 33.0 GB) - * Unavailable pool bytes : 0 ( 0.0 KB) - * Quota unavailable pool bytes : 0 ( 0.0 KB) - * Used bytes : 475,832,217,600 (443.2 GB) - * Total Reserved bytes : 26,562,560 ( 25.3 MB) - * Volume storage reserved bytes : 0 ( 0.0 KB) - * Available committed bytes : 0 ( 0.0 KB) - * Pool available bytes : 0 ( 0.0 KB) - * * @param outputLines lines to parse - * @return A data structure with disk usage - */ - @Deprecated - public static DiskUsageInfo parseDiskUsageInfoWindows(List outputLines) { - double freeGb = 0; - double usedGb = 0; - double totalGb = 0; - boolean usedBytesDetected = false; - for (int i = 0; i < outputLines.size(); i++) { - String line = outputLines.get(i); - if (line.contains("Total bytes") - || line.contains("Total de bytes:") // spanish - ) { - line = line.split(":")[1].trim().split("\\s+")[0]; - String bytes = line.replace(",", ""); - long totalBytes = Long.parseLong(bytes); - totalGb = (double) totalBytes / (double) (1024.0 * 1024.0 * 1024.0); - } else if (line.contains("Used bytes")) { - line = line.split(":")[1].trim().split("\\s+")[0]; - String bytes = line.replace(",", ""); - long usedBytes = Long.parseLong(bytes); - usedGb = (double) usedBytes / (double) (1024.0 * 1024.0 * 1024.0); - usedBytesDetected = true; - } else if (line.contains("Total free bytes") - || line.contains("Total de bytes:") // spanish - ) { - line = line.split(":")[1].trim().split("\\s+")[0]; - String bytes = line.replace(",", ""); - long freeBytes = Long.parseLong(bytes); - freeGb = (double) freeBytes / (double) (1024.0 * 1024.0 * 1024.0); - } - } - if (!usedBytesDetected) { - usedGb = totalGb - freeGb; - } - double percentUsed = usedGb / totalGb * 100; - System.out.println("-------------------------------------"); - System.out.println("freeGb=" + freeGb); - System.out.println("usedGb=" + usedGb); - System.out.println("totalGb=" + totalGb); - System.out.println("percentUsed=" + percentUsed); - return new DiskUsageInfo(percentUsed, freeGb, usedGb, totalGb); - } - - public static String getProcessorNameWindows() { - try { - ProcessBuilder pb = new ProcessBuilder("wmic", "cpu", "get", "Name"); - Process process = pb.start(); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - String line; - while ((line = reader.readLine()) != null) { - if (!line.startsWith("Name") && !line.trim().isEmpty()) { - return line.trim(); - } - } - } - int exitCode = process.waitFor(); - if (exitCode != 0) { - // Handle error if the process didn't exit successfully - LOGGER.log(Level.SEVERE, - "Failed to get processor name. Exit code: {0}", exitCode); - } - } catch (IOException e) { - // wmic may not be available on newer Windows versions (e.g. Windows 11) - LOGGER.log(Level.INFO, "wmic not available, falling back to PowerShell: {0}", e.getMessage()); - return getProcessorNameWindowsPowerShell(); - } catch (InterruptedException e) { - LOGGER.log(Level.SEVERE, null, e); - } - - return ""; // Return an empty string if no processor name was found - } - - static String getProcessorNameWindowsPowerShell() { - try { - ProcessBuilder pb = new ProcessBuilder( - "powershell.exe", "-Command", - "Get-CimInstance -Class Win32_Processor | Select-Object -ExpandProperty Name"); - Process process = pb.start(); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - String line; - while ((line = reader.readLine()) != null) { - if (!line.trim().isEmpty()) { - return line.trim(); - } - } - } - int exitCode = process.waitFor(); - if (exitCode != 0) { - LOGGER.log(Level.SEVERE, - "Failed to get processor name via PowerShell. Exit code: {0}", exitCode); - } - } catch (IOException | InterruptedException e) { - LOGGER.log(Level.SEVERE, null, e); - } - return ""; - } - - public static String getProcessorNameMacOS() { - try { - ProcessBuilder pb = new ProcessBuilder("sysctl", "-n", "machdep.cpu.brand_string"); - Map env = pb.environment(); - env.put("LC_ALL", "C"); // set language to english - Process process = pb.start(); - - try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - String line = reader.readLine(); - return line.trim(); // The first line contains the processor name - } - } catch (IOException e) { - LOGGER.log(Level.SEVERE, null, e); - } - - return ""; - } - - public static String getProcessorNameLinux() { - try { - // Use lscpu command to get details about the CPU - ProcessBuilder pb = new ProcessBuilder("lscpu"); - Map env = pb.environment(); - env.put("LC_ALL", "C"); // set language to english - Process process = pb.start(); - - try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - String line; - while ((line = reader.readLine()) != null) { - // Search for the line starting with "Model name:" - if (line.startsWith("Model name:")) { - // Extract the processor name after the colon - return line.substring(line.indexOf(":") + 2).trim(); - } - } - } - - int exitCode = process.waitFor(); - if (exitCode != 0) { - // Handle error if the process didn't exit successfully - Logger.getLogger(UtilOs.class.getName()).log(Level.SEVERE, - "Failed to get processor name. Exit code: {0}", exitCode); - } - } catch (IOException | InterruptedException e) { - LOGGER.log(Level.SEVERE, null, e); - } - - return ""; // Return an empty string if no processor name was found - } -} \ No newline at end of file +package jdiskmark; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.RandomAccessFile; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * OS Specific Utility methods for JDiskMark + */ +public class UtilOs { + + public static final Logger LOGGER = Logger.getLogger(UtilOs.class.getName()); + + // --- OS detection primitives --- + // Accept an explicit osName string so these can be used before App.os is + // populated (e.g. in CLI mode or very early in main()). + + /** Returns {@code true} when {@code osName} identifies macOS. */ + public static boolean isMacOs(String osName) { + return osName != null && osName.contains("Mac OS"); + } + + /** Returns {@code true} when {@code osName} identifies Windows. */ + public static boolean isWindows(String osName) { + return osName != null && osName.startsWith("Windows"); + } + + /** Returns {@code true} when {@code osName} identifies Linux. */ + public static boolean isLinux(String osName) { + return osName != null && osName.contains("Linux"); + } + + /** The disk model power shell utility. */ + public static final String DISK_MODEL_PS_FILENAME = "disk-model.ps1"; + + /** The capacity power shell utility. */ + public static final String CAPACITY_PS_FILENAME = "capacity.ps1"; + + /* Not used kept here for reference. */ + static public void readPhysicalDriveWindows() throws FileNotFoundException, IOException { + File diskRoot = new File ("\\\\.\\PhysicalDrive0"); + RandomAccessFile diskAccess = new RandomAccessFile (diskRoot, "r"); + byte[] content = new byte[1024]; + diskAccess.readFully (content); + System.out.println("done reading fully"); + System.out.println("content " + Arrays.toString(content)); + } + + /** + * This method became obsolete with an updated version of windows 10. + * A newer version of the method is used. + * * Get the drive model description based on the windows drive letter. + * Uses the powershell script disk-model.ps1 + * * This appears to be the output of the original ps script before the update: + * * d:\>powershell -ExecutionPolicy ByPass -File tmp.ps1 + + DiskSize : 128034708480 + RawSize : 117894545408 + FreeSpace : 44036825088 + Disk : \\.\PHYSICALDRIVE1 + DriveLetter : C: + DiskModel : SanDisk SD6SF1M128G + VolumeName : OS_Install + Size : 117894541312 + Partition : Disk #1, Partition #2 + + DiskSize : 320070320640 + RawSize : 320070836224 + FreeSpace : 29038071808 + Disk : \\.\PHYSICALDRIVE2 + DriveLetter : E: + DiskModel : TOSHIBA External USB 3.0 USB Device + VolumeName : TOSHIBA EXT + Size : 320070832128 + Partition : Disk #2, Partition #0 + + * We should be able to modify the new parser to detect the + * output type and adjust parsing as needed. + * * @param driveLetter The single character drive letter. + * @return Disk Drive Model description or empty string if not found. + */ + @Deprecated + public static String getDriveModelLegacyWindows(String driveLetter) { + try { + Process p = Runtime.getRuntime().exec("powershell -ExecutionPolicy ByPass -File disk-model.ps1"); + p.waitFor(); + BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream())); + String line = reader.readLine(); + + String curDriveLetter = null; + String curDiskModel = null; + while (line != null) { + System.out.println(line); + if (line.trim().isEmpty()) { + if (curDriveLetter != null && curDiskModel != null && + curDriveLetter.equalsIgnoreCase(driveLetter)) { + return curDiskModel; + } + } + if (line.contains("DriveLetter : ")) { + curDriveLetter = line.split(" : ")[1].substring(0, 1); + System.out.println("current letter=" + curDriveLetter); + } + if (line.contains("DiskModel : ")) { + curDiskModel = line.split(" : ")[1]; + System.out.println("current model=" + curDiskModel); + } + line = reader.readLine(); + } + } + catch(IOException | InterruptedException e) { + Logger.getLogger(UtilOs.class.getName()).log(Level.SEVERE, null, e); + } + return null; + } + + public static String getDriveLetterWindows(Path dataDirPath) { + // get disk info for windows + String driveLetter = dataDirPath.getRoot().toFile().toString().split(":")[0]; + if (driveLetter.length() == 1 && Character.isLetter(driveLetter.charAt(0))) { + // Only proceed if the driveLetter is a single character and a letter + return driveLetter; + } + return "unknown"; + } + + /** + * Get the drive model description based on the windows drive letter. + * Uses the powershell script disk-model.ps1 + * * Parses output such as the following: + * * DiskModel DriveLetter + * --------- ----------- + * ST31500341AS ATA Device D: + * Samsung SSD 850 EVO 1TB ATA Device C: + * * Tested on Windows 10 on 3/6/2017 + * * @param driveLetter as a string + * @return the model as a string + */ + public static String getDriveModelWindows(String driveLetter) { + // match powershell uppercase output + driveLetter = driveLetter.toUpperCase(); + File diskModelPsFile = new File(DISK_MODEL_PS_FILENAME); + if (!diskModelPsFile.exists()) { + diskModelPsFile = new File(".//app//" + DISK_MODEL_PS_FILENAME); + } + + try { + ProcessBuilder pb = new ProcessBuilder("powershell", "-ExecutionPolicy", + "ByPass", "-File", diskModelPsFile.getAbsolutePath()); + pb.redirectErrorStream(true); + Process process = pb.start(); + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + String line; + while ((line = reader.readLine()) != null) { + if (App.verbose) { + System.out.println(line); + } + if (line.trim().endsWith(driveLetter + ":")) { + String model = line.split(driveLetter + ":")[0].trim(); + return model; + } + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "IO exception getting model", e); + } + return null; + } + + public static DiskUsageInfo getCapacityWindows(String driveLetter) { + File capacityPsFile = new File(CAPACITY_PS_FILENAME); + if (!capacityPsFile.exists()) { + capacityPsFile = new File(".//app//" + CAPACITY_PS_FILENAME); + } + + DiskUsageInfo usageInfo = new DiskUsageInfo(); + + try { + ProcessBuilder pb = new ProcessBuilder("powershell", "-ExecutionPolicy", + "ByPass", "-File", capacityPsFile.getAbsolutePath(), driveLetter); + + // FIX: Set to false so error messages don't get mixed into the JSON output + pb.redirectErrorStream(false); + + final Process process = pb.start(); + + // Create a thread to handle the error stream + Thread errorThread = new Thread(() -> { + try (BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + String errorLine; + while ((errorLine = errorReader.readLine()) != null) { + LOGGER.log(Level.SEVERE, "PowerShell script error: {0}", errorLine); + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Error reading from error stream", e); + } + }); + errorThread.start(); + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + StringBuilder jsonBuilder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + jsonBuilder.append(line); + } + + String jsonOutput = jsonBuilder.toString().trim(); + + // FIX: Guard clause. Only parse if it actually looks like JSON. + if (jsonOutput.isEmpty() || !jsonOutput.startsWith("{")) { + LOGGER.log(Level.WARNING, "PowerShell returned non-JSON data: {0}", jsonOutput); + return usageInfo; // Returns default 0 values to prevent a UI crash + } + + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode rootNode = objectMapper.readTree(jsonOutput); + usageInfo.totalGb = rootNode.get("TotalSpaceGb").asDouble(); + usageInfo.freeGb = rootNode.get("FreeSpaceGb").asDouble(); + usageInfo.usedGb = rootNode.get("UsedSpaceGb").asDouble(); + usageInfo.calcPercentageUsed(); + } + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Exception retrieving disk capacity: " + e.getLocalizedMessage(), e); + } + return usageInfo; + } + + /** + * On Linux OS get the device path when given a file path. + * eg. filePath = /home/james/Desktop/jdm-data + * devicePath = /dev/sda + * * Example command and output: + * $ df /home/james/jdm-data + * Filesystem 1K-blocks Used Available Use% Mounted on + * /dev/sda2 238737052 54179492 172357524 24% / + * * @param path the file path + * @return the device path + */ + static public String getPartitionFromFilePathLinux(Path path) { + if (App.verbose) { + System.out.println("filePath=" + path.toString()); + } + try { + ProcessBuilder pb = new ProcessBuilder("df", "-k", path.toString()); + Map env = pb.environment(); + env.put("LC_ALL", "C"); // set language to english + pb.redirectErrorStream(true); + Process process = pb.start(); + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + String line; + String curPartition; + while ((line = reader.readLine()) != null) { + if (App.verbose) { + System.out.println("curLine=" + line); + } + if (line.contains("/dev/")) { + curPartition = line.split(" ")[0]; + return curPartition; + } + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, null, e); + } + return null; + } + + /** + * This method returns a list to handle multiple physical drives + * in case the partition is part of an LVM or RAID in Linux + * @param partition the partition to look up + * @return list of physical drives + */ + static public List getDeviceNamesFromPartitionLinux(String partition) { + List deviceNames = new ArrayList<>(); + try { + ProcessBuilder pb = new ProcessBuilder("lsblk", "-no", "pkname", partition); + Map env = pb.environment(); + env.put("LC_ALL", "C"); // set language to english + pb.redirectErrorStream(true); + Process process = pb.start(); + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + // detect multiple lines and if so indicate it is an LVM + String line; + while ((line = reader.readLine()) != null) { + if (App.verbose) { + System.err.println("devName=" + line); + } + deviceNames.add(line); + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, null, e); + } + return deviceNames; + } + + /** + * On Linux OS use the lsblk command to get the disk model number for a + * specific Device ie. /dev/sda + * * Example output of command: + * ~$ lsblk /dev/sda --output MODEL + * MODEL + * Samsung SSD 860 EVO M.2 250GB + * * @param devicePath path of the device + * @return the disk model number + */ + static public String getDeviceModelLinux(String devicePath) { + try { + ProcessBuilder pb = new ProcessBuilder("lsblk", devicePath, "--output", "MODEL"); + Map env = pb.environment(); + env.put("LC_ALL", "C"); // set language to english + pb.redirectErrorStream(true); + Process process = pb.start(); + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + String line; + while ((line = reader.readLine()) != null) { + // return the first line that does not contain the header + if (!line.equals("MODEL") && !line.trim().isEmpty()) { + return line.trim(); + } + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, null, e); + } + return null; + } + + /** + * On Linux OS use the lsblk command to get the disk size for a + * specific Device ie. /dev/sda + * * The full command is: + * * $ lsblk /dev/sda + * NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS + * sda 8:0 0 232.9G 0 disk + * ├─sda1 8:1 0 512M 0 part /boot/efi + * └─sda2 8:2 0 232.4G 0 part /var/snap/firefox/common/host-hunspell + * * Retrieving just the size column is: + * * $ lsblk /dev/sda --output SIZE + * SIZE + * 232.9G + * 512M + * 232.4G + * * @param devicePath path of the device + * @return the size of the device + */ + static public String getDeviceSizeLinux(String devicePath) { + System.out.println("getting size of " + devicePath); + try { + ProcessBuilder pb = new ProcessBuilder("lsblk", devicePath, "--output", "SIZE"); + Map env = pb.environment(); + env.put("LC_ALL", "C"); // set language to english + pb.redirectErrorStream(true); + Process process = pb.start(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + String line; + // return the first entry which is not the column header + while ((line = reader.readLine()) != null) { + if (!line.contains("SIZE") && !line.trim().isEmpty()) { + return line; + } + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, null, e); + } + return null; + } + + static public String getDeviceFromPathMacOs(Path path) { + try { + ProcessBuilder pb = new ProcessBuilder("df", "-k", path.toString()); + Map env = pb.environment(); + env.put("LC_ALL", "C"); // set language to english + pb.redirectErrorStream(true); + Process process = pb.start(); + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + String line; + while ((line = reader.readLine()) != null) { + System.out.println(line); + if (line.contains("/dev/")) { + return line.split(" ")[0]; + } + } + } catch(IOException e) { + LOGGER.log(Level.SEVERE, null, e); + } + return null; + } + + static public String getDeviceModelMacOs(String devicePath) { + + if (devicePath == null || devicePath.isEmpty()) { + throw new IllegalArgumentException("Invalid device path"); + } + + try { + ProcessBuilder pb = new ProcessBuilder("diskutil", "info", devicePath); + Map env = pb.environment(); + env.put("LC_ALL", "C"); // set language to english + pb.redirectErrorStream(true); + Process process = pb.start(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + String line; + while ((line = reader.readLine()) != null) { + if (line.contains("Device / Media Name:")) { + return line.split("Device / Media Name:")[1].trim(); + } + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, null, e); + } + + String deviceId = devicePath; + if (deviceId.contains("/dev/")) { + deviceId = deviceId.split("/dev/")[1]; + } + + try { + ProcessBuilder pb = new ProcessBuilder("system_profiler", "SPStorageDataType"); + pb.redirectErrorStream(true); + Process process = pb.start(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + String line; + while ((line = reader.readLine()) != null) { + if (line.contains(deviceId)) { + // Lines after deviceId + String lineAfterId; + while ((lineAfterId = reader.readLine()) != null) { + if (lineAfterId.contains("Device Name: ")) { + return lineAfterId.split("Device Name: ")[1]; + } + } + } + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, null, e); + } + + return "Model unavailable for " + deviceId; + } + + static public void flushDataToDriveMacOs() { + flushDataToDriveLinux(); + } + + /** + * GH-2 flush data to disk + */ + static public void flushDataToDriveLinux() { + String[] command = {"sync"}; + System.out.println("running: " + Arrays.toString(command)); + + try { + ProcessBuilder builder = new ProcessBuilder(command); + Process process = builder.start(); + boolean interrupted = false; + boolean finished = false; + // prevent interruption from interfering with flush + while (!finished) { + try { + int exitValue = process.waitFor(); + + try (BufferedReader outputReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = outputReader.readLine()) != null) { + System.out.println(line); + } + } + + StringBuilder stderr = new StringBuilder(); + try (BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + String line; + while ((line = errorReader.readLine()) != null) { + stderr.append(line).append(System.lineSeparator()); + } + } + + if (!stderr.isEmpty() || exitValue != 0) { + String errMsg = "sync failed (exit=" + exitValue + ")" + + (stderr.isEmpty() ? "" : ": " + stderr.toString().trim()); + LOGGER.log(Level.WARNING, errMsg); + App.err(errMsg); + } + System.out.println("EXIT VALUE: " + exitValue); + finished = true; + } catch (InterruptedException e) { + interrupted = true; + LOGGER.log(Level.WARNING, "Interrupted while waiting for sync, retrying", e); + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, null, e); + App.err("sync command failed: " + e.getMessage()); + } + } + + static public void dropWriteCacheMacOs() { + + String[] command = {"purge"}; + System.out.println("running: " + Arrays.toString(command)); + + try { + ProcessBuilder builder = new ProcessBuilder(command); + Process process = builder.start(); + int exitValue = process.waitFor(); + + try (BufferedReader outputReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + System.out.println("Standard Output:"); + while ((line = outputReader.readLine()) != null) { + System.out.println(line); + } + } + + try (BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + String line; + System.err.println("Standard Error:"); + while ((line = errorReader.readLine()) != null) { + System.err.println(line); + } + } + + System.out.println("EXIT VALUE: " + exitValue); + + } catch (IOException | InterruptedException e) { + LOGGER.log(Level.SEVERE, "Error executing command", e); + } + } + + /** + * GH-2 Drop the write cache, used to prevent invalid read measurement + */ + static public void dropWriteCacheLinux() { + + String[] command = {"/bin/sh", "-c", "echo 1 > /proc/sys/vm/drop_caches"}; + System.out.println("running: " + Arrays.toString(command)); + + try { + ProcessBuilder builder = new ProcessBuilder(command); + Process process = builder.start(); + boolean interrupted = false; + boolean finished = false; + // prevent interruption from interfering with cleaning cache + while (!finished) { + try { + int exitValue = process.waitFor(); + + try (BufferedReader outputReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = outputReader.readLine()) != null) { + System.out.println(line); + } + } + + StringBuilder stderr = new StringBuilder(); + try (BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + String line; + while ((line = errorReader.readLine()) != null) { + stderr.append(line).append(System.lineSeparator()); + } + } + + if (!stderr.isEmpty() || exitValue != 0) { + String errMsg = "drop_caches failed (exit=" + exitValue + ")" + + (stderr.isEmpty() ? "" : ": " + stderr.toString().trim()); + LOGGER.log(Level.WARNING, errMsg); + App.err(errMsg); + } + System.out.println("EXIT VALUE: " + exitValue); + finished = true; + } catch (InterruptedException e) { + interrupted = true; + LOGGER.log(Level.WARNING, "Interrupted while waiting for drop_caches, retrying", e); + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Error executing command", e); + App.err("drop_caches command failed: " + e.getMessage()); + } + } + + public static boolean isRunningAsRootMacOs() { + return isRunningAsRootLinux(); + } + + public static boolean isRunningAsRootLinux() { + try { + ProcessBuilder pb = new ProcessBuilder("id", "-u"); + Process process = pb.start(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line = reader.readLine(); + if (line != null) { + int uid = Integer.parseInt(line); + return uid == 0; + } + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Error executing command", e); + return false; + } + return false; + } + + static boolean isRunningAsAdminWindows() { + try { + ProcessBuilder pb = new ProcessBuilder("cmd", "/c", "net session"); + // Redirect output and error streams to avoid hanging if admin privileges are missing + pb.redirectOutput(ProcessBuilder.Redirect.DISCARD); + pb.redirectError(ProcessBuilder.Redirect.DISCARD); + Process process = pb.start(); + int exitCode = process.waitFor(); + return exitCode == 0; + } catch (IOException | InterruptedException e) { + LOGGER.log(Level.SEVERE, "Error executing command", e); + return false; + } + } + + static public void emptyStandbyListWindows(File esblExe) { + + // there seem to be some testing issues with only doing the standbylist + //String[] command = { ".\\EmptyStandbyList.exe", "standbylist" }; + + //String[] command = {".\\EmptyStandbyList.exe"}; + String[] command = { esblExe.getAbsolutePath() }; + System.out.println("running: " + Arrays.toString(command)); + + try { + ProcessBuilder builder = new ProcessBuilder(command); + Process process = builder.start(); + int exitValue = process.waitFor(); + + try (BufferedReader outputReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + System.out.println("Standard Output:"); + while ((line = outputReader.readLine()) != null) { + System.out.println(line); + } + } + + try (BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + String line; + System.err.println("Standard Error:"); + while ((line = errorReader.readLine()) != null) { + System.err.println(line); + } + } + System.out.println("EXIT VALUE: " + exitValue); + + } catch (IOException | InterruptedException e) { + LOGGER.log(Level.SEVERE, "Error executing command", e); + } + } + + /** + * $ df -h /home/james + * Filesystem Size Used Avail Use% Mounted on + * /dev/sda2 228G 52G 165G 24% / + * * @param outputLines + * @return usage object + */ + static DiskUsageInfo parseDiskUsageInfoLinux(List outputLines) { + String usageLine = outputLines.get(1); // Assuming the relevant information is on the second line + String[] parts = usageLine.trim().split("\\s+"); + + /* Grab relevant bits from df output and convert from kilobytes to gigabytes. - JSL 2024-01-06 */ + double usedGb = Double.parseDouble(parts[2])/Math.pow(2,20); + double totalGb = Double.parseDouble(parts[1])/Math.pow(2,20); + double percentUsed = usedGb / totalGb * 100; + + return new DiskUsageInfo(percentUsed, usedGb, totalGb); + } + + /** + * $ df -h /Users/james + * Filesystem Size Used Avail Capacity iused ifree %iused Mounted on + * /dev/disk1s1 466Gi 191Gi 273Gi 42% 947563 9223372036853828244 0% / + * * @param outputLines + * @return usage object + */ + static DiskUsageInfo parseDiskUsageInfoMacOs(List outputLines) { + String usageLine = outputLines.get(1); // Assuming the relevant information is on the second line + String[] parts = usageLine.trim().split("\\s+"); + + /* Grab relevant bits from df output and convert from kilobytes to gigabytes. - JSL 2024-01-06 */ + double usedGb = Double.parseDouble(parts[2])/Math.pow(2,20); + double totalGb = Double.parseDouble(parts[1])/Math.pow(2,20); + double percentUsed = usedGb / totalGb * 100; + + return new DiskUsageInfo(percentUsed, usedGb, totalGb); + } + + /** + * This parses disk usage on windows, tested on w11. + * * >cmd.exe /c fsutil volume diskfree c:\Users\james + * Total free bytes : 35,466,014,720 ( 33.0 GB) + * Total bytes : 511,324,794,880 (476.2 GB) + * Total quota free bytes : 35,466,014,720 ( 33.0 GB) + * Unavailable pool bytes : 0 ( 0.0 KB) + * Quota unavailable pool bytes : 0 ( 0.0 KB) + * Used bytes : 475,832,217,600 (443.2 GB) + * Total Reserved bytes : 26,562,560 ( 25.3 MB) + * Volume storage reserved bytes : 0 ( 0.0 KB) + * Available committed bytes : 0 ( 0.0 KB) + * Pool available bytes : 0 ( 0.0 KB) + * * @param outputLines lines to parse + * @return A data structure with disk usage + */ + @Deprecated + public static DiskUsageInfo parseDiskUsageInfoWindows(List outputLines) { + double freeGb = 0; + double usedGb = 0; + double totalGb = 0; + boolean usedBytesDetected = false; + for (int i = 0; i < outputLines.size(); i++) { + String line = outputLines.get(i); + if (line.contains("Total bytes") + || line.contains("Total de bytes:") // spanish + ) { + line = line.split(":")[1].trim().split("\\s+")[0]; + String bytes = line.replace(",", ""); + long totalBytes = Long.parseLong(bytes); + totalGb = (double) totalBytes / (double) (1024.0 * 1024.0 * 1024.0); + } else if (line.contains("Used bytes")) { + line = line.split(":")[1].trim().split("\\s+")[0]; + String bytes = line.replace(",", ""); + long usedBytes = Long.parseLong(bytes); + usedGb = (double) usedBytes / (double) (1024.0 * 1024.0 * 1024.0); + usedBytesDetected = true; + } else if (line.contains("Total free bytes") + || line.contains("Total de bytes:") // spanish + ) { + line = line.split(":")[1].trim().split("\\s+")[0]; + String bytes = line.replace(",", ""); + long freeBytes = Long.parseLong(bytes); + freeGb = (double) freeBytes / (double) (1024.0 * 1024.0 * 1024.0); + } + } + if (!usedBytesDetected) { + usedGb = totalGb - freeGb; + } + double percentUsed = usedGb / totalGb * 100; + System.out.println("-------------------------------------"); + System.out.println("freeGb=" + freeGb); + System.out.println("usedGb=" + usedGb); + System.out.println("totalGb=" + totalGb); + System.out.println("percentUsed=" + percentUsed); + return new DiskUsageInfo(percentUsed, freeGb, usedGb, totalGb); + } + + public static String getProcessorNameWindows() { + try { + ProcessBuilder pb = new ProcessBuilder("wmic", "cpu", "get", "Name"); + Process process = pb.start(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + if (!line.startsWith("Name") && !line.trim().isEmpty()) { + return line.trim(); + } + } + } + int exitCode = process.waitFor(); + if (exitCode != 0) { + // Handle error if the process didn't exit successfully + LOGGER.log(Level.SEVERE, + "Failed to get processor name. Exit code: {0}", exitCode); + } + } catch (IOException e) { + // wmic may not be available on newer Windows versions (e.g. Windows 11) + LOGGER.log(Level.INFO, "wmic not available, falling back to PowerShell: {0}", e.getMessage()); + return getProcessorNameWindowsPowerShell(); + } catch (InterruptedException e) { + LOGGER.log(Level.SEVERE, null, e); + } + + return ""; // Return an empty string if no processor name was found + } + + static String getProcessorNameWindowsPowerShell() { + try { + ProcessBuilder pb = new ProcessBuilder( + "powershell.exe", "-Command", + "Get-CimInstance -Class Win32_Processor | Select-Object -ExpandProperty Name"); + Process process = pb.start(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + if (!line.trim().isEmpty()) { + return line.trim(); + } + } + } + int exitCode = process.waitFor(); + if (exitCode != 0) { + LOGGER.log(Level.SEVERE, + "Failed to get processor name via PowerShell. Exit code: {0}", exitCode); + } + } catch (IOException | InterruptedException e) { + LOGGER.log(Level.SEVERE, null, e); + } + return ""; + } + + public static String getProcessorNameMacOS() { + try { + ProcessBuilder pb = new ProcessBuilder("sysctl", "-n", "machdep.cpu.brand_string"); + Map env = pb.environment(); + env.put("LC_ALL", "C"); // set language to english + Process process = pb.start(); + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line = reader.readLine(); + return line.trim(); // The first line contains the processor name + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, null, e); + } + + return ""; + } + + public static String getProcessorNameLinux() { + try { + // Use lscpu command to get details about the CPU + ProcessBuilder pb = new ProcessBuilder("lscpu"); + Map env = pb.environment(); + env.put("LC_ALL", "C"); // set language to english + Process process = pb.start(); + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + // Search for the line starting with "Model name:" + if (line.startsWith("Model name:")) { + // Extract the processor name after the colon + return line.substring(line.indexOf(":") + 2).trim(); + } + } + } + + int exitCode = process.waitFor(); + if (exitCode != 0) { + // Handle error if the process didn't exit successfully + Logger.getLogger(UtilOs.class.getName()).log(Level.SEVERE, + "Failed to get processor name. Exit code: {0}", exitCode); + } + } catch (IOException | InterruptedException e) { + LOGGER.log(Level.SEVERE, null, e); + } + + return ""; // Return an empty string if no processor name was found + } + + // ─── System ID ──────────────────────────────────────────────────────────── + + /** + * Returns a stable, non-PII system identifier suitable for anonymous + * benchmark attribution. + * + *

Strategy (first successful source wins): + *

    + *
  1. Windows — {@code MachineGuid} from the Cryptography registry key + * (readable without admin)
  2. + *
  3. Linux — {@code /etc/machine-id} (world-readable)
  4. + *
  5. macOS — {@code IOPlatformUUID} via {@code ioreg} (no admin)
  6. + *
  7. Fallback — the previously persisted {@code systemId} from + * {@code jdm.properties}, or a freshly generated {@link java.util.UUID}
  8. + *
+ * + *

The raw OS value is SHA-256 hashed and the first 32 hex characters are + * returned, so the original system identifier is never stored or transmitted. + * + * @param osName the value of {@code System.getProperty("os.name")} + * @param persistedId the value already stored in {@code jdm.properties} + * (may be {@code null} or blank on first run) + * @return a 32-character lowercase hex string identifying this system + */ + public static String getMachineSystemId(String osName, String persistedId) { + String raw = null; + try { + if (isWindows(osName)) { + raw = readWindowsMachineGuid(); + } else if (isLinux(osName)) { + raw = readLinuxMachineId(); + } else if (isMacOs(osName)) { + raw = readMacOsPlatformUuid(); + } + } catch (Exception e) { + LOGGER.log(Level.WARNING, "getMachineSystemId: OS source failed, using fallback", e); + } + + if (raw != null && !raw.isBlank()) { + return sha256Hex32(raw); + } + + // Fallback: reuse persisted id (survives across sessions) or generate once. + if (persistedId != null && !persistedId.isBlank()) { + return persistedId; + } + LOGGER.warning("getMachineSystemId: all sources failed, generating a random id"); + return java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 32); + } + + /** Reads HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid (no admin required). */ + private static String readWindowsMachineGuid() throws IOException, InterruptedException { + Process p = new ProcessBuilder( + "reg", "query", + "HKLM\\SOFTWARE\\Microsoft\\Cryptography", + "/v", "MachineGuid") + .redirectErrorStream(true) + .start(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) { + String line; + while ((line = br.readLine()) != null) { + if (line.contains("MachineGuid")) { + String[] parts = line.trim().split("\\s+"); + if (parts.length >= 3) { + return parts[parts.length - 1].trim(); + } + } + } + } + p.waitFor(); + return null; + } + + /** Reads /etc/machine-id (world-readable on all mainstream Linux distros). */ + private static String readLinuxMachineId() throws IOException { + java.nio.file.Path mid = java.nio.file.Paths.get("/etc/machine-id"); + if (java.nio.file.Files.exists(mid)) { + return java.nio.file.Files.readString(mid).trim(); + } + java.nio.file.Path dbus = java.nio.file.Paths.get("/var/lib/dbus/machine-id"); + if (java.nio.file.Files.exists(dbus)) { + return java.nio.file.Files.readString(dbus).trim(); + } + return null; + } + + /** Reads IOPlatformUUID via ioreg (no admin required on macOS). */ + private static String readMacOsPlatformUuid() throws IOException, InterruptedException { + Process p = new ProcessBuilder( + "ioreg", "-rd1", "-c", "IOPlatformExpertDevice") + .redirectErrorStream(true) + .start(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) { + String line; + while ((line = br.readLine()) != null) { + if (line.contains("IOPlatformUUID")) { + int eq = line.indexOf('='); + if (eq >= 0) { + return line.substring(eq + 1).trim().replace("\"", ""); + } + } + } + } + p.waitFor(); + return null; + } + + /** Returns the first 32 hex characters of the SHA-256 hash of {@code input}. */ + private static String sha256Hex32(String input) { + try { + java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(input.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(64); + for (byte b : digest) { + sb.append(String.format("%02x", b)); + } + return sb.substring(0, 32); + } catch (java.security.NoSuchAlgorithmException e) { + throw new RuntimeException(e); // SHA-256 is mandatory in every JVM + } + } +} \ No newline at end of file diff --git a/jdm-core/src/test/java/jdiskmark/AppTest.java b/jdm-core/src/test/java/jdiskmark/AppTest.java index dcc92be..6ed10f5 100644 --- a/jdm-core/src/test/java/jdiskmark/AppTest.java +++ b/jdm-core/src/test/java/jdiskmark/AppTest.java @@ -26,6 +26,16 @@ void sharePortal_onStartup_isFalse() { "sharePortal must default to false — network activity requires explicit user opt-in each session"); } + /** + * The first-run consent flag must default to false so that a brand-new + * installation always presents the portal-upload consent dialog (issue #117). + */ + @Test + void portalConsentAsked_onStartup_isFalse() { + assertFalse(App.portalConsentAsked, + "portalConsentAsked must default to false so first-run consent dialog is shown on a new install"); + } + /** * Every AppIcon enum entry must resolve to an actual classpath resource. * This guards against icon renames or path typos that would cause the From e093c088eda11373ad353892943d2c05ab73ccc6 Mon Sep 17 00:00:00 2001 From: James Mark Chan Date: Sun, 14 Jun 2026 18:09:27 -0700 Subject: [PATCH 2/6] #117 update sharing enable messaging instructions Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- jdm-core/src/main/java/jdiskmark/App.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jdm-core/src/main/java/jdiskmark/App.java b/jdm-core/src/main/java/jdiskmark/App.java index 9bb7aa2..70a995f 100644 --- a/jdm-core/src/main/java/jdiskmark/App.java +++ b/jdm-core/src/main/java/jdiskmark/App.java @@ -592,7 +592,7 @@ public static void promptFirstRunPortalConsent() { msg("Portal upload enabled — thank you for sharing!"); } else { sharePortal = false; - msg("Portal upload declined. You can enable it later via Help \u203a Portal Upload."); + msg("Portal upload declined. You can enable it later via the Sharing tab."); } saveConfig(); // persist consent flag and choice immediately // sync the Sharing tab to reflect the resolved state From 37ec7c68c26901963adf36194cac42d0dd14daab Mon Sep 17 00:00:00 2001 From: James Mark Chan Date: Sun, 14 Jun 2026 18:10:22 -0700 Subject: [PATCH 3/6] #117 remove unused import Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- jdm-core/src/main/java/jdiskmark/SharingPanel.java | 1 - 1 file changed, 1 deletion(-) diff --git a/jdm-core/src/main/java/jdiskmark/SharingPanel.java b/jdm-core/src/main/java/jdiskmark/SharingPanel.java index c12ac57..e084d2a 100644 --- a/jdm-core/src/main/java/jdiskmark/SharingPanel.java +++ b/jdm-core/src/main/java/jdiskmark/SharingPanel.java @@ -1,7 +1,6 @@ package jdiskmark; import javax.swing.*; -import javax.swing.border.TitledBorder; import java.awt.*; /** From de46c1ff7bd4529b8ad7ba216d2362281426870b Mon Sep 17 00:00:00 2001 From: James Mark Chan Date: Sun, 14 Jun 2026 18:14:59 -0700 Subject: [PATCH 4/6] #117 improve comment accuracy on when the prompt fires Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- jdm-core/src/main/java/jdiskmark/App.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jdm-core/src/main/java/jdiskmark/App.java b/jdm-core/src/main/java/jdiskmark/App.java index 70a995f..2831eb6 100644 --- a/jdm-core/src/main/java/jdiskmark/App.java +++ b/jdm-core/src/main/java/jdiskmark/App.java @@ -566,9 +566,9 @@ public static void checkPermission() { /** * Shows the one-time first-run consent dialog for anonymous portal upload - * (issue #117). Fires only when the production endpoint is configured and - * {@link #portalConsentAsked} is {@code false}. After the user responds the - * flag is set to {@code true} and persisted so the dialog never appears again. + * (issue #117). Fires when {@link #portalConsentAsked} is {@code false}. + * After the user responds the flag is set to {@code true} and persisted so + * the dialog never appears again. */ public static void promptFirstRunPortalConsent() { String message = "" From 0f5d31b91c5dc897683c0b9c1eeb0d9abef908af Mon Sep 17 00:00:00 2001 From: James Mark Chan Date: Sun, 14 Jun 2026 19:11:56 -0700 Subject: [PATCH 5/6] #117 review feedback --- jdm-core/src/main/java/jdiskmark/App.java | 72 +------------------ .../src/main/java/jdiskmark/Benchmark.java | 3 +- jdm-core/src/main/java/jdiskmark/Gui.java | 67 ++++++++++++++++- 3 files changed, 69 insertions(+), 73 deletions(-) diff --git a/jdm-core/src/main/java/jdiskmark/App.java b/jdm-core/src/main/java/jdiskmark/App.java index 2831eb6..2177c97 100644 --- a/jdm-core/src/main/java/jdiskmark/App.java +++ b/jdm-core/src/main/java/jdiskmark/App.java @@ -472,7 +472,7 @@ public void run() { // This runs before the re-enable check so a brand-new install shows // the consent dialog rather than nothing. if (!portalConsentAsked) { - javax.swing.SwingUtilities.invokeLater(App::promptFirstRunPortalConsent); + javax.swing.SwingUtilities.invokeLater(Gui::promptFirstRunPortalConsent); } else if (sharePortalPreviouslyEnabled) { // Consent was already given and upload was active last session — // silently restore it. No need to ask again once consent is on record. @@ -564,72 +564,6 @@ public static void checkPermission() { } } - /** - * Shows the one-time first-run consent dialog for anonymous portal upload - * (issue #117). Fires when {@link #portalConsentAsked} is {@code false}. - * After the user responds the flag is set to {@code true} and persisted so - * the dialog never appears again. - */ - public static void promptFirstRunPortalConsent() { - String message = "" - + "Help improve JDiskMark by sharing your results!

" - + "Would you like to share your benchmark results with " - + "the JDiskMark community portal?

" - + "

    " - + "
  • Only benchmark metrics (speeds, block sizes, OS) are submitted.
  • " - + "
  • You can change this at any time via the Sharing tab.
  • " - + "
" - + ""; - int choice = javax.swing.JOptionPane.showConfirmDialog( - Gui.mainFrame, - new javax.swing.JLabel(message), - "Share Benchmark Results?", - javax.swing.JOptionPane.YES_NO_OPTION, - javax.swing.JOptionPane.QUESTION_MESSAGE); - portalConsentAsked = true; // mark as answered regardless of choice - if (choice == javax.swing.JOptionPane.YES_OPTION) { - sharePortal = true; - msg("Portal upload enabled — thank you for sharing!"); - } else { - sharePortal = false; - msg("Portal upload declined. You can enable it later via the Sharing tab."); - } - saveConfig(); // persist consent flag and choice immediately - // sync the Sharing tab to reflect the resolved state - if (Gui.mainFrame != null) { - Gui.mainFrame.loadPropertiesConfig(); - } - } - - /** - * Offers a one-click prompt to re-enable portal upload when it was active - * in the previous session. Called after the main window is visible so the - * dialog has a proper parent. This avoids silent outbound network activity - * while keeping the dev workflow convenient (no password re-entry required). - */ - public static void promptResumePortalUpload() { - int choice = javax.swing.JOptionPane.showConfirmDialog( - Gui.mainFrame, - "Portal upload was enabled in your last session.\nResume uploading benchmarks to " - + Portal.getUploadUrl() + "?", - "Resume Portal Upload?", - javax.swing.JOptionPane.YES_NO_OPTION, - javax.swing.JOptionPane.QUESTION_MESSAGE); - if (choice == javax.swing.JOptionPane.YES_OPTION) { - sharePortal = true; - msg("Portal upload resumed."); - } else { - sharePortal = false; - sharePortalPreviouslyEnabled = false; // clear so we don't prompt again next launch - msg("Portal upload not resumed."); - saveConfig(); // persist the cleared state - } - // sync the menu checkbox to reflect the resolved state - if (Gui.mainFrame != null) { - Gui.mainFrame.loadPropertiesConfig(); - } - } - public static void loadProfile(BenchmarkProfile profile) { try { activeProfile = profile; @@ -675,9 +609,7 @@ public static void loadConfig() { // configure settings from properties String value; - // Never silently re-enable portal upload on startup — network activity must - // always be explicitly user-confirmed each session. We remember the previous - // state only to offer a convenient one-click re-enable prompt. + // Remember previous state only to offer convenient one-click re-enable prompt. value = p.getProperty("sharePortal", "false"); sharePortalPreviouslyEnabled = Boolean.parseBoolean(value); sharePortal = false; // always start disabled; prompt offered after window visible diff --git a/jdm-core/src/main/java/jdiskmark/Benchmark.java b/jdm-core/src/main/java/jdiskmark/Benchmark.java index 7ebed9c..ef064ed 100644 --- a/jdm-core/src/main/java/jdiskmark/Benchmark.java +++ b/jdm-core/src/main/java/jdiskmark/Benchmark.java @@ -126,8 +126,7 @@ public void serialize(UUID value, JsonGenerator gen, SerializerProvider serializ @JsonSerialize(using = UuidToMongoIdSerializer.class) private UUID id; - // PII: username field disabled (#117). Defaults to "anonymous" for portal upload. - // Restore (or replace with a non-PII device/machine id) when needed. + // PII: #117 set only after a user elects to login to access their profile // @Column String username = "anonymous"; // "user" is reserved in Derby // public String getUsername() { return username; } diff --git a/jdm-core/src/main/java/jdiskmark/Gui.java b/jdm-core/src/main/java/jdiskmark/Gui.java index 0e6d65a..b6f3664 100644 --- a/jdm-core/src/main/java/jdiskmark/Gui.java +++ b/jdm-core/src/main/java/jdiskmark/Gui.java @@ -313,7 +313,72 @@ public static void showAboutDialog() { mainFrame, msgPane, "About " + App.APP_NAME, javax.swing.JOptionPane.PLAIN_MESSAGE, icon); } - + + /** + * #117 Shows the one-time first-run consent dialog for portal sharing. + * Fires when {@link App#portalConsentAsked} is {@code false}. After the user + * responds the flag is set to {@code true} and persisted so the dialog + * never appears again. + */ + public static void promptFirstRunPortalConsent() { + String message = "" + + "Help the community make smarter hardware decisions!

" + + "Your benchmark results, combined with others', help users compare real-world storage " + + "performance and identify reliability trends across drives and platforms.

" + + "Would you like to share your results with the jdiskmark.net community portal?

" + + "
    " + + "
  • Benchmark results (speeds, IOPS, latency) and hardware context (CPU, drive, OS) used to validate results.
  • " + + "
  • A non-reversible system identifier — no name or account required.
  • " + + "
  • You can change this at any time via the Sharing tab.
  • " + + "
" + + ""; + int choice = javax.swing.JOptionPane.showConfirmDialog( + mainFrame, + new javax.swing.JLabel(message), + "Share Benchmark Results?", + javax.swing.JOptionPane.YES_NO_OPTION, + javax.swing.JOptionPane.QUESTION_MESSAGE); + App.portalConsentAsked = true; // mark as answered regardless of choice + if (choice == javax.swing.JOptionPane.YES_OPTION) { + App.sharePortal = true; + App.msg("Portal upload enabled — thank you for sharing!"); + } else { + App.sharePortal = false; + App.msg("Portal upload declined. You can enable it later via the Sharing tab."); + } + App.saveConfig(); // persist consent flag and choice immediately + if (mainFrame != null) { + mainFrame.loadPropertiesConfig(); + } + } + + /** + * Offers a one-click prompt to re-enable portal upload when it was active + * in the previous session. Called after the main window is visible so the + * dialog has a proper parent. + */ + public static void promptResumePortalUpload() { + int choice = javax.swing.JOptionPane.showConfirmDialog( + mainFrame, + "Portal upload was enabled in your last session.\nResume uploading benchmarks to " + + Portal.getUploadUrl() + "?", + "Resume Portal Upload?", + javax.swing.JOptionPane.YES_NO_OPTION, + javax.swing.JOptionPane.QUESTION_MESSAGE); + if (choice == javax.swing.JOptionPane.YES_OPTION) { + App.sharePortal = true; + App.msg("Portal upload resumed."); + } else { + App.sharePortal = false; + App.sharePortalPreviouslyEnabled = false; + App.msg("Portal upload not resumed."); + App.saveConfig(); + } + if (mainFrame != null) { + mainFrame.loadPropertiesConfig(); + } + } + public static void updateChartPanelStyle() { // correct the parenthesis from being below vertical centering chart.getTitle().setFont(new Font("Verdana", Font.BOLD, 17)); From 3ee4558bd52c05ce19b3a730268f386f98218191 Mon Sep 17 00:00:00 2001 From: James Mark Chan Date: Sun, 14 Jun 2026 19:27:57 -0700 Subject: [PATCH 6/6] #117 adjust share prompt text --- jdm-core/src/main/java/jdiskmark/Gui.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jdm-core/src/main/java/jdiskmark/Gui.java b/jdm-core/src/main/java/jdiskmark/Gui.java index b6f3664..8bb24dd 100644 --- a/jdm-core/src/main/java/jdiskmark/Gui.java +++ b/jdm-core/src/main/java/jdiskmark/Gui.java @@ -323,11 +323,11 @@ public static void showAboutDialog() { public static void promptFirstRunPortalConsent() { String message = "" + "Help the community make smarter hardware decisions!

" - + "Your benchmark results, combined with others', help users compare real-world storage " + + "Your benchmark data, combined with others', help users compare real-world storage " + "performance and identify reliability trends across drives and platforms.

" + "Would you like to share your results with the jdiskmark.net community portal?

" + "
    " - + "
  • Benchmark results (speeds, IOPS, latency) and hardware context (CPU, drive, OS) used to validate results.
  • " + + "
  • Performance metrics (speeds, IOPS, latency) and hardware context (CPU, drive, OS).
  • " + "
  • A non-reversible system identifier — no name or account required.
  • " + "
  • You can change this at any time via the Sharing tab.
  • " + "
"