diff --git a/pom.xml b/pom.xml
index 17e7ae2..71f45dc 100644
--- a/pom.xml
+++ b/pom.xml
@@ -7,7 +7,7 @@
com.ajinkyagokhale
espflasher
- 1.0.3
+ 1.0.4
jar
diff --git a/src/main/java/com/ajinkyagokhale/espflasher/model/FirmwareDefinition.java b/src/main/java/com/ajinkyagokhale/espflasher/model/FirmwareDefinition.java
new file mode 100644
index 0000000..414a482
--- /dev/null
+++ b/src/main/java/com/ajinkyagokhale/espflasher/model/FirmwareDefinition.java
@@ -0,0 +1,44 @@
+package com.ajinkyagokhale.espflasher.model;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+public class FirmwareDefinition {
+
+ private final String name;
+ private final String description;
+ private final String githubRepo;
+ private final String websiteUrl;
+ private final Map chipBinMap;
+
+ // constructor
+ public FirmwareDefinition(String name, String description,
+ String githubRepo, String websiteUrl,
+ Map chipBinMap) {
+ this.name = name;
+ this.description = description;
+ this.githubRepo = githubRepo;
+ this.websiteUrl = websiteUrl;
+ this.chipBinMap = chipBinMap == null
+ ? Collections.emptyMap()
+ : Collections.unmodifiableMap(new LinkedHashMap<>(chipBinMap));
+ }
+
+ public String getName() { return name; }
+ public String getDescription() { return description; }
+ public String getGithubRepo() { return githubRepo; }
+ public String getWebsiteUrl() { return websiteUrl; }
+ @SuppressFBWarnings(value = "EI_EXPOSE_REP",
+ justification = "chipBinMap is an unmodifiableMap wrapping a defensive copy; safe to expose")
+ public Map getChipBinMap() { return chipBinMap; }
+
+ public String getBinForChip(String chip) {
+ return chipBinMap.getOrDefault(chip, chipBinMap.get("default"));
+ }
+
+ @Override
+ public String toString() { return name; }
+}
\ No newline at end of file
diff --git a/src/main/java/com/ajinkyagokhale/espflasher/service/FirmwareCatalog.java b/src/main/java/com/ajinkyagokhale/espflasher/service/FirmwareCatalog.java
new file mode 100644
index 0000000..8699c13
--- /dev/null
+++ b/src/main/java/com/ajinkyagokhale/espflasher/service/FirmwareCatalog.java
@@ -0,0 +1,54 @@
+package com.ajinkyagokhale.espflasher.service;
+
+import com.ajinkyagokhale.espflasher.model.FirmwareDefinition;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+public class FirmwareCatalog {
+
+ public static List getCatalog() {
+ return List.of(
+ new FirmwareDefinition(
+ "Tasmota",
+ "Universal smart home firmware with web UI and MQTT",
+ "arendst/Tasmota",
+ "https://tasmota.github.io",
+ tasmotaBins()
+ ),
+ new FirmwareDefinition(
+ "Tasmota SML (ottelo9)",
+ "Tasmota with SML support for smart meter reading",
+ "ottelo9/tasmota-sml-images",
+ "https://github.com/ottelo9/tasmota-sml-images",
+ tasmotaSmlBins()
+ )
+ );
+ }
+
+ private static Map tasmotaBins() {
+ LinkedHashMap m = new LinkedHashMap<>();
+ m.put("esp32c6", "tasmota32c6.bin");
+ m.put("esp32", "tasmota32.bin");
+ m.put("esp32s2", "tasmota32s2.bin");
+ m.put("esp32s3", "tasmota32s3.bin");
+ m.put("esp32c3", "tasmota32c3.bin");
+ m.put("esp8266", "tasmota.bin");
+ m.put("default", "tasmota32.bin");
+ return m;
+ }
+
+ private static Map tasmotaSmlBins() {
+ LinkedHashMap m = new LinkedHashMap<>();
+ m.put("esp32c6", "tasmota32c6_ottelo_tas.zip");
+ m.put("esp32", "tasmota32_ottelo_tas.zip");
+ m.put("esp32s2", "tasmota32s2_ottelo_tas.zip");
+ m.put("esp32s3", "tasmota32s3_ottelo_tas.zip");
+ m.put("esp32c3", "tasmota32c3_ottelo_tas.zip");
+ m.put("esp8266", "tasmota8266_bundle_ottelo.zip");
+ m.put("default", "tasmota32_ottelo_tas.zip");
+ return m;
+ }
+
+}
diff --git a/src/main/java/com/ajinkyagokhale/espflasher/service/FirmwareDownloader.java b/src/main/java/com/ajinkyagokhale/espflasher/service/FirmwareDownloader.java
new file mode 100644
index 0000000..113087d
--- /dev/null
+++ b/src/main/java/com/ajinkyagokhale/espflasher/service/FirmwareDownloader.java
@@ -0,0 +1,403 @@
+package com.ajinkyagokhale.espflasher.service;
+
+import com.ajinkyagokhale.espflasher.model.FirmwareDefinition;
+
+import java.io.*;
+import java.net.URI;
+import java.net.http.*;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.*;
+import java.util.Properties;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.Map;
+import java.util.function.Consumer;
+
+public class FirmwareDownloader {
+
+ private static final String CACHE_DIR =
+ System.getProperty("user.home") + "/.esp-flasher/firmware-cache";
+
+ private static final Path VERSION_CACHE_FILE =
+ Paths.get(System.getProperty("user.home"), ".esp-flasher", "version-cache.properties");
+
+ // 12-hour TTL — long enough to avoid the rate limit, short enough to catch new releases
+ private static final long VERSION_TTL_MS = 12L * 60L * 60L * 1000L;
+
+ // In-memory version cache — populated on first fetch, kept for app lifetime
+ private final Map versionCache = new ConcurrentHashMap<>();
+
+ private Properties diskCache;
+ private boolean diskCacheLoaded;
+
+ // Optional sink for diagnostic messages (e.g. UI log area). Null = silent.
+ private Consumer logger;
+
+ public void setLogger(Consumer logger) {
+ this.logger = logger;
+ }
+
+ private void log(String msg) {
+ if (logger != null) logger.accept(msg);
+ }
+
+ // ── Internet check ────────────────────────────────────
+ public boolean isOnline() {
+ try {
+ HttpClient client = HttpClient.newBuilder()
+ .connectTimeout(java.time.Duration.ofSeconds(3))
+ .build();
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("https://api.github.com"))
+ .GET()
+ .build();
+ HttpResponse response = client.send(
+ request, HttpResponse.BodyHandlers.discarding()
+ );
+ return response.statusCode() < 500;
+ } catch (java.io.IOException | InterruptedException e) {
+ if (e instanceof InterruptedException) Thread.currentThread().interrupt();
+ return false;
+ }
+ }
+
+ // ── Fetch latest version (Atom feed first, REST fallback, disk-cached) ─────
+ public String fetchLatestVersion(FirmwareDefinition firmware) {
+ String repo = firmware.getGithubRepo();
+
+ // 1. In-memory cache (this session)
+ String cached = versionCache.get(repo);
+ if (cached != null) return cached;
+
+ // 2. Disk cache (survives restarts, 12h TTL)
+ String fromDisk = readDiskCache(repo);
+ if (fromDisk != null) {
+ versionCache.put(repo, fromDisk);
+ return fromDisk;
+ }
+
+ // 3. Atom feeds — not rate-limited, no auth needed
+ String tag = fetchAtomTag(repo, "releases.atom");
+ if (tag == null) tag = fetchAtomTag(repo, "tags.atom");
+
+ if (tag == null) return "unknown";
+
+ if (tag.startsWith("v") || tag.startsWith("V")) tag = tag.substring(1);
+ versionCache.put(repo, tag);
+ writeDiskCache(repo, tag);
+ return tag;
+ }
+
+ private String fetchAtomTag(String repo, String suffix) {
+ try {
+ String url = "https://github.com/" + repo + "/" + suffix;
+ HttpClient client = HttpClient.newBuilder()
+ .connectTimeout(java.time.Duration.ofSeconds(5))
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .build();
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .header("Accept", "application/atom+xml")
+ .build();
+
+ HttpResponse response = client.send(
+ request, HttpResponse.BodyHandlers.ofString()
+ );
+
+ if (response.statusCode() != 200) {
+ log("[version] " + repo + " — " + suffix + " HTTP " + response.statusCode());
+ return null;
+ }
+
+ String body = response.body();
+
+ // Prefer the /releases/tag/ URL fragment (present in releases.atom)
+ String marker = "/releases/tag/";
+ int idx = body.indexOf(marker);
+ if (idx != -1) {
+ int start = idx + marker.length();
+ int end = start;
+ while (end < body.length()) {
+ char c = body.charAt(end);
+ if (c == '"' || c == '<' || c == ' ' || c == '\n' || c == '\r') break;
+ end++;
+ }
+ String tag = body.substring(start, end);
+ if (!tag.isEmpty()) return tag;
+ }
+
+ // Fallback: first 's (works for tags.atom and unusual releases.atom)
+ int entryIdx = body.indexOf("", entryIdx);
+ if (titleStart == -1) return null;
+ titleStart += "".length();
+ int titleEnd = body.indexOf("", titleStart);
+ if (titleEnd == -1) return null;
+ String tag = body.substring(titleStart, titleEnd).trim();
+ return tag.isEmpty() ? null : tag;
+
+ } catch (Exception e) {
+ log("[version] " + repo + " — " + suffix + " fetch failed: "
+ + e.getClass().getSimpleName() + ": " + e.getMessage());
+ return null;
+ }
+ }
+
+ // ── Disk-cache helpers ─────────────────────────────────
+ private synchronized String readDiskCache(String repo) {
+ loadDiskCacheIfNeeded();
+ String entry = diskCache.getProperty(repo);
+ if (entry == null) return null;
+ int sep = entry.indexOf('|');
+ if (sep <= 0) return null;
+ try {
+ long ts = Long.parseLong(entry.substring(0, sep));
+ if (System.currentTimeMillis() - ts > VERSION_TTL_MS) return null;
+ String version = entry.substring(sep + 1);
+ return version.isEmpty() ? null : version;
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ private synchronized void writeDiskCache(String repo, String version) {
+ loadDiskCacheIfNeeded();
+ diskCache.setProperty(repo, System.currentTimeMillis() + "|" + version);
+ try {
+ Path parent = VERSION_CACHE_FILE.getParent();
+ if (parent != null) Files.createDirectories(parent);
+ try (OutputStream out = Files.newOutputStream(VERSION_CACHE_FILE)) {
+ diskCache.store(out, "ESP Flasher firmware version cache");
+ }
+ } catch (IOException e) {
+ log("[version] could not persist cache: " + e.getMessage());
+ }
+ }
+
+ private void loadDiskCacheIfNeeded() {
+ if (diskCacheLoaded) return;
+ diskCache = new Properties();
+ if (Files.exists(VERSION_CACHE_FILE)) {
+ try (InputStream in = Files.newInputStream(VERSION_CACHE_FILE)) {
+ diskCache.load(in);
+ } catch (IOException e) {
+ log("[version] could not read cache: " + e.getMessage());
+ }
+ }
+ diskCacheLoaded = true;
+ }
+
+ // ── Fetch download URL for specific bin ──────────────
+ public String fetchDownloadUrl(FirmwareDefinition firmware, String chip) {
+ String repo = firmware.getGithubRepo();
+ String binName = firmware.getBinForChip(chip);
+ if (binName == null) return null;
+
+ // GitHub's "/releases/latest/download/" is a public redirect — no API, no rate limits.
+ // The actual download() call follows the redirect to the asset's S3 URL.
+ String directUrl = "https://github.com/" + repo + "/releases/latest/download/" + binName;
+ if (assetExists(directUrl)) return directUrl;
+
+ // Fallback: parse releases.atom to find the latest tag, then build a tagged-asset URL.
+ String tag = fetchAtomTag(repo, "releases.atom");
+ if (tag != null) {
+ String taggedUrl = "https://github.com/" + repo + "/releases/download/" + tag + "/" + binName;
+ if (assetExists(taggedUrl)) return taggedUrl;
+ log("[download] asset '" + binName + "' not found at " + taggedUrl);
+ }
+
+ log("[download] no published asset named '" + binName + "' for " + repo);
+ return null;
+ }
+
+ /** Returns true if a GET (HEAD isn't always honoured by GitHub redirects) lands on a 2xx response. */
+ private boolean assetExists(String url) {
+ try {
+ HttpClient client = HttpClient.newBuilder()
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .connectTimeout(java.time.Duration.ofSeconds(5))
+ .build();
+ HttpRequest req = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .method("HEAD", HttpRequest.BodyPublishers.noBody())
+ .build();
+ HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.discarding());
+ return resp.statusCode() >= 200 && resp.statusCode() < 300;
+ } catch (Exception e) {
+ log("[download] assetExists check failed: " + e.getMessage());
+ return false;
+ }
+ }
+
+ // ── Download bin file ────────────────────────────────
+ public String download(String downloadUrl, String fileName,
+ DownloadListener listener) throws Exception {
+ // create cache dir
+ Files.createDirectories(Paths.get(CACHE_DIR));
+ String destPath = CACHE_DIR + "/" + fileName;
+
+ HttpClient client = HttpClient.newBuilder()
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .build();
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(downloadUrl))
+ .build();
+
+ HttpResponse response = client.send(
+ request, HttpResponse.BodyHandlers.ofInputStream()
+ );
+
+ if (response.statusCode() >= 400) {
+ throw new Exception("HTTP " + response.statusCode() + " downloading " + downloadUrl);
+ }
+
+ long totalBytes = response.headers()
+ .firstValueAsLong("content-length")
+ .orElse(-1);
+
+ try (InputStream in = response.body();
+ OutputStream out = new FileOutputStream(destPath)) {
+
+ byte[] buffer = new byte[8192];
+ long downloaded = 0;
+ int read;
+
+ while ((read = in.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ downloaded += read;
+ if (listener != null) {
+ listener.onProgress(downloaded, totalBytes);
+ }
+ }
+ }
+
+ return destPath;
+ }
+
+ // ── Listener interface ───────────────────────────────
+ public interface DownloadListener {
+ void onProgress(long downloaded, long total);
+ }
+
+ public String downloadAndExtract(String downloadUrl, String zipName,
+ DownloadListener listener) throws Exception {
+ return downloadAndExtract(downloadUrl, zipName, null, null, listener);
+ }
+
+ public String downloadAndExtract(String downloadUrl, String zipName, String chip,
+ String version, DownloadListener listener) throws Exception {
+
+ String dirName = (version != null && !version.isBlank())
+ ? zipName.replace(".zip", "") + "-v" + version
+ : zipName.replace(".zip", "");
+ String extractDir = CACHE_DIR + "/" + dirName;
+
+ // ── Cache hit: extracted dir already has the bin for this chip ─────
+ File existing = new File(extractDir);
+ if (existing.isDirectory()) {
+ String hit = pickBinForChip(existing, chip);
+ if (hit != null) {
+ log("[download] cache hit: " + hit);
+ return hit;
+ }
+ }
+
+ String zipPath = download(downloadUrl, zipName, listener);
+ log("[download] saved " + zipPath + " (" + new File(zipPath).length() + " bytes)");
+
+ long size = new File(zipPath).length();
+ if (size < 1024) {
+ // Likely an HTML error page, not a zip
+ String preview = new String(
+ Files.readAllBytes(Paths.get(zipPath)),
+ StandardCharsets.UTF_8
+ );
+ throw new Exception("Downloaded file is too small to be a zip ("
+ + size + " bytes). Content: " + preview.substring(0, Math.min(200, preview.length())));
+ }
+
+ Files.createDirectories(Paths.get(extractDir));
+
+ java.util.List entries = new java.util.ArrayList<>();
+
+ try (java.util.zip.ZipInputStream zis = new java.util.zip.ZipInputStream(
+ new FileInputStream(zipPath))) {
+
+ java.util.zip.ZipEntry entry;
+ while ((entry = zis.getNextEntry()) != null) {
+ if (entry.isDirectory()) continue;
+ entries.add(entry.getName() + " (" + entry.getSize() + " bytes)");
+
+ String name = entry.getName();
+ String lower = name.toLowerCase();
+ if (lower.endsWith(".bin")) {
+ Path out = Paths.get(extractDir, new File(name).getName());
+ try (OutputStream os = new FileOutputStream(out.toString())) {
+ zis.transferTo(os);
+ }
+ }
+ }
+ }
+
+ String picked = pickBinForChip(new File(extractDir), chip);
+ if (picked != null) return picked;
+
+ log("[download] zip entries: " + entries);
+ boolean requireFactory = zipName.toLowerCase().contains("ottelo");
+ throw new Exception((requireFactory ? "No factory .bin" : "No .bin")
+ + " for chip '" + chip + "' inside " + zipName + ". Entries: " + entries);
+ }
+
+ /**
+ * Pick the best matching factory bin in an extracted dir for the given chip.
+ * Preference order: factory bin matching chip → any bin matching chip → any factory bin.
+ * Returns null if the directory has no bin we'd be confident flashing.
+ */
+ private String pickBinForChip(File dir, String chip) {
+ File[] files = dir.listFiles();
+ if (files == null) return null;
+
+ File factoryForChip = null;
+ File anyForChip = null;
+
+ for (File f : files) {
+ String lower = f.getName().toLowerCase();
+ if (!lower.endsWith(".bin")) continue;
+ if (!binMatchesChip(lower, chip)) continue; // never flash a bin for the wrong chip
+ if (lower.contains("factory") && factoryForChip == null) factoryForChip = f;
+ else if (anyForChip == null) anyForChip = f;
+ }
+
+ if (factoryForChip != null) return factoryForChip.getAbsolutePath();
+ return anyForChip != null ? anyForChip.getAbsolutePath() : null;
+ }
+
+ private static boolean binMatchesChip(String binNameLower, String chip) {
+ if (chip == null || chip.isBlank() || chip.equals("auto")) return false;
+ if (chip.equals("esp8266")) {
+ // tasmota.bin / *8266*.bin — exclude any tasmota32* variants
+ return binNameLower.contains("8266")
+ || (binNameLower.contains("tasmota") && !binNameLower.contains("tasmota32"));
+ }
+ if (chip.equals("esp32")) {
+ // match "32" but NOT followed by a chip-family letter (c/s/h)
+ int i = binNameLower.indexOf("32");
+ while (i != -1) {
+ int next = i + 2;
+ if (next >= binNameLower.length()) return true;
+ char c = binNameLower.charAt(next);
+ if (c != 'c' && c != 's' && c != 'h') return true;
+ i = binNameLower.indexOf("32", next);
+ }
+ return false;
+ }
+ // esp32c6, esp32c3, esp32s2, esp32s3, esp32h2 → match "32c6", "32c3", etc.
+ if (chip.startsWith("esp32") && chip.length() > 5) {
+ return binNameLower.contains("32" + chip.substring(5));
+ }
+ return binNameLower.contains(chip);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/ajinkyagokhale/espflasher/ui/FlasherApp.java b/src/main/java/com/ajinkyagokhale/espflasher/ui/FlasherApp.java
index 544727f..73d09d9 100644
--- a/src/main/java/com/ajinkyagokhale/espflasher/ui/FlasherApp.java
+++ b/src/main/java/com/ajinkyagokhale/espflasher/ui/FlasherApp.java
@@ -1,1247 +1,1524 @@
-package com.ajinkyagokhale.espflasher.ui;
-
-import com.ajinkyagokhale.espflasher.listener.FlashListener;
-import com.ajinkyagokhale.espflasher.listener.PortListener;
-import com.ajinkyagokhale.espflasher.model.AppSettings;
-import com.ajinkyagokhale.espflasher.model.FlashConfig;
-import com.ajinkyagokhale.espflasher.model.FlashResult;
-import com.ajinkyagokhale.espflasher.service.*;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
-
-import javafx.application.Application;
-import javafx.application.Platform;
-import javafx.geometry.Insets;
-import javafx.geometry.Pos;
-import javafx.scene.Scene;
-import javafx.scene.control.*;
-import javafx.scene.control.Button;
-import javafx.scene.control.Label;
-import javafx.scene.control.TextArea;
-import javafx.scene.control.TextField;
-import javafx.scene.layout.HBox;
-import javafx.scene.layout.Priority;
-import javafx.scene.layout.StackPane;
-import javafx.scene.layout.VBox;
-import javafx.stage.FileChooser;
-import javafx.stage.Modality;
-import javafx.stage.Stage;
-
-import javafx.scene.media.AudioClip;
-
-import java.awt.*;
-import java.io.File;
-import java.nio.charset.StandardCharsets;
-import java.util.List;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-public class FlasherApp extends Application implements FlashListener, PortListener {
- // UI Controls
- private TextField binPathField;
- private ComboBox chipCombo;
- private ComboBox portCombo;
- private ComboBox baudCombo;
- private TextField flashOffsetField;
- private ProgressBar progressBar;
- private TextArea logArea;
- private Button flashButton;
- private Button stopButton;
- private Button factoryButton;
- private Label statusLabel;
-
- // Services
- private EsptoolRunner esptoolRunner;
- private PortWatcher portWatcher;
- private PrereqChecker prereqChecker;
-
- //audio clips
- private AudioClip successSound;
- private AudioClip failSound;
-
- //flashcounter
- private int flashCount = 0;
- private Label flashCountLabel;
- private Label flashCountNumber;
- private boolean isFactoryMode = false;
-
-//settings
- private SettingsManager settingsManager;
- private FlashLogger flashLogger;
-
- // tracks state across flash lifecycle
- private String currentFlashPort;
- private String currentFlashMac;
-
- @Override
- public void onProgress(int percent) {
- Platform.runLater(() -> {
- progressBar.setProgress(percent / 100.0);
- });
- }
-
- @Override
- public void onLog(String line) {
- if (line.contains("MAC:")) {
- String[] parts = line.split("MAC:");
- if (parts.length > 1) currentFlashMac = parts[1].trim();
- }
- Platform.runLater(() -> logArea.appendText(line + "\n"));
- }
-
- @Override
- public void onComplete(boolean success, String message) {
- String timestamp = java.time.LocalDateTime.now()
- .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
- flashLogger.append(new FlashResult(success, message, currentFlashMac, currentFlashPort, timestamp));
-
- Platform.runLater(() -> {
- statusLabel.setText(message);
- flashButton.setDisable(false);
- factoryButton.setDisable(false);
- stopButton.setDisable(true);
- if (success) {
- flashCount++;
- flashCountNumber.setText(String.valueOf(flashCount));
- flashCountLabel.setStyle("-fx-background-color: rgba(102,187,106,0.25); -fx-border-color: #66bb6a;");
- new javafx.animation.Timeline(
- new javafx.animation.KeyFrame(javafx.util.Duration.millis(800),
- ev -> flashCountLabel.setStyle(""))
- ).play();
- progressBar.setStyle("-fx-accent: green;");
- progressBar.setProgress(1.0);
- successSound.play();
- statusLabel.setStyle("-fx-text-fill: green;");
- } else {
- failSound.play();
- progressBar.setStyle("-fx-accent: red;");
- progressBar.setProgress(1.0);
- statusLabel.setStyle("-fx-text-fill: red;");
- }
-
- if (isFactoryMode) {
- statusLabel.setText("Waiting for next device...");
- statusLabel.setStyle("-fx-text-fill: #66bb6a;");
- flashButton.setDisable(true);
- stopButton.setDisable(false);
- }
- });
- }
-
- @Override
- public void onNewPort(String portName) {
- Platform.runLater(() -> {
- statusLabel.setText("Device detected: " + portName + " — flashing...");
- startFlash(portName);
- });
- }
-
- private void showPythonMissingDialog() {
- flashButton.setDisable(true);
- factoryButton.setDisable(true);
-
- Stage dialog = new Stage();
- dialog.initModality(Modality.APPLICATION_MODAL);
- dialog.setTitle("Python Not Found");
- dialog.setResizable(false);
-
- Label heading = new Label("Python 3 is required to run esptool.");
- heading.setStyle("-fx-font-weight: bold; -fx-font-size: 13px;");
-
- String os = System.getProperty("os.name", "").toLowerCase();
- String steps;
- if (os.contains("win")) {
- steps = """
- 1. Go to https://python.org/downloads
- 2. Download the latest Python 3 installer for Windows.
- 3. Run the installer — check "Add Python to PATH" before clicking Install.
- 4. Restart ESP Flasher after installation.""";
- } else if (os.contains("mac")) {
- steps = """
- Option A — Homebrew (recommended):
- brew install python3
-
- Option B — Installer:
- 1. Go to https://python.org/downloads
- 2. Download and run the macOS installer.
- 3. Restart ESP Flasher after installation.""";
- } else {
- steps = """
- Debian / Ubuntu:
- sudo apt install python3 python3-pip
-
- Fedora / RHEL:
- sudo dnf install python3
-
- Arch:
- sudo pacman -S python
-
- Restart ESP Flasher after installation.""";
- }
-
- TextArea guide = new TextArea(steps);
- guide.setEditable(false);
- guide.setWrapText(true);
- guide.setPrefHeight(140);
- guide.setPrefWidth(400);
- guide.setStyle("-fx-font-family: monospace; -fx-font-size: 11px;");
-
- Button downloadBtn = new Button("Open python.org");
- downloadBtn.setOnAction(e -> getHostServices().showDocument("https://python.org/downloads"));
-
- Button closeBtn = new Button("Close");
- closeBtn.setOnAction(e -> dialog.close());
-
- HBox buttons = new HBox(10, downloadBtn, closeBtn);
- buttons.setAlignment(Pos.CENTER_RIGHT);
-
- VBox root = new VBox(12, heading, guide, buttons);
- root.setPadding(new Insets(16));
-
- dialog.setScene(new Scene(root));
- dialog.showAndWait();
-
- statusLabel.setText("⚠ Python not found — install Python and restart.");
- statusLabel.setStyle("-fx-text-fill: red;");
- }
-
- private void autoInstallEsptool() {
- showEsptoolInstallDialog();
- }
-
- private void startFlash(String overridePort) {
-
- //guard
- if (!prereqChecker.isReady()) {
- statusLabel.setText("esptool not found. Please install it first.");
- return;
- }
- //factory mode
- String port = overridePort != null ? overridePort : portCombo.getValue();
-
- // 1. validate inputs
- String binPath = binPathField.getText();
- if (binPath.isEmpty()) {
- statusLabel.setText("Please select a firmware file.");
- return;
- }
-
- //String port = portCombo.getValue();
- if (port == null || port.isEmpty()) {
- statusLabel.setText("Please select a port.");
- return;
- }
-
- // 2. build FlashConfig
- FlashConfig config = new FlashConfig(
- chipCombo.getValue(),
- Integer.parseInt(baudCombo.getValue()),
- port,
- binPath,
- flashOffsetField.getText(),
- prereqChecker.getEsptoolCmd()
- );
-
- // 3. update UI
- flashButton.setDisable(true);
- stopButton.setDisable(false);
- progressBar.setStyle("");
- progressBar.setProgress(0);
- statusLabel.setText("Flashing...");
-
- // 4. start flashing
- currentFlashPort = port;
- currentFlashMac = null;
- esptoolRunner.startFlashing(config, this);
- }
-
- private void startFactoryMode() {
-
- if (binPathField.getText().isEmpty()) {
- Alert alert = new Alert(Alert.AlertType.WARNING);
- alert.setTitle("No Firmware Selected");
- alert.setHeaderText(null);
- alert.setContentText("Please select a firmware .bin file before starting factory mode.");
- alert.showAndWait();
- return;
- }
- if (!prereqChecker.isReady()) {
- statusLabel.setText("esptool not ready.");
- return;
- }
-
-
- isFactoryMode = true;
-
- flashButton.setDisable(true);
- factoryButton.setText("Stop Factory");
- factoryButton.setOnAction(e -> stopAll());
- stopButton.setDisable(false);
- statusLabel.setText("Factory mode — waiting for device...");
- statusLabel.setStyle("-fx-text-fill: #66bb6a;");
-
-
-
- //confirmation box
- // confirmation dialog
- Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
- confirm.setTitle("Start Factory Mode");
- confirm.setHeaderText("Ready to flash?");
-
-// big font bin file name
- Label binLabel = new Label(new File(binPathField.getText()).getName());
- binLabel.getStyleClass().add("confirm-bin-name");
-
- Label chipLabel = new Label("Chip: " + chipCombo.getValue() + " Baud: " + baudCombo.getValue());
- chipLabel.getStyleClass().add("confirm-details");
-
- VBox content = new VBox(8, binLabel, chipLabel);
- confirm.getDialogPane().setContent(content);
- // add this here ↓
- confirm.getDialogPane().getStylesheets().add(
- getClass().getResource("/styles.css").toExternalForm()
- );
- confirm.setGraphic(null); //
- ButtonType startBtn = new ButtonType("Start Factory", ButtonBar.ButtonData.OK_DONE);
- ButtonType cancelBtn = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
- confirm.getButtonTypes().setAll(startBtn, cancelBtn);
-
- Optional result = confirm.showAndWait();
- if (result.isEmpty() || result.get() != startBtn) return; // cancelled
-
-
- portWatcher.startWatching(this);
- }
-
- private void stopAll() {
- portWatcher.stopWatching();
- esptoolRunner.stopFlashing();
- isFactoryMode = false;
- flashButton.setDisable(false);
- factoryButton.setText("Factory Mode");
- factoryButton.setOnAction(e -> startFactoryMode());
- stopButton.setDisable(true);
- statusLabel.setText("Stopped.");
- statusLabel.setStyle("");
- }
-
-
-// private void showSettingsDialog(Stage owner) {
-// Stage dialog = new Stage();
-// dialog.initOwner(owner);
-// dialog.initModality(Modality.APPLICATION_MODAL);
-// dialog.setTitle("Settings");
-//
-// // ── Detected paths (read-only) ──────────────────────────────────
-// Label detectedHeading = new Label("Detected paths");
-// detectedHeading.setStyle("-fx-font-weight: bold;");
-//
-// Label detectedPython = detectedPathLabel("Python", prereqChecker.getPythonCmd());
-// Label detectedPip = detectedPathLabel("pip", prereqChecker.getPipCmd());
-// Label detectedEsptool = detectedPathLabel("esptool", prereqChecker.getEsptoolCmd());
-//
-// VBox detectedBox = new VBox(4, detectedPython, detectedPip, detectedEsptool);
-// detectedBox.setStyle("-fx-background-color: rgba(255,255,255,0.04); "
-// + "-fx-border-color: rgba(255,255,255,0.08); "
-// + "-fx-border-radius: 6; -fx-background-radius: 6; -fx-padding: 8;");
-//
-// // ── Custom overrides ────────────────────────────────────────────
-// Label overrideHeading = new Label("Custom overrides (leave blank to use auto-detect)");
-// overrideHeading.setStyle("-fx-font-weight: bold;");
-//
-// TextField pythonField = new TextField(PrereqChecker.getCustomPythonPath());
-// pythonField.setPromptText("e.g. C:\\Python313\\python.exe");
-// pythonField.setPrefWidth(400);
-// Button pythonBrowse = new Button("Browse...");
-// pythonBrowse.setOnAction(e -> {
-// FileChooser fc = new FileChooser();
-// fc.setTitle("Select Python executable");
-// File f = fc.showOpenDialog(dialog);
-// if (f != null) pythonField.setText(f.getAbsolutePath());
-// });
-//
-// TextField esptoolField = new TextField(PrereqChecker.getCustomEsptoolPath());
-// esptoolField.setPromptText("e.g. C:\\Tools\\esptool.exe");
-// esptoolField.setPrefWidth(400);
-// Button esptoolBrowse = new Button("Browse...");
-// esptoolBrowse.setOnAction(e -> {
-// FileChooser fc = new FileChooser();
-// fc.setTitle("Select esptool executable");
-// File f = fc.showOpenDialog(dialog);
-// if (f != null) esptoolField.setText(f.getAbsolutePath());
-// });
-//
-// Label status = new Label();
-//
-// Button save = new Button("Save & Recheck");
-// save.setOnAction(e -> {
-// PrereqChecker.setCustomPaths(pythonField.getText(), esptoolField.getText());
-// status.setText("Rechecking...");
-// new Thread(() -> {
-// prereqChecker.checkAll();
-// Platform.runLater(() -> {
-// detectedPython.setText(detectedText("Python", prereqChecker.getPythonCmd()));
-// detectedPip.setText(detectedText("pip", prereqChecker.getPipCmd()));
-// detectedEsptool.setText(detectedText("esptool", prereqChecker.getEsptoolCmd()));
-// if (prereqChecker.isReady()) {
-// status.setText("✓ Ready.");
-// status.setStyle("-fx-text-fill: #66bb6a;");
-// statusLabel.setText("Ready.");
-// statusLabel.setStyle("");
-// } else {
-// status.setText("✗ esptool not found. Check paths.");
-// status.setStyle("-fx-text-fill: red;");
-// }
-// });
-// }).start();
-// });
-// Button close = new Button("Close");
-// close.setOnAction(e -> dialog.close());
-//
-// HBox actions = new HBox(10, save, close);
-// actions.setAlignment(Pos.CENTER_RIGHT);
-//
-// VBox content = new VBox(12,
-// detectedHeading,
-// detectedBox,
-// new Separator(),
-// overrideHeading,
-// new Label("Python:"),
-// new HBox(8, pythonField, pythonBrowse),
-// new Label("esptool:"),
-// new HBox(8, esptoolField, esptoolBrowse),
-// status,
-// actions
-// );
-// content.setPadding(new Insets(16));
-//
-// dialog.setScene(new Scene(content));
-// dialog.show();
-// }
-private void showSettingsDialog(Stage owner) {
- Stage dialog = new Stage();
- dialog.initOwner(owner);
- dialog.initModality(Modality.APPLICATION_MODAL);
- dialog.setTitle("Settings");
- dialog.setResizable(false);
-
- // ── Left menu ──────────────────────────────────────
- VBox leftMenu = new VBox(0);
- leftMenu.getStyleClass().add("settings-menu");
- leftMenu.setPrefWidth(150);
-
- Label menuTitle = new Label("Settings");
- menuTitle.getStyleClass().add("settings-menu-title");
- menuTitle.setPadding(new Insets(16));
-
- Button generalBtn = new Button("General");
- generalBtn.getStyleClass().add("settings-menu-item");
- generalBtn.setMaxWidth(Double.MAX_VALUE);
-
- Button pathsBtn = new Button("Paths");
- pathsBtn.getStyleClass().add("settings-menu-item");
- pathsBtn.setMaxWidth(Double.MAX_VALUE);
-
- Button logFileBtn = new Button("Log File");
- logFileBtn.getStyleClass().add("settings-menu-item");
- logFileBtn.setMaxWidth(Double.MAX_VALUE);
-
- leftMenu.getChildren().addAll(menuTitle, generalBtn, pathsBtn, logFileBtn);
-
- // ── Right panels ────────────────────────────────────
- VBox generalPane = buildGeneralPane();
- VBox pathsPane = buildPathsPane(dialog);
- VBox logFilePane = buildLogFilePane(dialog);
-
- pathsPane.setVisible(false);
- logFilePane.setVisible(false);
-
- StackPane rightContent = new StackPane(generalPane, pathsPane, logFilePane);
- rightContent.setPadding(new Insets(20));
- rightContent.setPrefWidth(370);
-
- // ── Menu click handlers ─────────────────────────────
- generalBtn.setOnAction(e -> {
- generalPane.setVisible(true);
- pathsPane.setVisible(false);
- logFilePane.setVisible(false);
- setActiveMenu(generalBtn, pathsBtn, logFileBtn);
- });
- pathsBtn.setOnAction(e -> {
- generalPane.setVisible(false);
- pathsPane.setVisible(true);
- logFilePane.setVisible(false);
- setActiveMenu(pathsBtn, generalBtn, logFileBtn);
- });
- logFileBtn.setOnAction(e -> {
- generalPane.setVisible(false);
- pathsPane.setVisible(false);
- logFilePane.setVisible(true);
- setActiveMenu(logFileBtn, generalBtn, pathsBtn);
- });
-
- // General active by default
- generalBtn.getStyleClass().add("settings-menu-item-active");
-
- // ── Bottom bar ──────────────────────────────────────
- Button saveBtn = new Button("Save");
- saveBtn.getStyleClass().add("btn-primary");
- saveBtn.setOnAction(e -> {
- settingsManager.save();
- dialog.close();
- });
-
- Button cancelBtn = new Button("Cancel");
- cancelBtn.getStyleClass().add("btn-secondary");
- cancelBtn.setOnAction(e -> dialog.close());
-
- HBox bottomBar = new HBox(10, cancelBtn, saveBtn);
- bottomBar.setAlignment(Pos.CENTER_RIGHT);
- bottomBar.setPadding(new Insets(10, 20, 16, 20));
-
- // ── Main layout ─────────────────────────────────────
- HBox mainLayout = new HBox(leftMenu, rightContent);
- VBox root = new VBox(mainLayout, bottomBar);
- root.getStyleClass().add("settings-root");
-
- Scene scene = new Scene(root, 520, 380);
- scene.getStylesheets().add(
- Objects.requireNonNull(getClass().getResource("/styles.css")).toExternalForm()
- );
- dialog.setScene(scene);
- dialog.show();
-}
-
- // helper — sets active style on clicked button
- private void setActiveMenu(Button active, Button... others) {
- active.getStyleClass().add("settings-menu-item-active");
- for (Button b : others) b.getStyleClass().remove("settings-menu-item-active");
- }
-
- // ── General pane ────────────────────────────────────────
- private VBox buildGeneralPane() {
- VBox pane = new VBox(12);
-
- Label heading = new Label("General");
- heading.setStyle("-fx-font-weight: bold; -fx-font-size: 14px;");
-
- Label info = new Label("Last used settings are saved automatically.");
- info.setStyle("-fx-text-fill: #888888;");
-
- pane.getChildren().addAll(heading, info);
- return pane;
- }
-
- // ── Paths pane — moved from old showSettingsDialog ───────
- private VBox buildPathsPane(Stage dialog) {
- VBox pane = new VBox(12);
-
- Label heading = new Label("Detected Paths");
- heading.setStyle("-fx-font-weight: bold; -fx-font-size: 14px;");
-
- Label detectedPython = detectedPathLabel("Python", prereqChecker.getPythonCmd());
- Label detectedPip = detectedPathLabel("pip", prereqChecker.getPipCmd());
- Label detectedEsptool = detectedPathLabel("esptool", prereqChecker.getEsptoolCmd());
-
- VBox detectedBox = new VBox(4, detectedPython, detectedPip, detectedEsptool);
- detectedBox.setStyle("-fx-background-color: rgba(255,255,255,0.04); "
- + "-fx-border-color: rgba(255,255,255,0.08); "
- + "-fx-border-radius: 6; -fx-background-radius: 6; -fx-padding: 8;");
-
- Label overrideHeading = new Label("Custom Overrides");
- overrideHeading.setStyle("-fx-font-weight: bold;");
-
- TextField pythonField = new TextField(PrereqChecker.getCustomPythonPath());
- pythonField.setPromptText("e.g. /usr/bin/python3");
- HBox.setHgrow(pythonField, Priority.ALWAYS);
- Button pythonBrowse = new Button("Browse...");
- pythonBrowse.setOnAction(e -> {
- FileChooser fc = new FileChooser();
- File f = fc.showOpenDialog(dialog);
- if (f != null) pythonField.setText(f.getAbsolutePath());
- });
-
- TextField esptoolField = new TextField(PrereqChecker.getCustomEsptoolPath());
- esptoolField.setPromptText("e.g. /usr/local/bin/esptool.py");
- HBox.setHgrow(esptoolField, Priority.ALWAYS);
- Button esptoolBrowse = new Button("Browse...");
- esptoolBrowse.setOnAction(e -> {
- FileChooser fc = new FileChooser();
- File f = fc.showOpenDialog(dialog);
- if (f != null) esptoolField.setText(f.getAbsolutePath());
- });
-
- Label status = new Label();
- Button recheckBtn = new Button("Save & Recheck");
- recheckBtn.setOnAction(e -> {
- PrereqChecker.setCustomPaths(pythonField.getText(), esptoolField.getText());
- status.setText("Rechecking...");
- new Thread(() -> {
- prereqChecker.checkAll();
- Platform.runLater(() -> {
- detectedPython.setText(detectedText("Python", prereqChecker.getPythonCmd()));
- detectedPip.setText(detectedText("pip", prereqChecker.getPipCmd()));
- detectedEsptool.setText(detectedText("esptool", prereqChecker.getEsptoolCmd()));
- status.setText(prereqChecker.isReady() ? "✓ Ready." : "✗ esptool not found.");
- status.setStyle(prereqChecker.isReady() ? "-fx-text-fill: green;" : "-fx-text-fill: red;");
- });
- }).start();
- });
-
- pane.getChildren().addAll(
- heading, detectedBox,
- new Separator(), overrideHeading,
- new Label("Python:"), new HBox(8, pythonField, pythonBrowse),
- new Label("esptool:"), new HBox(8, esptoolField, esptoolBrowse),
- status, recheckBtn
- );
- return pane;
- }
-
- // ── Log File pane ────────────────────────────────────────
- private VBox buildLogFilePane(Stage dialog) {
- VBox pane = new VBox(12);
- AppSettings settings = settingsManager.getSettings();
-
- Label heading = new Label("Log File");
- heading.setStyle("-fx-font-weight: bold; -fx-font-size: 14px;");
-
- Label infoBtn = new Label("ℹ");
- infoBtn.setStyle(
- "-fx-border-color: #666; -fx-border-radius: 50%; -fx-background-radius: 50%; " +
- "-fx-background-color: #2a2a2a; -fx-font-size: 12px; -fx-text-fill: #cccccc; " +
- "-fx-min-width: 22px; -fx-min-height: 22px; -fx-max-width: 22px; -fx-max-height: 22px; " +
- "-fx-alignment: center; -fx-cursor: hand;");
- infoBtn.setOnMouseClicked(e -> {
- Alert info = new Alert(Alert.AlertType.NONE);
- info.setTitle("Why use a log file?");
- info.getButtonTypes().add(javafx.scene.control.ButtonType.OK);
-
- Label title = new Label("MAC Address Provisioning Audit");
- title.setStyle("-fx-font-weight: bold; -fx-font-size: 13px;");
-
- Label recordedLabel = new Label("Each flash attempt is recorded with:");
- recordedLabel.setStyle("-fx-font-size: 12px;");
-
- VBox bullets = new VBox(4,
- bullet("Timestamp"),
- bullet("Serial port"),
- bullet("Device MAC address"),
- bullet("Status (success / failed)")
- );
- bullets.setStyle("-fx-padding: 0 0 0 12;");
-
- Label useCaseLabel = new Label("Use case — backend provisioning check:");
- useCaseLabel.setStyle("-fx-font-weight: bold; -fx-font-size: 12px; -fx-padding: 8 0 0 0;");
-
- Label useCaseBody = new Label(
- "Upload flash-log.csv to your backend and cross-reference the MAC " +
- "addresses against your provisioned-device registry. This lets you verify " +
- "that every flashed device was registered, catch devices that were flashed " +
- "but never provisioned, and flag duplicates (same MAC flashed twice)."
- );
- useCaseBody.setStyle("-fx-font-size: 12px;");
- useCaseBody.setWrapText(true);
- useCaseBody.setMaxWidth(380);
-
- VBox content = new VBox(8, title, recordedLabel, bullets, useCaseLabel, useCaseBody);
- content.setStyle("-fx-padding: 4;");
-
- info.getDialogPane().setContent(content);
- info.getDialogPane().getStylesheets().addAll(dialog.getScene().getStylesheets());
- info.showAndWait();
- });
-
- HBox headingRow = new HBox(8, heading, infoBtn);
- headingRow.setAlignment(javafx.geometry.Pos.CENTER_LEFT);
-
- CheckBox enableToggle = new CheckBox("Enable log file");
- enableToggle.setSelected(settings.isLogFileEnabled());
- enableToggle.setStyle(settings.isLogFileEnabled() ? "-fx-text-fill: #66bb6a;" : "");
-
-
- Label pathLabel = new Label("Log folder:");
- TextField pathField = new TextField(settings.getLogFilePath());
- pathField.setDisable(!settings.isLogFileEnabled());
- HBox.setHgrow(pathField, Priority.ALWAYS);
-
- Button browseBtn = new Button("Browse...");
- browseBtn.setDisable(!settings.isLogFileEnabled());
- browseBtn.setOnAction(e -> {
- javafx.stage.DirectoryChooser dc = new javafx.stage.DirectoryChooser();
- dc.setTitle("Select Log Folder");
- File dir = dc.showDialog(dialog);
- if (dir != null) pathField.setText(dir.getAbsolutePath());
- });
-
- Label formatInfo = new Label("Format: timestamp, port, MAC address, status");
- formatInfo.setStyle("-fx-text-fill: #888888; -fx-font-size: 11px;");
-
- // enable/disable fields when toggle changes
- enableToggle.setOnAction(e -> {
- boolean enabled = enableToggle.isSelected();
- pathField.setDisable(!enabled);
- browseBtn.setDisable(!enabled);
- settings.setLogFileEnabled(enabled);
- enableToggle.setStyle(enabled ? "-fx-text-fill: #66bb6a;" : "");
- });
-
- // save path on change
- pathField.textProperty().addListener((obs, old, val) ->
- settings.setLogFilePath(val)
- );
-
- pane.getChildren().addAll(
- headingRow,
- enableToggle,
- pathLabel,
- new HBox(8, pathField, browseBtn),
- formatInfo
- );
- return pane;
- }
- private Label bullet(String text) {
- Label l = new Label("• " + text);
- l.setStyle("-fx-font-size: 12px;");
- return l;
- }
-
- private Label detectedPathLabel(String name, String value) {
- Label l = new Label(detectedText(name, value));
- l.setStyle("-fx-font-family: monospace; -fx-font-size: 11px;");
- return l;
- }
-
- private String detectedText(String name, String value) {
- if (value == null || value.isBlank())
- return "✗ " + name + ": not found";
- return "✓ " + name + ": " + value;
- }
-
- private void browseBin(Stage stage) {
- FileChooser fileChooser = new FileChooser();
- fileChooser.setTitle("Select Firmware");
- fileChooser.getExtensionFilters().add(
- new FileChooser.ExtensionFilter("Binary files (*.bin)", "*.bin")
- );
- File file = fileChooser.showOpenDialog(stage);
- if (file != null) {
- binPathField.setText(file.getAbsolutePath());
- }
-
- }
-
- private void refreshPorts() {
- portCombo.getItems().clear();
- List espPorts = PortWatcher.listEsp32Ports();
-
- if (espPorts.isEmpty()) {
- portCombo.setPromptText("No ESP32 detected...");
- } else {
- portCombo.getItems().addAll(espPorts);
- portCombo.getSelectionModel().selectFirst(); // auto select first ESP32
- }
- }
-
- private void installEsptool() {
- statusLabel.setStyle("");
- statusLabel.setOnMouseClicked(null);
- if (prereqChecker.getPythonCmd() == null) {
- showPythonMissingDialog();
- return;
- }
- if (prereqChecker.getPipCmd() == null) {
- statusLabel.setText("⚠ pip not found — reinstall Python with pip included.");
- statusLabel.setStyle("-fx-text-fill: red;");
- return;
- }
- showEsptoolInstallDialog();
- }
-
- private void showEsptoolInstallDialog() {
- Stage dialog = new Stage();
- dialog.initModality(Modality.APPLICATION_MODAL);
- dialog.setTitle("Installing esptool");
- dialog.setResizable(false);
-
- Label titleLabel = new Label("Installing esptool via pip...");
- titleLabel.setStyle("-fx-font-weight: bold;");
-
- ProgressBar bar = new ProgressBar();
- bar.setProgress(ProgressBar.INDETERMINATE_PROGRESS);
- bar.setMaxWidth(Double.MAX_VALUE);
-
- TextArea output = new TextArea();
- output.setEditable(false);
- output.setWrapText(true);
- output.setPrefHeight(180);
- output.setPrefWidth(440);
-
- Label statusMsg = new Label("Please wait...");
-
- Button closeBtn = new Button("Close");
- closeBtn.setDisable(true);
- closeBtn.setOnAction(e -> dialog.close());
-
- VBox root = new VBox(10, titleLabel, bar, output, statusMsg, closeBtn);
- root.setPadding(new Insets(16));
- root.setAlignment(Pos.CENTER_LEFT);
-
- dialog.setScene(new Scene(root));
-
- Thread thread = new Thread(() -> {
- boolean success = prereqChecker.installEsptool(line ->
- Platform.runLater(() -> output.appendText(line + "\n"))
- );
-
- Platform.runLater(() -> {
- bar.setProgress(1.0);
- if (success) {
- logArea.clear();
- statusLabel.setText("Ready.");
- statusLabel.setStyle("");
- flashButton.setDisable(false);
- factoryButton.setDisable(false);
- dialog.close();
- } else {
- statusMsg.setText("✗ Install failed. Check output above.");
- statusMsg.setStyle("-fx-text-fill: red;");
- statusLabel.setText("⚠ esptool install failed.");
- closeBtn.setDisable(false);
- }
- });
- });
- thread.setDaemon(true);
- thread.start();
-
- dialog.show();
- }
-
- private void checkPrerequisites() {
- statusLabel.setText("Checking prerequisites...");
-
- Thread thread = new Thread(() -> {
- prereqChecker.checkAll();
-
- Platform.runLater(() -> {
- if (prereqChecker.isReady()) {
- statusLabel.setText("Ready.");
- } else {
- statusLabel.setText("esptool not found — click here to install.");
- statusLabel.setStyle("-fx-text-fill: #2196f3; -fx-underline: true; -fx-cursor: hand;");
- statusLabel.setOnMouseClicked(e -> installEsptool());
- }
- });
- });
- thread.setDaemon(true);
- thread.start();
-
- Platform.runLater(() -> {
- if (prereqChecker.isReady()) {
- statusLabel.setText("Ready.");
- } else if (prereqChecker.getPythonCmd() == null) {
- showPythonMissingDialog();
- } else {
- autoInstallEsptool();
- }
- });
- }
-
- private boolean isDarkMode() {
- String os = System.getProperty("os.name").toLowerCase();
- try {
- if (os.contains("mac")) {
- Process p = Runtime.getRuntime().exec(
- new String[]{"defaults", "read", "-g", "AppleInterfaceStyle"}
- );
- String result = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip();
- return result.equalsIgnoreCase("dark");
- } else if (os.contains("windows")) {
- Process p = Runtime.getRuntime().exec(new String[]{
- "reg", "query",
- "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
- "/v", "AppsUseLightTheme"
- });
- String result = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
- return result.contains("0x0");
- }
- } catch (Exception e) {
- return false;
- }
- return false;
- }
-
- private void showAboutDialog() {
- Stage dialog = new Stage();
- dialog.setTitle("About");
- dialog.setResizable(false);
-
- VBox content = new VBox(12);
- content.setPadding(new Insets(24));
- content.setAlignment(Pos.CENTER);
- content.getStyleClass().add("about-dialog");
-
- var iconUrl = getClass().getResource("/icons/icon.png");
- if (iconUrl != null) {
- javafx.scene.image.ImageView logo = new javafx.scene.image.ImageView(
- new javafx.scene.image.Image(iconUrl.toExternalForm()));
- logo.setFitWidth(72);
- logo.setFitHeight(72);
- logo.setPreserveRatio(true);
- content.getChildren().add(logo);
- }
-
- Label title = new Label("ESP Flasher");
- title.getStyleClass().add("about-title");
-
- String appVersion = new UpdateService().currentVersion();
- Label version = new Label("v" + appVersion);
- version.getStyleClass().add("about-version");
-
- Separator sep = new Separator();
- sep.setStyle("-fx-background-color: #3a3a3c;");
-
- Label author = new Label("Built by Ajinkya Gokhale");
- author.getStyleClass().add("about-author");
-
- Label email = new Label("✉ hi@ajinkyagokhale.com");
- email.getStyleClass().add("about-link");
- email.setOnMouseClicked(e ->
- getHostServices().showDocument("mailto:hi@ajinkyagokhale.com"));
-
- Label github = new Label("⚡ github.com/ajinkyagokhale");
- github.getStyleClass().add("about-link");
- github.setOnMouseClicked(e ->
- getHostServices().showDocument("https://github.com/ajinkyagokhale"));
-
- Label license = new Label("MIT License — 2026");
- license.getStyleClass().add("about-license");
-
- Button closeBtn = new Button("Close");
- closeBtn.setOnAction(e -> dialog.close());
- closeBtn.setPrefWidth(100);
-
- content.getChildren().addAll(
- title, version, sep,
- author, email, github,
- license, closeBtn
- );
-
- Scene scene = new Scene(content, 280, 360);
- dialog.setScene(scene);
- dialog.show();
- }
-
- @Override
- public void stop() {
- AppSettings settings = settingsManager.getSettings();
- settings.setLastChip(chipCombo.getValue());
- settings.setLastBaudRate(baudCombo.getValue());
- settings.setLastBinPath(binPathField.getText());
- settings.setLastPort(portCombo.getValue());
- settingsManager.save();
-
- portWatcher.stopWatching();
- esptoolRunner.stopFlashing();
- }
-
-
- @Override
- public void start(Stage primaryStage) throws Exception {
- checkForUpdates();
- // set app icon — JavaFX Image only supports PNG/JPEG/BMP, not .ico/.icns
- var iconUrl = getClass().getResource("/icons/icon.png");
- if (iconUrl != null) {
- primaryStage.getIcons().add(new javafx.scene.image.Image(iconUrl.toExternalForm()));
- }
-
-
- esptoolRunner = new EsptoolRunner();
- portWatcher = new PortWatcher();
- prereqChecker = new PrereqChecker();
- settingsManager = new SettingsManager();
- settingsManager.load();
- flashLogger = new FlashLogger(settingsManager.getSettings());
-
- var successUrl = getClass().getResource("/sounds/success.wav");
- var failUrl = getClass().getResource("/sounds/failure.wav");
-
- if (successUrl != null && failUrl != null) {
- successSound = new AudioClip(successUrl.toExternalForm());
- failSound = new AudioClip(failUrl.toExternalForm());
- } else {
- System.out.println("Sound files not found at /sounds/success.wav or /sounds/failure.wav");
- }
-
-
- primaryStage.setTitle("ESP Flasher");
- primaryStage.setWidth(750);
- primaryStage.setHeight(650);
-
- VBox root = new VBox(10); // 10px spacing between children
- root.setPadding(new Insets(20));
-
- Scene scene = new Scene(root);
- if (isDarkMode()) {
- scene.getStylesheets().add(
- getClass().getResource("/styles.css").toExternalForm()
- );
- }
- primaryStage.setScene(scene);
- // File row
- binPathField = new TextField();
- binPathField.setPromptText("Select firmware .bin file...");
- binPathField.setEditable(false);
- HBox.setHgrow(binPathField, Priority.ALWAYS);
-
- Button browseButton = new Button("Browse...");
- browseButton.setOnAction(e -> browseBin(primaryStage));
-
- HBox fileRow = new HBox(10, new Label("Firmware"), binPathField, browseButton);
- fileRow.setAlignment(Pos.CENTER_LEFT);
-
- //root.getChildren().add(fileRow);
-
- // Chip row
- chipCombo = new ComboBox<>();
- chipCombo.getItems().addAll(
- "auto", "esp32c6", "esp32", "esp32s2",
- "esp32s3", "esp32c3", "esp32h2", "esp8266"
- );
- chipCombo.setValue("auto");
-
- HBox chipRow = new HBox(10, new Label("Chip"), chipCombo);
- chipRow.setAlignment(Pos.CENTER_LEFT);
-
- //root.getChildren().add(chipRow);
- // Port row
- portCombo = new ComboBox<>();
- portCombo.setEditable(true);
- portCombo.setPromptText("Select port...");
- refreshPorts();
- Button refreshButton = new Button("Refresh");
- refreshButton.setOnAction(e -> refreshPorts());
-
- HBox portRow = new HBox(10, new Label("Port"), portCombo, refreshButton);
- portRow.setAlignment(Pos.CENTER_LEFT);
-
- //root.getChildren().add(portRow);
-
- // Baud row
- baudCombo = new ComboBox<>();
- baudCombo.getItems().addAll(
- "460800", "921600", "230400", "115200"
- );
- baudCombo.setValue("460800");
-
- HBox baudRow = new HBox(10, new Label("Baud Rate"), baudCombo);
- baudRow.setAlignment(Pos.CENTER_LEFT);
- //root.getChildren().add(baudRow);
-
-// Offset row
- flashOffsetField = new TextField("0x0");
- flashOffsetField.setPrefWidth(120);
-
- HBox offsetRow = new HBox(10, new Label("Flash Offset"), flashOffsetField);
- offsetRow.setAlignment(Pos.CENTER_LEFT);
- //root.getChildren().add(offsetRow);
- VBox configCard = new VBox(10);
- configCard.getStyleClass().add("config-card");
- configCard.getChildren().addAll(
- fileRow, chipRow, portRow, baudRow, offsetRow
- );
- root.getChildren().add(configCard);
- // Buttons row
- flashButton = new Button("Flash Once");
- flashButton.setOnAction(e -> startFlash(null));
-
- factoryButton = new Button("Factory Mode");
- factoryButton.setOnAction(e -> startFactoryMode());
-
-
- flashCountNumber = new Label("0");
- flashCountNumber.getStyleClass().add("flash-count-number");
-
- Label flashCountSub = new Label("flashed");
- flashCountSub.getStyleClass().add("flash-count-sub");
-
- flashCountLabel = new Label();
- flashCountLabel.getStyleClass().add("flash-count-card");
- VBox flashCard = new VBox(0, flashCountNumber, flashCountSub);
- flashCard.setAlignment(Pos.CENTER);
- flashCountLabel.setGraphic(flashCard);
-
- stopButton = new Button("Stop");
- stopButton.setOnAction(e -> stopAll());
- stopButton.setDisable(true);
-
-// Button aboutButton = new Button("About");
-// aboutButton.setOnAction(e -> showAboutDialog());
-
-
- Button settingsButton = new Button("Settings");
- settingsButton.setOnAction(e -> showSettingsDialog(primaryStage));
-
- javafx.scene.layout.Region spacer = new javafx.scene.layout.Region();
- HBox.setHgrow(spacer, Priority.ALWAYS);
- HBox buttonRow = new HBox(10, flashButton, factoryButton, settingsButton, spacer, flashCountLabel);
- buttonRow.setAlignment(Pos.CENTER_LEFT);
-
- root.getChildren().add(buttonRow);
-// Progress bar
- progressBar = new ProgressBar(0);
- progressBar.setMaxWidth(Double.MAX_VALUE);
- statusLabel = new Label("Ready.");
-
- root.getChildren().addAll(progressBar, statusLabel);
- checkPrerequisites();
-
-
- //apply last settings
- AppSettings settings = settingsManager.getSettings();
- if (!settings.getLastChip().isEmpty()) chipCombo.setValue(settings.getLastChip());
- if (!settings.getLastBaudRate().isEmpty()) baudCombo.setValue(settings.getLastBaudRate());
- if (!settings.getLastBinPath().isEmpty()) binPathField.setText(settings.getLastBinPath());
-
-
-// Log area
- logArea = new TextArea();
- logArea.setEditable(false);
- logArea.setWrapText(false);
- VBox.setVgrow(logArea, Priority.ALWAYS);
-
- root.getChildren().add(logArea);
-
- //footer
- Label footer = new Label("Built with ❤ by Ajinkya Gokhale");
- footer.setMaxWidth(Double.MAX_VALUE);
- footer.setAlignment(Pos.CENTER);
- footer.getStyleClass().add("footer");
-
- root.getChildren().add(footer);
- footer.setOnMouseClicked(e -> showAboutDialog());
- footer.setStyle("-fx-cursor: hand;");
- primaryStage.show();
- if (isDarkMode()) applyDarkTitleBar(primaryStage);
-
- } //start-end
-
- private interface Dwmapi extends com.sun.jna.Library {
- Dwmapi INSTANCE = com.sun.jna.Native.load("dwmapi", Dwmapi.class);
- void DwmSetWindowAttribute(com.sun.jna.platform.win32.WinDef.HWND hwnd, int attr,
- com.sun.jna.ptr.IntByReference value, int size);
- }
-
- @SuppressFBWarnings("DE_MIGHT_IGNORE")
- private void applyDarkTitleBar(Stage stage) {
- if (!System.getProperty("os.name", "").toLowerCase().contains("win")) return;
- try {
- com.sun.jna.platform.win32.WinDef.HWND hwnd =
- com.sun.jna.platform.win32.User32.INSTANCE.FindWindow(null, stage.getTitle());
- if (hwnd == null) return;
- com.sun.jna.ptr.IntByReference dark = new com.sun.jna.ptr.IntByReference(1);
- Dwmapi.INSTANCE.DwmSetWindowAttribute(hwnd, 20, dark, 4); // Windows 10 20H1+
- Dwmapi.INSTANCE.DwmSetWindowAttribute(hwnd, 19, dark, 4); // older Win10 builds
- } catch (Exception ignored) {}
- }
-
- private void checkForUpdates() {
- Thread worker = new Thread(() -> {
- UpdateService updates = new UpdateService();
- String current = updates.currentVersion();
- updates.latestRelease()
- .filter(release -> updates.isNewer(release.version(), current))
- .ifPresent(release ->
- Platform.runLater(() -> promptForcedUpdate(updates, release, current)));
- }, "update-check");
- worker.setDaemon(true);
- worker.start();
- }
-
- @SuppressFBWarnings("DM_EXIT")
- private void promptForcedUpdate(UpdateService updates, UpdateService.Release release, String current) {
- ButtonType updateNow = new ButtonType("Update Now", ButtonBar.ButtonData.OK_DONE);
- ButtonType quit = new ButtonType("Quit", ButtonBar.ButtonData.CANCEL_CLOSE);
-
- Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
- alert.setTitle("Update Required");
- alert.setHeaderText("A new version of ESP Flasher is available");
- alert.setContentText("Installed: " + current + "\nLatest: " + release.version()
- + "\n\nYou must update to continue.");
- alert.getButtonTypes().setAll(updateNow, quit);
- alert.initModality(Modality.APPLICATION_MODAL);
-
- Optional choice = alert.showAndWait();
- while (choice.isEmpty()) {
- choice = alert.showAndWait();
- }
-
- if (choice.get() == updateNow) {
- downloadUpdate(updates, release);
- } else {
- System.exit(0);
- }
- }
-
- private void downloadUpdate(UpdateService updates, UpdateService.Release release) {
- AtomicBoolean cancelled = new AtomicBoolean(false);
-
- ProgressBar bar = new ProgressBar(0);
- bar.setPrefWidth(380);
- Label info = new Label("Starting download...");
- Label hint = new Label("The app will restart to install the update.");
- hint.getStyleClass().add("footer");
- VBox box = new VBox(10, info, bar, hint);
- box.setPadding(new Insets(16));
-
- ButtonType cancelType = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
- Alert dialog = new Alert(Alert.AlertType.NONE);
- dialog.setTitle("Downloading Update");
- dialog.setHeaderText("Updating to " + release.version());
- dialog.getDialogPane().setContent(box);
- dialog.getButtonTypes().setAll(cancelType);
- dialog.initModality(Modality.APPLICATION_MODAL);
- dialog.setOnHidden(e -> cancelled.set(true));
-
- Thread worker = new Thread(() -> {
- try {
- updates.downloadAndLaunch(release,
- (downloaded, total) -> Platform.runLater(
- () -> updateProgress(bar, info, downloaded, total)),
- cancelled);
- Platform.runLater(() -> {
- dialog.close();
- promptForcedUpdate(updates, release, updates.currentVersion());
- });
- } catch (Exception e) {
- Platform.runLater(() -> {
- dialog.close();
- Alert error = new Alert(Alert.AlertType.ERROR);
- error.setTitle("Update Failed");
- error.setHeaderText("Could not download the update");
- error.setContentText(e.getMessage());
- error.showAndWait();
- promptForcedUpdate(updates, release, updates.currentVersion());
- });
- }
- }, "update-download");
- worker.setDaemon(true);
- worker.start();
-
- dialog.show();
- }
-
- private void updateProgress(ProgressBar bar, Label info, long downloaded, long total) {
- double mb = downloaded / 1048576.0;
- if (total > 0) {
- double fraction = (double) downloaded / total;
- bar.setProgress(fraction);
- info.setText(String.format("%.1f MB / %.1f MB (%d%%)",
- mb, total / 1048576.0, (int) (fraction * 100)));
- } else {
- bar.setProgress(ProgressBar.INDETERMINATE_PROGRESS);
- info.setText(String.format("%.1f MB downloaded", mb));
- }
- }
-
-}
+package com.ajinkyagokhale.espflasher.ui;
+
+import com.ajinkyagokhale.espflasher.listener.FlashListener;
+import com.ajinkyagokhale.espflasher.listener.PortListener;
+import com.ajinkyagokhale.espflasher.model.AppSettings;
+import com.ajinkyagokhale.espflasher.model.FirmwareDefinition;
+import com.ajinkyagokhale.espflasher.model.FlashConfig;
+import com.ajinkyagokhale.espflasher.model.FlashResult;
+import com.ajinkyagokhale.espflasher.service.*;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+
+import javafx.application.Application;
+import javafx.application.Platform;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.Scene;
+import javafx.scene.control.*;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.StackPane;
+import javafx.scene.layout.VBox;
+import javafx.scene.media.AudioClip;
+import javafx.stage.FileChooser;
+import javafx.stage.Modality;
+import javafx.stage.Stage;
+
+import javafx.scene.Node;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.scene.layout.FlowPane;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class FlasherApp extends Application implements FlashListener, PortListener {
+
+ // ── UI controls ─────────────────────────────────────────
+ private TextField binPathField;
+ private ComboBox chipCombo;
+ private ComboBox portCombo;
+ private ComboBox baudCombo;
+ private TextField flashOffsetField;
+ private ProgressBar progressBar;
+ private TextArea logArea;
+ private Button flashButton;
+ private Button stopButton;
+ private Button factoryButton;
+ private Label statusLabel;
+ private Label flashCountLabel;
+ private Label flashCountNumber;
+
+ // ── Services ────────────────────────────────────────────
+ private EsptoolRunner esptoolRunner;
+ private PortWatcher portWatcher;
+ private PrereqChecker prereqChecker;
+ private SettingsManager settingsManager;
+ private FlashLogger flashLogger;
+
+ // ── Audio ───────────────────────────────────────────────
+ private AudioClip successSound;
+ private AudioClip failSound;
+
+ // ── Flash state ─────────────────────────────────────────
+ private int flashCount = 0;
+ private boolean isFactoryMode = false;
+ private String currentFlashPort;
+ private String currentFlashMac;
+
+ private RadioButton myBinaryRadio;
+ private RadioButton popularFirmwareRadio;
+ private ComboBox firmwareCombo;
+ private Label firmwareDescLabel;
+ private Label firmwareVersionLabel;
+ private VBox popularFirmwareRow;
+ private FirmwareDownloader firmwareDownloader;
+ private FirmwareDefinition selectedFirmware;
+
+ // Top-level views (toggled by toolbar / Explore button)
+ private VBox flasherView;
+ private VBox settingsView;
+ private VBox exploreView;
+ private Button toolbarFlasherBtn;
+ private Button toolbarSettingsBtn;
+
+
+ // ════════════════════════════════════════════════════════
+ // Application lifecycle
+ // ════════════════════════════════════════════════════════
+
+ @Override
+ public void start(Stage primaryStage) throws Exception {
+ checkForUpdates();
+
+
+ initIcon(primaryStage);
+ initService();
+ initSound();
+
+ primaryStage.setTitle("ESP Flasher");
+ primaryStage.setWidth(750);
+ primaryStage.setHeight(650);
+ primaryStage.setMinWidth(750);
+ primaryStage.setMinHeight(650);
+
+ VBox root = new VBox(10);
+ root.setPadding(new Insets(20));
+
+ // Flasher view container — all existing build methods feed into this
+ flasherView = new VBox(10);
+ VBox.setVgrow(flasherView, Priority.ALWAYS);
+
+ buildConfigCard(primaryStage, flasherView);
+ buildButtonRow(primaryStage, flasherView);
+ addProgressBar(flasherView);
+ checkPrerequisites();
+ applyLastSettings();
+ buildLogArea(flasherView);
+
+ // Settings view — initially hidden
+ settingsView = buildSettingsView(primaryStage);
+ VBox.setVgrow(settingsView, Priority.ALWAYS);
+ settingsView.setVisible(false);
+ settingsView.setManaged(false);
+
+ // Explore view — initially hidden
+ exploreView = buildExploreView();
+ VBox.setVgrow(exploreView, Priority.ALWAYS);
+ exploreView.setVisible(false);
+ exploreView.setManaged(false);
+
+ HBox toolbar = buildToolbar();
+ root.getChildren().addAll(toolbar, flasherView, settingsView, exploreView);
+
+ // Footer
+ buildFooter(root);
+
+ Scene scene = new Scene(root);
+ if (isDarkMode()) {
+ scene.getStylesheets().add(Objects.requireNonNull(getClass().getResource("/styles.css")).toExternalForm());
+ }
+ primaryStage.setScene(scene);
+
+ primaryStage.show();
+ if (isDarkMode()) applyDarkTitleBar(primaryStage);
+ }
+
+ private HBox buildToolbar() {
+ toolbarFlasherBtn = new Button("Flasher");
+ toolbarSettingsBtn = new Button("Settings");
+
+ toolbarFlasherBtn.getStyleClass().addAll("toolbar-btn", "toolbar-btn-active");
+ toolbarSettingsBtn.getStyleClass().add("toolbar-btn");
+
+ toolbarFlasherBtn.setOnAction(e -> showView(flasherView));
+ toolbarSettingsBtn.setOnAction(e -> showView(settingsView));
+
+ HBox toolbar = new HBox(2, toolbarFlasherBtn, toolbarSettingsBtn);
+ toolbar.getStyleClass().add("toolbar");
+ toolbar.setAlignment(Pos.CENTER);
+
+ HBox toolbarWrapper = new HBox(toolbar);
+ toolbarWrapper.setAlignment(Pos.CENTER);
+ toolbarWrapper.setPadding(new Insets(4, 0, 4, 0));
+ return toolbarWrapper;
+ }
+
+ /** Show one of flasher/settings/explore; hide the others. Updates toolbar active state. */
+ private void showView(VBox target) {
+ VBox[] all = { flasherView, settingsView, exploreView };
+ for (VBox v : all) {
+ boolean show = v == target;
+ v.setVisible(show);
+ v.setManaged(show);
+ }
+ toolbarFlasherBtn.getStyleClass().remove("toolbar-btn-active");
+ toolbarSettingsBtn.getStyleClass().remove("toolbar-btn-active");
+ if (target == flasherView) {
+ toolbarFlasherBtn.getStyleClass().add("toolbar-btn-active");
+ } else if (target == settingsView) {
+ toolbarSettingsBtn.getStyleClass().add("toolbar-btn-active");
+ }
+ // explore view leaves both toolbar buttons inactive (deliberate)
+ }
+
+ private void initIcon(Stage primaryStage) {
+ // set app icon — JavaFX Image only supports PNG/JPEG/BMP, not .ico/.icns
+ var iconUrl = getClass().getResource("/icons/icon.png");
+ if (iconUrl != null) {
+ primaryStage.getIcons().add(new javafx.scene.image.Image(iconUrl.toExternalForm()));
+ }
+ }
+
+ private void buildFooter(VBox root) {
+ Label footer = new Label("Built with ❤ by Ajinkya Gokhale");
+ footer.setMaxWidth(Double.MAX_VALUE);
+ footer.setAlignment(Pos.CENTER);
+ footer.getStyleClass().add("footer");
+ footer.setOnMouseClicked(e -> showAboutDialog());
+ footer.setStyle("-fx-cursor: hand;");
+ root.getChildren().add(footer);
+ }
+
+ private void applyLastSettings() {
+ // Apply last-used settings
+ AppSettings settings = settingsManager.getSettings();
+ if (!settings.getLastChip().isEmpty()) chipCombo.setValue(settings.getLastChip());
+ if (!settings.getLastBaudRate().isEmpty()) baudCombo.setValue(settings.getLastBaudRate());
+ if (!settings.getLastBinPath().isEmpty()) binPathField.setText(settings.getLastBinPath());
+ }
+
+ private void initSound() {
+ var successUrl = getClass().getResource("/sounds/success.wav");
+ var failUrl = getClass().getResource("/sounds/failure.wav");
+ if (successUrl != null && failUrl != null) {
+ successSound = new AudioClip(successUrl.toExternalForm());
+ failSound = new AudioClip(failUrl.toExternalForm());
+ } else {
+ System.out.println("Sound files not found at /sounds/success.wav or /sounds/failure.wav");
+ }
+ }
+
+ private void initService() {
+ esptoolRunner = new EsptoolRunner();
+ portWatcher = new PortWatcher();
+ prereqChecker = new PrereqChecker();
+ settingsManager = new SettingsManager();
+ settingsManager.load();
+ flashLogger = new FlashLogger(settingsManager.getSettings());
+ firmwareDownloader = new FirmwareDownloader();
+ }
+
+ private void buildLogArea(VBox root) {
+ // Log area
+ logArea = new TextArea();
+ logArea.setEditable(false);
+ logArea.setWrapText(false);
+ VBox.setVgrow(logArea, Priority.ALWAYS);
+ root.getChildren().add(logArea);
+
+ // Route firmware-downloader diagnostics here (thread-safe via Platform.runLater)
+ firmwareDownloader.setLogger(msg ->
+ Platform.runLater(() -> logArea.appendText(msg + "\n"))
+ );
+ }
+
+ private void addProgressBar(VBox root) {
+ // Progress + status
+ progressBar = new ProgressBar(0);
+ progressBar.setMaxWidth(Double.MAX_VALUE);
+ statusLabel = new Label("Ready.");
+ root.getChildren().addAll(progressBar, statusLabel);
+ }
+
+ private void buildButtonRow(Stage primaryStage, VBox root) {
+
+ // Action buttons + flash counter
+ flashButton = new Button("Flash Once");
+ flashButton.setOnAction(e -> startFlash(null));
+
+ factoryButton = new Button("Factory Mode");
+ factoryButton.setOnAction(e -> startFactoryMode());
+
+ stopButton = new Button("Stop");
+ stopButton.setOnAction(e -> stopAll());
+ stopButton.setDisable(true);
+
+ flashCountNumber = new Label("0");
+ flashCountNumber.getStyleClass().add("flash-count-number");
+ Label flashCountSub = new Label("flashed");
+ flashCountSub.getStyleClass().add("flash-count-sub");
+ flashCountLabel = new Label();
+ flashCountLabel.getStyleClass().add("flash-count-card");
+ VBox flashCard = new VBox(0, flashCountNumber, flashCountSub);
+ flashCard.setAlignment(Pos.CENTER);
+ flashCountLabel.setGraphic(flashCard);
+
+ javafx.scene.layout.Region spacer = new javafx.scene.layout.Region();
+ HBox.setHgrow(spacer, Priority.ALWAYS);
+ HBox buttonRow = new HBox(10, flashButton, factoryButton, spacer, flashCountLabel);
+ buttonRow.setAlignment(Pos.CENTER_LEFT);
+ root.getChildren().add(buttonRow);
+ }
+ private void buildConfigCard(Stage primaryStage, VBox root) {
+
+ // ── Firmware source selector ────────────────────────
+ ComboBox firmwareSourceCombo = new ComboBox<>();
+ firmwareSourceCombo.getItems().add("Custom Binary");
+ FirmwareCatalog.getCatalog().forEach(f ->
+ firmwareSourceCombo.getItems().add(f.getName())
+ );
+ firmwareSourceCombo.setValue("Custom Binary");
+
+ // ── Inline firmware description (next to Source dropdown) ──
+ firmwareDescLabel = new Label("");
+ firmwareDescLabel.getStyleClass().add("config-label");
+ firmwareDescLabel.setStyle("-fx-text-fill: #888888; -fx-font-size: 11px;");
+ firmwareDescLabel.setWrapText(true);
+ HBox.setHgrow(firmwareDescLabel, Priority.ALWAYS);
+
+ Button exploreBtn = new Button("Explore Popular Projects →");
+ exploreBtn.getStyleClass().add("link-button");
+ exploreBtn.setOnAction(e -> showView(exploreView));
+
+ HBox sourceRow = new HBox(10, new Label("Source"), firmwareSourceCombo, firmwareDescLabel, exploreBtn);
+ sourceRow.setAlignment(Pos.CENTER_LEFT);
+
+ // ── Custom binary file row ──────────────────────────
+ binPathField = new TextField();
+ binPathField.setPromptText("Select firmware .bin file...");
+ binPathField.setEditable(false);
+ HBox.setHgrow(binPathField, Priority.ALWAYS);
+
+ Button browseButton = new Button("Browse...");
+ browseButton.setOnAction(e -> browseBin(primaryStage));
+
+ HBox fileRow = new HBox(10, new Label("Firmware"), binPathField, browseButton);
+ fileRow.setAlignment(Pos.CENTER_LEFT);
+
+ // ── Popular firmware version row ────────────────────
+ firmwareVersionLabel = new Label("");
+ firmwareVersionLabel.setStyle("-fx-text-fill: #66bb6a; -fx-font-size: 11px;");
+
+ popularFirmwareRow = new VBox(4, firmwareVersionLabel);
+ popularFirmwareRow.setVisible(false);
+ popularFirmwareRow.setManaged(false);
+
+ // ── Source toggle behavior ──────────────────────────
+ firmwareSourceCombo.setOnAction(e -> {
+ String selected = firmwareSourceCombo.getValue();
+ boolean isCustom = selected.equals("Custom Binary");
+
+ fileRow.setVisible(isCustom);
+ fileRow.setManaged(isCustom);
+ popularFirmwareRow.setVisible(!isCustom);
+ popularFirmwareRow.setManaged(!isCustom);
+
+ if (isCustom) {
+ firmwareDescLabel.setText("");
+ selectedFirmware = null;
+ updateChipListForFirmware(null);
+ return;
+ }
+
+ // find matching FirmwareDefinition
+ FirmwareDefinition def = FirmwareCatalog.getCatalog().stream()
+ .filter(f -> f.getName().equals(selected))
+ .findFirst()
+ .orElse(null);
+
+ selectedFirmware = def;
+ updateChipListForFirmware(def);
+
+ if (def != null) {
+ firmwareDescLabel.setText(def.getDescription());
+ firmwareVersionLabel.setText("Fetching version...");
+ new Thread(() -> {
+ boolean online = firmwareDownloader.isOnline();
+ if (!online) {
+ Platform.runLater(() ->
+ firmwareVersionLabel.setText("⚠ No internet connection")
+ );
+ return;
+ }
+ String version = firmwareDownloader.fetchLatestVersion(def);
+ Platform.runLater(() ->
+ firmwareVersionLabel.setText("Latest: v" + version)
+ );
+ }).start();
+ }
+ });
+
+ // ── Chip row ────────────────────────────────────────
+ chipCombo = new ComboBox<>();
+ chipCombo.getItems().addAll(
+ "auto", "esp32c6", "esp32", "esp32s2",
+ "esp32s3", "esp32c3", "esp32h2", "esp8266"
+ );
+ chipCombo.setValue("auto");
+ HBox chipRow = new HBox(10, new Label("Chip"), chipCombo);
+ chipRow.setAlignment(Pos.CENTER_LEFT);
+
+ // ── Port row ────────────────────────────────────────
+ portCombo = new ComboBox<>();
+ portCombo.setEditable(true);
+ portCombo.setPromptText("Select port...");
+ refreshPorts();
+ Button refreshButton = new Button("Refresh");
+ refreshButton.setOnAction(e -> refreshPorts());
+ HBox portRow = new HBox(10, new Label("Port"), portCombo, refreshButton);
+ portRow.setAlignment(Pos.CENTER_LEFT);
+
+ // ── Baud row ────────────────────────────────────────
+ baudCombo = new ComboBox<>();
+ baudCombo.getItems().addAll("460800", "921600", "230400", "115200");
+ baudCombo.setValue("460800");
+ HBox baudRow = new HBox(10, new Label("Baud Rate"), baudCombo);
+ baudRow.setAlignment(Pos.CENTER_LEFT);
+
+ // ── Offset row ──────────────────────────────────────
+ flashOffsetField = new TextField("0x0");
+ flashOffsetField.setPrefWidth(120);
+ HBox offsetRow = new HBox(10, new Label("Flash Offset"), flashOffsetField);
+ offsetRow.setAlignment(Pos.CENTER_LEFT);
+
+ // ── Config card ─────────────────────────────────────
+ VBox configCard = new VBox(10);
+ configCard.getStyleClass().add("config-card");
+ configCard.setMaxWidth(Double.MAX_VALUE);
+ configCard.setFillWidth(true);
+ configCard.getChildren().addAll(
+ sourceRow,
+ fileRow,
+ popularFirmwareRow,
+ chipRow, portRow, baudRow, offsetRow
+ );
+ root.getChildren().add(configCard);
+ }
+
+ @Override
+ public void stop() {
+ AppSettings settings = settingsManager.getSettings();
+ settings.setLastChip(chipCombo.getValue());
+ settings.setLastBaudRate(baudCombo.getValue());
+ settings.setLastBinPath(binPathField.getText());
+ settings.setLastPort(portCombo.getValue());
+ settingsManager.save();
+
+ portWatcher.stopWatching();
+ esptoolRunner.stopFlashing();
+ }
+
+
+ // ════════════════════════════════════════════════════════
+ // Listener callbacks (FlashListener, PortListener)
+ // ════════════════════════════════════════════════════════
+
+ @Override
+ public void onProgress(int percent) {
+ Platform.runLater(() -> progressBar.setProgress(percent / 100.0));
+ }
+
+ @Override
+ public void onLog(String line) {
+ if (line.contains("MAC:")) {
+ String[] parts = line.split("MAC:");
+ if (parts.length > 1) currentFlashMac = parts[1].trim();
+ }
+ Platform.runLater(() -> logArea.appendText(line + "\n"));
+ }
+
+ @Override
+ public void onComplete(boolean success, String message) {
+ String timestamp = java.time.LocalDateTime.now()
+ .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
+ flashLogger.append(new FlashResult(success, message, currentFlashMac, currentFlashPort, timestamp));
+
+ Platform.runLater(() -> {
+ statusLabel.setText(message);
+ flashButton.setDisable(false);
+ factoryButton.setDisable(false);
+ stopButton.setDisable(true);
+ if (success) {
+ flashCount++;
+ flashCountNumber.setText(String.valueOf(flashCount));
+ flashCountLabel.setStyle("-fx-background-color: rgba(102,187,106,0.25); -fx-border-color: #66bb6a;");
+ new javafx.animation.Timeline(
+ new javafx.animation.KeyFrame(javafx.util.Duration.millis(800),
+ ev -> flashCountLabel.setStyle(""))
+ ).play();
+ progressBar.setStyle("-fx-accent: green;");
+ progressBar.setProgress(1.0);
+ successSound.play();
+ statusLabel.setStyle("-fx-text-fill: green;");
+ } else {
+ failSound.play();
+ progressBar.setStyle("-fx-accent: red;");
+ progressBar.setProgress(1.0);
+ statusLabel.setStyle("-fx-text-fill: red;");
+ }
+
+ if (isFactoryMode) {
+ statusLabel.setText("Waiting for next device...");
+ statusLabel.setStyle("-fx-text-fill: #66bb6a;");
+ flashButton.setDisable(true);
+ stopButton.setDisable(false);
+ }
+ });
+ }
+
+ @Override
+ public void onNewPort(String portName) {
+ Platform.runLater(() -> {
+ statusLabel.setText("Device detected: " + portName + " — flashing...");
+ startFlash(portName);
+ });
+ }
+
+
+ // ════════════════════════════════════════════════════════
+ // Flash actions
+ // ════════════════════════════════════════════════════════
+
+ private void startFlash(String overridePort) {
+ if (!prereqChecker.isReady()) {
+ statusLabel.setText("esptool not found. Please install it first.");
+ return;
+ }
+
+ String port = overridePort != null ? overridePort : portCombo.getValue();
+ if (port == null || port.isEmpty()) {
+ statusLabel.setText("Please select a port.");
+ return;
+ }
+
+ if (selectedFirmware != null) {
+ downloadAndFlash(selectedFirmware, port);
+ return;
+ }
+
+ String binPath = binPathField.getText();
+ if (binPath.isEmpty()) {
+ statusLabel.setText("Please select a firmware file.");
+ return;
+ }
+
+ runFlash(port, binPath);
+ }
+
+ private void runFlash(String port, String binPath) {
+ FlashConfig config = new FlashConfig(
+ chipCombo.getValue(),
+ Integer.parseInt(baudCombo.getValue()),
+ port,
+ binPath,
+ flashOffsetField.getText(),
+ prereqChecker.getEsptoolCmd()
+ );
+
+ flashButton.setDisable(true);
+ stopButton.setDisable(false);
+ progressBar.setStyle("");
+ progressBar.setProgress(0);
+ statusLabel.setText("Flashing...");
+
+ currentFlashPort = port;
+ currentFlashMac = null;
+ esptoolRunner.startFlashing(config, this);
+ }
+
+ private void downloadAndFlash(FirmwareDefinition fw, String port) {
+ flashButton.setDisable(true);
+ stopButton.setDisable(false);
+ progressBar.setStyle("");
+ progressBar.setProgress(0);
+ statusLabel.setText("Downloading " + fw.getName() + "...");
+
+ String chip = chipCombo.getValue();
+ String binName = fw.getBinForChip(chip);
+ if (binName == null) {
+ Platform.runLater(() -> {
+ statusLabel.setText("No binary defined for " + chip);
+ statusLabel.setStyle("-fx-text-fill: red;");
+ flashButton.setDisable(false);
+ stopButton.setDisable(true);
+ });
+ return;
+ }
+
+ new Thread(() -> {
+ try {
+ String url = firmwareDownloader.fetchDownloadUrl(fw, chip);
+ if (url == null) {
+ Platform.runLater(() -> {
+ statusLabel.setText("Could not find download URL for " + binName);
+ statusLabel.setStyle("-fx-text-fill: red;");
+ flashButton.setDisable(false);
+ stopButton.setDisable(true);
+ });
+ return;
+ }
+
+ FirmwareDownloader.DownloadListener listener = (downloaded, total) ->
+ Platform.runLater(() -> {
+ if (total > 0) progressBar.setProgress((double) downloaded / total);
+ });
+
+ String version = firmwareDownloader.fetchLatestVersion(fw);
+ String localPath = binName.toLowerCase().endsWith(".zip")
+ ? firmwareDownloader.downloadAndExtract(url, binName, chip, version, listener)
+ : firmwareDownloader.download(url, binName, listener);
+
+ Platform.runLater(() -> {
+ binPathField.setText(localPath);
+ progressBar.setProgress(0);
+ runFlash(port, localPath);
+ });
+ } catch (Exception ex) {
+ Platform.runLater(() -> {
+ logArea.appendText("[download] " + ex.getClass().getSimpleName() + ": " + ex.getMessage() + "\n");
+ statusLabel.setText("Download failed.");
+ statusLabel.setStyle("-fx-text-fill: red;");
+ flashButton.setDisable(false);
+ stopButton.setDisable(true);
+ });
+ }
+ }).start();
+ }
+
+ private void startFactoryMode() {
+ if (binPathField.getText().isEmpty()) {
+ Alert alert = new Alert(Alert.AlertType.WARNING);
+ alert.setTitle("No Firmware Selected");
+ alert.setHeaderText(null);
+ alert.setContentText("Please select a firmware .bin file before starting factory mode.");
+ alert.showAndWait();
+ return;
+ }
+ if (!prereqChecker.isReady()) {
+ statusLabel.setText("esptool not ready.");
+ return;
+ }
+
+ isFactoryMode = true;
+ flashButton.setDisable(true);
+ factoryButton.setText("Stop Factory");
+ factoryButton.setOnAction(e -> stopAll());
+ stopButton.setDisable(false);
+ statusLabel.setText("Factory mode — waiting for device...");
+ statusLabel.setStyle("-fx-text-fill: #66bb6a;");
+
+ // Confirmation dialog
+ Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
+ confirm.setTitle("Start Factory Mode");
+ confirm.setHeaderText("Ready to flash?");
+
+ Label binLabel = new Label(new File(binPathField.getText()).getName());
+ binLabel.getStyleClass().add("confirm-bin-name");
+
+ Label chipLabel = new Label("Chip: " + chipCombo.getValue() + " Baud: " + baudCombo.getValue());
+ chipLabel.getStyleClass().add("confirm-details");
+
+ VBox content = new VBox(8, binLabel, chipLabel);
+ confirm.getDialogPane().setContent(content);
+ confirm.getDialogPane().getStylesheets().add(
+ getClass().getResource("/styles.css").toExternalForm()
+ );
+ confirm.setGraphic(null);
+
+ ButtonType startBtn = new ButtonType("Start Factory", ButtonBar.ButtonData.OK_DONE);
+ ButtonType cancelBtn = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
+ confirm.getButtonTypes().setAll(startBtn, cancelBtn);
+
+ Optional result = confirm.showAndWait();
+ if (result.isEmpty() || result.get() != startBtn) return;
+
+ portWatcher.startWatching(this);
+ }
+
+ private void stopAll() {
+ portWatcher.stopWatching();
+ esptoolRunner.stopFlashing();
+ isFactoryMode = false;
+ flashButton.setDisable(false);
+ factoryButton.setText("Factory Mode");
+ factoryButton.setOnAction(e -> startFactoryMode());
+ stopButton.setDisable(true);
+ statusLabel.setText("Stopped.");
+ statusLabel.setStyle("");
+ }
+
+
+ // ════════════════════════════════════════════════════════
+ // Settings dialog
+ // ════════════════════════════════════════════════════════
+
+ private VBox buildSettingsView(Stage parentStage) {
+ // Left menu
+ VBox leftMenu = new VBox(0);
+ leftMenu.getStyleClass().add("settings-menu");
+ leftMenu.setPrefWidth(150);
+ leftMenu.setMinWidth(150);
+
+ Label menuTitle = new Label("Settings");
+ menuTitle.getStyleClass().add("settings-menu-title");
+ menuTitle.setPadding(new Insets(16));
+
+ Button generalBtn = new Button("General");
+ generalBtn.getStyleClass().add("settings-menu-item");
+ generalBtn.setMaxWidth(Double.MAX_VALUE);
+
+ Button pathsBtn = new Button("Paths");
+ pathsBtn.getStyleClass().add("settings-menu-item");
+ pathsBtn.setMaxWidth(Double.MAX_VALUE);
+
+ Button logFileBtn = new Button("Log File");
+ logFileBtn.getStyleClass().add("settings-menu-item");
+ logFileBtn.setMaxWidth(Double.MAX_VALUE);
+
+ leftMenu.getChildren().addAll(menuTitle, generalBtn, pathsBtn, logFileBtn);
+
+ // Right panels
+ VBox generalPane = buildGeneralPane();
+ VBox pathsPane = buildPathsPane(parentStage);
+ VBox logFilePane = buildLogFilePane(parentStage);
+
+ pathsPane.setVisible(false);
+ logFilePane.setVisible(false);
+
+ StackPane rightContent = new StackPane(generalPane, pathsPane, logFilePane);
+ rightContent.setPadding(new Insets(20));
+ HBox.setHgrow(rightContent, Priority.ALWAYS);
+
+ generalBtn.setOnAction(e -> {
+ generalPane.setVisible(true);
+ pathsPane.setVisible(false);
+ logFilePane.setVisible(false);
+ setActiveMenu(generalBtn, pathsBtn, logFileBtn);
+ });
+ pathsBtn.setOnAction(e -> {
+ generalPane.setVisible(false);
+ pathsPane.setVisible(true);
+ logFilePane.setVisible(false);
+ setActiveMenu(pathsBtn, generalBtn, logFileBtn);
+ });
+ logFileBtn.setOnAction(e -> {
+ generalPane.setVisible(false);
+ pathsPane.setVisible(false);
+ logFilePane.setVisible(true);
+ setActiveMenu(logFileBtn, generalBtn, pathsBtn);
+ });
+
+ generalBtn.getStyleClass().add("settings-menu-item-active");
+
+ HBox mainLayout = new HBox(leftMenu, rightContent);
+ VBox.setVgrow(mainLayout, Priority.ALWAYS);
+
+ VBox view = new VBox(mainLayout);
+ view.getStyleClass().add("settings-root");
+ return view;
+ }
+
+ private void setActiveMenu(Button active, Button... others) {
+ active.getStyleClass().add("settings-menu-item-active");
+ for (Button b : others) b.getStyleClass().remove("settings-menu-item-active");
+ }
+
+ private VBox buildGeneralPane() {
+ VBox pane = new VBox(12);
+
+ Label heading = new Label("General");
+ heading.setStyle("-fx-font-weight: bold; -fx-font-size: 14px;");
+
+ Label info = new Label("Last used settings are saved automatically.");
+ info.setStyle("-fx-text-fill: #888888;");
+
+ pane.getChildren().addAll(heading, info);
+ return pane;
+ }
+
+ private VBox buildPathsPane(Stage dialog) {
+ VBox pane = new VBox(12);
+
+ Label heading = new Label("Detected Paths");
+ heading.setStyle("-fx-font-weight: bold; -fx-font-size: 14px;");
+
+ Label detectedPython = detectedPathLabel("Python", prereqChecker.getPythonCmd());
+ Label detectedPip = detectedPathLabel("pip", prereqChecker.getPipCmd());
+ Label detectedEsptool = detectedPathLabel("esptool", prereqChecker.getEsptoolCmd());
+
+ VBox detectedBox = new VBox(4, detectedPython, detectedPip, detectedEsptool);
+ detectedBox.setStyle("-fx-background-color: rgba(255,255,255,0.04); "
+ + "-fx-border-color: rgba(255,255,255,0.08); "
+ + "-fx-border-radius: 6; -fx-background-radius: 6; -fx-padding: 8;");
+
+ Label overrideHeading = new Label("Custom Overrides");
+ overrideHeading.setStyle("-fx-font-weight: bold;");
+
+ TextField pythonField = new TextField(PrereqChecker.getCustomPythonPath());
+ pythonField.setPromptText("e.g. /usr/bin/python3");
+ HBox.setHgrow(pythonField, Priority.ALWAYS);
+ Button pythonBrowse = new Button("Browse...");
+ pythonBrowse.setOnAction(e -> {
+ FileChooser fc = new FileChooser();
+ File f = fc.showOpenDialog(dialog);
+ if (f != null) pythonField.setText(f.getAbsolutePath());
+ });
+
+ TextField esptoolField = new TextField(PrereqChecker.getCustomEsptoolPath());
+ esptoolField.setPromptText("e.g. /usr/local/bin/esptool.py");
+ HBox.setHgrow(esptoolField, Priority.ALWAYS);
+ Button esptoolBrowse = new Button("Browse...");
+ esptoolBrowse.setOnAction(e -> {
+ FileChooser fc = new FileChooser();
+ File f = fc.showOpenDialog(dialog);
+ if (f != null) esptoolField.setText(f.getAbsolutePath());
+ });
+
+ Label status = new Label();
+ Button recheckBtn = new Button("Save & Recheck");
+ recheckBtn.setOnAction(e -> {
+ PrereqChecker.setCustomPaths(pythonField.getText(), esptoolField.getText());
+ status.setText("Rechecking...");
+ new Thread(() -> {
+ prereqChecker.checkAll();
+ Platform.runLater(() -> {
+ detectedPython.setText(detectedText("Python", prereqChecker.getPythonCmd()));
+ detectedPip.setText(detectedText("pip", prereqChecker.getPipCmd()));
+ detectedEsptool.setText(detectedText("esptool", prereqChecker.getEsptoolCmd()));
+ status.setText(prereqChecker.isReady() ? "✓ Ready." : "✗ esptool not found.");
+ status.setStyle(prereqChecker.isReady() ? "-fx-text-fill: green;" : "-fx-text-fill: red;");
+ });
+ }).start();
+ });
+
+ pane.getChildren().addAll(
+ heading, detectedBox,
+ new Separator(), overrideHeading,
+ new Label("Python:"), new HBox(8, pythonField, pythonBrowse),
+ new Label("esptool:"), new HBox(8, esptoolField, esptoolBrowse),
+ status, recheckBtn
+ );
+ return pane;
+ }
+
+ private VBox buildLogFilePane(Stage dialog) {
+ VBox pane = new VBox(12);
+ AppSettings settings = settingsManager.getSettings();
+
+ Label heading = new Label("Log File");
+ heading.setStyle("-fx-font-weight: bold; -fx-font-size: 14px;");
+
+ Label infoBtn = new Label("ℹ");
+ infoBtn.setStyle(
+ "-fx-border-color: #666; -fx-border-radius: 50%; -fx-background-radius: 50%; " +
+ "-fx-background-color: #2a2a2a; -fx-font-size: 12px; -fx-text-fill: #cccccc; " +
+ "-fx-min-width: 22px; -fx-min-height: 22px; -fx-max-width: 22px; -fx-max-height: 22px; " +
+ "-fx-alignment: center; -fx-cursor: hand;");
+ infoBtn.setOnMouseClicked(e -> {
+ Alert info = new Alert(Alert.AlertType.NONE);
+ info.setTitle("Why use a log file?");
+ info.getButtonTypes().add(ButtonType.OK);
+
+ Label title = new Label("MAC Address Provisioning Audit");
+ title.setStyle("-fx-font-weight: bold; -fx-font-size: 13px;");
+
+ Label recordedLabel = new Label("Each flash attempt is recorded with:");
+ recordedLabel.setStyle("-fx-font-size: 12px;");
+
+ VBox bullets = new VBox(4,
+ bullet("Timestamp"),
+ bullet("Serial port"),
+ bullet("Device MAC address"),
+ bullet("Status (success / failed)")
+ );
+ bullets.setStyle("-fx-padding: 0 0 0 12;");
+
+ Label useCaseLabel = new Label("Use case — backend provisioning check:");
+ useCaseLabel.setStyle("-fx-font-weight: bold; -fx-font-size: 12px; -fx-padding: 8 0 0 0;");
+
+ Label useCaseBody = new Label(
+ "Upload flash-log.csv to your backend and cross-reference the MAC " +
+ "addresses against your provisioned-device registry. This lets you verify " +
+ "that every flashed device was registered, catch devices that were flashed " +
+ "but never provisioned, and flag duplicates (same MAC flashed twice)."
+ );
+ useCaseBody.setStyle("-fx-font-size: 12px;");
+ useCaseBody.setWrapText(true);
+ useCaseBody.setMaxWidth(380);
+
+ VBox content = new VBox(8, title, recordedLabel, bullets, useCaseLabel, useCaseBody);
+ content.setStyle("-fx-padding: 4;");
+
+ info.getDialogPane().setContent(content);
+ info.getDialogPane().getStylesheets().addAll(dialog.getScene().getStylesheets());
+ info.showAndWait();
+ });
+
+ HBox headingRow = new HBox(8, heading, infoBtn);
+ headingRow.setAlignment(Pos.CENTER_LEFT);
+
+ CheckBox enableToggle = new CheckBox("Enable log file");
+ enableToggle.setSelected(settings.isLogFileEnabled());
+ enableToggle.setStyle(settings.isLogFileEnabled() ? "-fx-text-fill: #66bb6a;" : "");
+
+ Label pathLabel = new Label("Log folder:");
+ TextField pathField = new TextField(settings.getLogFilePath());
+ pathField.setDisable(!settings.isLogFileEnabled());
+ HBox.setHgrow(pathField, Priority.ALWAYS);
+
+ Button browseBtn = new Button("Browse...");
+ browseBtn.setDisable(!settings.isLogFileEnabled());
+ browseBtn.setOnAction(e -> {
+ javafx.stage.DirectoryChooser dc = new javafx.stage.DirectoryChooser();
+ dc.setTitle("Select Log Folder");
+ File dir = dc.showDialog(dialog);
+ if (dir != null) {
+ pathField.setText(dir.getAbsolutePath());
+ settingsManager.save();
+ }
+ });
+
+ Label formatInfo = new Label("Format: timestamp, port, MAC address, status");
+ formatInfo.setStyle("-fx-text-fill: #888888; -fx-font-size: 11px;");
+
+ enableToggle.setOnAction(e -> {
+ boolean enabled = enableToggle.isSelected();
+ pathField.setDisable(!enabled);
+ browseBtn.setDisable(!enabled);
+ settings.setLogFileEnabled(enabled);
+ enableToggle.setStyle(enabled ? "-fx-text-fill: #66bb6a;" : "");
+ settingsManager.save();
+ });
+
+ // Persist path on focus loss to avoid saving on every keystroke
+ pathField.textProperty().addListener((obs, old, val) -> settings.setLogFilePath(val));
+ pathField.focusedProperty().addListener((obs, was, isFocused) -> {
+ if (!isFocused) settingsManager.save();
+ });
+
+ pane.getChildren().addAll(
+ headingRow,
+ enableToggle,
+ pathLabel,
+ new HBox(8, pathField, browseBtn),
+ formatInfo
+ );
+ return pane;
+ }
+
+
+ // ════════════════════════════════════════════════════════
+ // Explore Popular Projects view
+ // ════════════════════════════════════════════════════════
+
+ private VBox buildExploreView() {
+ Label title = new Label("Explore Popular Projects");
+ title.getStyleClass().add("explore-title");
+
+ Label subtitle = new Label("Tap any project to open its GitHub repository.");
+ subtitle.setStyle("-fx-text-fill: #888888; -fx-font-size: 12px;");
+
+ Button backBtn = new Button("← Back to Flasher");
+ backBtn.getStyleClass().add("link-button");
+ backBtn.setOnAction(e -> showView(flasherView));
+
+ HBox header = new HBox(12, backBtn);
+ header.setAlignment(Pos.CENTER_LEFT);
+
+ VBox content = new VBox(18);
+ content.setPadding(new Insets(4, 4, 16, 4));
+ content.getChildren().addAll(header, title, subtitle);
+
+ Map> grouped = groupByCategory();
+ for (Map.Entry> entry : grouped.entrySet()) {
+ Label catLabel = new Label(entry.getKey());
+ catLabel.getStyleClass().add("explore-category");
+
+ FlowPane cards = new FlowPane(12, 12);
+ for (FirmwareDefinition fw : entry.getValue()) {
+ cards.getChildren().add(buildFirmwareCard(fw));
+ }
+ content.getChildren().addAll(catLabel, cards);
+ }
+
+ ScrollPane scroll = new ScrollPane(content);
+ scroll.setFitToWidth(true);
+ scroll.setStyle("-fx-background: transparent; -fx-background-color: transparent;");
+ VBox.setVgrow(scroll, Priority.ALWAYS);
+
+ VBox view = new VBox(scroll);
+ view.getStyleClass().add("explore-root");
+ return view;
+ }
+
+ private Node buildFirmwareCard(FirmwareDefinition fw) {
+ VBox card = new VBox(8);
+ card.getStyleClass().add("firmware-card");
+ card.setPrefWidth(210);
+ card.setMinWidth(210);
+ card.setMaxWidth(210);
+
+ String repo = fw.getGithubRepo();
+ String org = repo != null && repo.contains("/") ? repo.split("/")[0] : null;
+
+ ImageView logo = new ImageView();
+ logo.setFitWidth(48);
+ logo.setFitHeight(48);
+ logo.setPreserveRatio(true);
+
+ String localIcon = localLogoFor(fw.getName());
+ try {
+ if (localIcon != null) {
+ var url = getClass().getResource(localIcon);
+ if (url != null) logo.setImage(new Image(url.toExternalForm()));
+ } else if (org != null) {
+ logo.setImage(new Image(
+ "https://github.com/" + org + ".png?size=96",
+ true
+ ));
+ }
+ } catch (Exception ignored) {
+ }
+
+ Label name = new Label(fw.getName());
+ name.getStyleClass().add("firmware-card-title");
+ name.setWrapText(true);
+
+ Label desc = new Label(fw.getDescription());
+ desc.getStyleClass().add("firmware-card-desc");
+ desc.setWrapText(true);
+
+ HBox header = new HBox(10, logo, name);
+ header.setAlignment(Pos.CENTER_LEFT);
+
+ card.getChildren().addAll(header, desc);
+
+ card.setOnMouseClicked(e -> {
+ String url = (fw.getGithubRepo() != null && !fw.getGithubRepo().isBlank())
+ ? "https://github.com/" + fw.getGithubRepo()
+ : fw.getWebsiteUrl();
+ if (url != null && !url.isBlank()) {
+ getHostServices().showDocument(url);
+ }
+ });
+ card.setStyle("-fx-cursor: hand;");
+
+ return card;
+ }
+
+ private String localLogoFor(String firmwareName) {
+ if (firmwareName == null) return null;
+ if (firmwareName.startsWith("Tasmota")) return "/icons/firmware/tasmota.png";
+ return null;
+ }
+
+ private Map> groupByCategory() {
+ Map categoryOf = new LinkedHashMap<>();
+ categoryOf.put("Tasmota", "Smart Home & Automation");
+ categoryOf.put("Tasmota SML (ottelo9)", "Smart Home & Automation");
+
+ Map> grouped = new LinkedHashMap<>();
+ grouped.put("Smart Home & Automation", new ArrayList<>());
+ grouped.put("Other", new ArrayList<>());
+
+ for (FirmwareDefinition fw : FirmwareCatalog.getCatalog()) {
+ String cat = categoryOf.getOrDefault(fw.getName(), "Other");
+ grouped.get(cat).add(fw);
+ }
+
+ grouped.values().removeIf(List::isEmpty);
+ return grouped;
+ }
+
+
+ // ════════════════════════════════════════════════════════
+ // Prerequisite checks & esptool install
+ // ════════════════════════════════════════════════════════
+
+ private void checkPrerequisites() {
+ statusLabel.setText("Checking prerequisites...");
+
+ Thread thread = new Thread(() -> {
+ prereqChecker.checkAll();
+ Platform.runLater(() -> {
+ if (prereqChecker.isReady()) {
+ statusLabel.setText("Ready.");
+ } else {
+ statusLabel.setText("esptool not found — click here to install.");
+ statusLabel.setStyle("-fx-text-fill: #2196f3; -fx-underline: true; -fx-cursor: hand;");
+ statusLabel.setOnMouseClicked(e -> installEsptool());
+ }
+ });
+ });
+ thread.setDaemon(true);
+ thread.start();
+
+ Platform.runLater(() -> {
+ if (prereqChecker.isReady()) {
+ statusLabel.setText("Ready.");
+ } else if (prereqChecker.getPythonCmd() == null) {
+ showPythonMissingDialog();
+ } else {
+ autoInstallEsptool();
+ }
+ });
+ }
+
+ private void installEsptool() {
+ statusLabel.setStyle("");
+ statusLabel.setOnMouseClicked(null);
+ if (prereqChecker.getPythonCmd() == null) {
+ showPythonMissingDialog();
+ return;
+ }
+ if (prereqChecker.getPipCmd() == null) {
+ statusLabel.setText("⚠ pip not found — reinstall Python with pip included.");
+ statusLabel.setStyle("-fx-text-fill: red;");
+ return;
+ }
+ showEsptoolInstallDialog();
+ }
+
+ private void autoInstallEsptool() {
+ showEsptoolInstallDialog();
+ }
+
+ private void showEsptoolInstallDialog() {
+ Stage dialog = new Stage();
+ dialog.initModality(Modality.APPLICATION_MODAL);
+ dialog.setTitle("Installing esptool");
+ dialog.setResizable(false);
+
+ Label titleLabel = new Label("Installing esptool via pip...");
+ titleLabel.setStyle("-fx-font-weight: bold;");
+
+ ProgressBar bar = new ProgressBar();
+ bar.setProgress(ProgressBar.INDETERMINATE_PROGRESS);
+ bar.setMaxWidth(Double.MAX_VALUE);
+
+ TextArea output = new TextArea();
+ output.setEditable(false);
+ output.setWrapText(true);
+ output.setPrefHeight(180);
+ output.setPrefWidth(440);
+
+ Label statusMsg = new Label("Please wait...");
+
+ Button closeBtn = new Button("Close");
+ closeBtn.setDisable(true);
+ closeBtn.setOnAction(e -> dialog.close());
+
+ VBox root = new VBox(10, titleLabel, bar, output, statusMsg, closeBtn);
+ root.setPadding(new Insets(16));
+ root.setAlignment(Pos.CENTER_LEFT);
+
+ dialog.setScene(new Scene(root));
+
+ Thread thread = new Thread(() -> {
+ boolean success = prereqChecker.installEsptool(line ->
+ Platform.runLater(() -> output.appendText(line + "\n"))
+ );
+
+ Platform.runLater(() -> {
+ bar.setProgress(1.0);
+ if (success) {
+ logArea.clear();
+ statusLabel.setText("Ready.");
+ statusLabel.setStyle("");
+ flashButton.setDisable(false);
+ factoryButton.setDisable(false);
+ dialog.close();
+ } else {
+ statusMsg.setText("✗ Install failed. Check output above.");
+ statusMsg.setStyle("-fx-text-fill: red;");
+ statusLabel.setText("⚠ esptool install failed.");
+ closeBtn.setDisable(false);
+ }
+ });
+ });
+ thread.setDaemon(true);
+ thread.start();
+
+ dialog.show();
+ }
+
+ private void showPythonMissingDialog() {
+ flashButton.setDisable(true);
+ factoryButton.setDisable(true);
+
+ Stage dialog = new Stage();
+ dialog.initModality(Modality.APPLICATION_MODAL);
+ dialog.setTitle("Python Not Found");
+ dialog.setResizable(false);
+
+ Label heading = new Label("Python 3 is required to run esptool.");
+ heading.setStyle("-fx-font-weight: bold; -fx-font-size: 13px;");
+
+ String os = System.getProperty("os.name", "").toLowerCase();
+ String steps;
+ if (os.contains("win")) {
+ steps = """
+ 1. Go to https://python.org/downloads
+ 2. Download the latest Python 3 installer for Windows.
+ 3. Run the installer — check "Add Python to PATH" before clicking Install.
+ 4. Restart ESP Flasher after installation.""";
+ } else if (os.contains("mac")) {
+ steps = """
+ Option A — Homebrew (recommended):
+ brew install python3
+
+ Option B — Installer:
+ 1. Go to https://python.org/downloads
+ 2. Download and run the macOS installer.
+ 3. Restart ESP Flasher after installation.""";
+ } else {
+ steps = """
+ Debian / Ubuntu:
+ sudo apt install python3 python3-pip
+
+ Fedora / RHEL:
+ sudo dnf install python3
+
+ Arch:
+ sudo pacman -S python
+
+ Restart ESP Flasher after installation.""";
+ }
+
+ TextArea guide = new TextArea(steps);
+ guide.setEditable(false);
+ guide.setWrapText(true);
+ guide.setPrefHeight(140);
+ guide.setPrefWidth(400);
+ guide.setStyle("-fx-font-family: monospace; -fx-font-size: 11px;");
+
+ Button downloadBtn = new Button("Open python.org");
+ downloadBtn.setOnAction(e -> getHostServices().showDocument("https://python.org/downloads"));
+
+ Button closeBtn = new Button("Close");
+ closeBtn.setOnAction(e -> dialog.close());
+
+ HBox buttons = new HBox(10, downloadBtn, closeBtn);
+ buttons.setAlignment(Pos.CENTER_RIGHT);
+
+ VBox root = new VBox(12, heading, guide, buttons);
+ root.setPadding(new Insets(16));
+
+ dialog.setScene(new Scene(root));
+ dialog.showAndWait();
+
+ statusLabel.setText("⚠ Python not found — install Python and restart.");
+ statusLabel.setStyle("-fx-text-fill: red;");
+ }
+
+
+ // ════════════════════════════════════════════════════════
+ // About dialog
+ // ════════════════════════════════════════════════════════
+
+ private void showAboutDialog() {
+ Stage dialog = new Stage();
+ dialog.setTitle("About");
+ dialog.setResizable(false);
+
+ VBox content = new VBox(12);
+ content.setPadding(new Insets(24));
+ content.setAlignment(Pos.CENTER);
+ content.getStyleClass().add("about-dialog");
+
+ var iconUrl = getClass().getResource("/icons/icon.png");
+ if (iconUrl != null) {
+ javafx.scene.image.ImageView logo = new javafx.scene.image.ImageView(
+ new javafx.scene.image.Image(iconUrl.toExternalForm()));
+ logo.setFitWidth(72);
+ logo.setFitHeight(72);
+ logo.setPreserveRatio(true);
+ content.getChildren().add(logo);
+ }
+
+ Label title = new Label("ESP Flasher");
+ title.getStyleClass().add("about-title");
+
+ String appVersion = new UpdateService().currentVersion();
+ Label version = new Label("v" + appVersion);
+ version.getStyleClass().add("about-version");
+
+ Separator sep = new Separator();
+ sep.setStyle("-fx-background-color: #3a3a3c;");
+
+ Label author = new Label("Built by Ajinkya Gokhale");
+ author.getStyleClass().add("about-author");
+
+ Label email = new Label("✉ hi@ajinkyagokhale.com");
+ email.getStyleClass().add("about-link");
+ email.setOnMouseClicked(e -> getHostServices().showDocument("mailto:hi@ajinkyagokhale.com"));
+
+ Label github = new Label("⚡ github.com/ajinkyagokhale");
+ github.getStyleClass().add("about-link");
+ github.setOnMouseClicked(e -> getHostServices().showDocument("https://github.com/ajinkyagokhale"));
+
+ Label license = new Label("MIT License — 2026");
+ license.getStyleClass().add("about-license");
+
+ Button closeBtn = new Button("Close");
+ closeBtn.setOnAction(e -> dialog.close());
+ closeBtn.setPrefWidth(100);
+
+ content.getChildren().addAll(
+ title, version, sep,
+ author, email, github,
+ license, closeBtn
+ );
+
+ Scene scene = new Scene(content, 280, 360);
+ dialog.setScene(scene);
+ dialog.show();
+ }
+
+
+ // ════════════════════════════════════════════════════════
+ // Auto-update flow
+ // ════════════════════════════════════════════════════════
+
+ private void checkForUpdates() {
+ Thread worker = new Thread(() -> {
+ UpdateService updates = new UpdateService();
+ String current = updates.currentVersion();
+ updates.latestRelease()
+ .filter(release -> updates.isNewer(release.version(), current))
+ .ifPresent(release ->
+ Platform.runLater(() -> promptForcedUpdate(updates, release, current)));
+ }, "update-check");
+ worker.setDaemon(true);
+ worker.start();
+ }
+
+ @SuppressFBWarnings("DM_EXIT")
+ private void promptForcedUpdate(UpdateService updates, UpdateService.Release release, String current) {
+ ButtonType updateNow = new ButtonType("Update Now", ButtonBar.ButtonData.OK_DONE);
+ ButtonType quit = new ButtonType("Quit", ButtonBar.ButtonData.CANCEL_CLOSE);
+
+ Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
+ alert.setTitle("Update Required");
+ alert.setHeaderText("A new version of ESP Flasher is available");
+ alert.setContentText("Installed: " + current + "\nLatest: " + release.version()
+ + "\n\nYou must update to continue.");
+ alert.getButtonTypes().setAll(updateNow, quit);
+ alert.initModality(Modality.APPLICATION_MODAL);
+
+ Optional choice = alert.showAndWait();
+ while (choice.isEmpty()) {
+ choice = alert.showAndWait();
+ }
+
+ if (choice.get() == updateNow) {
+ downloadUpdate(updates, release);
+ } else {
+ System.exit(0);
+ }
+ }
+
+ private void downloadUpdate(UpdateService updates, UpdateService.Release release) {
+ AtomicBoolean cancelled = new AtomicBoolean(false);
+
+ ProgressBar bar = new ProgressBar(0);
+ bar.setPrefWidth(380);
+ Label info = new Label("Starting download...");
+ Label hint = new Label("The app will restart to install the update.");
+ hint.getStyleClass().add("footer");
+ VBox box = new VBox(10, info, bar, hint);
+ box.setPadding(new Insets(16));
+
+ ButtonType cancelType = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
+ Alert dialog = new Alert(Alert.AlertType.NONE);
+ dialog.setTitle("Downloading Update");
+ dialog.setHeaderText("Updating to " + release.version());
+ dialog.getDialogPane().setContent(box);
+ dialog.getButtonTypes().setAll(cancelType);
+ dialog.initModality(Modality.APPLICATION_MODAL);
+ dialog.setOnHidden(e -> cancelled.set(true));
+
+ Thread worker = new Thread(() -> {
+ try {
+ updates.downloadAndLaunch(release,
+ (downloaded, total) -> Platform.runLater(
+ () -> updateProgress(bar, info, downloaded, total)),
+ cancelled);
+ Platform.runLater(() -> {
+ dialog.close();
+ promptForcedUpdate(updates, release, updates.currentVersion());
+ });
+ } catch (Exception e) {
+ Platform.runLater(() -> {
+ dialog.close();
+ Alert error = new Alert(Alert.AlertType.ERROR);
+ error.setTitle("Update Failed");
+ error.setHeaderText("Could not download the update");
+ error.setContentText(e.getMessage());
+ error.showAndWait();
+ promptForcedUpdate(updates, release, updates.currentVersion());
+ });
+ }
+ }, "update-download");
+ worker.setDaemon(true);
+ worker.start();
+
+ dialog.show();
+ }
+
+ private void updateProgress(ProgressBar bar, Label info, long downloaded, long total) {
+ double mb = downloaded / 1048576.0;
+ if (total > 0) {
+ double fraction = (double) downloaded / total;
+ bar.setProgress(fraction);
+ info.setText(String.format("%.1f MB / %.1f MB (%d%%)",
+ mb, total / 1048576.0, (int) (fraction * 100)));
+ } else {
+ bar.setProgress(ProgressBar.INDETERMINATE_PROGRESS);
+ info.setText(String.format("%.1f MB downloaded", mb));
+ }
+ }
+
+
+ // ════════════════════════════════════════════════════════
+ // Helpers
+ // ════════════════════════════════════════════════════════
+
+ private void browseBin(Stage stage) {
+ FileChooser fileChooser = new FileChooser();
+ fileChooser.setTitle("Select Firmware");
+ fileChooser.getExtensionFilters().add(
+ new FileChooser.ExtensionFilter("Binary files (*.bin)", "*.bin")
+ );
+ File file = fileChooser.showOpenDialog(stage);
+ if (file != null) {
+ binPathField.setText(file.getAbsolutePath());
+ }
+ }
+
+ private void updateChipListForFirmware(FirmwareDefinition def) {
+ String previous = chipCombo.getValue();
+ chipCombo.getItems().clear();
+
+ if (def == null) {
+ chipCombo.getItems().addAll(
+ "auto", "esp32c6", "esp32", "esp32s2",
+ "esp32s3", "esp32c3", "esp32h2", "esp8266"
+ );
+ } else {
+ for (String chip : def.getChipBinMap().keySet()) {
+ if (!"default".equals(chip)) chipCombo.getItems().add(chip);
+ }
+ }
+
+ if (previous != null && chipCombo.getItems().contains(previous)) {
+ chipCombo.setValue(previous);
+ } else if (!chipCombo.getItems().isEmpty()) {
+ chipCombo.getSelectionModel().selectFirst();
+ }
+ }
+
+ private void refreshPorts() {
+ portCombo.getItems().clear();
+ List espPorts = PortWatcher.listEsp32Ports();
+
+ if (espPorts.isEmpty()) {
+ portCombo.setPromptText("No ESP32 detected...");
+ } else {
+ portCombo.getItems().addAll(espPorts);
+ portCombo.getSelectionModel().selectFirst();
+ }
+ }
+
+ private Label bullet(String text) {
+ Label l = new Label("• " + text);
+ l.setStyle("-fx-font-size: 12px;");
+ return l;
+ }
+
+ private Label detectedPathLabel(String name, String value) {
+ Label l = new Label(detectedText(name, value));
+ l.setStyle("-fx-font-family: monospace; -fx-font-size: 11px;");
+ return l;
+ }
+
+ private String detectedText(String name, String value) {
+ if (value == null || value.isBlank())
+ return "✗ " + name + ": not found";
+ return "✓ " + name + ": " + value;
+ }
+
+ private boolean isDarkMode() {
+ String os = System.getProperty("os.name").toLowerCase();
+ try {
+ if (os.contains("mac")) {
+ Process p = Runtime.getRuntime().exec(
+ new String[]{"defaults", "read", "-g", "AppleInterfaceStyle"}
+ );
+ String result = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip();
+ return result.equalsIgnoreCase("dark");
+ } else if (os.contains("windows")) {
+ Process p = Runtime.getRuntime().exec(new String[]{
+ "reg", "query",
+ "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
+ "/v", "AppsUseLightTheme"
+ });
+ String result = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+ return result.contains("0x0");
+ }
+ } catch (Exception e) {
+ return false;
+ }
+ return false;
+ }
+
+ @SuppressFBWarnings("DE_MIGHT_IGNORE")
+ private void applyDarkTitleBar(Stage stage) {
+ if (!System.getProperty("os.name", "").toLowerCase().contains("win")) return;
+ try {
+ com.sun.jna.platform.win32.WinDef.HWND hwnd =
+ com.sun.jna.platform.win32.User32.INSTANCE.FindWindow(null, stage.getTitle());
+ if (hwnd == null) return;
+ com.sun.jna.ptr.IntByReference dark = new com.sun.jna.ptr.IntByReference(1);
+ Dwmapi.INSTANCE.DwmSetWindowAttribute(hwnd, 20, dark, 4); // Windows 10 20H1+
+ Dwmapi.INSTANCE.DwmSetWindowAttribute(hwnd, 19, dark, 4); // older Win10 builds
+ } catch (Exception ignored) {
+ }
+ }
+
+ private interface Dwmapi extends com.sun.jna.Library {
+ Dwmapi INSTANCE = com.sun.jna.Native.load("dwmapi", Dwmapi.class);
+
+ void DwmSetWindowAttribute(com.sun.jna.platform.win32.WinDef.HWND hwnd, int attr,
+ com.sun.jna.ptr.IntByReference value, int size);
+ }
+}
diff --git a/src/main/resources/icons/firmware/tasmota.png b/src/main/resources/icons/firmware/tasmota.png
new file mode 100644
index 0000000..36dba2e
Binary files /dev/null and b/src/main/resources/icons/firmware/tasmota.png differ
diff --git a/src/main/resources/styles.css b/src/main/resources/styles.css
index a32a632..ba848c6 100644
--- a/src/main/resources/styles.css
+++ b/src/main/resources/styles.css
@@ -18,6 +18,13 @@
.label {
-fx-text-fill: #e0e0e0;
}
+.radio-button .label {
+ -fx-text-fill: #e0e0e0;
+}
+
+.radio-button:selected .label {
+ -fx-text-fill: #ffffff;
+}
.text-field, .combo-box {
-fx-background-color: #2c2c2e;
@@ -201,15 +208,19 @@
-fx-background-color: #4a4a4c;
}
-/* ── Settings dialog ─────────────────────────────────── */
+/* ── Settings view (inline) ──────────────────────────── */
.settings-root {
- -fx-background-color: #1c1c1e;
+ -fx-background-color: transparent;
}
.settings-menu {
- -fx-background-color: #141416;
- -fx-border-color: #3a3a3c;
- -fx-border-width: 0 1 0 0;
+ -fx-background-color: rgba(255, 255, 255, 0.05);
+ -fx-border-color: rgba(255, 255, 255, 0.1);
+ -fx-border-width: 1;
+ -fx-border-radius: 12;
+ -fx-background-radius: 12;
+ -fx-effect: dropshadow(gaussian, rgba(0,0,0,0.3), 10, 0, 0, 2);
+ -fx-padding: 4 0 4 0;
}
.settings-menu-title {
@@ -226,21 +237,138 @@
-fx-padding: 10 16 10 16;
-fx-border-width: 0;
-fx-cursor: hand;
+ -fx-background-radius: 6;
+ -fx-focus-color: transparent;
+ -fx-faint-focus-color: transparent;
+}
+
+.settings-menu-item:focused {
+ -fx-background-color: transparent;
}
.settings-menu-item:hover {
- -fx-background-color: rgba(255,255,255,0.05);
+ -fx-background-color: rgba(255,255,255,0.06);
}
-.settings-menu-item-active {
- -fx-background-color: rgba(255,255,255,0.08);
+.settings-menu-item-active,
+.settings-menu-item-active:focused {
+ -fx-background-color: rgba(255,255,255,0.10);
-fx-text-fill: white;
-fx-font-weight: bold;
}
+
+/* primary action button (Save in settings, etc.) */
+.btn-primary {
+ -fx-background-color: #66bb6a;
+ -fx-text-fill: #1c1c1e;
+ -fx-font-weight: bold;
+ -fx-background-radius: 6;
+ -fx-border-radius: 6;
+ -fx-padding: 6 18 6 18;
+ -fx-cursor: hand;
+}
+
+.btn-primary:hover {
+ -fx-background-color: #7ccc81;
+}
.check-box .label {
-fx-text-fill: -fx-text-base-color;
}
.check-box:selected .label {
-fx-text-fill: #ffffff;
+}
+/* ── Toolbar ─────────────────────────────────────────── */
+.toolbar {
+ -fx-background-color: rgba(255, 255, 255, 0.06);
+ -fx-border-color: rgba(255, 255, 255, 0.10);
+ -fx-border-width: 1;
+ -fx-border-radius: 20;
+ -fx-background-radius: 20;
+ -fx-effect: dropshadow(gaussian, rgba(0,0,0,0.3), 10, 0, 0, 2);
+ -fx-padding: 4 4 4 4;
+}
+
+.toolbar-btn {
+ -fx-background-color: transparent;
+ -fx-text-fill: #888888;
+ -fx-font-size: 13px;
+ -fx-padding: 5 24 5 24;
+ -fx-background-radius: 16;
+ -fx-border-width: 0;
+ -fx-cursor: hand;
+ -fx-min-width: 90;
+}
+
+.toolbar-btn:hover {
+ -fx-text-fill: #cccccc;
+}
+
+.toolbar-btn-active {
+ -fx-background-color: rgba(255, 255, 255, 0.12);
+ -fx-text-fill: #ffffff;
+ -fx-font-weight: bold;
+}
+
+/* ── Link-style button (Explore Popular Projects, Back) ── */
+.link-button {
+ -fx-background-color: transparent;
+ -fx-text-fill: #66bb6a;
+ -fx-font-size: 12px;
+ -fx-padding: 4 8 4 8;
+ -fx-cursor: hand;
+ -fx-border-width: 0;
+ -fx-background-radius: 6;
+ -fx-focus-color: transparent;
+ -fx-faint-focus-color: transparent;
+}
+
+.link-button:hover {
+ -fx-text-fill: #7ccc81;
+ -fx-background-color: rgba(102,187,106,0.08);
+ -fx-underline: true;
+}
+
+/* ── Explore Popular Projects view ─────────────────────── */
+.explore-root {
+ -fx-background-color: transparent;
+}
+
+.explore-title {
+ -fx-text-fill: #ffffff;
+ -fx-font-size: 20px;
+ -fx-font-weight: bold;
+}
+
+.explore-category {
+ -fx-text-fill: #cccccc;
+ -fx-font-size: 14px;
+ -fx-font-weight: bold;
+ -fx-padding: 8 0 4 0;
+}
+
+.firmware-card {
+ -fx-background-color: rgba(255, 255, 255, 0.05);
+ -fx-border-color: rgba(255, 255, 255, 0.10);
+ -fx-border-width: 1;
+ -fx-border-radius: 10;
+ -fx-background-radius: 10;
+ -fx-padding: 12;
+ -fx-effect: dropshadow(gaussian, rgba(0,0,0,0.25), 6, 0, 0, 1);
+}
+
+.firmware-card:hover {
+ -fx-background-color: rgba(255, 255, 255, 0.09);
+ -fx-border-color: rgba(102, 187, 106, 0.5);
+}
+
+.firmware-card-title {
+ -fx-text-fill: #ffffff;
+ -fx-font-size: 13px;
+ -fx-font-weight: bold;
+}
+
+.firmware-card-desc {
+ -fx-text-fill: #999999;
+ -fx-font-size: 11px;
}
\ No newline at end of file