diff --git a/build.gradle b/build.gradle index 4f47130..fa1e684 100644 --- a/build.gradle +++ b/build.gradle @@ -1,4 +1,5 @@ import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform +import java.time.Year plugins { id 'java' @@ -13,6 +14,7 @@ group 'com.codedead' version '1.5.1' def currentOS = DefaultNativePlatform.currentOperatingSystem +def currentYear = Year.now().getValue() java { toolchain { @@ -28,6 +30,12 @@ tasks.named("updateDaemonJvm") { } } +tasks.named('processResources') { + filesMatching('version.properties') { + expand(version: project.version) + } +} + application { mainModule = 'Opal' mainClass = 'com.codedead.opal.OpalApplication' @@ -60,7 +68,7 @@ jlink { '--win-upgrade-uuid', '876c5464-9a66-4913-89a4-c63a4b8b4bb9', '--win-help-url', 'https://codedead.com/contact', '--win-dir-chooser', - '--copyright', 'Copyright (c) 2026 CodeDead', + '--copyright', "Copyright (c) ${currentYear} CodeDead", '--description', 'Opal is a free and open-source JavaFX application that can play relaxing music in the background', '--vendor', 'CodeDead', '--license-file', 'LICENSE', @@ -76,7 +84,7 @@ jlink { '--linux-shortcut', '--linux-package-name', 'opal-codedead', '--linux-rpm-license-type', 'GPLv3', - '--copyright', 'Copyright (c) 2026 CodeDead', + '--copyright', "Copyright (c) ${currentYear} CodeDead", '--description', 'Opal is a free and open-source JavaFX application that can play relaxing music in the background', '--vendor', 'CodeDead', '--license-file', 'LICENSE', @@ -94,7 +102,7 @@ jlink { installerOptions = [ '--mac-package-name', 'Opal', '--mac-app-category', 'public.app-category.music', - '--copyright', 'Copyright (c) 2026 CodeDead', + '--copyright', "Copyright (c) ${currentYear} CodeDead", '--description', 'Opal is a free and open-source JavaFX application that can play relaxing music in the background', '--vendor', 'CodeDead', '--license-file', 'LICENSE', diff --git a/src/main/java/com/codedead/opal/OpalApplication.java b/src/main/java/com/codedead/opal/OpalApplication.java index 8245bfe..e42688a 100644 --- a/src/main/java/com/codedead/opal/OpalApplication.java +++ b/src/main/java/com/codedead/opal/OpalApplication.java @@ -107,7 +107,13 @@ public void start(final Stage primaryStage) { logger.info("Creating the MainWindowController"); final MainWindowController mainWindowController = loader.getController(); - final UpdateController updateController = new UpdateController(properties.getProperty("updateApi", "https://codedead.com/Software/Opal/version.json"), SharedVariables.CURRENT_VERSION); + final String configuredUpdateApi = properties.getProperty("updateApi", SharedVariables.UPDATE_API_URL); + final String updateApi = resolveUpdateApi(configuredUpdateApi); + if (!updateApi.equals(configuredUpdateApi)) { + properties.setProperty("updateApi", updateApi); + } + + final UpdateController updateController = new UpdateController(updateApi, SharedVariables.CURRENT_VERSION); mainWindowController.setControllers(settingsController, updateController); final Scene scene = new Scene(root); @@ -120,4 +126,21 @@ public void start(final Stage primaryStage) { logger.info("Showing the MainWindow"); primaryStage.show(); } + + /** + * Resolve the configured update API URL, migrating the legacy URL to the current API + * + * @param configuredUpdateApi The configured update API URL + * @return The update API URL that should be used + */ + static String resolveUpdateApi(final String configuredUpdateApi) { + if (configuredUpdateApi == null || configuredUpdateApi.isBlank()) + return SharedVariables.UPDATE_API_URL; + + final String updateApi = configuredUpdateApi.trim(); + if (SharedVariables.LEGACY_UPDATE_API_URL.equalsIgnoreCase(updateApi)) + return SharedVariables.UPDATE_API_URL; + + return updateApi; + } } diff --git a/src/main/java/com/codedead/opal/controller/AboutWindowController.java b/src/main/java/com/codedead/opal/controller/AboutWindowController.java index 83e702b..f39c7cf 100644 --- a/src/main/java/com/codedead/opal/controller/AboutWindowController.java +++ b/src/main/java/com/codedead/opal/controller/AboutWindowController.java @@ -4,14 +4,19 @@ import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.control.Button; +import javafx.scene.control.Label; import javafx.stage.Stage; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.time.Year; import java.util.ResourceBundle; public final class AboutWindowController { + @FXML + private Label lblAbout; + private ResourceBundle translationBundle; private final HelpUtils helpUtils; private final Logger logger; @@ -36,6 +41,10 @@ public void setResourceBundle(final ResourceBundle resourceBundle) { throw new NullPointerException("ResourceBundle cannot be null"); this.translationBundle = resourceBundle; + + lblAbout.setText(resourceBundle.getString("AboutText") + .replace("{version}", SharedVariables.APP_VERSION) + .replace("{year}", Integer.toString(Year.now().getValue()))); } /** diff --git a/src/main/java/com/codedead/opal/controller/MainWindowController.java b/src/main/java/com/codedead/opal/controller/MainWindowController.java index eab2f25..c52ea0f 100644 --- a/src/main/java/com/codedead/opal/controller/MainWindowController.java +++ b/src/main/java/com/codedead/opal/controller/MainWindowController.java @@ -35,6 +35,7 @@ import java.io.File; import java.io.IOException; import java.io.InputStreamReader; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.security.GeneralSecurityException; @@ -200,6 +201,7 @@ public final class MainWindowController implements IAudioTimer, TrayIconListener private TimerTask timerTask; private TimerTask countDownTask; private final String platformName; + private final String architecture; private final HelpUtils helpUtils; private final PasswordCipher passwordCipher; private final JsonMapper jsonMapper; @@ -218,7 +220,8 @@ public MainWindowController() { logger = LogManager.getLogger(MainWindowController.class); logger.info("Initializing new MainWindowController object"); - platformName = OsCheck.getOperatingSystemType().name(); + platformName = OsCheck.getPlatformName(); + architecture = OsCheck.getArchitecture(); helpUtils = new HelpUtils(); passwordCipher = new PasswordCipher(); @@ -414,22 +417,23 @@ private void checkForUpdates(final boolean showNoUpdates, final boolean showErro try { configureUpdateProxy(); - final Optional platformUpdate = updateController.checkForUpdates(platformName, SharedVariables.PORTABLE); + final Optional platformUpdate = updateController.checkForUpdates(platformName, architecture, SharedVariables.PORTABLE); if (platformUpdate.isPresent()) { final PlatformUpdate update = platformUpdate.get(); + final String displayVersion = update.getSemver().replaceFirst("^[vV]", ""); - logger.info("Version {}.{}.{}.{} is available", update.getMajorVersion(), update.getMinorVersion(), update.getBuildVersion(), update.getRevisionVersion()); + logger.info("Version {} is available for {}/{}", displayVersion, update.getPlatformName(), update.getArch()); if (FxUtils.showConfirmationAlert(resourceFactory .getResources() .getString("NewUpdateAvailable") - .replace("{v}", String.format("%1$s.%2$s.%3$s.%4$s", update.getMajorVersion(), update.getMinorVersion(), update.getBuildVersion(), update.getRevisionVersion())), + .replace("{v}", displayVersion), getClass().getResourceAsStream(SharedVariables.ICON_URL))) { - final String extension = update.getDownloadUrl().substring(update.getDownloadUrl().lastIndexOf('.')).toLowerCase(); + final String extension = getDownloadExtension(update.getDownloadUrl()); final FileChooser fileChooser = new FileChooser(); final FileChooser.ExtensionFilter extFilter = new FileChooser.ExtensionFilter( - String.format("%1$s (*%2$s)", extension.substring(1).toUpperCase(), extension), + String.format("%1$s (*%2$s)", extension.substring(1).toUpperCase(Locale.ROOT), extension), String.format("*%s", extension)); fileChooser.getExtensionFilters().add(extFilter); @@ -438,11 +442,19 @@ private void checkForUpdates(final boolean showNoUpdates, final boolean showErro if (file != null) { String filePath = file.getAbsolutePath(); - if (!filePath.toLowerCase().contains(extension)) { + if (!filePath.toLowerCase(Locale.ROOT).endsWith(extension.toLowerCase(Locale.ROOT))) { filePath += extension; } updateController.downloadFile(update.getDownloadUrl(), filePath); + + if (extension.equalsIgnoreCase(".AppImage")) { + final File downloadedFile = new File(filePath); + if (!downloadedFile.setExecutable(true)) { + logger.warn("Unable to mark {} as executable", filePath); + } + } + openFile(filePath); exitAction(); } @@ -464,6 +476,22 @@ private void checkForUpdates(final boolean showNoUpdates, final boolean showErro } } + /** + * Get the file extension from a download URL + * + * @param downloadUrl The download URL + * @return The file extension, including the leading dot + */ + private String getDownloadExtension(final String downloadUrl) { + final String path = URI.create(downloadUrl).getPath(); + final int extensionIndex = path.lastIndexOf('.'); + + if (extensionIndex < 0 || extensionIndex == path.length() - 1) + throw new IllegalArgumentException("Unable to determine the file extension from the download URL!"); + + return path.substring(extensionIndex); + } + /** * Get all {@link SoundPane} objects * diff --git a/src/main/java/com/codedead/opal/controller/UpdateController.java b/src/main/java/com/codedead/opal/controller/UpdateController.java index 2d04326..7917f5f 100644 --- a/src/main/java/com/codedead/opal/controller/UpdateController.java +++ b/src/main/java/com/codedead/opal/controller/UpdateController.java @@ -1,9 +1,13 @@ package com.codedead.opal.controller; import com.codedead.opal.domain.InvalidHttpResponseCodeException; +import com.codedead.opal.domain.PlatformRelease; import com.codedead.opal.domain.PlatformUpdate; +import com.codedead.opal.domain.Semver; +import com.codedead.opal.domain.VersionInfo; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import tools.jackson.core.JacksonException; import tools.jackson.databind.DeserializationFeature; import tools.jackson.databind.json.JsonMapper; @@ -58,90 +62,135 @@ public UpdateController(final String updateUrl, final String currentVersion) { * Check for updates * * @param currentPlatform The current platform + * @param currentArch The current architecture * @param isPortable Check for portable application updates or not * @return The {@link Optional} {@link PlatformUpdate} object - * @throws InterruptedException When the retrieval of the {@link List} of {@link PlatformUpdate} objects was interrupted + * @throws InterruptedException When the retrieval of the {@link VersionInfo} object was interrupted * @throws InvalidHttpResponseCodeException When an invalid HTTP response code was returned when checking for updates - * @throws IOException When the response that was returned from the server could not be serialized as a {@link List} of {@link PlatformUpdate} object + * @throws IOException When the response from the server could not be serialized as a {@link VersionInfo} object */ - public Optional checkForUpdates(final String currentPlatform, final boolean isPortable) throws InterruptedException, InvalidHttpResponseCodeException, IOException { + public Optional checkForUpdates(final String currentPlatform, final String currentArch, final boolean isPortable) throws InterruptedException, InvalidHttpResponseCodeException, IOException { if (currentPlatform == null) throw new NullPointerException("Current platform cannot be null!"); if (currentPlatform.isBlank()) throw new IllegalArgumentException("Current platform cannot be blank!"); + if (currentArch == null) + throw new NullPointerException("Current architecture cannot be null!"); + if (currentArch.isBlank()) + throw new IllegalArgumentException("Current architecture cannot be blank!"); - logger.info("Attempting to retrieve the latest PlatformUpdate objects"); + logger.info("Attempting to retrieve the latest VersionInfo object"); - final List updates = getUpdates(); + final VersionInfo version = getVersion(); + return resolveUpdate(version, currentPlatform, currentArch, isPortable, currentVersion); + } - final Optional update = updates - .stream() - .filter(e -> e.getPlatformName().equalsIgnoreCase(currentPlatform) && e.isPortable() == isPortable) - .findFirst(); + /** + * Resolve an available update from a version response + * + * @param version The version response + * @param currentPlatform The current platform + * @param currentArch The current architecture + * @param isPortable Whether a portable release is required + * @param currentVersion The current application version + * @return The {@link Optional} {@link PlatformUpdate} object + */ + static Optional resolveUpdate(final VersionInfo version, final String currentPlatform, final String currentArch, final boolean isPortable, final String currentVersion) { + if (version == null) + throw new NullPointerException("VersionInfo cannot be null!"); + if (version.getSemver() == null || version.getSemver().isBlank()) + throw new IllegalArgumentException("The version response does not contain a semantic version!"); + + final Optional platformRelease = findPlatform(version.getPlatforms(), currentPlatform, currentArch, isPortable); + if (platformRelease.isEmpty()) + return Optional.empty(); - if (update.isPresent()) { - final PlatformUpdate platformUpdate = update.get(); - if (versionCompare(currentVersion, platformUpdate.getMajorVersion() + "." + platformUpdate.getMinorVersion() + "." + platformUpdate.getBuildVersion() + "." + platformUpdate.getRevisionVersion()) < 0) { - return update; - } + if (Semver.compare(currentVersion, version.getSemver()) >= 0) return Optional.empty(); - } - return Optional.empty(); + + final PlatformRelease release = platformRelease.get(); + if (release.getDownloadUrl() == null || release.getDownloadUrl().isBlank()) + throw new IllegalArgumentException("The selected release does not contain a download URL!"); + + final PlatformUpdate update = new PlatformUpdate(); + update.setSemver(version.getSemver()); + update.setPlatformName(release.getName()); + update.setArch(release.getArch()); + update.setPortable(isPortable); + update.setDownloadUrl(release.getDownloadUrl()); + update.setInfoUrl(release.getInfoUrl()); + return Optional.of(update); } /** - * Compare to strings to check which version is larger + * Find the release that matches the provided platform, architecture and portability * - * @param v1 The first {@link String} object that contains a version - * @param v2 The second {@link String} object that contains a version - * @return 1 if v1 is larger than v2, -1 if v2 is larger than v1 and 0 if both are equal + * @param platforms The available releases + * @param platformName The platform name + * @param arch The platform architecture + * @param portable Whether a portable release is required + * @return The {@link Optional} {@link PlatformRelease} object */ - private int versionCompare(final String v1, final String v2) { - int vNum1 = 0; - int vNum2 = 0; - - for (int i = 0, j = 0; (i < v1.length() || j < v2.length()); ) { - while (i < v1.length() && v1.charAt(i) != '.') { - vNum1 = vNum1 * 10 + (v1.charAt(i) - '0'); - i++; - } - while (j < v2.length() && v2.charAt(j) != '.') { - vNum2 = vNum2 * 10 + (v2.charAt(j) - '0'); - j++; - } + static Optional findPlatform(final List platforms, final String platformName, final String arch, final boolean portable) { + if (platforms == null || platforms.isEmpty()) + return Optional.empty(); + + final Optional exactMatch = platforms.stream() + .filter(release -> matches(release.getName(), platformName)) + .filter(release -> matches(release.getArch(), arch)) + .filter(release -> Boolean.valueOf(portable).equals(release.getPortable())) + .findFirst(); - if (vNum1 > vNum2) - return 1; - if (vNum2 > vNum1) - return -1; + if (exactMatch.isPresent()) + return exactMatch; - vNum1 = vNum2 = 0; - i++; - j++; - } - return 0; + return platforms.stream() + .filter(release -> matches(release.getName(), platformName)) + .filter(release -> matches(release.getArch(), arch)) + .filter(release -> release.getPortable() == null) + .findFirst(); } /** - * Retrieve a ({@link List}) of {@link PlatformUpdate} objects + * Check whether two values match, ignoring case and surrounding whitespace * - * @return The {@link List} of {@link PlatformUpdate} objects - * @throws IOException When the HTTP request failed or when the response could not be parsed as a {@link List} of {@link PlatformUpdate} objects + * @param actual The actual value + * @param expected The expected value + * @return True if the values match, otherwise false + */ + private static boolean matches(final String actual, final String expected) { + return actual != null && expected != null && actual.trim().equalsIgnoreCase(expected.trim()); + } + + /** + * Retrieve the latest version information + * + * @return The {@link VersionInfo} object + * @throws IOException When the HTTP request failed or when the response could not be parsed * @throws InterruptedException When the HTTP request is interrupted * @throws InvalidHttpResponseCodeException When the HTTP response status code is not OK */ - public List getUpdates() throws IOException, InterruptedException, InvalidHttpResponseCodeException { - logger.info("Attempting to retrieve List of PlatformUpdate objects from {}", getUpdateUrl()); + public VersionInfo getVersion() throws IOException, InterruptedException, InvalidHttpResponseCodeException { + logger.info("Attempting to retrieve VersionInfo from {}", getUpdateUrl()); try (final HttpClient client = createHttpClient()) { final HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(getUpdateUrl())) + .header("Accept", "application/json") .build(); final HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() == 200) - return Arrays.asList(jsonMapper.readValue(response.body(), PlatformUpdate[].class)); + if (response.statusCode() == 200) { + try { + final VersionInfo version = jsonMapper.readValue(response.body(), VersionInfo.class); + if (version == null) + throw new IOException("The version response was empty"); + return version; + } catch (final JacksonException ex) { + throw new IOException("Unable to parse the version response", ex); + } + } throw new InvalidHttpResponseCodeException(String.format("Invalid HTTP response code (%s)", response.statusCode())); } diff --git a/src/main/java/com/codedead/opal/domain/OsCheck.java b/src/main/java/com/codedead/opal/domain/OsCheck.java index a0edd74..120b71b 100644 --- a/src/main/java/com/codedead/opal/domain/OsCheck.java +++ b/src/main/java/com/codedead/opal/domain/OsCheck.java @@ -5,6 +5,7 @@ public final class OsCheck { private static OSType detectedOS; + private static String detectedArchitecture; /** * Initialize a new OsCheck @@ -33,4 +34,44 @@ public static OSType getOperatingSystemType() { } return detectedOS; } + + /** + * Get the platform name used by the update API + * + * @return The lowercase platform name + */ + public static String getPlatformName() { + return getOperatingSystemType().name().toLowerCase(Locale.ROOT); + } + + /** + * Get the architecture used by the update API + * + * @return The normalized architecture + */ + public static String getArchitecture() { + if (detectedArchitecture == null) { + detectedArchitecture = normalizeArchitecture(System.getProperty("os.arch", "")); + } + return detectedArchitecture; + } + + /** + * Normalize an architecture name to the format used by the update API + * + * @param architecture The architecture name + * @return The normalized architecture + */ + static String normalizeArchitecture(final String architecture) { + if (architecture == null) + throw new NullPointerException("Architecture cannot be null!"); + + final String value = architecture.trim().toLowerCase(Locale.ROOT); + return switch (value) { + case "amd64", "x86_64", "x64" -> "x86_64"; + case "aarch64", "arm64" -> "aarch64"; + case "x86", "i386", "i486", "i586", "i686" -> "x86"; + default -> value; + }; + } } diff --git a/src/main/java/com/codedead/opal/domain/PlatformRelease.java b/src/main/java/com/codedead/opal/domain/PlatformRelease.java new file mode 100644 index 0000000..fbbfdb1 --- /dev/null +++ b/src/main/java/com/codedead/opal/domain/PlatformRelease.java @@ -0,0 +1,107 @@ +package com.codedead.opal.domain; + +public final class PlatformRelease { + + private String name; + private String arch; + private Boolean portable; + private String downloadUrl; + private String infoUrl; + + /** + * Initialize a new PlatformRelease + */ + public PlatformRelease() { + // Default constructor + } + + /** + * Get the platform name + * + * @return The platform name + */ + public String getName() { + return name; + } + + /** + * Set the platform name + * + * @param name The platform name + */ + public void setName(final String name) { + this.name = name; + } + + /** + * Get the platform architecture + * + * @return The platform architecture + */ + public String getArch() { + return arch; + } + + /** + * Set the platform architecture + * + * @param arch The platform architecture + */ + public void setArch(final String arch) { + this.arch = arch; + } + + /** + * Get whether the release is portable + * + * @return True if the release is portable, false if it is not, or null if this is unknown + */ + public Boolean getPortable() { + return portable; + } + + /** + * Set whether the release is portable + * + * @param portable True if the release is portable, false if it is not, or null if this is unknown + */ + public void setPortable(final Boolean portable) { + this.portable = portable; + } + + /** + * Get the download URL + * + * @return The download URL + */ + public String getDownloadUrl() { + return downloadUrl; + } + + /** + * Set the download URL + * + * @param downloadUrl The download URL + */ + public void setDownloadUrl(final String downloadUrl) { + this.downloadUrl = downloadUrl; + } + + /** + * Get the information URL + * + * @return The information URL + */ + public String getInfoUrl() { + return infoUrl; + } + + /** + * Set the information URL + * + * @param infoUrl The information URL + */ + public void setInfoUrl(final String infoUrl) { + this.infoUrl = infoUrl; + } +} diff --git a/src/main/java/com/codedead/opal/domain/PlatformUpdate.java b/src/main/java/com/codedead/opal/domain/PlatformUpdate.java index b25539a..11eb515 100644 --- a/src/main/java/com/codedead/opal/domain/PlatformUpdate.java +++ b/src/main/java/com/codedead/opal/domain/PlatformUpdate.java @@ -1,19 +1,15 @@ package com.codedead.opal.domain; -import java.util.Map; import java.util.Objects; public final class PlatformUpdate implements Comparable { + private String semver; private String platformName; + private String arch; private boolean portable; - private int majorVersion; - private int minorVersion; - private int buildVersion; - private int revisionVersion; - private String downloadUrl; - private Map extraAttributes; + private String infoUrl; /** * Initialize a new PlatformUpdate @@ -23,111 +19,75 @@ public PlatformUpdate() { } /** - * Get the platform name + * Get the semantic version * - * @return The platform name + * @return The semantic version */ - public String getPlatformName() { - return platformName; + public String getSemver() { + return semver; } /** - * Set the platform name + * Set the semantic version * - * @param platformName The platform name + * @param semver The semantic version */ - public void setPlatformName(final String platformName) { - this.platformName = platformName; + public void setSemver(final String semver) { + this.semver = semver; } /** - * Get whether the version is portable or not - * - * @return True if the version is portable, otherwise false - */ - public boolean isPortable() { - return portable; - } - - /** - * Set whether the version is portable or not - * - * @param portable True if the version is portable, otherwise false - */ - public void setPortable(final boolean portable) { - this.portable = portable; - } - - /** - * Get the major version - * - * @return The major version - */ - public int getMajorVersion() { - return majorVersion; - } - - /** - * Set the major version - * - * @param majorVersion The major version - */ - public void setMajorVersion(final int majorVersion) { - this.majorVersion = majorVersion; - } - - /** - * Get the minor version + * Get the platform name * - * @return The minor version + * @return The platform name */ - public int getMinorVersion() { - return minorVersion; + public String getPlatformName() { + return platformName; } /** - * Set the minor version + * Set the platform name * - * @param minorVersion The minor version + * @param platformName The platform name */ - public void setMinorVersion(final int minorVersion) { - this.minorVersion = minorVersion; + public void setPlatformName(final String platformName) { + this.platformName = platformName; } /** - * Get the build version + * Get the platform architecture * - * @return The build version + * @return The platform architecture */ - public int getBuildVersion() { - return buildVersion; + public String getArch() { + return arch; } /** - * Set the build version + * Set the platform architecture * - * @param buildVersion The build version + * @param arch The platform architecture */ - public void setBuildVersion(final int buildVersion) { - this.buildVersion = buildVersion; + public void setArch(final String arch) { + this.arch = arch; } /** - * Get the revision version + * Get whether the update is portable * - * @return The revision version + * @return True if the update is portable, otherwise false */ - public int getRevisionVersion() { - return revisionVersion; + public boolean isPortable() { + return portable; } /** - * Set the revision version + * Set whether the update is portable * - * @param revisionVersion The revision version + * @param portable True if the update is portable, otherwise false */ - public void setRevisionVersion(final int revisionVersion) { - this.revisionVersion = revisionVersion; + public void setPortable(final boolean portable) { + this.portable = portable; } /** @@ -149,48 +109,33 @@ public void setDownloadUrl(final String downloadUrl) { } /** - * Get the extra attributes + * Get the information URL * - * @return The Map that contains the extra attributes + * @return The information URL */ - public Map getExtraAttributes() { - return extraAttributes; + public String getInfoUrl() { + return infoUrl; } /** - * Set the extra attributes + * Set the information URL * - * @param extraAttributes The Map that contains the extra attributes + * @param infoUrl The information URL */ - public void setExtraAttributes(final Map extraAttributes) { - this.extraAttributes = extraAttributes; + public void setInfoUrl(final String infoUrl) { + this.infoUrl = infoUrl; } /** - * Compare this update with another update using natural version ordering + * Compare this update with another update using semantic version ordering * * @param other The platform update that should be compared - * @return A negative value if this update is older, zero if both versions are equal, or a positive value if this update is newer + * @return A negative value if this update is older, zero if both versions are equal, + * or a positive value if this update is newer */ @Override public int compareTo(final PlatformUpdate other) { Objects.requireNonNull(other, "other"); - - int result = Integer.compare(majorVersion, other.majorVersion); - if (result != 0) { - return result; - } - - result = Integer.compare(minorVersion, other.minorVersion); - if (result != 0) { - return result; - } - - result = Integer.compare(buildVersion, other.buildVersion); - if (result != 0) { - return result; - } - - return Integer.compare(revisionVersion, other.revisionVersion); + return Semver.compare(semver, other.semver); } } diff --git a/src/main/java/com/codedead/opal/domain/Semver.java b/src/main/java/com/codedead/opal/domain/Semver.java new file mode 100644 index 0000000..8a756c9 --- /dev/null +++ b/src/main/java/com/codedead/opal/domain/Semver.java @@ -0,0 +1,55 @@ +package com.codedead.opal.domain; + +import java.lang.module.ModuleDescriptor.Version; + +public final class Semver { + + /** + * Initialize a new Semver + */ + private Semver() { + // Private constructor + } + + /** + * Compare two semantic versions. + *

+ * A leading {@code v} is ignored, build metadata is ignored, and missing trailing numeric + * components are treated as zero. This allows the application's four-part version + * ({@code 1.5.1.0}) to be compared with the API's three-part version ({@code v1.5.1}). + * + * @param first The first version + * @param second The second version + * @return A negative value if the first version is older, zero if both versions are equal, + * or a positive value if the first version is newer + */ + public static int compare(final String first, final String second) { + return parse(first).compareTo(parse(second)); + } + + /** + * Parse a version string into a comparable version + * + * @param value The version string + * @return The parsed version + */ + private static Version parse(final String value) { + if (value == null) + throw new NullPointerException("Version cannot be null!"); + + String version = value.trim(); + if (version.startsWith("v") || version.startsWith("V")) { + version = version.substring(1); + } + + final int buildMetadataIndex = version.indexOf('+'); + if (buildMetadataIndex >= 0) { + version = version.substring(0, buildMetadataIndex); + } + + if (version.isBlank()) + throw new IllegalArgumentException("Version cannot be blank!"); + + return Version.parse(version); + } +} diff --git a/src/main/java/com/codedead/opal/domain/VersionInfo.java b/src/main/java/com/codedead/opal/domain/VersionInfo.java new file mode 100644 index 0000000..07ea7be --- /dev/null +++ b/src/main/java/com/codedead/opal/domain/VersionInfo.java @@ -0,0 +1,148 @@ +package com.codedead.opal.domain; + +import java.util.ArrayList; +import java.util.List; + +public final class VersionInfo { + + private String id; + private String createdAt; + private String updatedAt; + private String name; + private String description; + private String semver; + private List platforms = new ArrayList<>(); + + /** + * Initialize a new VersionInfo + */ + public VersionInfo() { + // Default constructor + } + + /** + * Get the version identifier + * + * @return The version identifier + */ + public String getId() { + return id; + } + + /** + * Set the version identifier + * + * @param id The version identifier + */ + public void setId(final String id) { + this.id = id; + } + + /** + * Get the creation timestamp + * + * @return The creation timestamp + */ + public String getCreatedAt() { + return createdAt; + } + + /** + * Set the creation timestamp + * + * @param createdAt The creation timestamp + */ + public void setCreatedAt(final String createdAt) { + this.createdAt = createdAt; + } + + /** + * Get the last update timestamp + * + * @return The last update timestamp + */ + public String getUpdatedAt() { + return updatedAt; + } + + /** + * Set the last update timestamp + * + * @param updatedAt The last update timestamp + */ + public void setUpdatedAt(final String updatedAt) { + this.updatedAt = updatedAt; + } + + /** + * Get the application name + * + * @return The application name + */ + public String getName() { + return name; + } + + /** + * Set the application name + * + * @param name The application name + */ + public void setName(final String name) { + this.name = name; + } + + /** + * Get the application description + * + * @return The application description + */ + public String getDescription() { + return description; + } + + /** + * Set the application description + * + * @param description The application description + */ + public void setDescription(final String description) { + this.description = description; + } + + /** + * Get the semantic version + * + * @return The semantic version + */ + public String getSemver() { + return semver; + } + + /** + * Set the semantic version + * + * @param semver The semantic version + */ + public void setSemver(final String semver) { + this.semver = semver; + } + + /** + * Get the available platform releases + * + * @return The available platform releases + */ + public List getPlatforms() { + return platforms; + } + + /** + * Set the available platform releases + * + * @param platforms The available platform releases + */ + public void setPlatforms(final List platforms) { + this.platforms = platforms == null ? new ArrayList<>() : platforms; + } +} diff --git a/src/main/java/com/codedead/opal/utils/SharedVariables.java b/src/main/java/com/codedead/opal/utils/SharedVariables.java index 6b4f5ee..d46827f 100644 --- a/src/main/java/com/codedead/opal/utils/SharedVariables.java +++ b/src/main/java/com/codedead/opal/utils/SharedVariables.java @@ -1,12 +1,20 @@ package com.codedead.opal.utils; +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + public final class SharedVariables { public static final String ICON_URL = "/images/opal.png"; - public static final String CURRENT_VERSION = "1.5.1.0"; + public static final String APP_VERSION = loadVersion(); + public static final String CURRENT_VERSION = APP_VERSION + ".0"; public static final boolean PORTABLE = false; public static final String DEFAULT_LOCALE = "en-US"; + public static final String UPDATE_API_URL = "https://api.codedead.com/api/v1/version/d71f84a7-2994-4d05-8836-f744f7a4df4c"; + public static final String LEGACY_UPDATE_API_URL = "https://codedead.com/Software/Opal/version.json"; + public static final String PROPERTIES_BASE_PATH = PORTABLE ? System.getProperty("user.dir") + "/.com.codedead.opal" : System.getProperty("user.home") + "/.config/com.codedead.opal"; @@ -21,6 +29,31 @@ public final class SharedVariables { public static final String LICENSE_RESOURCE_LOCATION = "/documents/license.pdf"; public static final String LICENSE_FILE_LOCATION = PROPERTIES_BASE_PATH + "/license.pdf"; + /** + * Load the application version from the bundled version.properties file + * + * @return The application version + */ + private static String loadVersion() { + try (final InputStream inputStream = SharedVariables.class.getResourceAsStream("/version.properties")) { + if (inputStream == null) { + throw new IllegalStateException("Unable to find version.properties"); + } + + final Properties properties = new Properties(); + properties.load(inputStream); + + final String version = properties.getProperty("version"); + if (version == null || version.isBlank()) { + throw new IllegalStateException("The version property is missing from version.properties"); + } + + return version; + } catch (final IOException ex) { + throw new IllegalStateException("Unable to load version.properties", ex); + } + } + /** * Initialize a new SharedVariables */ diff --git a/src/main/resources/default.properties b/src/main/resources/default.properties index 28be88b..8bdd117 100644 --- a/src/main/resources/default.properties +++ b/src/main/resources/default.properties @@ -1,6 +1,6 @@ locale=en-US autoUpdate=true -updateApi=https://codedead.com/Software/Opal/version.json +updateApi=https://api.codedead.com/api/v1/version/d71f84a7-2994-4d05-8836-f744f7a4df4c updateProxyEnabled=false updateProxyHost= updateProxyPort=8080 diff --git a/src/main/resources/translations/OpalApplication.properties b/src/main/resources/translations/OpalApplication.properties index 867c0d0..7759acb 100644 --- a/src/main/resources/translations/OpalApplication.properties +++ b/src/main/resources/translations/OpalApplication.properties @@ -1,5 +1,5 @@ About=About -AboutText=Opal was created by DeadLine\n\nAudio: ZapSplat.com\nImages: Remix Icon\nTheme: AtlantaFX\nVersion: 1.5.1\n\nCopyright © 2026 CodeDead +AboutText=Opal was created by DeadLine\n\nAudio: ZapSplat.com\nImages: Remix Icon\nTheme: AtlantaFX\nVersion: {version}\n\nCopyright © {year} CodeDead AboutWindowError=Unable to open the About Window! AboutWindowTitle=Opal - About AutoUpdate=Automatically check for updates diff --git a/src/main/resources/translations/OpalApplication_de_DE.properties b/src/main/resources/translations/OpalApplication_de_DE.properties index c68dcc9..6660200 100644 --- a/src/main/resources/translations/OpalApplication_de_DE.properties +++ b/src/main/resources/translations/OpalApplication_de_DE.properties @@ -1,5 +1,5 @@ About=Über -AboutText=Opal wurde erstellt von: DeadLine\n\nAudio: ZapSplat.com\nBilder: Remix Icon\nDesign: AtlantaFX\nÜbersetzung: github.com/uDEV2019\nVersion: 1.5.1\n\nCopyright © 2026 CodeDead +AboutText=Opal wurde erstellt von: DeadLine\n\nAudio: ZapSplat.com\nBilder: Remix Icon\nDesign: AtlantaFX\nÜbersetzung: github.com/uDEV2019\nVersion: {version}\n\nCopyright © {year} CodeDead AboutWindowError=Über-Dialog konnte nicht geöffnet werden! AboutWindowTitle=Opal - Über AutoUpdate=Automatisch auf Aktualisierungen prüfen diff --git a/src/main/resources/translations/OpalApplication_en_US.properties b/src/main/resources/translations/OpalApplication_en_US.properties index 867c0d0..7759acb 100644 --- a/src/main/resources/translations/OpalApplication_en_US.properties +++ b/src/main/resources/translations/OpalApplication_en_US.properties @@ -1,5 +1,5 @@ About=About -AboutText=Opal was created by DeadLine\n\nAudio: ZapSplat.com\nImages: Remix Icon\nTheme: AtlantaFX\nVersion: 1.5.1\n\nCopyright © 2026 CodeDead +AboutText=Opal was created by DeadLine\n\nAudio: ZapSplat.com\nImages: Remix Icon\nTheme: AtlantaFX\nVersion: {version}\n\nCopyright © {year} CodeDead AboutWindowError=Unable to open the About Window! AboutWindowTitle=Opal - About AutoUpdate=Automatically check for updates diff --git a/src/main/resources/translations/OpalApplication_es_ES.properties b/src/main/resources/translations/OpalApplication_es_ES.properties index 770e722..f84e56e 100644 --- a/src/main/resources/translations/OpalApplication_es_ES.properties +++ b/src/main/resources/translations/OpalApplication_es_ES.properties @@ -1,5 +1,5 @@ About=Acerca de -AboutText=Opal fue creado por DeadLine\n\nAudio: ZapSplat.com\nImágenes: Remix Icon\nTema: AtlantaFX\nVersión: 1.5.1\n\nCopyright © 2026 CodeDead +AboutText=Opal fue creado por DeadLine\n\nAudio: ZapSplat.com\nImágenes: Remix Icon\nTema: AtlantaFX\nVersión: {version}\n\nCopyright © {year} CodeDead AboutWindowError=¡No se puede abrir la ventana Acerca de! AboutWindowTitle=Opal - Acerca de AutoUpdate=Buscar actualizaciones automáticamente diff --git a/src/main/resources/translations/OpalApplication_fr_FR.properties b/src/main/resources/translations/OpalApplication_fr_FR.properties index 725ae0e..73116a1 100644 --- a/src/main/resources/translations/OpalApplication_fr_FR.properties +++ b/src/main/resources/translations/OpalApplication_fr_FR.properties @@ -1,5 +1,5 @@ About=À propos -AboutText=Opal a été créé par DeadLine\n\nAudio: ZapSplat.com\nImages: Remix Icon\nThème: AtlantaFX\nVersion: 1.5.1\n\nCopyright © 2026 CodeDead +AboutText=Opal a été créé par DeadLine\n\nAudio: ZapSplat.com\nImages: Remix Icon\nThème: AtlantaFX\nVersion: {version}\n\nCopyright © {year} CodeDead AboutWindowError=Impossible d'ouvrir la fenêtre À propos! AboutWindowTitle=Opal - À propos AutoUpdate=Rechercher automatiquement les mises à jour diff --git a/src/main/resources/translations/OpalApplication_hi_IN.properties b/src/main/resources/translations/OpalApplication_hi_IN.properties index c8eae90..ae6c6fe 100644 --- a/src/main/resources/translations/OpalApplication_hi_IN.properties +++ b/src/main/resources/translations/OpalApplication_hi_IN.properties @@ -1,5 +1,5 @@ About=के बारे में -AboutText=ओपल को डेडलाइन द्वारा बनाया गया था\n\nऑडियो: ZapSplat.com\nछवियाँ: रीमिक्स आइकन\nथीम: अटलांटाFX\nसंस्करण: 1.5.1\n\nCopyright © 2026 CodeDead +AboutText=ओपल को डेडलाइन द्वारा बनाया गया था\n\nऑडियो: ZapSplat.com\nछवियाँ: रीमिक्स आइकन\nथीम: अटलांटाFX\nसंस्करण: {version}\n\nCopyright © {year} CodeDead AboutWindowError=About विंडो खोलने में असमर्थ! AboutWindowTitle=Opal - के बारे में AutoUpdate=अपडेट के लिए स्वचालित रूप से जाँच करें diff --git a/src/main/resources/translations/OpalApplication_ja_JP.properties b/src/main/resources/translations/OpalApplication_ja_JP.properties index d603769..9784877 100644 --- a/src/main/resources/translations/OpalApplication_ja_JP.properties +++ b/src/main/resources/translations/OpalApplication_ja_JP.properties @@ -1,5 +1,5 @@ About=このアプリについて -AboutText=Opal は DeadLine によって作成されました\n\nオーディオ: ZapSplat.com\n画像: リミックス アイコン\nテーマ: AtlantaFX\nバージョン: 1.5.1\n\nCopyright © 2026 CodeDead +AboutText=Opal は DeadLine によって作成されました\n\nオーディオ: ZapSplat.com\n画像: リミックス アイコン\nテーマ: AtlantaFX\nバージョン: {version}\n\nCopyright © {year} CodeDead AboutWindowError=このアプリについてのウィンドウを開けません! AboutWindowTitle=Opal - このアプリについて AutoUpdate=アップデートを自動的に確認する diff --git a/src/main/resources/translations/OpalApplication_nl_NL.properties b/src/main/resources/translations/OpalApplication_nl_NL.properties index 5119ca9..6f02d64 100644 --- a/src/main/resources/translations/OpalApplication_nl_NL.properties +++ b/src/main/resources/translations/OpalApplication_nl_NL.properties @@ -1,5 +1,5 @@ About=Over -AboutText=Opal is gemaakt door DeadLine\n\nAudio: ZapSplat.com\nAfbeeldingen: Remix Icon\nThema: AtlantaFX\nVersie: 1.5.1\n\nCopyright © 2026 CodeDead +AboutText=Opal is gemaakt door DeadLine\n\nAudio: ZapSplat.com\nAfbeeldingen: Remix Icon\nThema: AtlantaFX\nVersie: {version}\n\nCopyright © {year} CodeDead AboutWindowError=Kan het Over venster niet openen! AboutWindowTitle=Opal - Over AutoUpdate=Automatisch controleren op updates diff --git a/src/main/resources/translations/OpalApplication_ru_RU.properties b/src/main/resources/translations/OpalApplication_ru_RU.properties index 64b5b52..1056284 100644 --- a/src/main/resources/translations/OpalApplication_ru_RU.properties +++ b/src/main/resources/translations/OpalApplication_ru_RU.properties @@ -1,5 +1,5 @@ About=О программе -AboutText=Opal был создан DeadLine\n\nАудио: ZapSplat.com\nИзображения: Remix Icon\nТема: AtlantaFX\nВерсия: 1.5.1\n\nАвторские права © 2026 CodeDead +AboutText=Opal был создан DeadLine\n\nАудио: ZapSplat.com\nИзображения: Remix Icon\nТема: AtlantaFX\nВерсия: {version}\n\nАвторские права © {year} CodeDead AboutWindowError=Не удается открыть окно «О программе»! AboutWindowTitle=Opal - О программе AutoUpdate=Автоматически проверять наличие обновлений diff --git a/src/main/resources/translations/OpalApplication_tr_TR.properties b/src/main/resources/translations/OpalApplication_tr_TR.properties index 6987703..6a8400b 100644 --- a/src/main/resources/translations/OpalApplication_tr_TR.properties +++ b/src/main/resources/translations/OpalApplication_tr_TR.properties @@ -1,5 +1,5 @@ About=Hakkında -AboutText=Opal, DeadLine tarafından oluşturuldu\n\nSes: ZapSplat.com\nGörüntüler: Remix Simgesi\nTema: AtlantaFX\nSürüm: 1.5.1\n\nTelif hakkı © 2026 CodeDead +AboutText=Opal, DeadLine tarafından oluşturuldu\n\nSes: ZapSplat.com\nGörüntüler: Remix Simgesi\nTema: AtlantaFX\nSürüm: {version}\n\nTelif hakkı © {year} CodeDead AboutWindowError=Hakkında Penceresi açılamıyor! AboutWindowTitle=Opal - Hakkında AutoUpdate=Güncellemeleri otomatik olarak kontrol et diff --git a/src/main/resources/translations/OpalApplication_uk_UA.properties b/src/main/resources/translations/OpalApplication_uk_UA.properties index 61200c2..f8c7f76 100644 --- a/src/main/resources/translations/OpalApplication_uk_UA.properties +++ b/src/main/resources/translations/OpalApplication_uk_UA.properties @@ -1,5 +1,5 @@ About=Про -AboutText=Opal був створений компанією DeadLine\n\nАудіо: ZapSplat.com\nЗображення: Remix Icon\nТема: AtlantaFX\nВерсія: 1.5.1\n\nCopyright © 2026 CodeDead +AboutText=Opal був створений компанією DeadLine\n\nАудіо: ZapSplat.com\nЗображення: Remix Icon\nТема: AtlantaFX\nВерсія: {version}\n\nCopyright © {year} CodeDead AboutWindowError=Не вдається відкрити вікно "Про програму"! AboutWindowTitle=Opal - Про AutoUpdate=Автоматично перевіряти наявність оновлень diff --git a/src/main/resources/translations/OpalApplication_zh_CN.properties b/src/main/resources/translations/OpalApplication_zh_CN.properties index 35efe79..6926dff 100644 --- a/src/main/resources/translations/OpalApplication_zh_CN.properties +++ b/src/main/resources/translations/OpalApplication_zh_CN.properties @@ -1,5 +1,5 @@ About=关于 -AboutText=Opal 由 DeadLine 制作\n\n音频:ZapSplat.com\n图像:Remix Icon\n主题:AtlantaFX\n版本:1.5.1\n\nCopyright © 2026 CodeDead +AboutText=Opal 由 DeadLine 制作\n\n音频:ZapSplat.com\n图像:Remix Icon\n主题:AtlantaFX\n版本:{version}\n\nCopyright © {year} CodeDead AboutWindowError=无法打开“关于”窗口! AboutWindowTitle=Opal - 关于 AutoUpdate=自动检查更新 diff --git a/src/main/resources/version.properties b/src/main/resources/version.properties new file mode 100644 index 0000000..a50bf5c --- /dev/null +++ b/src/main/resources/version.properties @@ -0,0 +1 @@ +version=${version} diff --git a/src/main/resources/windows/AboutWindow.fxml b/src/main/resources/windows/AboutWindow.fxml index 24035de..eb3b312 100644 --- a/src/main/resources/windows/AboutWindow.fxml +++ b/src/main/resources/windows/AboutWindow.fxml @@ -40,7 +40,7 @@ -