diff --git a/ant/project.properties b/ant/project.properties index db1cda2cb..d66fb8737 100644 --- a/ant/project.properties +++ b/ant/project.properties @@ -45,6 +45,10 @@ 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 +#jlink.api.url=https://api.bell-sw.com/v1/liberica/releases +#jlink.api.token= +#jlink.api.newest=true + # Skip bundling the java runtime # jre.skip=true @@ -60,4 +64,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/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 b760c3cb8..b0b2bb5a5 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,6 +29,7 @@ import java.io.File; import java.io.FileInputStream; import java.io.IOException; +import java.net.URL; import java.nio.file.*; import java.util.*; @@ -141,14 +146,124 @@ 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"); + String wantsNewest = System.getProperty("jlink.api.newest"); // defaults to true + + Version bestVersion = Version.of(0); + URL bestUrl = null; + + if(apiUrl != null) { + // assume we always want the newest build + boolean newest = wantsNewest == null || wantsNewest.isBlank() || Boolean.parseBoolean(wantsNewest); + // 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("bundleType", "").equals("jdk")) { + continue; + } + + String versionFound = entry.optString("version"); + String downloadUrl = entry.optString("downloadUrl"); + if(newest) { + if (versionFound != null) { + Version foundVersion = SystemUtilities.getJavaVersion(versionFound); + if (foundVersion.isHigherThan(bestVersion)) { + bestVersion = foundVersion; + bestUrl = new URL(downloadUrl); + } else if (foundVersion.equals(bestVersion)) { + // handle '+nnn' + if (foundVersion.buildMetadata().isPresent() && bestVersion.buildMetadata().isPresent()) { + if (foundVersion.buildMetadata().get().compareTo(bestVersion.buildMetadata().get()) > 0) { + bestVersion = foundVersion; + bestUrl = new URL(downloadUrl); + } + } + } + } + } else { + if(javaVersion.equals(versionFound)) { + bestVersion = SystemUtilities.getJavaVersion(versionFound); + bestUrl = new URL(downloadUrl); + } + } + } else { + log.warn("Entry {} is not a JSONObject", i); + } + } + } + catch(JSONException e) { + throw new IOException("Error parsing download file", 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); + // Check to see if we should use an API download + HashMap apiHeaders = getApiHeaders(); + Pair apiPair = getApiUrl(arch, platform, apiHeaders); + if(apiPair.getKey() != null) { + log.info("Using Java '{} from '{}' since an API url was provided", apiPair.getValue(), apiPair.getKey()); + 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", + apiPair.getKey() == null ? "" : "-api", + javaVendor.value(), + arch, + platform.value(), + javaSemver.toString().replaceAll("\\+", "_")); + + + String extractedJdk = new Fetcher(resourceName, url) .fetch() .uncompress(); 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);