diff --git a/ant/project.properties b/ant/project.properties index db1cda2cb..05563d485 100644 --- a/ant/project.properties +++ b/ant/project.properties @@ -35,16 +35,22 @@ javac.target=11 java.download=https://bell-sw.com/pages/downloads/#/java-11-lts # Java vendor to bundle into software (e.g. "*BellSoft|Adoptium|Microsoft|Amazon|IBM") -jlink.java.vendor="BellSoft" +jlink.java.vendor=BellSoft # Java vendor to bundle into software (e.g. "11.0.17+7") -jlink.java.version="11.0.30+9" +jlink.java.version=11.0.30+9 # Java garbage collector flavor to use (e.g. "hotspot|openj9") -jlink.java.gc="hotspot" +jlink.java.gc=hotspot # Java garbage collector version to use (e.g. openj9: "0.35.0", zulu: "11.62.17") -jlink.java.gc.version="gc-ver-is-empty" +jlink.java.gc.version=gc-ver-is-empty # Bundle a locally built copy of Java instead # jlink.java.target=/path/to/custom/jdk-x.x.x +# Fetch a copy of the JDK from BellSoft's support portal +jlink.api.enabled=false +jlink.api.url=https://api.bell-sw.com/v1/liberica/releases +# jlink.api.token= +# jlink.api.exact=false <-- false: use latest found version; true: match jlink.java.version exactly + # Skip bundling the java runtime # jre.skip=true @@ -60,4 +66,4 @@ provision.dir=${dist.dir}/provision java.mask.tray=true # Workaround to delay expansion of $${foo} (e.g. shell scripts) -dollar=$ +dollar=$ \ No newline at end of file diff --git a/build.xml b/build.xml index 53b3fca4b..b459a31d0 100644 --- a/build.xml +++ b/build.xml @@ -323,6 +323,10 @@ Downloading and bundling the jre for ${target.os} + + + + diff --git a/src/qz/build/Fetcher.java b/src/qz/build/Fetcher.java index db6edde08..bdd658384 100644 --- a/src/qz/build/Fetcher.java +++ b/src/qz/build/Fetcher.java @@ -9,10 +9,16 @@ import java.io.File; import java.io.FileInputStream; import java.io.IOException; +import java.net.URISyntaxException; import java.net.URL; +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 java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.Map; +import java.util.Objects; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -23,9 +29,10 @@ public class Fetcher { public enum Format { ZIP(".zip"), TARBALL(".tar.gz"), + JSON(".json"), UNKNOWN(null); - String suffix; + final String suffix; Format(String suffix) { this.suffix = suffix; } @@ -35,8 +42,12 @@ public String getSuffix() { } public static Format parse(String url) { + if(url.contains("?")) { + url = url.substring(0, url.lastIndexOf("?")); + log.debug("Stripped parameters from URL to help detecting file type: '{}'", url); + } for(Format format : Format.values()) { - if (url.endsWith(format.getSuffix())) { + if (format.getSuffix() != null && url.endsWith(format.getSuffix())) { return format; } } @@ -50,33 +61,39 @@ public static void main(String ... args) throws IOException { new Fetcher("jlink/qz-tray-src_x.x.x", "https://github.com/qzind/tray/archive/master.tar.gz").fetch().uncompress(); } - String resourceName; - String url; - Format format; - Path rootDir; - File tempArchive; + final String resourceName; + final String url; + final Format format; + final Path rootDir; + final Map headers; + + File tempFile; File tempExtracted; File extracted; - public Fetcher(String resourceName, String url) { - this.url = url; - this.resourceName = resourceName; - this.format = Format.parse(url); - // Try to calculate out/ - this.rootDir = SystemUtilities.getJarParentPath().getParent(); - } - - @SuppressWarnings("unused") - public Fetcher(String resourceName, String url, Format format, String rootDir) { + public Fetcher(String resourceName, String url, Format format, Path rootDir, Map headers) { this.resourceName = resourceName; this.url = url; this.format = format; - this.rootDir = Paths.get(rootDir); + this.rootDir = rootDir; + this.headers = headers; + } + + public Fetcher(String resourceName, String url, Format format, Map headers) { + this(resourceName, url, format, SystemUtilities.getJarParentPath().getParent(), headers); + } + + public Fetcher(String resourceName, String url, Map headers) { + this(resourceName, url, Format.parse(url), headers); + } + + public Fetcher(String resourceName, String url) { + this(resourceName, url, null); } public Fetcher fetch() throws IOException { extracted = new File(rootDir.toString(), resourceName); - if(extracted.isDirectory() && extracted.exists()) { + if(extracted.isDirectory() && extracted.exists() && Objects.requireNonNull(extracted.listFiles()).length > 0) { log.info("Resource '{}' from [{}] has already been downloaded and extracted. Using: [{}]", resourceName, url, extracted); } else { tempExtracted = new File(rootDir.toString(), resourceName + "~tmp"); @@ -84,21 +101,47 @@ public Fetcher fetch() throws IOException { FileUtils.deleteDirectory(tempExtracted); } // temp directory to thwart partial extraction - tempExtracted.mkdirs(); - tempArchive = File.createTempFile(resourceName, ".zip"); - log.info("Fetching '{}' from [{}] and saving to [{}]", resourceName, url, tempArchive); - FileUtils.copyURLToFile(new URL(url), tempArchive); + if(tempExtracted.mkdirs()) { + tempFile = File.createTempFile(resourceName, format == Format.JSON ? ".json" : ".zip"); + log.info("Fetching '{}' from [{}] and saving to [{}]", resourceName, url, tempFile); + copyUrlToFile(new URL(url), tempFile.toPath(), headers); + } else { + throw new IOException(String.format("Unable to create directory for jdk extraction '%s'", tempExtracted)); + } } return this; } + public void copyUrlToFile(URL url, Path targetPath, Map headers) throws IOException { + HttpRequest.Builder requestBuilder; + try { + requestBuilder = HttpRequest.newBuilder().uri(url.toURI()).GET(); + if(headers != null) { + headers.forEach(requestBuilder::header); + } + HttpRequest request = requestBuilder.build(); + + // stream response to file + HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofFile( + targetPath, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE + )); + } catch(URISyntaxException e) { + throw new IOException(String.format("Invalid URI specified '%s'", url), e); + } catch(InterruptedException e) { + throw new IOException(String.format("Request interrupted '%s'", url), e); + } + } + public String uncompress() throws IOException { - if(tempArchive != null) { - log.info("Unzipping '{}' from [{}] to [{}]", resourceName, tempArchive, tempExtracted); + if(tempFile != null) { + log.info("Unzipping '{}' from [{}] to [{}]", resourceName, tempFile, tempExtracted); if(format == Format.ZIP) { - unzip(tempArchive.getAbsolutePath(), tempExtracted); + unzip(tempFile.getAbsolutePath(), tempExtracted); } else { - untar(tempArchive.getAbsolutePath(), tempExtracted); + untar(tempFile.getAbsolutePath(), tempExtracted); } log.info("Moving [{}] to [{}]", tempExtracted, extracted); tempExtracted.renameTo(extracted); diff --git a/src/qz/build/JLink.java b/src/qz/build/JLink.java index bd604cd14..d43b457e2 100644 --- a/src/qz/build/JLink.java +++ b/src/qz/build/JLink.java @@ -11,10 +11,14 @@ package qz.build; import com.github.zafarkhaja.semver.Version; +import javafx.util.Pair; import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.codehaus.jettison.json.JSONArray; +import org.codehaus.jettison.json.JSONException; +import org.codehaus.jettison.json.JSONObject; import qz.build.jlink.Platform; import qz.build.jlink.Vendor; import qz.build.jlink.Url; @@ -25,13 +29,14 @@ import java.io.File; import java.io.FileInputStream; import java.io.IOException; +import java.net.URL; import java.nio.file.*; import java.util.*; public class JLink { private static final Logger log = LogManager.getLogger(JLink.class); public static final Vendor JAVA_DEFAULT_VENDOR = Vendor.BELLSOFT; - private static final String JAVA_DEFAULT_VERSION = "11.0.17+7"; + public static final String JAVA_DEFAULT_VERSION = "11.0.17+7"; private static final String JAVA_DEFAULT_GC_ENGINE = "hotspot"; // or "openj9" private static final String JAVA_DEFAULT_GC_VERSION = "0.35.0"; // openj9 gc only @@ -49,6 +54,7 @@ public class JLink { private String gcEngine; private String javaVersion; private String gcVersion; + private boolean apiEnabled; private Path targetJdk; @@ -67,7 +73,10 @@ public JLink(String targetPlatform, String targetArch, String javaVendor, String this.javaVersion = getParam("javaVersion", javaVersion, JAVA_DEFAULT_VERSION); this.gcVersion = getParam("gcVersion", gcVersion, JAVA_DEFAULT_GC_VERSION); - this.javaSemver = SystemUtilities.getJavaVersion(this.javaVersion); + // Check to see if we should use an API download + this.apiEnabled = Boolean.parseBoolean(System.getProperty("jlink.api.enabled")); + + this.javaSemver = JavaVersion.parse(this.javaVersion); // Optional: Provide the location of a custom JDK on the local filesystem if(!StringUtils.isEmpty(targetJdk)) { @@ -78,7 +87,7 @@ public JLink(String targetPlatform, String targetArch, String javaVendor, String if(customVersion.contains("\"")) { customVersion = customVersion.split("\"")[1]; } - Version customSemver = SystemUtilities.getJavaVersion(customVersion); + Version customSemver = JavaVersion.parse(customVersion); if(needsDownload(javaSemver, customSemver)) { // The "release" file doesn't have build info, so we can't auto-download :( if(javaSemver.getMajorVersion() != customSemver.getMajorVersion()) { @@ -125,13 +134,17 @@ public static void main(String ... args) throws IOException { /** * Handle incompatibilities between JDKs, download a fresh one if needed */ - private static boolean needsDownload(Version want, Version installed) { + private boolean needsDownload(Version want, Version installed) { + if(apiEnabled) { + // always assume api downloads differ from the local version + return true; + } // jdeps and jlink historically require matching major JDK versions. Download if needed. boolean downloadJdk = installed.getMajorVersion() != want.getMajorVersion(); // Per JDK-8240734: Major versions checks aren't enough starting with 11.0.16+8 // see also https://github.com/adoptium/adoptium-support/issues/557 - Version bad = SystemUtilities.getJavaVersion("11.0.16+8"); + Version bad = JavaVersion.parse("11.0.16+8"); if(want.greaterThanOrEqualTo(bad) && installed.lessThan(bad) || installed.greaterThanOrEqualTo(bad) && want.lessThan(bad)) { // Force download @@ -141,19 +154,130 @@ private static boolean needsDownload(Version want, Version installed) { return downloadJdk; } + private HashMap getApiHeaders() { + HashMap headers = new HashMap<>(); + + String apiKey = System.getProperty("jlink.api.token"); + if(apiKey == null) { + return headers; + } + + switch(javaVendor) { + case BELLSOFT: + headers.put("Authorization", String.format("Bearer %s", apiKey)); + break; + case ECLIPSE: + default: + throw new UnsupportedOperationException(String.format("API headers are not yet implemented for '%s'", javaVendor)); + } + return headers; + } + + /** + * Grabs a URL,Version pair from an API call + * TODO: Make this more vendor-agnostic + */ + private Pair getApiUrl(Arch arch, Platform platform, HashMap headers) throws IOException { + String apiUrl = System.getProperty("jlink.api.url"); + boolean exact = Boolean.parseBoolean(System.getProperty("jlink.api.exact")); // defaults to false + + Version bestVersion = Version.of(0); + URL bestUrl = null; + + if(apiUrl != null) { + // e.g. product-info-bell-soft-12345.json + String hostString = new URL(apiUrl).getHost().replaceAll("\\.", "-"); + long hoursSinceEpoch = System.currentTimeMillis() / 3600000; + String resourceName = String.format("product-info-%s-%s.json", hostString, hoursSinceEpoch); + + // add filters to narrow our results + apiUrl += "?" + javaVendor.getApiPlatform(platform) + + "&" + javaVendor.getApiArch(arch) + + "&" + javaVendor.getApiMajorVersion(javaSemver); + + File productInfo = new Fetcher(resourceName, apiUrl, Fetcher.Format.JSON, headers) + .fetch().tempFile; + + try { + JSONArray jsonArray = new JSONArray(FileUtilities.readLocalFile(productInfo.toPath())); + + for(int i = 0; i < jsonArray.length(); i++) { + if(jsonArray.get(i) instanceof JSONObject) { + JSONObject entry = jsonArray.getJSONObject(i); + if((!entry.optString("packageType", "").equals("zip") && + !entry.optString("packageType", "").equals("tar.gz")) || + !entry.optString("bundleType", "").equals("jdk")) { + continue; + } + + String versionFound = entry.optString("version"); + String downloadUrl = entry.optString("downloadUrl"); + if(exact) { + if (javaVersion.compareTo(versionFound) == 0) { + bestVersion = JavaVersion.parse(versionFound); + bestUrl = new URL(downloadUrl); + } + } else { + if (versionFound != null) { + Version foundVersion = JavaVersion.parse(versionFound); + if (foundVersion.compareTo(bestVersion) >= 1) { + bestVersion = foundVersion; + bestUrl = new URL(downloadUrl); + } + } + } + } else { + log.warn("Entry {} is not a JSONObject", i); + } + } + if(bestUrl == null) { + throw new IOException(String.format("Could not find a matching download URL for arch: '%s', platform: '%s', exactMatch: '%s'", arch, platform, exact)); + } else { + log.info(ShellUtilities.consoleBox( + String.format("API URL: %s", apiUrl), + String.format("Download URL: %s", bestUrl.toString().split("\\?")[0]), + String.format("Version: %s", bestVersion))); + } + } + catch(JSONException e) { + throw new IOException("Error parsing download file as JSON", e); + } + } + return new Pair<>(bestUrl, bestVersion); + } + /** * Download the JDK and return the path it was extracted to */ private String downloadJdk(Arch arch, Platform platform) throws IOException { String url = new Url(this.javaVendor).format(arch, platform, this.gcEngine, this.javaSemver, this.javaVersion, this.gcVersion); + if (apiEnabled) { + Pair apiPair = getApiUrl(arch, platform, getApiHeaders()); + log.info("Using Java '{}' from '{}' since an 'jlink.api.enabled={}' was provided", apiPair.getValue(), apiPair.getKey(), apiEnabled); + url = apiPair.getKey().toString(); + javaSemver = apiPair.getValue(); + } + // Saves to out e.g. "out/jlink/jdk-AdoptOpenjdk-amd64-platform-11_0_7" - String extractedJdk = new Fetcher(String.format("jlink/jdk-%s-%s-%s-%s", javaVendor.value(), arch, platform.value(), javaSemver.toString().replaceAll("\\+", "_")), url) + String resourceName = String.format("jlink/jdk%s-%s-%s-%s-%s", + apiEnabled ? "-api" : "", + javaVendor.value(), + arch, + platform.value(), + javaSemver.toString().replaceAll("\\+", "_")); + + + String extractedJdk = new Fetcher(resourceName, url) .fetch() .uncompress(); // Get first subfolder, e.g. jdk-11.0.7+10 - for(File subfolder : new File(extractedJdk).listFiles(pathname -> pathname.isDirectory())) { + File[] subFolders = new File(extractedJdk).listFiles(File::isDirectory); + if(subFolders == null) { + throw new IOException(String.format("Error locating a suitable JDK: Unable to list files for '%s'", extractedJdk)); + } + for(File subfolder : subFolders) { extractedJdk = subfolder.getPath(); if(platform == Platform.MAC && Paths.get(extractedJdk, "/Contents/Home").toFile().isDirectory()) { extractedJdk += "/Contents/Home"; @@ -201,7 +325,7 @@ private JLink calculateToolPaths(Path javaHome) throws IOException { log.info("Assuming jlink path: {}", jlinkPath); jdepsPath.toFile().setExecutable(true, false); jlinkPath.toFile().setExecutable(true, false); - jdepsVersion = SystemUtilities.getJavaVersion(jdepsPath); + jdepsVersion = JavaVersion.parseCli(jdepsPath); return this; } diff --git a/src/qz/build/jlink/Vendor.java b/src/qz/build/jlink/Vendor.java index 7405e6b9c..b410c10c6 100644 --- a/src/qz/build/jlink/Vendor.java +++ b/src/qz/build/jlink/Vendor.java @@ -87,6 +87,50 @@ public String getUrlArch(Arch arch) { } } + public String getApiPlatform(Platform platform) { + switch(this) { + case BELLSOFT: + // Assume they're the same unless we know otherwise + return String.format("os=%s",getUrlPlatform(platform)); + case ECLIPSE: + default: + throw new UnsupportedOperationException(String.format("Filtering API by os is not yet supported for this vendor (%s)", this)); + } + } + + public String getApiMajorVersion(Version version) { + switch(this) { + case BELLSOFT: + // Assume they're the same unless we know otherwise + return String.format("version-feature=%d", version.majorVersion()); + case ECLIPSE: + default: + throw new UnsupportedOperationException(String.format("Filtering API by major version is not yet supported for this vendor (%s)", this)); + } + } + + public String getApiArch(Arch arch) { + switch(this) { + case BELLSOFT: + switch(arch) { + case ARM32: + case AARCH64: + return String.format("arch=arm&bitness=%s", arch.getBitness()); + case X86: + case X86_64: + return String.format("arch=x86&bitness=%s", arch.getBitness()); + case RISCV32: + case RISCV64: + return String.format("arch=riscv&bitness=%s", arch.getBitness()); + case PPC64: + return String.format("arch=ppc&bitness=%s", arch.getBitness()); + } + case ECLIPSE: + default: + throw new UnsupportedOperationException(String.format("Filtering API by arch '%s' is not yet supported for this vendor (%s)", arch, this)); + } + } + /** * Map Vendor to Platform name */ diff --git a/src/qz/build/provision/params/Arch.java b/src/qz/build/provision/params/Arch.java index 4f8e6620d..60fdc8c47 100644 --- a/src/qz/build/provision/params/Arch.java +++ b/src/qz/build/provision/params/Arch.java @@ -23,9 +23,12 @@ public enum Arch { UNKNOWN(); private HashSet aliases = new HashSet<>(); + private int bitness; + Arch(String ... aliases) { this.aliases.add(name().toLowerCase(Locale.ENGLISH)); this.aliases.addAll(Arrays.asList(aliases)); + this.bitness = name().endsWith("64") ? 64 : 32; } public static Arch parseStrict(String input) throws UnsupportedOperationException { @@ -63,6 +66,10 @@ public static String serialize(HashSet archList) { return StringUtils.join(archList, "|"); } + public int getBitness() { + return bitness; + } + @Override public String toString() { return super.toString().toLowerCase(Locale.ENGLISH); diff --git a/src/qz/common/Constants.java b/src/qz/common/Constants.java index ced85c903..68ecb6bc1 100644 --- a/src/qz/common/Constants.java +++ b/src/qz/common/Constants.java @@ -1,7 +1,7 @@ package qz.common; import com.github.zafarkhaja.semver.Version; -import qz.utils.SystemUtilities; +import qz.utils.JavaVersion; import java.awt.*; @@ -15,7 +15,7 @@ public class Constants { public static final char[] HEXES_ARRAY = HEXES.toCharArray(); public static final int BYTE_BUFFER_SIZE = 8192; public static final Version VERSION = Version.valueOf("2.2.6"); - public static final Version JAVA_VERSION = SystemUtilities.getJavaVersion(); + public static final Version JAVA_VERSION = JavaVersion.current(); public static final String JAVA_VENDOR = System.getProperty("java.vendor"); /* QZ-Tray Constants */ diff --git a/src/qz/utils/JavaVersion.java b/src/qz/utils/JavaVersion.java new file mode 100644 index 000000000..541791670 --- /dev/null +++ b/src/qz/utils/JavaVersion.java @@ -0,0 +1,158 @@ +package qz.utils; + +import com.github.zafarkhaja.semver.Version; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Utility class for Java version handling including CLI parsing, special nuance handling of Runtime.Version + * and legacy "1.x" handling. + * + *

