diff --git a/build.gradle b/build.gradle index b4ded22..34f2178 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,6 @@ +import org.panteleyev.jpackage.ImageType +import org.springframework.boot.gradle.plugin.SpringBootPlugin + plugins { id 'java' id 'org.springframework.boot' version '3.4.5' @@ -29,7 +32,11 @@ repositories { } dependencies { + implementation(platform(SpringBootPlugin.BOM_COORDINATES)) implementation 'org.springframework.boot:spring-boot-starter' + implementation('org.springframework.boot:spring-boot-starter-webflux') { + exclude group: 'io.netty', module: 'netty-transport-native-epoll' + } implementation 'commons-io:commons-io:2.19.0' implementation 'com.google.code.gson:gson:2.13.1' implementation 'org.apache.commons:commons-lang3:3.17.0' @@ -63,8 +70,6 @@ tasks.register('copyInstaller', Copy) { doNotTrackState('Copying installation file need to re-run every time') } -import org.panteleyev.jpackage.ImageType - jpackage { dependsOn deleteLibs, cleanJpackageWorkdir, bootJar, copyInstaller mustRunAfter deleteLibs diff --git a/src/main/java/com/synops/replayreader/core/StageInitializer.java b/src/main/java/com/synops/replayreader/core/StageInitializer.java index ae19474..0ccb5dd 100644 --- a/src/main/java/com/synops/replayreader/core/StageInitializer.java +++ b/src/main/java/com/synops/replayreader/core/StageInitializer.java @@ -1,13 +1,12 @@ package com.synops.replayreader.core; import com.synops.replayreader.common.i18n.I18nUtils; +import com.synops.replayreader.ui.util.UiUtil; import java.io.IOException; -import java.util.Objects; import java.util.ResourceBundle; import javafx.fxml.FXMLLoader; import javafx.scene.Parent; import javafx.scene.Scene; -import javafx.scene.image.Image; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationListener; @@ -45,7 +44,7 @@ public void onApplicationEvent(StageReadyEvent event) { stage.setScene(new Scene(parent, applicationWidth, applicationHeight)); stage.setResizable(false); stage.setTitle(resourceBundle.getString("main.title")); - stage.getIcons().add(new Image(Objects.requireNonNull(stage.getClass().getResourceAsStream("/image/icon.png")))); + UiUtil.setDefaultIcon(stage); stage.show(); } catch (IOException e) { throw new RuntimeException(e); diff --git a/src/main/java/com/synops/replayreader/core/configuration/ReplayReaderConfiguration.java b/src/main/java/com/synops/replayreader/core/configuration/ReplayReaderConfiguration.java index 7bc7886..2c08019 100644 --- a/src/main/java/com/synops/replayreader/core/configuration/ReplayReaderConfiguration.java +++ b/src/main/java/com/synops/replayreader/core/configuration/ReplayReaderConfiguration.java @@ -1,6 +1,8 @@ package com.synops.replayreader.core.configuration; import com.synops.replayreader.clan.comparator.ClanListComparator; +import com.synops.replayreader.core.service.DialogService; +import com.synops.replayreader.core.service.DialogServiceImpl; import com.synops.replayreader.maps.comparator.MapsComparator; import com.synops.replayreader.common.comparator.SortingComparators; import com.synops.replayreader.replay.controller.ReplayController; @@ -21,6 +23,11 @@ public ReplayService replayService(ReplayController replayController) { return new ReplayServiceImpl(replayController); } + @Bean + public DialogService dialogService() { + return new DialogServiceImpl(); + } + @Bean public ClanStringConverter clanStringConverter() { return new ClanStringConverter(); diff --git a/src/main/java/com/synops/replayreader/core/service/DialogService.java b/src/main/java/com/synops/replayreader/core/service/DialogService.java new file mode 100644 index 0000000..285e04a --- /dev/null +++ b/src/main/java/com/synops/replayreader/core/service/DialogService.java @@ -0,0 +1,12 @@ +package com.synops.replayreader.core.service; + +import javafx.scene.control.Alert.AlertType; + +public interface DialogService { + + void showAlertError(Throwable throwable); + + void alert(AlertType alertType, String message); + + void alertConfirm(String message, Runnable runnable); +} diff --git a/src/main/java/com/synops/replayreader/core/service/DialogServiceImpl.java b/src/main/java/com/synops/replayreader/core/service/DialogServiceImpl.java new file mode 100644 index 0000000..bad502e --- /dev/null +++ b/src/main/java/com/synops/replayreader/core/service/DialogServiceImpl.java @@ -0,0 +1,93 @@ +package com.synops.replayreader.core.service; + +import static javafx.scene.control.Alert.AlertType.ERROR; + +import com.synops.replayreader.ui.util.UiUtil; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Alert; +import javafx.scene.control.Alert.AlertType; +import javafx.scene.control.ButtonType; +import javafx.scene.control.Label; +import javafx.scene.control.TextArea; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +@Service +public class DialogServiceImpl implements DialogService { + + private static final Logger LOGGER = LoggerFactory.getLogger(DialogServiceImpl.class); + + public void showAlertError(Throwable throwable) { + Platform.runLater(() -> buildAlert(ERROR, "Error", + throwable.getClass().getSimpleName() + ": " + throwable.getMessage(), + ExceptionUtils.getStackTrace(throwable)).showAndWait()); + LOGGER.error("Error {}", throwable.getMessage(), throwable); + } + + public void alert(AlertType alertType, String message) { + var alert = buildAlert(alertType, null, message, null); + alert.showAndWait(); + } + + public void alertConfirm(String message, Runnable runnable) { + var alert = buildAlert(AlertType.CONFIRMATION, null, message, null); + alert.showAndWait().filter(response -> response == ButtonType.OK) + .ifPresent(response -> runnable.run()); + } + + private Alert buildAlert(AlertType alertType, String title, String message, String stackTrace) { + var alert = new Alert(alertType); + var stage = (Stage) alert.getDialogPane().getScene().getWindow(); + UiUtil.setDefaultIcon(stage); + + if (title != null) { + alert.setTitle(title); + } + alert.setHeaderText(null); + + var vbox = new VBox(); + var hbox = new HBox(); + hbox.setAlignment(Pos.TOP_RIGHT); + + var textArea = new TextArea(); + textArea.setWrapText(true); + textArea.setEditable(false); + textArea.setText(message); + textArea.setPrefHeight(StringUtils.defaultString(message).length() < 120 ? 60 : 100); + vbox.setPadding(new Insets(14.0)); + vbox.getChildren().addAll(hbox, textArea); + alert.getDialogPane().setContent(vbox); + + if (stackTrace != null) { + var stackTraceTextArea = new TextArea(stackTrace); + stackTraceTextArea.setWrapText(false); + stackTraceTextArea.setEditable(false); + stackTraceTextArea.setMaxWidth(Double.MAX_VALUE); + stackTraceTextArea.setMaxHeight(Double.MAX_VALUE); + GridPane.setHgrow(stackTraceTextArea, Priority.ALWAYS); + GridPane.setVgrow(stackTraceTextArea, Priority.ALWAYS); + + var content = new GridPane(); + content.setMaxWidth(Double.MAX_VALUE); + content.add(new Label("Full stacktrace:"), 0, 0); + content.add(stackTraceTextArea, 0, 1); + + alert.getDialogPane().setExpandableContent(content); + } + + alert.getDialogPane().setMinHeight(Region.USE_PREF_SIZE); + + return alert; + } +} diff --git a/src/main/java/com/synops/replayreader/ui/controller/MainViewController.java b/src/main/java/com/synops/replayreader/ui/controller/MainViewController.java index f13361a..75ca956 100644 --- a/src/main/java/com/synops/replayreader/ui/controller/MainViewController.java +++ b/src/main/java/com/synops/replayreader/ui/controller/MainViewController.java @@ -8,6 +8,7 @@ import com.synops.replayreader.common.comparator.SortingComparators; import com.synops.replayreader.common.util.LogUtil; import com.synops.replayreader.core.event.ReplayProgressEvent; +import com.synops.replayreader.core.service.DialogService; import com.synops.replayreader.maps.comparator.MapsComparator; import com.synops.replayreader.maps.ui.MapListCell; import com.synops.replayreader.player.comparator.PlayerListComparatorLong; @@ -16,9 +17,12 @@ import com.synops.replayreader.replay.service.ReplayService; import com.synops.replayreader.ui.model.MainModel; import com.synops.replayreader.ui.util.DragDropSupport; +import com.synops.replayreader.update.UpdateClient; +import com.synops.replayreader.update.VersionChecker; import com.synops.replayreader.vehicle.ui.VehicleListCell; import com.synops.replayreader.vehicle.util.TanksUtil; import java.io.File; +import java.text.MessageFormat; import java.util.Comparator; import java.util.List; import java.util.ResourceBundle; @@ -36,6 +40,7 @@ import javafx.concurrent.Task; import javafx.concurrent.WorkerStateEvent; import javafx.fxml.FXML; +import javafx.scene.control.Alert.AlertType; import javafx.scene.control.ChoiceBox; import javafx.scene.control.Label; import javafx.scene.control.ListView; @@ -46,6 +51,7 @@ import javafx.scene.layout.AnchorPane; import javafx.scene.layout.HBox; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.info.BuildProperties; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; @@ -53,8 +59,11 @@ public class MainViewController { private static final String REPLAY_READER_BUGS_URL = "https://github.com/synopss/replay-reader/issues/new/choose"; + private static final String REPLAY_RELEASES_URL = "https://github.com/synopss/replay-reader/releases/latest"; private final ReplayService replayService; private final HostServices hostServices; + private final DialogService dialogService; + private final UpdateClient updateClient; private final ResourceBundle resourceBundle; private final ClanStringConverter clanStringConverter; private final ClanListComparator clanListComparator; @@ -64,6 +73,7 @@ public class MainViewController { private final StringProperty selectedPlayer = new SimpleStringProperty(""); private final StringProperty selectedVehicle = new SimpleStringProperty(""); private final StringProperty selectedClan = new SimpleStringProperty(""); + private final BuildProperties buildProperties; @Value("${replay-reader.config.max-players}") private int MAX_PLAYERS; @Value("${replay-reader.config.max-clans}") @@ -83,6 +93,8 @@ public class MainViewController { @FXML private MenuItem showAboutWindow; @FXML + private MenuItem versionCheck; + @FXML private ListView playersList; private Comparator playersComparator; @FXML @@ -140,19 +152,24 @@ public class MainViewController { @FXML private Label progressLabel; private MainModel mainModel; + private VersionChecker versionChecker; public MainViewController(ReplayService replayService, @Nullable HostServices hostServices, - ResourceBundle resourceBundle, ClanStringConverter clanStringConverter, - ClanListComparator clanListComparator, SortingComparators sortingComparators, - MapsComparator mapsComparator, DragDropSupport dragDropSupport) { + DialogService dialogService, UpdateClient updateClient, ResourceBundle resourceBundle, + ClanStringConverter clanStringConverter, ClanListComparator clanListComparator, + SortingComparators sortingComparators, MapsComparator mapsComparator, + DragDropSupport dragDropSupport, BuildProperties buildProperties) { this.replayService = replayService; this.hostServices = hostServices; + this.dialogService = dialogService; + this.updateClient = updateClient; this.resourceBundle = resourceBundle; this.clanStringConverter = clanStringConverter; this.clanListComparator = clanListComparator; this.sortingComparators = sortingComparators; this.mapsComparator = mapsComparator; this.dragDropSupport = dragDropSupport; + this.buildProperties = buildProperties; } @FXML @@ -166,12 +183,14 @@ public void initialize() { initClanChoiceBox(); bindingSelectedElements(); progressBar.setVisible(false); + versionChecker = new VersionChecker(); } private void initMenu() { exitApplication.setOnAction(_ -> Platform.exit()); reportBug.setOnAction(_ -> openUrl(REPLAY_READER_BUGS_URL)); showAboutWindow.setOnAction(_ -> (new AboutWindowController(resourceBundle)).showAndWait()); + versionCheck.setOnAction(_ -> checkForUpdate()); } private void initLists() { @@ -418,4 +437,18 @@ private void openUrl(String url) { } hostServices.showDocument(url); } + + private void checkForUpdate() { + updateClient.getLatestVersion().doOnSuccess(versionResponse -> Platform.runLater(() -> { + if (versionChecker.isNewVersionAvailable(versionResponse.tagName(), + buildProperties.getVersion())) { + dialogService.alertConfirm( + MessageFormat.format(resourceBundle.getString("update.new-version"), + versionResponse.tagName().substring(1)), () -> openUrl(REPLAY_RELEASES_URL)); + } else { + dialogService.alert(AlertType.INFORMATION, + resourceBundle.getString("update.latest-already")); + } + })).doOnError(dialogService::showAlertError).subscribe(); + } } diff --git a/src/main/java/com/synops/replayreader/ui/util/UiUtil.java b/src/main/java/com/synops/replayreader/ui/util/UiUtil.java new file mode 100644 index 0000000..dec2eb9 --- /dev/null +++ b/src/main/java/com/synops/replayreader/ui/util/UiUtil.java @@ -0,0 +1,13 @@ +package com.synops.replayreader.ui.util; + +import java.util.Objects; +import javafx.scene.image.Image; +import javafx.stage.Stage; + +public final class UiUtil { + + public static void setDefaultIcon(Stage stage) { + stage.getIcons().add( + new Image(Objects.requireNonNull(stage.getClass().getResourceAsStream("/image/icon.png")))); + } +} diff --git a/src/main/java/com/synops/replayreader/update/UpdateClient.java b/src/main/java/com/synops/replayreader/update/UpdateClient.java new file mode 100644 index 0000000..14e677e --- /dev/null +++ b/src/main/java/com/synops/replayreader/update/UpdateClient.java @@ -0,0 +1,29 @@ +package com.synops.replayreader.update; + +import com.synops.replayreader.core.StageReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Component +public class UpdateClient { + + private final WebClient.Builder webClientBuilder; + private WebClient webClient; + + public UpdateClient(WebClient.Builder webClientBuilder) { + this.webClientBuilder = webClientBuilder; + } + + @EventListener + public void init(StageReadyEvent event) { + webClient = webClientBuilder.baseUrl("https://api.github.com/repos/synopss/replay-reader/") + .defaultHeaders(HttpHeaders::clear).build(); + } + + public Mono getLatestVersion() { + return webClient.get().uri("releases/latest").retrieve().bodyToMono(VersionResponse.class); + } +} diff --git a/src/main/java/com/synops/replayreader/update/Version.java b/src/main/java/com/synops/replayreader/update/Version.java new file mode 100644 index 0000000..2e38c38 --- /dev/null +++ b/src/main/java/com/synops/replayreader/update/Version.java @@ -0,0 +1,33 @@ +package com.synops.replayreader.update; + +import org.jspecify.annotations.NonNull; + +public record Version(int major, int minor, int patch) implements Comparable { + + @Override + public int compareTo(Version version) { + if (major < version.major) { + return -1; + } else if (major > version.major) { + return 1; + } else { + if (minor < version.minor) { + return -1; + } else if (minor > version.minor) { + return 1; + } else { + return Integer.compare(patch, version.patch); + } + } + } + + @Override + @NonNull + public String toString() { + return major + "." + minor + "." + patch; + } + + public boolean isNotARelease() { + return major == 0 && minor == 0 && patch == 0; + } +} diff --git a/src/main/java/com/synops/replayreader/update/VersionChecker.java b/src/main/java/com/synops/replayreader/update/VersionChecker.java new file mode 100644 index 0000000..75fd450 --- /dev/null +++ b/src/main/java/com/synops/replayreader/update/VersionChecker.java @@ -0,0 +1,37 @@ +package com.synops.replayreader.update; + +import java.util.regex.Pattern; +import org.apache.commons.lang3.StringUtils; + +public class VersionChecker { + + private static final Pattern VERSION_PATTERN = Pattern.compile("^v(\\d+)\\.(\\d+)\\.(\\d+)$"); + + private static Version createVersion(String versionString) { + if (!versionString.startsWith("v")) { + versionString = "v" + versionString; + } + var matcher = VERSION_PATTERN.matcher(versionString); + + if (matcher.matches()) { + return new Version(Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2)), + Integer.parseInt(matcher.group(3))); + } + return new Version(0, 0, 0); + } + + public boolean isNewVersionAvailable(String newVersionString, String currentVersionString) { + if (StringUtils.isBlank(newVersionString)) { + return false; + } + + var currentVersion = createVersion(currentVersionString); + + if (currentVersion.isNotARelease()) { + return false; + } + + var newVersion = createVersion(newVersionString); + return newVersion.compareTo(currentVersion) > 0; + } +} diff --git a/src/main/java/com/synops/replayreader/update/VersionResponse.java b/src/main/java/com/synops/replayreader/update/VersionResponse.java new file mode 100644 index 0000000..9b2642f --- /dev/null +++ b/src/main/java/com/synops/replayreader/update/VersionResponse.java @@ -0,0 +1,7 @@ +package com.synops.replayreader.update; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record VersionResponse(@JsonProperty("tag_name") String tagName) { + +} diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 63deaef..68e702c 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -5,8 +5,9 @@ main.menu.file=File main.menu.file.exit=Exit main.menu.help=Help main.menu.help.about=About -main.menu.help.report-bug=Report bug main.menu.help.about.text=Replay Reader was created by Synops +main.menu.help.report-bug=Report bug +main.menu.help.version-check=Check for updates... ## Toolbar main.toolbar.sort=Sort by main.toolbar.sort.tooltip=Show the first %d players @@ -47,7 +48,10 @@ main.stats.xp-rank=XP rank ## Footer main.footer.drag=Drag & drop replay files onto this window (max. 1500) main.footer.corrupted=Corrupted -#Maps +# Update +update.latest-already=You already have the latest version. +update.new-version=There's a new version available ({0}). Download? +# Maps 01_karelia=Karelia 02_malinovka=Malinovka 03_campania_big=Province diff --git a/src/main/resources/i18n/messages_fr.properties b/src/main/resources/i18n/messages_fr.properties index 9189b7a..a6cfb96 100644 --- a/src/main/resources/i18n/messages_fr.properties +++ b/src/main/resources/i18n/messages_fr.properties @@ -5,8 +5,9 @@ main.menu.file=Fichier main.menu.file.exit=Quitter main.menu.help=Aide main.menu.help.about=A propos -main.menu.help.report-bug=Signaler un bug main.menu.help.about.text=Replay Reader a été créé par Synops +main.menu.help.report-bug=Signaler un bug +main.menu.help.version-check=Vérifiez les mises à jour... ## Toolbar main.toolbar.sort=Trier par main.toolbar.sort.tooltip=Afficher les %d premiers joueurs @@ -47,7 +48,10 @@ main.stats.xp-rank=Rang XP ## Footer main.footer.drag=Glissez-déposez les fichiers de replay sur cette fenêtre (max. 1500) main.footer.corrupted=Corrompu(s) -#Maps +# Update +update.latest-already=Vous disposez déjà de la dernière version. +update.new-version=Une nouvelle version est disponible ({0}). Télécharger ? +# Maps 01_karelia=Carélie 02_malinovka=Malinovka 03_campania_big=Province diff --git a/src/main/resources/views/main.fxml b/src/main/resources/views/main.fxml index 9d3d82e..add410c 100644 --- a/src/main/resources/views/main.fxml +++ b/src/main/resources/views/main.fxml @@ -31,6 +31,7 @@ + diff --git a/src/test/java/com/synops/replayreader/ReplayReaderApplicationTests.java b/src/test/java/com/synops/replayreader/ReplayReaderApplicationTests.java index 05fb02e..ed35ba7 100644 --- a/src/test/java/com/synops/replayreader/ReplayReaderApplicationTests.java +++ b/src/test/java/com/synops/replayreader/ReplayReaderApplicationTests.java @@ -1,13 +1,8 @@ package com.synops.replayreader; -import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class ReplayReaderApplicationTests { - @Test - void contextLoads() { - } - }