From 3c6b422e4ca1fa15d112aeef9acfce1edd3ea1bf Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Meyer Date: Sun, 17 May 2026 13:21:51 +0200 Subject: [PATCH] Add auto-update check with GitHub Releases integration (issue #80) - New UpdateChecker service: fetches latest release via GitHub REST API, compares semver, selects platform asset (.exe/.dmg/.deb) - New UpdateDialog: modal with Download & Install / Later buttons and "Skip this version" checkbox - AppConfig: persist autoCheckUpdate and skipVersion preferences - MarkNote: Help > Check for New Version menu item; silent startup check; download follows GitHub CDN redirects (HttpClient.Redirect.ALWAYS) - pom.xml: suppress JavaFX Marlin Unsafe warning (--sun-misc-unsafe-memory-access=allow) - i18n: update keys added for EN, FR, DE, ES, IT Co-Authored-By: Claude Sonnet 4.6 --- pom.xml | 3 +- src/main/java/MarkNote.java | 84 +++++++++++++++++- src/main/java/config/AppConfig.java | 16 ++++ src/main/java/services/UpdateChecker.java | 88 +++++++++++++++++++ src/main/java/ui/UpdateDialog.java | 69 +++++++++++++++ .../resources/i18n/messages_de.properties | 13 +++ .../resources/i18n/messages_en.properties | 13 +++ .../resources/i18n/messages_es.properties | 13 +++ .../resources/i18n/messages_fr.properties | 13 +++ .../resources/i18n/messages_it.properties | 13 +++ 10 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 src/main/java/services/UpdateChecker.java create mode 100644 src/main/java/ui/UpdateDialog.java diff --git a/pom.xml b/pom.xml index ffbab1e..c46daee 100644 --- a/pom.xml +++ b/pom.xml @@ -336,7 +336,7 @@ ${java.home}/bin/java ${project.basedir} - -Djava.net.preferIPv6Addresses=true -Xmx512m --module-path target/dependency --add-modules ${javafx.modules} -cp target/classes:target/dependency/* ${app.main.class} + -Djava.net.preferIPv6Addresses=true -Xmx512m --sun-misc-unsafe-memory-access=allow --module-path target/dependency --add-modules ${javafx.modules} -cp target/classes:target/dependency/* ${app.main.class} @@ -613,6 +613,7 @@ -Xmx512m + --sun-misc-unsafe-memory-access=allow diff --git a/src/main/java/MarkNote.java b/src/main/java/MarkNote.java index 1fe4316..f3ec1e2 100644 --- a/src/main/java/MarkNote.java +++ b/src/main/java/MarkNote.java @@ -31,7 +31,15 @@ import ui.WelcomeTab; import ui.CommitDialog; import ui.AddRemoteDialog; +import ui.UpdateDialog; import java.util.List; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import services.UpdateChecker; import utils.Debouncer; import utils.DocumentService; import utils.GitService; @@ -413,6 +421,7 @@ public void start(Stage stage) { if (config.isOpenDocOnStart() && !config.isShowWelcomePage()) { addNewDocument(); } + checkForUpdatesOnStartup(); }); splash.show(); } else { @@ -424,6 +433,7 @@ public void start(Stage stage) { if (config.isOpenDocOnStart() && !config.isShowWelcomePage()) { addNewDocument(); } + checkForUpdatesOnStartup(); } } @@ -545,7 +555,10 @@ private MenuBar createMenuBar() { MenuItem aboutItem = new MenuItem(messages.getString("menu.help.about")); aboutItem.setOnAction(e -> showAboutDialog()); - helpMenu.getItems().addAll(optionsItem, new SeparatorMenuItem(), aboutItem); + MenuItem checkUpdateItem = new MenuItem(messages.getString("menu.help.checkUpdate")); + checkUpdateItem.setOnAction(e -> checkForUpdatesManual()); + + helpMenu.getItems().addAll(optionsItem, checkUpdateItem, new SeparatorMenuItem(), aboutItem); // == Menu Édition == Menu editMenu = new Menu(messages.getString("menu.edit")); @@ -1510,6 +1523,75 @@ private void showAboutDialog() { about.showAndWait(); } + private void checkForUpdatesOnStartup() { + if (!config.isAutoCheckUpdate()) return; + new Thread(() -> { + UpdateChecker.VersionInfo info = + UpdateChecker.checkForUpdate(messages.getString("app.version")); + if (info == null) return; + if (info.tagName().equals(config.getSkipVersion())) return; + Platform.runLater(() -> showUpdateDialog(info)); + }, "update-check").start(); + } + + private void checkForUpdatesManual() { + new Thread(() -> { + UpdateChecker.VersionInfo info = + UpdateChecker.checkForUpdate(messages.getString("app.version")); + Platform.runLater(() -> { + if (info == null) { + Alert a = new Alert(Alert.AlertType.INFORMATION); + a.initOwner(primaryStage); + a.setTitle(messages.getString("update.uptodate.title")); + a.setContentText(messages.getString("update.uptodate.content") + .replace("{0}", messages.getString("app.version"))); + a.showAndWait(); + } else { + showUpdateDialog(info); + } + }); + }, "update-check-manual").start(); + } + + private void showUpdateDialog(UpdateChecker.VersionInfo info) { + UpdateDialog dlg = new UpdateDialog(messages, primaryStage, info, + messages.getString("app.version")); + UpdateDialog.UpdateResult result = dlg.showAndGet(); + if (result == UpdateDialog.UpdateResult.SKIP) { + config.setSkipVersion(info.tagName()); + config.save(); + } else if (result == UpdateDialog.UpdateResult.UPDATE) { + downloadAndInstall(info); + } + } + + private void downloadAndInstall(UpdateChecker.VersionInfo info) { + new Thread(() -> { + try { + String suffix = info.assetName() + .substring(info.assetName().lastIndexOf('.')); + Path dest = Files.createTempFile("marknote-update-", suffix); + HttpClient client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.ALWAYS) + .build(); + client.send( + HttpRequest.newBuilder(URI.create(info.downloadUrl())) + .header("Accept", "application/octet-stream") + .build(), + HttpResponse.BodyHandlers.ofFile(dest)); + java.awt.Desktop.getDesktop().open(dest.toFile()); + } catch (Exception e) { + Platform.runLater(() -> { + Alert err = new Alert(Alert.AlertType.ERROR); + err.initOwner(primaryStage); + err.setTitle(messages.getString("update.error.title")); + err.setContentText(e.getMessage()); + err.showAndWait(); + }); + } + }, "update-download").start(); + } + /** * Ouvre un fichier CSS de thème dans un ThemeTab. */ diff --git a/src/main/java/config/AppConfig.java b/src/main/java/config/AppConfig.java index 013d586..4c7a268 100644 --- a/src/main/java/config/AppConfig.java +++ b/src/main/java/config/AppConfig.java @@ -39,6 +39,9 @@ public class AppConfig { private String gitUsername = "token"; private String gitToolbarMode = "standard"; + private boolean autoCheckUpdate = true; + private String skipVersion = null; + public record PanelState(boolean visible, boolean docked, String zone) {} /** @@ -96,6 +99,11 @@ public void load() { gitUsername = line.substring("gitUsername=".length()).trim(); } else if (line.startsWith("gitToolbarMode=")) { gitToolbarMode = line.substring("gitToolbarMode=".length()).trim(); + } else if (line.startsWith("autoCheckUpdate=")) { + autoCheckUpdate = Boolean.parseBoolean(line.substring("autoCheckUpdate=".length()).trim()); + } else if (line.startsWith("skipVersion=")) { + String v = line.substring("skipVersion=".length()).trim(); + skipVersion = v.isEmpty() ? null : v; } else if (line.startsWith("panelState=")) { String raw = line.substring("panelState=".length()).trim(); String[] parts = raw.split("\\|", -1); @@ -138,6 +146,8 @@ public void save() { lines.add("gitToken=" + gitToken); lines.add("gitUsername=" + gitUsername); lines.add("gitToolbarMode=" + gitToolbarMode); + lines.add("autoCheckUpdate=" + autoCheckUpdate); + lines.add("skipVersion=" + (skipVersion != null ? skipVersion : "")); for (Map.Entry entry : panelStates.entrySet()) { PanelState state = entry.getValue(); lines.add("panelState=" + entry.getKey() + "|" + state.visible() + "|" + state.docked() + "|" + state.zone()); @@ -311,6 +321,12 @@ public void setReattachDiagramOnTabClose(boolean reattachDiagramOnTabClose) { public String getGitToolbarMode() { return gitToolbarMode; } public void setGitToolbarMode(String mode) { this.gitToolbarMode = (mode != null && !mode.isBlank()) ? mode : "standard"; } + public boolean isAutoCheckUpdate() { return autoCheckUpdate; } + public void setAutoCheckUpdate(boolean autoCheckUpdate) { this.autoCheckUpdate = autoCheckUpdate; } + + public String getSkipVersion() { return skipVersion; } + public void setSkipVersion(String version) { this.skipVersion = version; } + /** * Supprime un fichier de la liste des récents. * diff --git a/src/main/java/services/UpdateChecker.java b/src/main/java/services/UpdateChecker.java new file mode 100644 index 0000000..3a0dd51 --- /dev/null +++ b/src/main/java/services/UpdateChecker.java @@ -0,0 +1,88 @@ +package services; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; + +public class UpdateChecker { + + private static final String API_URL = + "https://api.github.com/repos/mcgivrer/MarkNote/releases/latest"; + + public record VersionInfo(String tagName, String downloadUrl, String assetName) {} + + public static VersionInfo checkForUpdate(String currentVersion) { + try { + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + HttpRequest req = HttpRequest.newBuilder(URI.create(API_URL)) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .build(); + HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() != 200) return null; + + JSONObject json = (JSONObject) new JSONParser().parse(resp.body()); + String tagName = (String) json.get("tag_name"); + if (tagName == null || !isNewer(tagName, currentVersion)) return null; + + JSONArray assets = (JSONArray) json.get("assets"); + JSONObject asset = selectAsset(assets); + if (asset == null) return null; + + return new VersionInfo(tagName, + (String) asset.get("browser_download_url"), + (String) asset.get("name")); + } catch (Exception e) { + return null; + } + } + + private static boolean isNewer(String remote, String current) { + int[] r = parseParts(remote.startsWith("v") ? remote.substring(1) : remote); + int[] c = parseParts(current.startsWith("v") ? current.substring(1) : current); + int len = Math.max(r.length, c.length); + for (int i = 0; i < len; i++) { + int rv = i < r.length ? r[i] : 0; + int cv = i < c.length ? c[i] : 0; + if (rv > cv) return true; + if (rv < cv) return false; + } + return false; + } + + private static int[] parseParts(String version) { + String[] parts = version.split("\\."); + int[] ints = new int[parts.length]; + for (int i = 0; i < parts.length; i++) { + try { + ints[i] = Integer.parseInt(parts[i]); + } catch (NumberFormatException e) { + ints[i] = 0; + } + } + return ints; + } + + private static JSONObject selectAsset(JSONArray assets) { + if (assets == null) return null; + String os = System.getProperty("os.name").toLowerCase(); + String ext = os.contains("win") ? ".exe" : os.contains("mac") ? ".dmg" : ".deb"; + for (Object o : assets) { + JSONObject a = (JSONObject) o; + String name = (String) a.get("name"); + String state = (String) a.get("state"); + if ("uploaded".equals(state) && name != null && name.endsWith(ext)) { + return a; + } + } + return null; + } +} diff --git a/src/main/java/ui/UpdateDialog.java b/src/main/java/ui/UpdateDialog.java new file mode 100644 index 0000000..e989de1 --- /dev/null +++ b/src/main/java/ui/UpdateDialog.java @@ -0,0 +1,69 @@ +package ui; + +import java.util.ResourceBundle; + +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.stage.Modality; +import javafx.stage.Stage; +import javafx.stage.Window; + +import services.UpdateChecker.VersionInfo; + +public class UpdateDialog { + + public enum UpdateResult { UPDATE, DECLINE, SKIP } + + private final Stage dialog; + private UpdateResult result = UpdateResult.DECLINE; + + public UpdateDialog(ResourceBundle messages, Window owner, VersionInfo info, String currentVersion) { + dialog = new Stage(); + dialog.initModality(Modality.APPLICATION_MODAL); + dialog.initOwner(owner); + dialog.setTitle(messages.getString("update.available.title")); + dialog.setResizable(false); + + Label headerLabel = new Label( + messages.getString("update.available.header").replace("{0}", info.tagName())); + headerLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold;"); + + Label bodyLabel = new Label( + messages.getString("update.available.content").replace("{0}", currentVersion)); + + CheckBox skipBox = new CheckBox(messages.getString("update.skip")); + + Button downloadBtn = new Button(messages.getString("update.download")); + downloadBtn.setDefaultButton(true); + downloadBtn.setOnAction(e -> { + result = UpdateResult.UPDATE; + dialog.close(); + }); + + Button declineBtn = new Button(messages.getString("update.decline")); + declineBtn.setOnAction(e -> { + result = skipBox.isSelected() ? UpdateResult.SKIP : UpdateResult.DECLINE; + dialog.close(); + }); + + HBox buttons = new HBox(10, downloadBtn, declineBtn); + buttons.setAlignment(Pos.CENTER_RIGHT); + + VBox root = new VBox(12, headerLabel, bodyLabel, skipBox, buttons); + root.setPadding(new Insets(20)); + root.setPrefWidth(420); + + dialog.setScene(new Scene(root)); + } + + public UpdateResult showAndGet() { + dialog.showAndWait(); + return result; + } +} diff --git a/src/main/resources/i18n/messages_de.properties b/src/main/resources/i18n/messages_de.properties index 8659f20..09f8112 100644 --- a/src/main/resources/i18n/messages_de.properties +++ b/src/main/resources/i18n/messages_de.properties @@ -436,3 +436,16 @@ git.operation.fetch=Fetch\u2026 # Lesemodus menu.view.readingMode=Lesemodus reading.mode.exit=Lesemodus beenden + +# Update-Prüfung +menu.help.checkUpdate=Nach Updates suchen... +update.available.title=Neue Version verfügbar +update.available.header=MarkNote {0} ist verfügbar +update.available.content=Sie verwenden derzeit Version {0}. +update.skip=Diese Version überspringen +update.download=Herunterladen und installieren +update.decline=Später +update.uptodate.title=Aktuell +update.uptodate.content=Sie verwenden die neueste Version von MarkNote ({0}). +update.error.title=Update-Prüfung fehlgeschlagen +update.error.content=Updates konnten nicht geprüft werden. Bitte versuchen Sie es erneut. diff --git a/src/main/resources/i18n/messages_en.properties b/src/main/resources/i18n/messages_en.properties index d55647f..eb023c7 100644 --- a/src/main/resources/i18n/messages_en.properties +++ b/src/main/resources/i18n/messages_en.properties @@ -438,3 +438,16 @@ git.operation.fetch=Fetching\u2026 # Reading mode menu.view.readingMode=Enter Reading Mode reading.mode.exit=Exit Reading Mode + +# Update check +menu.help.checkUpdate=Check for New Version... +update.available.title=New Version Available +update.available.header=MarkNote {0} is available +update.available.content=You are currently running version {0}. +update.skip=Skip this version +update.download=Download & Install +update.decline=Later +update.uptodate.title=Up to Date +update.uptodate.content=You are running the latest version of MarkNote ({0}). +update.error.title=Update Check Failed +update.error.content=Unable to check for updates. Please try again later. diff --git a/src/main/resources/i18n/messages_es.properties b/src/main/resources/i18n/messages_es.properties index 6d0cc59..bfb4a2d 100644 --- a/src/main/resources/i18n/messages_es.properties +++ b/src/main/resources/i18n/messages_es.properties @@ -437,3 +437,16 @@ git.operation.fetch=Recuperando\u2026 menu.view.readingMode=Modo lectura reading.mode.exit=Salir del modo lectura +# Comprobación de actualizaciones +menu.help.checkUpdate=Buscar nueva versión... +update.available.title=Nueva versión disponible +update.available.header=MarkNote {0} está disponible +update.available.content=Está utilizando actualmente la versión {0}. +update.skip=Omitir esta versión +update.download=Descargar e instalar +update.decline=Más tarde +update.uptodate.title=Actualizado +update.uptodate.content=Está utilizando la última versión de MarkNote ({0}). +update.error.title=Error al comprobar actualizaciones +update.error.content=No se pudo comprobar si hay actualizaciones. Inténtelo de nuevo. + diff --git a/src/main/resources/i18n/messages_fr.properties b/src/main/resources/i18n/messages_fr.properties index 4cf0adb..91c5dac 100644 --- a/src/main/resources/i18n/messages_fr.properties +++ b/src/main/resources/i18n/messages_fr.properties @@ -439,3 +439,16 @@ git.operation.fetch=R\u00e9cup\u00e9rer\u2026 # Mode lecture menu.view.readingMode=Mode lecture reading.mode.exit=Quitter le mode lecture + +# Vérification de mise à jour +menu.help.checkUpdate=Vérifier les mises à jour... +update.available.title=Nouvelle version disponible +update.available.header=MarkNote {0} est disponible +update.available.content=Vous utilisez actuellement la version {0}. +update.skip=Ignorer cette version +update.download=Télécharger et installer +update.decline=Plus tard +update.uptodate.title=À jour +update.uptodate.content=Vous utilisez la dernière version de MarkNote ({0}). +update.error.title=Échec de la vérification +update.error.content=Impossible de vérifier les mises à jour. Veuillez réessayer. diff --git a/src/main/resources/i18n/messages_it.properties b/src/main/resources/i18n/messages_it.properties index 26ba6dd..4525866 100644 --- a/src/main/resources/i18n/messages_it.properties +++ b/src/main/resources/i18n/messages_it.properties @@ -436,3 +436,16 @@ git.operation.fetch=Aggiornamento in corso\u2026 menu.view.readingMode=Modalit\u00e0 lettura reading.mode.exit=Esci dalla modalit\u00e0 lettura +# Controllo aggiornamenti +menu.help.checkUpdate=Verifica nuova versione... +update.available.title=Nuova versione disponibile +update.available.header=MarkNote {0} \u00e8 disponibile +update.available.content=Stai attualmente usando la versione {0}. +update.skip=Ignora questa versione +update.download=Scarica e installa +update.decline=Pi\u00f9 tardi +update.uptodate.title=Aggiornato +update.uptodate.content=Stai usando l'ultima versione di MarkNote ({0}). +update.error.title=Verifica aggiornamenti fallita +update.error.content=Impossibile verificare gli aggiornamenti. Riprova pi\u00f9 tardi. +