+ * This class aims to handle all historical and future nuances caused by Java's incompliance and inconsistencies despite the great unlikelihood of some of these + * past decisions ever being relevant in the future. + *

+ * + *

+ * Starting with Java 9, Runtime.Version is Java's officially supported way for handling Java version + * handling, but this library is not fully semver compliant as it prohibits certain items + * such as trailing zeros yet appends non-standard items such as "-LTS" to the build information. These discrepancies + * break predictability. For example "11.0.3+23-LTS" stores "+23" and "LTS" as + * separate build meta-data, which is equivalent to "11.0.3+23.LTS" (notice the "-" has been + * replaced with a "."), so we try to strip that off as it's irrelevant for semantic comparison. Additionally, + * Java historically has conflated certain numbering. For example, "Java 8" was internally known as "1.8.0" + * and would report as "1.8.0" so parsing "8.0" should always match "1.8.0" . + * Worse yet, the old build information was formatted "1.8.0_202" which can't be parsed by ANY semver library, + * so special considerations must be made to sanitize all possible input variants. + *

+ */ +public class JavaVersion { + + /** + * Returns the current Runtime.version() as a semantic version + */ + public static Version current() { + return toSemantic(Runtime.version()); + } + + /** + * Parses the input and converts to a semantic version. Allows both "bare" formatted data such as "11.0" + * as well as a "wall of text" such as that outputted by "java --version" and handles any sanitization + * thereof. + */ + public static Version parse(String rawInput) { + String isolated = isolate(rawInput); + String sanitized = sanitize(isolated); + return parseStrict(sanitized); + } + + /** + * Call a java command (e.g. java) with "--version" and parse the output + * The double dash "--" is since JDK9 but important to send the command output to stdout + */ + public static Version parseCli(Path javaBin) { + return parse(ShellUtilities.executeRaw(javaBin.toString(), "--version")); + } + + /** + * Parses an isolated and sanitized java version (e.g. "1.8" NOT "1.8.0") using + * a combination of Java's internal versioning and a dedicated semver library. + */ + static Version parseStrict(String javaVersion) { + return toSemantic(Runtime.Version.parse(javaVersion)); + } + + /** + * Handle nuances with Java's semver reporting. + * e.g. Strip invalid "-LTS" suffix, coerce "Java 8 = 1.8.0", etc + */ + static String sanitize(String fuzzyVersion) { + String sanitized = fuzzyVersion; + + // Chomp off "-LTS" + if(sanitized.endsWith("-LTS")) { + sanitized = sanitized.substring(0, sanitized.length() - 4); + } + // Legacy formatting + // isolate first digit + String firstDigit = (sanitized.length() > 1 && sanitized.contains(".")) ? sanitized.split("\\.")[0] : sanitized; + switch(firstDigit) { + case "8": + case "7": + case "6": + case "5": + case "4": + case "3": + case "2": + sanitized = "1." + sanitized; + } + + // Legacy versions: Replace "_" with "+", strip "-bNN" (e.g. "1.8.0_202-b08") + if(sanitized.startsWith("1.")) { + sanitized = sanitized.replaceFirst("_", "+"); + if(sanitized.contains("-b")) { + sanitized = sanitized.split("-b", 2)[0]; + } + } + + // Java prohibits trailing zeros for no fricken reason + // We limit the split to 2 parts so we don't break this same rule in the metadata + String[] parts = sanitized.split("(?=[+\\-])", 2); + parts[0] = parts[0].replaceAll("(\\.0)+$", ""); + return (parts.length > 1) ? parts[0] + parts[1] : parts[0]; + } + + /** + * Convert Runtime.Version to a semver-compatible Version + */ + static Version toSemantic(Runtime.Version rv) { + Version converted = rv.pre().isPresent() ? Version.of(rv.feature(), rv.interim(), rv.update(), rv.pre().get()) : + Version.of(rv.feature(), rv.interim(), rv.update()); + + List optionals = new ArrayList<>(); + rv.build().ifPresent(o -> optionals.add(o.toString())); + rv.optional() + .filter(val -> !"LTS".equals(val)) + .ifPresent(optionals::add); + + if(!optionals.isEmpty()) { + converted = converted.withBuildMetadata(optionals.toArray(new String[0])); + } + return converted; + } + + /** + * Isolate the version string part from the raw input. Can handle both bare version information + * and output from "java --version" + */ + static String isolate(String rawInput) { + // Try to find the "(build 11.0.27+0)" line + String buildMatch = "(build"; + if(rawInput.contains("\n")) { + String[] lines = rawInput.split("\n"); + for(String line : lines) { + int buildLoc = line.indexOf(buildMatch); + if(buildLoc != -1) { + rawInput = line.substring(buildLoc + buildMatch.length() , line.length() -1); + break; + } + } + } + // Chomp off leading "openjdk", etc + int i; + for(i = 0; i < rawInput.length() - 1; i++) { + if(ByteUtilities.isNumber(rawInput.substring(i, i+1))) { + rawInput = rawInput.substring(i).trim(); + break; + } + } + // Chomp off any trailing data + if(rawInput.contains(" ")) { + rawInput = rawInput.split(" ", 2)[0]; + } + + return rawInput; + } +} diff --git a/src/qz/utils/ShellUtilities.java b/src/qz/utils/ShellUtilities.java index a2693b85f..0b7f09534 100644 --- a/src/qz/utils/ShellUtilities.java +++ b/src/qz/utils/ShellUtilities.java @@ -336,4 +336,43 @@ public static String envpToString() { } return "(suppressed)"; } + + /** + * Prints a pretty ASCII box on the screen + */ + public static String consoleBox(String... lines) { + if (lines == null || lines.length == 0) return ""; + + int padding = 2; // Number of spaces on each side + int maxWidth = 0; + + for (String s : lines) { + if (s != null && s.length() > maxWidth) { + maxWidth = s.length(); + } + } + + // Total internal width including padding + int totalWidth = maxWidth + (padding * 2); + + StringBuilder sb = new StringBuilder("\n"); + + // Top border + sb.append("╔").append("═".repeat(totalWidth)).append("╗\n"); + + // Content lines + for (String s : lines) { + if (s == null) s = ""; + int trailingSpaces = totalWidth - s.length() - padding; + sb.append("║") + .append(" ".repeat(padding)) // Left padding + .append(s) + .append(" ".repeat(trailingSpaces)) // Right padding to fill the box + .append("║\n"); + } + + // Bottom border + sb.append("╚").append("═".repeat(totalWidth)).append("╝").append("\n"); + return sb.toString(); + } } diff --git a/src/qz/utils/SystemUtilities.java b/src/qz/utils/SystemUtilities.java index 7d39c2a7e..7553fc9bc 100644 --- a/src/qz/utils/SystemUtilities.java +++ b/src/qz/utils/SystemUtilities.java @@ -41,6 +41,7 @@ import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.*; +import java.util.List; import java.util.concurrent.ThreadLocalRandom; /** @@ -204,22 +205,10 @@ public static String whoami() { return whoami; } - public static Version getJavaVersion() { - return getJavaVersion(System.getProperty("java.version")); - } - - /** - * Call a java command (e.g. java) with "--version" and parse the output - * The double dash "--" is since JDK9 but important to send the command output to stdout - */ - public static Version getJavaVersion(Path javaCommand) { - return getJavaVersion(ShellUtilities.executeRaw(javaCommand.toString(), "--version")); - } - public static int getProcessId() { if(pid == null) { // Try Java 9+ - if(Constants.JAVA_VERSION.getMajorVersion() >= 9) { + if(Constants.JAVA_VERSION.majorVersion() >= 9) { pid = getProcessIdJigsaw(); } // Try JNA @@ -246,47 +235,6 @@ private static int getProcessIdJigsaw() { return -1; } - /** - * Handle Java versioning nuances - * To eventually be replaced with java.lang.Runtime.Version (JDK9+) - */ - public static Version getJavaVersion(String version) { - String[] parts = version.trim().split("\\D+"); - - int major = 1; - int minor = 0; - int patch = 0; - String meta = ""; - - try { - switch(parts.length) { - default: - case 4: - meta = parts[3]; - case 3: - patch = Integer.parseInt(parts[2]); - case 2: - minor = Integer.parseInt(parts[1]); - major = Integer.parseInt(parts[0]); - break; - case 1: - major = Integer.parseInt(parts[0]); - if (major <= 8) { - // Force old 1.x style formatting - minor = major; - major = 1; - } - } - } catch(NumberFormatException e) { - log.warn("Could not parse Java version \"{}\"", version, e); - } - if(meta.trim().isEmpty()) { - return Version.of(major, minor, patch); - } else { - return Version.of(major, minor, patch, null, meta); - } - } - /** * Determines the currently running Jar's absolute path on the local filesystem * todo: make this return a sane directory for running via ide diff --git a/test/qz/utils/JavaVersionTests.java b/test/qz/utils/JavaVersionTests.java new file mode 100644 index 000000000..8e3d11724 --- /dev/null +++ b/test/qz/utils/JavaVersionTests.java @@ -0,0 +1,196 @@ +package qz.utils; + +import com.github.zafarkhaja.semver.Version; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.testng.Assert; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; +import qz.build.JLink; + +import java.io.FileReader; +import java.io.IOException; +import java.nio.file.Paths; +import java.util.Properties; + +import static qz.utils.JavaVersion.*; + +public class JavaVersionTests { + private static final Logger log = LogManager.getLogger(JavaVersionTests.class); + + @DataProvider(name = "versions") + public Object[][] versions() { + return new Object[][] { + // final, sanitized, isolated, raw input + { + "25.0.0+1", + "25+1", + "25+1", + "openjdk 25 2025-01-21\n" + + "OpenJDK Runtime Environment (build 25+1)\n" + + "OpenJDK 64-Bit Server VM (build 25+1, mixed mode, sharing)" + }, + { + "25.0.2+12", + "25.0.2+12", // chomp off "-LTS" + "25.0.2+12-LTS", + "openjdk 25.0.2 2026-01-20 LTS\n" + + "OpenJDK Runtime Environment (build 25.0.2+12-LTS)\n" + + "OpenJDK 64-Bit Server VM (build 25.0.2+12-LTS, mixed mode, sharing)" + }, + { + "11.0.27+0", + "11.0.27+0", + "11.0.27+0", + "openjdk 11.0.27 2025-04-15\n" + + "OpenJDK Runtime Environment Homebrew (build 11.0.27+0)\n" + + "OpenJDK 64-Bit Server VM Homebrew (build 11.0.27+0, mixed mode)" + }, + { + "25.0.0", + "25", + "25", + "openjdk 25 2025-09-16\n" + + "OpenJDK Runtime Environment Homebrew (build 25)\n" + + "OpenJDK 64-Bit Server VM Homebrew (build 25, mixed mode, sharing)" + }, + { + "11.0.4+10", + "11.0.4+10", // chomp off "-LTS" + "11.0.4+10-LTS", + "Picked up _JAVA_OPTIONS: - Xmx512M\n" + + "java version \"11.0.4\" 2019-07-16 LTS\n" + + "Java(TM) SE Runtime Environment 18.9 (build 11.0.4+10-LTS)\n" + + "Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.4+10-LTS, mixed mode)" + }, + { + "1.8.0+202", + "1.8+202", // Runtime.Version doesn't permit trailing zeros + "1.8.0_202-b08", + "java version \"1.8.0_202\"\n" + + "Java(TM) SE Runtime Environment (build 1.8.0_202-b08)\n" + + "Java HotSpot (TM) 64-Bit Server VM (build 25.202-h08, mixed mode)" + }, + { + "1.7.0+55", + "1.7+55", // Runtime.Version doesn't permit trailing zeros + "1.7.0_55-b13", + "java version \"1.7.0_55\"\n" + + "Java(TM) SE Runtime Environment (build 1.7.0_55-b13)\n" + + "Java HotSpot(TM) 64-Bit Server VM (build 24.55-b03, mixed mode)" + }, + { + "27.0.0-ea+18.1643", // semver wants a dot not a dash :/ + "27-ea+18-1643", + "27-ea+18-1643", + "openjdk 27-ea 2026-09-15\n" + + "OpenJDK Runtime Environment (build 27-ea+18-1643)\n" + + "OpenJDK 64-Bit Server VM (build 27-ea+18-1643, mixed mode, sharing)" + }, + { + "17.0.9+9.jvmci-23.0-b22", // semver wants a dot not a dash :/ + "17.0.9+9-jvmci-23.0-b22", + "17.0.9+9-jvmci-23.0-b22", + "openjdk version \"17.0.9\" 2023-10-17\n" + + "OpenJDK Runtime Environment GraalVM CE 17.0.9+9.1 (build 17.0.9+9-jvmci-23.0-b22)\n" + + "OpenJDK 64-Bit Server VM GraalVM CE 17.0.9+9.1 (build 17.0.9+9-jvmci-23.0-b22, mixed mode, sharing)" + }, + // Java 8 was actually Java 1.8.0 + { + "1.8.0", + "1.8", + "8", + "8" + } + }; + } + + /** + * Test stdout values from java --version + */ + @Test(dataProvider = "versions") + public void rawInputTests(String finalExpected, String sanitizedExpected, String isolatedExpected, String rawInput) { + // Ensure we can isolate "11.0.3" from "(build 11.0.3)" + String isolatedActual = isolate(rawInput); + log.trace("Comparing isolated values: '{}' : '{}'", isolatedExpected, isolatedActual); + Assert.assertEquals(isolatedExpected, isolatedActual); + + // Sanitize the value to confirm with Runtime.Version parsing + String sanitizedActual = sanitize(isolatedActual); + log.trace("Comparing sanitized values: '{}' : '{}'", sanitizedExpected, sanitizedActual); + Assert.assertEquals(sanitizedExpected, sanitizedActual); + + // Ensure we can actually parse the isolated value in a predicable fashion + Version versionExpected = Version.parse(finalExpected); + Version versionActual = parseStrict(sanitizedActual); + log.trace("Comparing parsed values: '{}' : '{}'", versionExpected, versionActual); + Assert.assertEquals(versionExpected, versionActual); + } + + /** + * Make sure the JVM and our internal classes pass the most basic of "smoke" tests by comparing the major version + */ + @Test + public void smokeTests() { + // Ensure we're stripping off "-LTS" even for internal versioning + Runtime.Version rv = Runtime.Version.parse("11.0.4+10-LTS"); + Version semverActual = JavaVersion.toSemantic(rv); + Version semverExpected = Version.parse("11.0.4+10"); + log.trace("Comparing toSemantic values '{}' : '{}'", semverExpected, semverActual); + Assert.assertEquals(semverActual, semverExpected); + log.trace("Ensuring toSemantic stripped '-LTS' suffix '{}' : '{}'", "10", semverExpected.buildMetadata().get()); + Assert.assertFalse(semverExpected.buildMetadata().get().contains("-LTS")); + + // JLink internal class version + Assert.assertTrue(parse(JLink.JAVA_DEFAULT_VERSION).majorVersion() >= 11); + + // Currently installed Java version + Assert.assertTrue(current().majorVersion() >= 11); + + // From ant properties + Properties antProperties = new Properties(); + try { + antProperties.load(new FileReader(Paths.get("ant/project.properties").toAbsolutePath().toFile())); + } catch(IOException e) { + System.err.printf("Can't load properties file: %s", e.getLocalizedMessage()); + } + String javaVersion = antProperties.getProperty("jlink.java.version"); + // Ensures version in project.properties doesn't get corrupted by our own complicated parsing logic + Assert.assertEquals(Version.parse(javaVersion), parse(javaVersion)); + } + + @DataProvider(name = "comparisons") + public Object[][] comparisons() { + return new Object[][] { + { "1.8.0_202-b13", "<", "1.8.0+203" }, + { "25+1", ">", "25.0.0" }, + { "11.0.3+11", ">", "11.0.3+8" }, + { "25-ea", "<", "25" }, + { "25", "=", "25.0.0" }, + { "27-ea+18", ">", "27-ea+8-1643" }, + { "17.0.9+10-jvmci-23.0-b22", ">", "17.0.9+9" } + }; + } + + @Test(dataProvider = "comparisons") + public void compareTests(String lhs, String operator, String rhs) { + Version left = JavaVersion.parse(lhs); + Version right = JavaVersion.parse(rhs); + int diff = left.compareTo(right); + + log.trace("Raw: '{}' {} '{}'", lhs, operator, rhs); + log.trace("Parsed: '{}' {} '{}'", left, operator, right); + + switch(operator) { + case "<": + Assert.assertEquals(diff, -1); + break; + case ">": + Assert.assertEquals(diff, 1); + break; + case "=": + Assert.assertEquals(diff, 0); + break; + } + } +}