diff --git a/.gitignore b/.gitignore
index 6d505cf44..7fc0368b4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,5 +7,6 @@
/.idea/compiler.xml
/fx.zip
/README.md
+/provision.json
.DS_Store
windows-debug-launcher.nsi.in
\ No newline at end of file
diff --git a/ant/apple/installer.xml b/ant/apple/installer.xml
index 6fbc96401..1d9d8e014 100644
--- a/ant/apple/installer.xml
+++ b/ant/apple/installer.xml
@@ -199,6 +199,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ant/project.properties b/ant/project.properties
index 29d758324..40ae19f09 100644
--- a/ant/project.properties
+++ b/ant/project.properties
@@ -51,6 +51,10 @@ jlink.java.gc.version="gc-ver-is-empty"
javafx.version=19_monocle
javafx.mirror=https://download2.gluonhq.com/openjfx
+# Provisioning
+provision.file=${basedir}/provision.json
+provision.dir=${dist.dir}/provision
+
# Mask tray toggle (Apple only)
java.mask.tray=true
diff --git a/build.xml b/build.xml
index fb06c1487..8eddf3e75 100644
--- a/build.xml
+++ b/build.xml
@@ -42,8 +42,9 @@
-
-
+
+
+
@@ -240,6 +241,31 @@
The "include-assets" task is deprecated, please use "build-demo" instead.
+
+
+
+
+
+
+ ${provision.message}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -264,10 +290,10 @@
-
-
-
-
+
+
+
+
\ No newline at end of file
diff --git a/src/log4j2.xml b/src/log4j2.xml
index 35dd09e9d..22e36d069 100644
--- a/src/log4j2.xml
+++ b/src/log4j2.xml
@@ -8,6 +8,7 @@
+
diff --git a/src/org/jdesktop/swinghelper/tray/JXTrayIcon.java b/src/org/jdesktop/swinghelper/tray/JXTrayIcon.java
index cbc609806..2e14fb6af 100644
--- a/src/org/jdesktop/swinghelper/tray/JXTrayIcon.java
+++ b/src/org/jdesktop/swinghelper/tray/JXTrayIcon.java
@@ -19,7 +19,6 @@
package org.jdesktop.swinghelper.tray;
-import com.github.zafarkhaja.semver.Version;
import qz.common.Constants;
import qz.utils.MacUtilities;
import qz.utils.SystemUtilities;
@@ -162,7 +161,7 @@ public void actionPerformed(ActionEvent e) {
@Override
public Dimension getSize() {
Dimension iconSize = new Dimension(super.getSize());
- switch(SystemUtilities.getOsType()) {
+ switch(SystemUtilities.getOs()) {
// macOS icons are slightly smaller than the size reported
case MAC:
// Handle retina display
diff --git a/src/qz/App.java b/src/qz/App.java
index 069dd66aa..39f31a81e 100644
--- a/src/qz/App.java
+++ b/src/qz/App.java
@@ -10,12 +10,14 @@
import org.apache.logging.log4j.core.appender.rolling.SizeBasedTriggeringPolicy;
import org.apache.logging.log4j.core.filter.ThresholdFilter;
import org.apache.logging.log4j.core.layout.PatternLayout;
+import qz.build.provision.params.Phase;
import qz.common.Constants;
import qz.installer.Installer;
import qz.installer.certificate.CertificateManager;
import qz.installer.certificate.ExpiryTask;
import qz.installer.certificate.KeyPairWrapper;
import qz.installer.certificate.NativeCertificateInstaller;
+import qz.installer.provision.ProvisionInstaller;
import qz.utils.*;
import qz.ws.PrintSocketServer;
import qz.ws.SingleInstanceChecker;
@@ -67,6 +69,14 @@ public static void main(String ... args) {
}
}
+ // Invoke any provisioning steps that are phase=startup
+ try {
+ ProvisionInstaller provisionInstaller = new ProvisionInstaller(SystemUtilities.getJarParentPath().resolve(Constants.PROVISION_DIR));
+ provisionInstaller.invoke(Phase.STARTUP);
+ } catch(Exception e) {
+ log.warn("An error occurred provisioning \"phase\": \"startup\" entries", e);
+ }
+
try {
log.info("Starting {} {}", Constants.ABOUT_TITLE, Constants.VERSION);
// Start the WebSocket
diff --git a/src/qz/build/JLink.java b/src/qz/build/JLink.java
index de45f5482..5e6cc10d8 100644
--- a/src/qz/build/JLink.java
+++ b/src/qz/build/JLink.java
@@ -15,10 +15,10 @@
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
-import qz.build.jlink.Arch;
import qz.build.jlink.Platform;
import qz.build.jlink.Vendor;
import qz.build.jlink.Url;
+import qz.build.provision.params.Arch;
import qz.common.Constants;
import qz.utils.*;
@@ -60,7 +60,7 @@ public class JLink {
public JLink(String targetPlatform, String targetArch, String javaVendor, String javaVersion, String gcEngine, String gcVersion, String targetJdk) throws IOException {
this.hostPlatform = Platform.getCurrentPlatform();
- this.hostArch = Arch.getCurrentArch();
+ this.hostArch = SystemUtilities.getArch();
this.targetPlatform = Platform.parse(targetPlatform, this.hostPlatform);
this.targetArch = Arch.parse(targetArch, this.hostArch);
@@ -150,7 +150,7 @@ 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);
// 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.value(), platform.value(), javaSemver.toString().replaceAll("\\+", "_")), url)
+ String extractedJdk = new Fetcher(String.format("jlink/jdk-%s-%s-%s-%s", javaVendor.value(), arch, platform.value(), javaSemver.toString().replaceAll("\\+", "_")), url)
.fetch()
.uncompress();
diff --git a/src/qz/build/jlink/Arch.java b/src/qz/build/jlink/Arch.java
index 8e41ea518..e69de29bb 100644
--- a/src/qz/build/jlink/Arch.java
+++ b/src/qz/build/jlink/Arch.java
@@ -1,26 +0,0 @@
-package qz.build.jlink;
-
-/**
- * Handling of architectures
- */
-public enum Arch implements Parsable {
- AMD64("amd64", "x86_64", "x64"),
- AARCH64("aarch64", "arm64"),
- ARM32("arm", "arm32", "arm32hf", "aarch32", "aarch32hf"),
- RISCV64("riscv", "riscv64");
-
- public final String[] matches;
- Arch(String ... matches) { this.matches = matches; }
-
- public static Arch parse(String value, Arch fallback) {
- return Parsable.parse(Arch.class, value, fallback);
- }
-
- public static Arch parse(String value) {
- return Parsable.parse(Arch.class, value);
- }
-
- public static Arch getCurrentArch() {
- return Parsable.parse(Arch.class, System.getProperty("os.arch"));
- }
-}
diff --git a/src/qz/build/jlink/Platform.java b/src/qz/build/jlink/Platform.java
index 2271cb9e9..87149ceea 100644
--- a/src/qz/build/jlink/Platform.java
+++ b/src/qz/build/jlink/Platform.java
@@ -24,7 +24,7 @@ public static Platform parse(String value) {
}
public static Platform getCurrentPlatform() {
- switch(SystemUtilities.getOsType()) {
+ switch(SystemUtilities.getOs()) {
case MAC:
return Platform.MAC;
case WINDOWS:
diff --git a/src/qz/build/jlink/Url.java b/src/qz/build/jlink/Url.java
index f8748cfb8..ca98f9b1c 100644
--- a/src/qz/build/jlink/Url.java
+++ b/src/qz/build/jlink/Url.java
@@ -3,6 +3,7 @@
import com.github.zafarkhaja.semver.Version;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import qz.build.provision.params.Arch;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
diff --git a/src/qz/build/jlink/Vendor.java b/src/qz/build/jlink/Vendor.java
index e0401c93e..0b1cdbe9c 100644
--- a/src/qz/build/jlink/Vendor.java
+++ b/src/qz/build/jlink/Vendor.java
@@ -1,7 +1,7 @@
package qz.build.jlink;
import com.github.zafarkhaja.semver.Version;
-import qz.build.JLink;
+import qz.build.provision.params.Arch;
/**
* Handling of java vendors
@@ -61,7 +61,7 @@ public String getUrlArch(Arch arch) {
default:
return "arm";
}
- case AMD64:
+ case X86_64:
switch(this) {
// BellSoft uses "amd64"
case BELLSOFT:
diff --git a/src/qz/build/provision/ProvisionBuilder.java b/src/qz/build/provision/ProvisionBuilder.java
new file mode 100644
index 000000000..b13ec8d30
--- /dev/null
+++ b/src/qz/build/provision/ProvisionBuilder.java
@@ -0,0 +1,233 @@
+package qz.build.provision;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.FilenameUtils;
+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.provision.params.Arch;
+import qz.build.provision.params.Os;
+import qz.build.provision.params.Type;
+import qz.build.provision.params.types.Script;
+import qz.build.provision.params.types.Software;
+import qz.common.Constants;
+import qz.utils.SystemUtilities;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+public class ProvisionBuilder {
+ protected static final Logger log = LogManager.getLogger(ProvisionBuilder.class);
+
+ public static final Path BUILD_PROVISION_FOLDER = SystemUtilities.getJarParentPath().resolve(Constants.PROVISION_DIR);
+ public static final File BUILD_PROVISION_FILE = BUILD_PROVISION_FOLDER.resolve(Constants.PROVISION_FILE).toFile();
+
+ private File ingestFile;
+ private JSONArray jsonSteps;
+ private Arch targetArch;
+ private Os targetOs;
+
+ /**
+ * Parses command line input to create a "provision" folder in the dist directory for customizing the installation or startup
+ */
+ public ProvisionBuilder(String type, String phase, String os, String arch, String data, String args, String description, String ... varArgs) throws IOException, JSONException {
+ createProvisionDirectory(false);
+
+ targetOs = Os.ALL;
+ targetArch = Arch.ALL;
+ jsonSteps = new JSONArray();
+
+ // Wrap into JSON so that we can save it
+ JSONObject jsonStep = new JSONObject();
+ putPattern(jsonStep, "description", description);
+ putPattern(jsonStep, "type", type);
+ putPattern(jsonStep, "phase", phase);
+ putPattern(jsonStep, "os", os);
+ putPattern(jsonStep, "arch", arch);
+ putPattern(jsonStep, "data", data);
+ putPattern(jsonStep, "args", args);
+ putPattern(jsonStep, "arg%d", varArgs);
+
+ // Command line invocation, use the working directory
+ Path relativePath = Paths.get(System.getProperty("user.dir"));
+ ingestStep(jsonStep, relativePath);
+ }
+
+ /**
+ * To be called by ant's provision
target
+ */
+ public ProvisionBuilder(File antJsonFile, String antTargetOs, String antTargetArch) throws IOException, JSONException {
+ createProvisionDirectory(true);
+
+ // Calculate the target os, architecture
+ this.targetArch = Arch.parseStrict(antTargetArch);
+ this.targetOs = Os.parseStrict(antTargetOs);
+
+ this.jsonSteps = new JSONArray();
+ this.ingestFile = antJsonFile;
+
+ String jsonData = FileUtils.readFileToString(antJsonFile, StandardCharsets.UTF_8);
+ JSONArray pendingSteps = new JSONArray(jsonData);
+
+ // Cycle through so that each Step can be individually processed
+ Path relativePath = antJsonFile.toPath().getParent();
+ for(int i = 0; i < pendingSteps.length(); i++) {
+ JSONObject jsonStep = pendingSteps.getJSONObject(i);
+ System.out.println();
+ try {
+ ingestStep(jsonStep, relativePath);
+ } catch(Exception e) {
+ log.warn("[SKIPPED] Step '{}'", jsonStep, e);
+ }
+ }
+
+ }
+
+ public JSONArray getJson() {
+ return jsonSteps;
+ }
+
+ /**
+ * Construct as a Step to perform basic parsing/sanity checks
+ * Copy resources (if needed) to provisioning directory
+ */
+ private void ingestStep(JSONObject jsonStep, Path relativePath) throws JSONException, IOException {
+ Step step = Step.parse(jsonStep, relativePath);
+ if(!targetOs.matches(step.os)) {
+ log.info("[SKIPPED] Os '{}' does not match target Os '{}' '{}'", Os.serialize(step.os), targetOs, step);
+ return;
+ }
+
+ if(!targetArch.matches(step.arch)) {
+ log.info("[SKIPPED] Arch '{}' does not match target Os '{}' '{}'", Arch.serialize(step.arch), targetArch, step);
+ return;
+ }
+
+ if(copyResource(step)) {
+ log.info("[SUCCESS] Step successfully processed '{}'", step);
+ jsonSteps.put(step.toJSON());
+ } else {
+ log.error("[SKIPPED] Resources could not be saved '{}'", step);
+ }
+ }
+
+ /**
+ * Save any resources files required for INSTALL and SCRIPT steps to provision folder
+ */
+ public boolean copyResource(Step step) throws IOException {
+ switch(step.getType()) {
+ case CERT:
+ case SCRIPT:
+ case SOFTWARE:
+ boolean isRelative = !Paths.get(step.getData()).isAbsolute();
+ File src;
+ if(isRelative) {
+ if(ingestFile != null) {
+ Path parentDir = ingestFile.getParentFile().toPath();
+ src = parentDir.resolve(step.getData()).toFile();
+ } else {
+ throw formatted("Unable to resolve path: '%s' '%s'", step.getData(), step);
+ }
+ } else {
+ src = new File(step.getData());
+ }
+ String fileName = src.getName();
+ if(fileName.equals(BUILD_PROVISION_FILE.getName())) {
+ throw formatted("Resource name conflicts with provision file '%s' '%s'", fileName, step);
+ }
+ File dest = BUILD_PROVISION_FOLDER.resolve(fileName).toFile();
+ int i = 0;
+ // Avoid conflicting file names
+ String name = dest.getName();
+
+ // Avoid resource clobbering when being invoked by command line or providing certificates.
+ // Otherwise, assume the intent is to re-use the same resource (e.g. "my_script.sh", etc)
+ if(ingestFile == null || step.getType() == Type.CERT) {
+ while(dest.exists()) {
+ // Append "filename-1.txt" until there's no longer a conflict
+ if (name.contains(".")) {
+ dest = BUILD_PROVISION_FOLDER.resolve(String.format("%s-%s.%s", FilenameUtils.removeExtension(name), ++i,
+ FilenameUtils.getExtension(name))).toFile();
+ } else {
+ dest = BUILD_PROVISION_FOLDER.resolve(String.format("%-%", name, ++i)).toFile();
+ }
+ }
+ }
+
+ FileUtils.copyFile(src, dest);
+ if(dest.exists()) {
+ step.setData(BUILD_PROVISION_FOLDER.relativize(dest.toPath()).toString());
+ } else {
+ return false;
+ }
+ break;
+ default:
+ }
+ return true;
+ }
+
+ /**
+ * Appends the JSONObject to the end of the provisionFile
+ */
+ public boolean saveJson(boolean overwrite) throws IOException, JSONException {
+ // Read existing JSON file if exists
+ JSONArray mergeSteps;
+ if(!overwrite && BUILD_PROVISION_FILE.exists()) {
+ String jsonData = FileUtils.readFileToString(BUILD_PROVISION_FILE, StandardCharsets.UTF_8);
+ mergeSteps = new JSONArray(jsonData);
+ } else {
+ mergeSteps = new JSONArray();
+ }
+
+ // Merge in new steps
+ for(int i = 0; i < jsonSteps.length(); i++) {
+ mergeSteps.put(jsonSteps.getJSONObject(i));
+ }
+
+ FileUtils.writeStringToFile(BUILD_PROVISION_FILE, mergeSteps.toString(3), StandardCharsets.UTF_8);
+ return true;
+ }
+
+ /**
+ * Convenience method for adding a name/value pair into the JSONObject
+ */
+ private static void putPattern(JSONObject jsonStep, String name, String val) throws JSONException {
+ if(val != null && !val.isEmpty()) {
+ jsonStep.put(name, val);
+ }
+ }
+
+ /**
+ * Convenience method for adding consecutive patterned value pairs into the JSONObject
+ * e.g. --arg1 "foo" --arg2 "bar"
+ */
+ private static void putPattern(JSONObject jsonStep, String pattern, String ... varArgs) throws JSONException {
+ int argCounter = 0;
+ for(String arg : varArgs) {
+ jsonStep.put(String.format(pattern, ++argCounter), arg);
+ }
+ }
+
+ private static void createProvisionDirectory(boolean cleanDirectory) throws IOException {
+ if(cleanDirectory) {
+ FileUtils.deleteDirectory(BUILD_PROVISION_FOLDER.toFile());
+ }
+ if(BUILD_PROVISION_FOLDER.toFile().isDirectory()) {
+ return;
+ }
+ if(BUILD_PROVISION_FOLDER.toFile().mkdirs()) {
+ return;
+ }
+ throw formatted("Could not create provision destination: '%'", BUILD_PROVISION_FOLDER);
+ }
+
+ private static IOException formatted(String message, Object ... args) {
+ String formatted = String.format(message, args);
+ return new IOException(formatted);
+ }
+}
diff --git a/src/qz/build/provision/Step.java b/src/qz/build/provision/Step.java
new file mode 100644
index 000000000..6c73ce661
--- /dev/null
+++ b/src/qz/build/provision/Step.java
@@ -0,0 +1,303 @@
+package qz.build.provision;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.codehaus.jettison.json.JSONException;
+import org.codehaus.jettison.json.JSONObject;
+import qz.build.provision.params.Arch;
+import qz.build.provision.params.Os;
+import qz.build.provision.params.Phase;
+import qz.build.provision.params.Type;
+import qz.build.provision.params.types.Remover;
+import qz.build.provision.params.types.Software;
+
+import java.nio.file.Path;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+
+public class Step {
+ protected static final Logger log = LogManager.getLogger(Step.class);
+
+ String description;
+ Type type;
+ List args; // Type.SCRIPT or Type.INSTALLER only
+ HashSet os;
+ HashSet arch;
+ Phase phase;
+ String data;
+
+ Path relativePath;
+ Class relativeClass;
+
+ public Step(Path relativePath, String description, Type type, HashSet os, HashSet arch, Phase phase, String data, List args) {
+ this.relativePath = relativePath;
+ this.description = description;
+ this.type = type;
+ this.os = os;
+ this.arch = arch;
+ this.phase = phase;
+ this.data = data;
+ this.args = args;
+ }
+
+ /**
+ * Only should be used by unit tests
+ */
+ Step(Class relativeClass, String description, Type type, HashSet os, HashSet arch, Phase phase, String data, List args) {
+ this.relativeClass = relativeClass;
+ this.description = description;
+ this.type = type;
+ this.os = os;
+ this.arch = arch;
+ this.phase = phase;
+ this.data = data;
+ this.args = args;
+ }
+
+ @Override
+ public String toString() {
+ return "Step { " +
+ "description=\"" + description + "\", " +
+ "type=\"" + type + "\", " +
+ "os=\"" + Os.serialize(os) + "\", " +
+ "arch=\"" + Arch.serialize(arch) + "\", " +
+ "phase=\"" + phase + "\", " +
+ "data=\"" + data + "\", " +
+ "args=\"" + StringUtils.join(args, ",") + "\" " +
+ "}";
+ }
+
+ public JSONObject toJSON() throws JSONException {
+ JSONObject json = new JSONObject();
+ json.put("description", description)
+ .put("type", type)
+ .put("os", Os.serialize(os))
+ .put("arch", Arch.serialize(arch))
+ .put("phase", phase)
+ .put("data", data);
+
+ for(int i = 0; i < args.size(); i++) {
+ json.put(String.format("arg%s", i + 1), args.get(i));
+ }
+ return json;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public Type getType() {
+ return type;
+ }
+
+ public void setType(Type type) {
+ this.type = type;
+ }
+
+ public List getArgs() {
+ return args;
+ }
+
+ public void setArgs(List args) {
+ this.args = args;
+ }
+
+ public HashSet getOs() {
+ return os;
+ }
+
+ public void setOs(HashSet os) {
+ this.os = os;
+ }
+
+ public HashSet getArch() {
+ return arch;
+ }
+
+ public void setArch(HashSet arch) {
+ this.arch = arch;
+ }
+
+ public Phase getPhase() {
+ return phase;
+ }
+
+ public void setPhase(Phase phase) {
+ this.phase = phase;
+ }
+
+ public String getData() {
+ return data;
+ }
+
+ public void setData(String data) {
+ this.data = data;
+ }
+
+ public Class getRelativeClass() {
+ return relativeClass;
+ }
+
+ public Path getRelativePath() {
+ return relativePath;
+ }
+
+ public boolean usingClass() {
+ return relativeClass != null;
+ }
+
+ public boolean usingPath() {
+ return relativePath != null;
+ }
+
+ public static Step parse(JSONObject jsonStep, Object relativeObject) {
+ String description = jsonStep.optString("description", "");
+ Type type = Type.parse(jsonStep.optString("type", null));
+ String data = jsonStep.optString("data", null);
+
+ // Handle installer args
+ List args = new LinkedList<>();
+ if(type == Type.SOFTWARE) {
+ // Handle space-delimited args
+ args = Software.parseArgs(jsonStep.optString("args", ""));
+ // Handle standalone single args (won't break on whitespace)
+ // e.g. "arg1": "C:\Program Files\Foo"
+ int argCounter = 0;
+ while(true) {
+ String singleArg = jsonStep.optString(String.format("arg%d", ++argCounter), "");
+ if(!singleArg.trim().isEmpty()) {
+ args.add(singleArg.trim());
+ }
+ // stop searching if the next incremental arg (e.g. "arg2") isn't found
+ break;
+ }
+ }
+
+ HashSet os = new HashSet<>();
+ if(jsonStep.has("os")) {
+ // Do not tolerate bad os values
+ String osString = jsonStep.optString("os");
+ os = Os.parse(osString);
+ if(os.size() == 0) {
+ throw formatted("Os provided '%s' could not be parsed", osString);
+ }
+ }
+
+ HashSet arch = new HashSet<>();
+ if(jsonStep.has("arch")) {
+ // Do not tolerate bad arch values
+ String archString = jsonStep.optString("arch");
+ arch = Arch.parse(archString);
+ if(arch.size() == 0) {
+ throw formatted("Arch provided \"%s\" could not be parsed", archString);
+ }
+ }
+
+ Phase phase = null;
+ if(jsonStep.has("phase")) {
+ String phaseString = jsonStep.optString("phase", null);
+ phase = Phase.parse(phaseString);
+ if(phase == null) {
+ log.warn("Phase provided \"{}\" could not be parsed", phaseString);
+ }
+ }
+ Step step;
+ if(relativeObject instanceof Path) {
+ step = new Step((Path)relativeObject, description, type, os, arch, phase, data, args);
+ } else if(relativeObject instanceof Class) {
+ step = new Step((Class)relativeObject, description, type, os, arch, phase, data, args);
+ } else {
+ throw formatted("Parameter relativeObject must be of type 'Path' or 'Class' but '%s' was provided", relativeObject.getClass());
+ }
+ return step.sanitize();
+ }
+
+ private Step sanitize() {
+ return throwIfNull("Type", type)
+ .throwIfNull("Data", data)
+ .validateOs()
+ .validateArch()
+ .enforcePhase(Type.PREFERENCE, Phase.STARTUP)
+ .enforcePhase(Type.CERT, Phase.STARTUP)
+ .enforcePhase(Type.SOFTWARE, Phase.INSTALL)
+ .enforcePhase(Type.REMOVER, Phase.INSTALL)
+ .enforcePhase(Type.PROPERTY, Phase.CERTGEN)
+ .validateRemover();
+ }
+
+ private Step validateRemover() {
+ if(type != Type.REMOVER) {
+ return this;
+ }
+ Remover remover = Remover.parse(data);
+ switch(remover) {
+ case CUSTOM:
+ break;
+ default:
+ if(remover.matchesCurrentSystem()) {
+ throw formatted("Remover '%s' would conflict with this installer, skipping. ", remover);
+ }
+ return this;
+ }
+
+ // Custom removers must have three elements
+ if(data == null || data.split(",").length != 3) {
+ throw formatted("Remover data '%s' is invalid. Data must match a known type [%s] or contain exactly 3 elements.", data, Remover.valuesDelimited(","));
+ }
+ return this;
+ }
+
+ private Step throwIfNull(String name, Object value) {
+ if(value == null) {
+ throw formatted("%s cannot be null", name);
+ }
+ return this;
+ }
+
+ private Step validateOs() {
+ if(os == null) {
+ if(type == Type.SOFTWARE) {
+ // Software must default to a sane operating system
+ os = Software.parse(data).defaultOs();
+ } else {
+ os = new HashSet<>();
+ }
+ }
+ if(os.size() == 0) {
+ os.add(Os.ALL);
+ log.debug("Os list is null, assuming '{}'", Os.ALL);
+ }
+ return this;
+ }
+
+ private Step validateArch() {
+ if(arch == null) {
+ arch = new HashSet<>();
+ }
+ if(arch.size() == 0) {
+ arch.add(Arch.ALL);
+ log.debug("Arch list is null, assuming '{}'", Arch.ALL);
+ }
+ return this;
+ }
+
+ private Step enforcePhase(Type matchType, Phase requiredPhase) {
+ if(type == matchType) {
+ if(phase == null) {
+ phase = requiredPhase;
+ log.debug("Phase is null, defaulting to '{}' based on Type '{}'", phase, type);
+ } else if (phase != requiredPhase) {
+ log.debug("Phase '{}' is unsupported for Type '{}', defaulting to '{}'", phase, type,
+ phase = requiredPhase);
+ }
+ }
+ return this;
+ }
+
+ private static UnsupportedOperationException formatted(String message, Object ... args) {
+ String formatted = String.format(message, args);
+ return new UnsupportedOperationException(formatted);
+ }
+}
diff --git a/src/qz/build/provision/params/Arch.java b/src/qz/build/provision/params/Arch.java
new file mode 100644
index 000000000..4f8e6620d
--- /dev/null
+++ b/src/qz/build/provision/params/Arch.java
@@ -0,0 +1,70 @@
+package qz.build.provision.params;
+
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Locale;
+
+/**
+ * Basic architecture parser
+ *
+ * Note: All aliases must be lowercase
+ */
+public enum Arch {
+ X86("x32", "i386", "i486", "i586", "i686"),
+ X86_64("amd64"),
+ ARM32("arm", "armv1", "armv2", "armv3", "armv4", "armv5", "armv6", "armv7"),
+ AARCH64("arm64", "armv8", "armv9"),
+ RISCV32("rv32"),
+ RISCV64("rv64"),
+ PPC64("powerpc", "powerpc64"),
+ ALL(), // special handling
+ UNKNOWN();
+
+ private HashSet aliases = new HashSet<>();
+ Arch(String ... aliases) {
+ this.aliases.add(name().toLowerCase(Locale.ENGLISH));
+ this.aliases.addAll(Arrays.asList(aliases));
+ }
+
+ public static Arch parseStrict(String input) throws UnsupportedOperationException {
+ return EnumParser.parseStrict(Arch.class, input, ALL, UNKNOWN);
+ }
+
+ public static HashSet parse(String input) {
+ return EnumParser.parseSet(Arch.class, Arch.ALL, input);
+ }
+
+ public static Arch parse(String input, Arch fallback) {
+ Arch found = bestMatch(input);
+ return found == UNKNOWN ? fallback : found;
+ }
+
+ public static Arch bestMatch(String input) {
+ if(input != null) {
+ for(Arch arch : values()) {
+ if (arch.aliases.contains(input.toLowerCase())) {
+ return arch;
+ }
+ }
+ }
+ return Arch.UNKNOWN;
+ }
+
+ public boolean matches(HashSet archList) {
+ return this == ALL || archList.contains(ALL) || (this != UNKNOWN && archList.contains(this));
+ }
+
+ public static String serialize(HashSet archList) {
+ if(archList.contains(ALL)) {
+ return "*";
+ }
+ return StringUtils.join(archList, "|");
+ }
+
+ @Override
+ public String toString() {
+ return super.toString().toLowerCase(Locale.ENGLISH);
+ }
+}
diff --git a/src/qz/build/provision/params/EnumParser.java b/src/qz/build/provision/params/EnumParser.java
new file mode 100644
index 000000000..5a9dd3336
--- /dev/null
+++ b/src/qz/build/provision/params/EnumParser.java
@@ -0,0 +1,64 @@
+package qz.build.provision.params;
+
+import java.util.EnumSet;
+import java.util.HashSet;
+
+public interface EnumParser {
+ /**
+ * Basic enum parser
+ */
+
+ static > T parse(Class clazz, String s) {
+ return parse(clazz, s, null);
+ }
+
+ static > T parse(Class clazz, String s, T fallbackValue) {
+ if(s != null) {
+ for(T en : EnumSet.allOf(clazz)) {
+ if (en.name().equalsIgnoreCase(s)) {
+ return en;
+ }
+ }
+ }
+ return fallbackValue;
+ }
+
+ static > T parseStrict(Class clazz, String s, T ... blocklist) throws UnsupportedOperationException {
+ if(s != null) {
+ HashSet matched = parseSet(clazz, null, s);
+ if (matched.size() == 1) {
+ T returnVal = matched.iterator().next();
+ boolean blocked = false;
+ for(T block : blocklist) {
+ if(returnVal == block) {
+ blocked = true;
+ break;
+ }
+ }
+ if(!blocked) {
+ return returnVal;
+ }
+ }
+ }
+ throw new UnsupportedOperationException(String.format("%s value '%s' failed to match one and only one item", clazz.getSimpleName(), s));
+ }
+
+ static > HashSet parseSet(Class clazz, T all, String s) {
+ HashSet matched = new HashSet<>();
+ if(s != null) {
+ // Handle ALL="*"
+ if (all != null && s.equals("*")) {
+ matched.add(all);
+ }
+
+ String[] parts = s.split("\\|");
+ for(String part : parts) {
+ T parsed = parse(clazz, part);
+ if (parsed != null) {
+ matched.add(parsed);
+ }
+ }
+ }
+ return matched;
+ }
+}
diff --git a/src/qz/build/provision/params/Os.java b/src/qz/build/provision/params/Os.java
new file mode 100644
index 000000000..7785912cc
--- /dev/null
+++ b/src/qz/build/provision/params/Os.java
@@ -0,0 +1,67 @@
+package qz.build.provision.params;
+
+import org.apache.commons.lang3.StringUtils;
+import qz.utils.SystemUtilities;
+
+import java.util.*;
+
+/**
+ * Basic OS parser
+ */
+public enum Os {
+ WINDOWS,
+ MAC,
+ LINUX,
+ SOLARIS, // unsupported
+ ALL, // special handling
+ UNKNOWN;
+
+ public boolean matches(HashSet osList) {
+ return this == ALL || osList.contains(ALL) || (this != UNKNOWN && osList.contains(this));
+ }
+
+ public static boolean matchesHost(HashSet osList) {
+ for(Os os : osList) {
+ if(os == SystemUtilities.getOs() || os == ALL) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public static Os parseStrict(String input) throws UnsupportedOperationException {
+ return EnumParser.parseStrict(Os.class, input, ALL, UNKNOWN);
+ }
+
+ public static Os bestMatch(String input) {
+ if(input != null) {
+ String name = input.toLowerCase(Locale.ENGLISH);
+ if (name.contains("win")) {
+ return Os.WINDOWS;
+ } else if (name.contains("mac")) {
+ return Os.MAC;
+ } else if (name.contains("linux")) {
+ return Os.LINUX;
+ } else if (name.contains("sunos")) {
+ return Os.SOLARIS;
+ }
+ }
+ return Os.UNKNOWN;
+ }
+
+ public static HashSet parse(String input) {
+ return EnumParser.parseSet(Os.class, Os.ALL, input);
+ }
+
+ public static String serialize(HashSet osList) {
+ if(osList.contains(ALL)) {
+ return "*";
+ }
+ return StringUtils.join(osList, "|");
+ }
+
+ @Override
+ public String toString() {
+ return super.toString().toLowerCase(Locale.ENGLISH);
+ }
+}
\ No newline at end of file
diff --git a/src/qz/build/provision/params/Phase.java b/src/qz/build/provision/params/Phase.java
new file mode 100644
index 000000000..382f1192a
--- /dev/null
+++ b/src/qz/build/provision/params/Phase.java
@@ -0,0 +1,19 @@
+package qz.build.provision.params;
+
+import java.util.Locale;
+
+public enum Phase {
+ INSTALL,
+ CERTGEN,
+ STARTUP,
+ UNINSTALL;
+
+ public static Phase parse(String input) {
+ return EnumParser.parse(Phase.class, input);
+ }
+
+ @Override
+ public String toString() {
+ return super.toString().toLowerCase(Locale.ENGLISH);
+ }
+}
diff --git a/src/qz/build/provision/params/Type.java b/src/qz/build/provision/params/Type.java
new file mode 100644
index 000000000..ddc7fc8bc
--- /dev/null
+++ b/src/qz/build/provision/params/Type.java
@@ -0,0 +1,21 @@
+package qz.build.provision.params;
+
+import java.util.Locale;
+
+public enum Type {
+ SCRIPT,
+ SOFTWARE,
+ REMOVER, // QZ Tray remover
+ CERT,
+ PROPERTY,
+ PREFERENCE;
+
+ public static Type parse(String input) {
+ return EnumParser.parse(Type.class, input);
+ }
+
+ @Override
+ public String toString() {
+ return super.toString().toLowerCase(Locale.ENGLISH);
+ }
+}
diff --git a/src/qz/build/provision/params/types/Remover.java b/src/qz/build/provision/params/types/Remover.java
new file mode 100644
index 000000000..7b8b635f8
--- /dev/null
+++ b/src/qz/build/provision/params/types/Remover.java
@@ -0,0 +1,67 @@
+package qz.build.provision.params.types;
+
+import org.apache.commons.lang3.StringUtils;
+import qz.build.provision.params.EnumParser;
+import qz.common.Constants;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Locale;
+
+public enum Remover {
+ QZ("QZ Tray", "qz-tray", "qz"),
+ CUSTOM(null, null, null); // reserved
+
+ private String aboutTitle;
+ private String propsFile;
+ private String dataDir;
+
+ Remover(String aboutTitle, String propsFile, String dataDir) {
+ this.aboutTitle = aboutTitle;
+ this.propsFile = propsFile;
+ this.dataDir = dataDir;
+ }
+
+ public String getAboutTitle() {
+ return aboutTitle;
+ }
+
+ public String getPropsFile() {
+ return propsFile;
+ }
+
+ public String getDataDir() {
+ return dataDir;
+ }
+
+ public static String valuesDelimited(String delimiter) {
+ ArrayList listing = new ArrayList<>(Arrays.asList(values()));
+ listing.remove(CUSTOM);
+ return StringUtils.join(listing, delimiter).toLowerCase(Locale.ENGLISH);
+ }
+
+ /**
+ * Defaults to custom if not found
+ */
+ public static Remover parse(String input) {
+ Remover remover = EnumParser.parse(Remover.class, input);
+ if(remover == CUSTOM) {
+ throw new UnsupportedOperationException("Remover 'custom' is reserved for internal purposes");
+ }
+ if(remover == null) {
+ remover = CUSTOM;
+ }
+ return remover;
+ }
+
+ public boolean matchesCurrentSystem() {
+ return Constants.ABOUT_TITLE.equals(aboutTitle) ||
+ Constants.PROPS_FILE.equals(propsFile) ||
+ Constants.DATA_DIR.equals(dataDir);
+ }
+
+ @Override
+ public String toString() {
+ return super.toString().toLowerCase(Locale.ENGLISH);
+ }
+}
diff --git a/src/qz/build/provision/params/types/Script.java b/src/qz/build/provision/params/types/Script.java
new file mode 100644
index 000000000..1f7eeaa67
--- /dev/null
+++ b/src/qz/build/provision/params/types/Script.java
@@ -0,0 +1,36 @@
+package qz.build.provision.params.types;
+
+import qz.build.provision.params.EnumParser;
+
+import java.nio.file.Path;
+import java.util.Locale;
+
+public enum Script {
+ PS1,
+ BAT,
+ SH,
+ PY,
+ RB;
+
+ public static Script parse(String input) {
+ if(input != null) {
+ if(input.contains(".")) {
+ String extension = input.substring(input.lastIndexOf(".") + 1);
+ return EnumParser.parse(Script.class, extension);
+ } else {
+ // If no file extension, assume a shell script
+ return SH;
+ }
+ }
+ return null;
+ }
+
+ public static Script parse(Path path) {
+ return parse(path.toAbsolutePath().toString());
+ }
+
+ @Override
+ public String toString() {
+ return super.toString().toLowerCase(Locale.ENGLISH);
+ }
+}
\ No newline at end of file
diff --git a/src/qz/build/provision/params/types/Software.java b/src/qz/build/provision/params/types/Software.java
new file mode 100644
index 000000000..d96f2015d
--- /dev/null
+++ b/src/qz/build/provision/params/types/Software.java
@@ -0,0 +1,62 @@
+package qz.build.provision.params.types;
+
+import qz.build.provision.params.EnumParser;
+import qz.build.provision.params.Os;
+
+import java.nio.file.Path;
+import java.util.*;
+
+public enum Software {
+ EXE,
+ MSI,
+ PKG,
+ DMG,
+ RUN,
+ UNKNOWN;
+
+ public static Software parse(String input) {
+ return EnumParser.parse(Software.class, input, UNKNOWN);
+ }
+
+ public static Software parse(Path path) {
+ return parse(path.toString());
+ }
+
+ public static List parseArgs(String input) {
+ List args = new LinkedList<>();
+ if(input != null) {
+ String[] parts = input.split(" ");
+ for(String part : parts) {
+ if(!part.trim().isEmpty()) {
+ args.add(part.trim());
+ }
+ }
+ }
+ return args;
+ }
+
+ public HashSet defaultOs() {
+ HashSet list = new HashSet<>();
+ switch(this) {
+ case EXE:
+ case MSI:
+ list.add(Os.WINDOWS);
+ break;
+ case PKG:
+ case DMG:
+ list.add(Os.MAC);
+ break;
+ case RUN:
+ list.add(Os.LINUX);
+ break;
+ default:
+ list.add(Os.ALL);
+ }
+ return list;
+ }
+
+ @Override
+ public String toString() {
+ return super.toString().toLowerCase(Locale.ENGLISH);
+ }
+}
diff --git a/src/qz/common/AboutInfo.java b/src/qz/common/AboutInfo.java
index 14d0a3fe2..775202745 100644
--- a/src/qz/common/AboutInfo.java
+++ b/src/qz/common/AboutInfo.java
@@ -92,7 +92,7 @@ private static JSONObject environment() throws JSONException {
environment
.put("os", SystemUtilities.getOsDisplayName())
.put("os version", SystemUtilities.getOsDisplayVersion())
- .put("java", String.format("%s (%s)", Constants.JAVA_VERSION, SystemUtilities.getJreArch().toString().toLowerCase()))
+ .put("java", String.format("%s (%s)", Constants.JAVA_VERSION, SystemUtilities.getArch().toString().toLowerCase()))
.put("java (location)", System.getProperty("java.home"))
.put("java (vendor)", Constants.JAVA_VENDOR)
.put("uptime", DurationFormatUtils.formatDurationWords(uptime, true, false))
diff --git a/src/qz/common/Constants.java b/src/qz/common/Constants.java
index 7ba5fe1dd..ad8f7174f 100644
--- a/src/qz/common/Constants.java
+++ b/src/qz/common/Constants.java
@@ -74,6 +74,8 @@ public class Constants {
public static final String OVERRIDE_CERT = "override.crt";
public static final String WHITELIST_CERT_DIR = "whitelist";
+ public static final String PROVISION_DIR = "provision";
+ public static final String PROVISION_FILE = "provision.json";
public static final String SIGNING_PRIVATE_KEY = "private-key.pem";
public static final String SIGNING_CERTIFICATE = "digital-certificate.txt";
diff --git a/src/qz/common/PropertyHelper.java b/src/qz/common/PropertyHelper.java
index ad1fe60f6..aa3fc6f9e 100644
--- a/src/qz/common/PropertyHelper.java
+++ b/src/qz/common/PropertyHelper.java
@@ -13,7 +13,7 @@
* Created by Tres on 12/16/2015.
*/
public class PropertyHelper extends Properties {
- private static final Logger log = LogManager.getLogger(TrayManager.class);
+ private static final Logger log = LogManager.getLogger(PropertyHelper.class);
private String file;
/**
diff --git a/src/qz/common/TrayManager.java b/src/qz/common/TrayManager.java
index 12b773ac0..edb860381 100644
--- a/src/qz/common/TrayManager.java
+++ b/src/qz/common/TrayManager.java
@@ -115,7 +115,7 @@ public TrayManager(boolean isHeadless) {
iconCache = new IconCache();
if (SystemUtilities.isSystemTraySupported(headless)) { // UI mode with tray
- switch(SystemUtilities.getOsType()) {
+ switch(SystemUtilities.getOs()) {
case WINDOWS:
tray = TrayType.JX.init(iconCache);
// Undocumented HiDPI behavior
diff --git a/src/qz/installer/Installer.java b/src/qz/installer/Installer.java
index bbd089f84..4ae3c8c2a 100644
--- a/src/qz/installer/Installer.java
+++ b/src/qz/installer/Installer.java
@@ -14,8 +14,10 @@
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.auth.Certificate;
+import qz.build.provision.params.Phase;
import qz.installer.certificate.*;
import qz.installer.certificate.firefox.FirefoxCertificateInstaller;
+import qz.installer.provision.ProvisionInstaller;
import qz.utils.FileUtilities;
import qz.utils.SystemUtilities;
@@ -59,7 +61,7 @@ public enum PrivilegeLevel {
public static Installer getInstance() {
if(instance == null) {
- switch(SystemUtilities.getOsType()) {
+ switch(SystemUtilities.getOs()) {
case WINDOWS:
instance = new WindowsInstaller();
break;
@@ -94,13 +96,15 @@ public static void install() throws Exception {
getInstance();
log.info("Installing to {}", instance.getDestination());
instance.removeLibs()
+ .removeProvisioning()
.deployApp()
.removeLegacyStartup()
.removeLegacyFiles()
.addSharedDirectory()
.addAppLauncher()
.addStartupEntry()
- .addSystemSettings();
+ .addSystemSettings()
+ .invokeProvisioning(Phase.INSTALL);
}
public static void uninstall() {
@@ -110,7 +114,8 @@ public static void uninstall() {
log.info("Uninstalling from {}", instance.getDestination());
instance.removeSharedDirectory()
.removeSystemSettings()
- .removeCerts();
+ .removeCerts()
+ .invokeProvisioning(Phase.UNINSTALL);
}
public Installer deployApp() throws IOException {
@@ -126,7 +131,10 @@ public Installer deployApp() throws IOException {
// Note: preserveFileDate=false per https://github.com/qzind/tray/issues/1011
FileUtils.copyDirectory(src.toFile(), dest.toFile(), false);
FileUtilities.setPermissionsRecursively(dest, false);
-
+ // Fix permissions for provisioned files
+ FileUtilities.setExecutableRecursively(SystemUtilities.isMac() ?
+ dest.resolve("Contents/Resources").resolve(PROVISION_DIR) :
+ dest.resolve(PROVISION_DIR), false);
if(!SystemUtilities.isWindows()) {
setExecutable(SystemUtilities.isMac() ? "Contents/Resources/uninstall" : "uninstall");
setExecutable(SystemUtilities.isMac() ? "Contents/MacOS/" + ABOUT_TITLE : PROPS_FILE);
@@ -302,18 +310,24 @@ public CertificateManager certGen(boolean forceNew, String... hostNames) throws
log.error("Something went wrong obtaining the certificate. HTTPS will fail.", e);
}
+ // Add provisioning steps that come after certgen
+ if(SystemUtilities.isAdmin()) {
+ invokeProvisioning(Phase.CERTGEN);
+ }
+
return certificateManager;
}
/**
* Remove matching certs from user|system, then Firefox
*/
- public void removeCerts() {
+ public Installer removeCerts() {
// System certs
NativeCertificateInstaller instance = NativeCertificateInstaller.getInstance();
instance.remove(instance.find());
// Firefox certs
FirefoxCertificateInstaller.uninstall();
+ return this;
}
/**
@@ -338,6 +352,31 @@ public Installer addUserSettings() {
return instance;
}
+ public Installer invokeProvisioning(Phase phase) {
+ try {
+ Path provisionPath = SystemUtilities.isMac() ?
+ Paths.get(getDestination()).resolve("Contents/Resources").resolve(PROVISION_DIR) :
+ Paths.get(getDestination()).resolve(PROVISION_DIR);
+ ProvisionInstaller provisionInstaller = new ProvisionInstaller(provisionPath);
+ provisionInstaller.invoke(phase);
+ } catch(Exception e) {
+ log.warn("An error occurred deleting provisioning directory \"phase\": \"{}\" entries", phase, e);
+ }
+ return this;
+ }
+
+ public Installer removeProvisioning() {
+ try {
+ Path provisionPath = SystemUtilities.isMac() ?
+ Paths.get(getDestination()).resolve("Contents/Resources").resolve(PROVISION_DIR) :
+ Paths.get(getDestination()).resolve(PROVISION_DIR);
+ FileUtils.deleteDirectory(provisionPath.toFile());
+ } catch(Exception e) {
+ log.warn("An error occurred removing provision directory", e);
+ }
+ return this;
+ }
+
public static Properties persistProperties(File oldFile, Properties newProps) {
if(oldFile.exists()) {
Properties oldProps = new Properties();
diff --git a/src/qz/installer/MacInstaller.java b/src/qz/installer/MacInstaller.java
index 73b029d2a..3b5b1c7e3 100644
--- a/src/qz/installer/MacInstaller.java
+++ b/src/qz/installer/MacInstaller.java
@@ -9,7 +9,6 @@
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
-import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -19,7 +18,6 @@
import java.io.File;
import java.io.IOException;
-import java.nio.file.*;
import java.util.HashMap;
import java.util.List;
diff --git a/src/qz/installer/certificate/CertificateManager.java b/src/qz/installer/certificate/CertificateManager.java
index c67674c91..6e69514a2 100644
--- a/src/qz/installer/certificate/CertificateManager.java
+++ b/src/qz/installer/certificate/CertificateManager.java
@@ -414,9 +414,15 @@ public static Properties loadProperties(KeyPairWrapper... keyPairs) {
public static Properties loadKeyPair(KeyPairWrapper keyPair, Path parent, Properties existing) throws Exception {
Properties props;
+
if (existing == null) {
- props = new Properties();
- props.load(new FileInputStream(new File(parent.toFile(), Constants.PROPS_FILE + ".properties")));
+ FileInputStream fis = null;
+ try {
+ props = new Properties();
+ props.load(fis = new FileInputStream(new File(parent.toFile(), Constants.PROPS_FILE + ".properties")));
+ } finally {
+ if(fis != null) fis.close();
+ }
} else {
props = existing;
}
diff --git a/src/qz/installer/certificate/NativeCertificateInstaller.java b/src/qz/installer/certificate/NativeCertificateInstaller.java
index 4b765c450..cbe3178f3 100644
--- a/src/qz/installer/certificate/NativeCertificateInstaller.java
+++ b/src/qz/installer/certificate/NativeCertificateInstaller.java
@@ -34,7 +34,7 @@ public static NativeCertificateInstaller getInstance() {
}
public static NativeCertificateInstaller getInstance(Installer.PrivilegeLevel type) {
if (instance == null) {
- switch(SystemUtilities.getOsType()) {
+ switch(SystemUtilities.getOs()) {
case WINDOWS:
instance = new WindowsCertificateInstaller(type);
break;
diff --git a/src/qz/installer/certificate/firefox/locator/AppLocator.java b/src/qz/installer/certificate/firefox/locator/AppLocator.java
index c70ab0f08..e02d5d713 100644
--- a/src/qz/installer/certificate/firefox/locator/AppLocator.java
+++ b/src/qz/installer/certificate/firefox/locator/AppLocator.java
@@ -74,7 +74,7 @@ public static AppLocator getInstance() {
}
private static AppLocator getPlatformSpecificAppLocator() {
- switch(SystemUtilities.getOsType()) {
+ switch(SystemUtilities.getOs()) {
case WINDOWS:
return new WindowsAppLocator();
case MAC:
diff --git a/src/qz/installer/provision/ProvisionInstaller.java b/src/qz/installer/provision/ProvisionInstaller.java
new file mode 100644
index 000000000..0996b594a
--- /dev/null
+++ b/src/qz/installer/provision/ProvisionInstaller.java
@@ -0,0 +1,148 @@
+package qz.installer.provision;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.IOUtils;
+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.provision.Step;
+import qz.build.provision.params.Os;
+import qz.build.provision.params.Phase;
+import qz.build.provision.params.types.Script;
+import qz.build.provision.params.types.Software;
+import qz.common.Constants;
+import qz.installer.provision.invoker.*;
+import qz.utils.ShellUtilities;
+import qz.utils.SystemUtilities;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.ArrayList;
+
+import static qz.common.Constants.*;
+import static qz.utils.FileUtilities.*;
+
+public class ProvisionInstaller {
+ protected static final Logger log = LogManager.getLogger(ProvisionInstaller.class);
+ private ArrayList steps;
+
+ static {
+ // Populate variables for scripting environment
+ ShellUtilities.addEnvp("APP_TITLE", ABOUT_TITLE,
+ "APP_VERSION", VERSION,
+ "APP_ABBREV", PROPS_FILE,
+ "APP_VENDOR", ABOUT_COMPANY,
+ "APP_VENDOR_ABBREV", DATA_DIR,
+ "APP_ARCH", SystemUtilities.getArch(),
+ "APP_OS", SystemUtilities.getOs(),
+ "APP_DIR", SystemUtilities.getAppPath(),
+ "APP_USER_DIR", USER_DIR,
+ "APP_SHARED_DIR", SHARED_DIR);
+ }
+
+ public ProvisionInstaller(Path relativePath) throws IOException, JSONException {
+ this(relativePath, relativePath.resolve(Constants.PROVISION_FILE).toFile());
+ }
+
+ public ProvisionInstaller(Path relativePath, File jsonFile) throws IOException, JSONException {
+ if(!jsonFile.exists()) {
+ log.info("Provision file not found '{}', skipping", jsonFile);
+ this.steps = new ArrayList<>();
+ return;
+ }
+ this.steps = parse(FileUtils.readFileToString(jsonFile, StandardCharsets.UTF_8), relativePath);
+ }
+
+ /**
+ * Package private for internal testing only
+ * Assumes files located in ./resources/ subdirectory
+ */
+ ProvisionInstaller(Class relativeClass, InputStream in) throws IOException, JSONException {
+ this(relativeClass, IOUtils.toString(in, StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Package private for internal testing only
+ * Assumes files located in ./resources/ subdirectory
+ */
+ ProvisionInstaller(Class relativeClass, String jsonData) throws JSONException {
+ this.steps = parse(jsonData, relativeClass);
+ }
+
+ public void invoke(Phase phase) {
+ for(Step step : this.steps) {
+ if(phase == null || step.getPhase() == phase) {
+ try {
+ invokeStep(step);
+ }
+ catch(Exception e) {
+ log.error("[PROVISION] Provisioning step failed '{}'", step, e);
+ }
+ }
+ }
+ }
+
+ public void invoke() {
+ invoke(null);
+ }
+
+ private static ArrayList parse(String jsonData, Object relativeObject) throws JSONException {
+ return parse(new JSONArray(jsonData), relativeObject);
+ }
+
+ private boolean invokeStep(Step step) throws Exception {
+ if(Os.matchesHost(step.getOs())) {
+ log.info("[PROVISION] Invoking step '{}'", step.toString());
+ } else {
+ log.info("[PROVISION] Skipping step for different OS '{}'", step.toString());
+ return false;
+ }
+
+ Invokable invoker;
+ switch(step.getType()) {
+ case CERT:
+ invoker = new CertInvoker(step);
+ break;
+ case SCRIPT:
+ invoker = new ScriptInvoker(step);
+ break;
+ case SOFTWARE:
+ invoker = new SoftwareInvoker(step);
+ break;
+ case REMOVER:
+ invoker = new RemoverInvoker(step);
+ break;
+ case PREFERENCE:
+ invoker = new PropertyInvoker(step, PropertyInvoker.getPreferences(step));
+ break;
+ case PROPERTY:
+ invoker = new PropertyInvoker(step, PropertyInvoker.getProperties(step));
+ break;
+ default:
+ throw new UnsupportedOperationException("Type " + step.getType() + " is not yet supported.");
+ }
+ return invoker.invoke();
+ }
+
+ private static ArrayList parse(JSONArray jsonArray, Object relativeObject) throws JSONException {
+ ArrayList steps = new ArrayList<>();
+ for(int i = 0; i < jsonArray.length(); i++) {
+ JSONObject jsonStep = jsonArray.getJSONObject(i);
+ try {
+ steps.add(Step.parse(jsonStep, relativeObject));
+ } catch(Exception e) {
+ log.warn("[PROVISION] Unable to add step '{}'", jsonStep, e);
+ }
+ }
+ return steps;
+ }
+
+ public static boolean shouldBeExecutable(Path path) {
+ return Script.parse(path) != null || Software.parse(path) != Software.UNKNOWN;
+ }
+}
diff --git a/src/qz/installer/provision/invoker/CertInvoker.java b/src/qz/installer/provision/invoker/CertInvoker.java
new file mode 100644
index 000000000..8e7b32e87
--- /dev/null
+++ b/src/qz/installer/provision/invoker/CertInvoker.java
@@ -0,0 +1,26 @@
+package qz.installer.provision.invoker;
+
+import qz.build.provision.Step;
+import qz.common.Constants;
+import qz.utils.FileUtilities;
+
+import java.io.File;
+
+import static qz.utils.ArgParser.ExitStatus.*;
+
+public class CertInvoker extends InvokableResource {
+ private Step step;
+
+ public CertInvoker(Step step) {
+ this.step = step;
+ }
+
+ @Override
+ public boolean invoke() throws Exception {
+ File cert = dataToFile(step);
+ if(cert == null) {
+ return false;
+ }
+ return FileUtilities.addToCertList(Constants.ALLOW_FILE, cert) == SUCCESS;
+ }
+}
diff --git a/src/qz/installer/provision/invoker/Invokable.java b/src/qz/installer/provision/invoker/Invokable.java
new file mode 100644
index 000000000..508e48990
--- /dev/null
+++ b/src/qz/installer/provision/invoker/Invokable.java
@@ -0,0 +1,10 @@
+package qz.installer.provision.invoker;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+public interface Invokable {
+ Logger log = LogManager.getLogger(Invokable.class);
+
+ boolean invoke() throws Exception;
+}
diff --git a/src/qz/installer/provision/invoker/InvokableResource.java b/src/qz/installer/provision/invoker/InvokableResource.java
new file mode 100644
index 000000000..5a76d54be
--- /dev/null
+++ b/src/qz/installer/provision/invoker/InvokableResource.java
@@ -0,0 +1,63 @@
+package qz.installer.provision.invoker;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import qz.build.provision.Step;
+import qz.build.provision.params.Type;
+import qz.common.Constants;
+import qz.utils.SystemUtilities;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+
+public abstract class InvokableResource implements Invokable {
+ static final Logger log = LogManager.getLogger(InvokableResource.class);
+
+ public static File dataToFile(Step step) throws IOException {
+ Path resourcePath = Paths.get(step.getData());
+ if(resourcePath.isAbsolute() || step.usingPath()) {
+ return pathResourceToFile(step);
+ }
+ if(step.usingClass()) {
+ return classResourceToFile(step);
+ }
+ return null;
+ }
+
+ /**
+ * Resolves the resource directly from file
+ */
+ private static File pathResourceToFile(Step step) {
+ String resourcePath = step.getData();
+ Path dataPath = Paths.get(resourcePath);
+ return dataPath.isAbsolute() ? dataPath.toFile() : step.getRelativePath().resolve(resourcePath).toFile();
+ }
+
+ /**
+ * Copies resource from JAR to a temp file for use in installation
+ */
+ private static File classResourceToFile(Step step) throws IOException {
+ // Resource may be inside the jar
+ InputStream in = step.getRelativeClass().getResourceAsStream("resources/" + step.getData());
+ if(in == null) {
+ log.warn("Resource '{}' is missing, skipping step", step.getData());
+ return null;
+ }
+ String suffix = "_" + Paths.get(step.getData()).getFileName().toString();
+ File destination = File.createTempFile(Constants.DATA_DIR + "_provision_", suffix);
+ Files.copy(in, destination.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ IOUtils.closeQuietly(in);
+
+ // Set scripts executable
+ if(step.getType() == Type.SCRIPT && !SystemUtilities.isWindows()) {
+ destination.setExecutable(true, false);
+ }
+ return destination;
+ }
+}
diff --git a/src/qz/installer/provision/invoker/PropertyInvoker.java b/src/qz/installer/provision/invoker/PropertyInvoker.java
new file mode 100644
index 000000000..75691bdfd
--- /dev/null
+++ b/src/qz/installer/provision/invoker/PropertyInvoker.java
@@ -0,0 +1,85 @@
+package qz.installer.provision.invoker;
+
+import qz.build.provision.Step;
+import qz.common.Constants;
+import qz.common.PropertyHelper;
+import qz.utils.FileUtilities;
+import qz.utils.SystemUtilities;
+
+import java.io.File;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+
+public class PropertyInvoker implements Invokable {
+ private Step step;
+ PropertyHelper properties;
+
+ public PropertyInvoker(Step step, PropertyHelper properties) {
+ this.step = step;
+ this.properties = properties;
+ }
+
+ public boolean invoke() {
+ if(step.getData() != null && !step.getData().trim().isEmpty()) {
+ String[] props = step.getData().split("\\|");
+ ArrayList> pairs = new ArrayList<>();
+ for(String prop : props) {
+ AbstractMap.SimpleEntry pair = parsePropertyPair(step, prop);
+ if (pair != null) {
+ pairs.add(pair);
+ }
+ }
+ if (!pairs.isEmpty()) {
+ for(AbstractMap.SimpleEntry pair : pairs) {
+ properties.setProperty(pair.getKey(), pair.getValue());
+ }
+ if (properties.save()) {
+ log.info("Successfully provisioned '{}' '{}'", pairs.size(), step.getType());
+ return true;
+ }
+ log.error("An error occurred saving properties '{}' to file", step.getData());
+ }
+ } else {
+ log.error("Skipping Step '{}', Data is null or empty", step.getType());
+ }
+ return false;
+ }
+
+ public static PropertyHelper getProperties(Step step) {
+ File propertiesFile;
+ if(step.getRelativePath() != null) {
+ // Assume qz-tray.properties is one directory up from provision folder
+ // required to prevent installing to payload
+ propertiesFile = step.getRelativePath().getParent().resolve(Constants.PROPS_FILE + ".properties").toFile();
+ } else {
+ // If relative path isn't set, fallback to the jar's parent path
+ propertiesFile = SystemUtilities.getJarParentPath(".").resolve(Constants.PROPS_FILE + ".properties").toFile();
+ }
+ log.info("Provisioning '{}' to properties file: '{}'", step.getData(), propertiesFile);
+ return new PropertyHelper(propertiesFile);
+ }
+
+ public static PropertyHelper getPreferences(Step step) {
+ return new PropertyHelper(FileUtilities.USER_DIR + File.separator + Constants.PREFS_FILE + ".properties");
+ }
+
+
+ private static AbstractMap.SimpleEntry parsePropertyPair(Step step, String prop) {
+ if(prop.contains("=")) {
+ String[] pair = prop.split("=", 2);
+ if (!pair[0].trim().isEmpty()) {
+ if (!pair[1].trim().isEmpty()) {
+ return new AbstractMap.SimpleEntry<>(pair[0], pair[1]);
+ } else {
+ log.warn("Skipping '{}' '{}', property value is malformed", step.getType(), prop);
+ }
+ } else {
+ log.warn("Skipping '{}' '{}', property name is malformed", step.getType(), prop);
+ }
+ } else {
+ log.warn("Skipping '{}' '{}', property is malformed", step.getType(), prop);
+ }
+
+ return null;
+ }
+}
diff --git a/src/qz/installer/provision/invoker/RemoverInvoker.java b/src/qz/installer/provision/invoker/RemoverInvoker.java
new file mode 100644
index 000000000..3b8271a21
--- /dev/null
+++ b/src/qz/installer/provision/invoker/RemoverInvoker.java
@@ -0,0 +1,100 @@
+package qz.installer.provision.invoker;
+
+import qz.build.provision.Step;
+import qz.build.provision.params.Os;
+import qz.build.provision.params.types.Remover;
+import qz.utils.ShellUtilities;
+import qz.utils.SystemUtilities;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+
+public class RemoverInvoker extends InvokableResource {
+ private Step step;
+ private String aboutTitle; // e.g. "QZ Tray"
+ private String propsFile; // e.g. "qz-tray"
+ private String dataDir; // e.g. "qz"
+
+
+ public RemoverInvoker(Step step) {
+ this.step = step;
+ Remover remover = Remover.parse(step.getData());
+ if(remover == Remover.CUSTOM) {
+ // Fields are comma delimited in the data field
+ parseCustomFromData(step.getData());
+ } else {
+ aboutTitle = remover.getAboutTitle();
+ propsFile = remover.getPropsFile();
+ dataDir = remover.getDataDir();
+ }
+ }
+
+ @Override
+ public boolean invoke() throws Exception {
+ ArrayList command = getRemoveCommand();
+ if(command.size() == 0) {
+ log.info("An existing installation of '{}' was not found. Skipping.", aboutTitle);
+ return true;
+ }
+ boolean success = ShellUtilities.execute(command.toArray(new String[command.size()]));
+ if(!success) {
+ log.error("An error occurred invoking [{}]", step.getData());
+ }
+ return success;
+ }
+
+ public void parseCustomFromData(String data) {
+ String[] parts = data.split(",");
+ aboutTitle = parts[0].trim();
+ propsFile = parts[1].trim();
+ dataDir = parts[2].trim();
+ }
+
+ /**
+ * Returns the installer command (including the installer itself and if needed, arguments) to
+ * invoke the installer file
+ */
+ public ArrayList getRemoveCommand() {
+ ArrayList removeCmd = new ArrayList<>();
+ Os os = SystemUtilities.getOs();
+ switch(os) {
+ case WINDOWS:
+ Path win = Paths.get(System.getenv("PROGRAMFILES"))
+ .resolve(aboutTitle)
+ .resolve("uninstall.exe");
+
+ if(win.toFile().exists()) {
+ removeCmd.add(win.toString());
+ removeCmd.add("/S");
+ break;
+ }
+ case MAC:
+ Path legacy = Paths.get("/Applications")
+ .resolve(aboutTitle + ".app")
+ .resolve("Contents")
+ .resolve("uninstall");
+
+ Path mac = Paths.get("/Applications")
+ .resolve(aboutTitle + ".app")
+ .resolve("Contents")
+ .resolve("Resources")
+ .resolve("uninstall");
+
+ if(legacy.toFile().exists()) {
+ removeCmd.add(legacy.toString());
+ } else if(mac.toFile().exists()) {
+ removeCmd.add(mac.toString());
+ }
+ break;
+ default:
+ Path linux = Paths.get("/opt")
+ .resolve(propsFile)
+ .resolve("uninstall");
+ if(linux.toFile().exists()) {
+ removeCmd.add(linux.toString());
+ }
+ }
+ return removeCmd;
+ }
+}
diff --git a/src/qz/installer/provision/invoker/ScriptInvoker.java b/src/qz/installer/provision/invoker/ScriptInvoker.java
new file mode 100644
index 000000000..bbd9d9a6d
--- /dev/null
+++ b/src/qz/installer/provision/invoker/ScriptInvoker.java
@@ -0,0 +1,77 @@
+package qz.installer.provision.invoker;
+
+import qz.build.provision.Step;
+import qz.build.provision.params.Os;
+import qz.build.provision.params.types.Script;
+import qz.utils.ShellUtilities;
+import qz.utils.SystemUtilities;
+
+import java.io.File;
+import java.util.ArrayList;
+
+public class ScriptInvoker extends InvokableResource {
+ private Step step;
+
+ public ScriptInvoker(Step step) {
+ this.step = step;
+ }
+
+ @Override
+ public boolean invoke() throws Exception {
+ File script = dataToFile(step);
+ if(script == null) {
+ return false;
+ }
+ Script engine = Script.parse(step.getData());
+ ArrayList command = getInterpreter(engine);
+ if(command.isEmpty() && SystemUtilities.isWindows()) {
+ log.warn("No interpreter found for {}, skipping", step.getData());
+ return false;
+ }
+ command.add(script.toString());
+ boolean success = ShellUtilities.execute(command.toArray(new String[command.size()]));
+ if(!success) {
+ log.error("An error occurred invoking [{}]", step.getData());
+ }
+ return success;
+ }
+
+
+ /**
+ * Returns the interpreter command (and if needed, arguments) to invoke the script file
+ *
+ * An empty array will fall back to Unix "shebang" notation, e.g. #!/usr/bin/python3
+ * which will allow the OS to select the correct interpreter for the given file
+ *
+ * No special attention is given to "shebang", behavior may differ between OSs
+ */
+ private static ArrayList getInterpreter(Script engine) {
+ ArrayList interpreter = new ArrayList<>();
+ Os osType = SystemUtilities.getOs();
+ switch(engine) {
+ case PS1:
+ if(osType == Os.WINDOWS) {
+ interpreter.add("powershell.exe");
+ } else if(osType == Os.MAC) {
+ interpreter.add("/usr/local/bin/pwsh");
+ } else {
+ interpreter.add("pwsh");
+ }
+ interpreter.add("-File");
+ break;
+ case PY:
+ interpreter.add(osType == Os.WINDOWS ? "python3.exe" : "python3");
+ break;
+ case BAT:
+ interpreter.add(osType == Os.WINDOWS ? "cmd.exe" : "wineconsole");
+ break;
+ case RB:
+ interpreter.add(osType == Os.WINDOWS ? "ruby.exe" : "ruby");
+ break;
+ case SH:
+ default:
+ // Allow the environment to parse it from the shebang at invocation time
+ }
+ return interpreter;
+ }
+}
diff --git a/src/qz/installer/provision/invoker/SoftwareInvoker.java b/src/qz/installer/provision/invoker/SoftwareInvoker.java
new file mode 100644
index 000000000..bd5c7f843
--- /dev/null
+++ b/src/qz/installer/provision/invoker/SoftwareInvoker.java
@@ -0,0 +1,87 @@
+package qz.installer.provision.invoker;
+
+import qz.build.provision.Step;
+import qz.build.provision.params.Os;
+import qz.build.provision.params.types.Software;
+import qz.utils.ShellUtilities;
+import qz.utils.SystemUtilities;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+public class SoftwareInvoker extends InvokableResource {
+ private Step step;
+
+ public SoftwareInvoker(Step step) {
+ this.step = step;
+ }
+
+ @Override
+ public boolean invoke() throws Exception {
+ File payload = dataToFile(step);
+ if(payload == null) {
+ return false;
+ }
+ Software installer = Software.parse(step.getData());
+ ArrayList command = getInstallCommand(installer, step.getArgs(), payload);
+ boolean success = ShellUtilities.execute(command.toArray(new String[command.size()]));
+ if(!success) {
+ log.error("An error occurred invoking [{}]", step.getData());
+ }
+ return success;
+ }
+
+ /**
+ * Returns the installer command (including the installer itself and if needed, arguments) to
+ * invoke the installer file
+ */
+ public ArrayList getInstallCommand(Software installer, List args, File payload) {
+ ArrayList interpreter = new ArrayList<>();
+ Os os = SystemUtilities.getOs();
+ switch(installer) {
+ case EXE:
+ if(!SystemUtilities.isWindows()) {
+ interpreter.add("wine");
+ }
+ // Executable on its own
+ interpreter.add(payload.toString());
+ interpreter.addAll(args); // Assume exe args come after payload
+ break;
+ case MSI:
+ interpreter.add(os == Os.WINDOWS ? "msiexec.exe" : "msiexec");
+ interpreter.add("/i"); // Assume standard install
+ interpreter.add(payload.toString());
+ interpreter.addAll(args); // Assume msiexec args come after payload
+ break;
+ case PKG:
+ if(os == Os.MAC) {
+ interpreter.add("installer");
+ interpreter.addAll(args); // Assume installer args come before payload
+ interpreter.add("-package");
+ interpreter.add(payload.toString());
+ interpreter.add("-target");
+ interpreter.add("/"); // Assume we don't want this on a removable volume
+ } else {
+ throw new UnsupportedOperationException("PKG is not yet supported on this platform");
+ }
+ break;
+ case DMG:
+ // DMG requires "hdiutil attach", but the mount point is unknown
+ throw new UnsupportedOperationException("DMG is not yet supported");
+ case RUN:
+ if(SystemUtilities.isWindows()) {
+ interpreter.add("bash");
+ interpreter.add("-c");
+ }
+ interpreter.add(payload.toString());
+ interpreter.addAll(args); // Assume run args come after payload
+ // Executable on its own
+ break;
+ default:
+ // We'll try to parse it from the shebang just before invocation time
+ }
+ return interpreter;
+ }
+
+}
diff --git a/src/qz/printer/action/html/WebApp.java b/src/qz/printer/action/html/WebApp.java
index 1de7df139..b4f1da08f 100644
--- a/src/qz/printer/action/html/WebApp.java
+++ b/src/qz/printer/action/html/WebApp.java
@@ -185,7 +185,7 @@ public static synchronized void initialize() throws IOException {
log.trace("Initializing monocle platform");
System.setProperty("javafx.platform", "monocle");
// Don't set glass.platform on Linux per https://github.com/qzind/tray/issues/702
- switch(SystemUtilities.getOsType()) {
+ switch(SystemUtilities.getOs()) {
case WINDOWS:
case MAC:
System.setProperty("glass.platform", "Monocle");
diff --git a/src/qz/printer/info/NativePrinterMap.java b/src/qz/printer/info/NativePrinterMap.java
index b30fb1569..7f07ad55c 100644
--- a/src/qz/printer/info/NativePrinterMap.java
+++ b/src/qz/printer/info/NativePrinterMap.java
@@ -21,7 +21,7 @@ public abstract class NativePrinterMap extends ConcurrentHashMap(Arrays.asList(args));
+
+ // Apple grossly allows adding weird flags
+ // This can be removed when it's removed from unix-launcher.sh.in
+ if(this.args.size() > 2 && this.args.get(0).startsWith("-NS")) {
+ this.args.remove(0);
+ this.args.remove(0);
+ }
}
public List getArgs() {
return args;
@@ -76,8 +84,10 @@ public int getExitCode() {
*/
private boolean hasFlag(String ... matches) {
for(String match : matches) {
- if (args.contains(match)) {
- return true;
+ for(String arg : args) {
+ if(match.equals(arg)) {
+ return true;
+ }
}
}
return false;
@@ -103,6 +113,22 @@ public boolean hasFlag(ArgValueOption argValueOption) {
return hasFlag(argValueOption.getMatches());
}
+ /**
+ * Allows a pattern such as "--arg%d" to look for "--arg1", "--arg2" in succession
+ */
+ private String[] valuesOpt(String pattern) throws MissingArgException {
+ List all = new LinkedList<>();
+ int argCounter = 0;
+ while(true) {
+ String found = valueOpt(String.format(pattern, ++argCounter));
+ if(found == null) {
+ break;
+ }
+ all.add(found);
+ }
+ return all.toArray(new String[all.size()]);
+ }
+
private String valueOf(String ... matches) throws MissingArgException {
return valueOf(false, matches);
}
@@ -128,6 +154,7 @@ private String valueOf(boolean optional, String ... matches) throws MissingArgEx
return val;
}
}
+ // FIXME: This doesn't fire when one might expect it to, but fixing it may cause regressions
if(!optional) {
throw new MissingArgException();
}
@@ -227,6 +254,30 @@ public ExitStatus processBuildArgs(ArgValue argValue) {
valueOpt("--targetjdk", "-j")
);
return SUCCESS;
+ case PROVISION:
+ ProvisionBuilder provisionBuilder;
+
+ String jsonParam = valueOpt("--json");
+ if(jsonParam != null) {
+ // Process JSON provision file (overwrites existing provisions)
+ provisionBuilder = new ProvisionBuilder(new File(jsonParam), valueOpt("--target-os"), valueOpt("--target-arch"));
+ provisionBuilder.saveJson(true);
+ } else {
+ // Process single provision step (preserves existing provisions)
+ provisionBuilder = new ProvisionBuilder(
+ valueOf("--type"),
+ valueOpt("--phase"),
+ valueOpt("--os"),
+ valueOpt("--arch"),
+ valueOf("--data"),
+ valueOpt("--args"),
+ valueOpt("--description"),
+ valuesOpt("--arg%d")
+ );
+ provisionBuilder.saveJson(false);
+ }
+ log.info("Successfully added provisioning step(s) {} to file '{}'", provisionBuilder.getJson(), ProvisionBuilder.BUILD_PROVISION_FILE);
+ return SUCCESS;
default:
throw new UnsupportedOperationException("Build type " + argValue + " is not yet supported");
}
diff --git a/src/qz/utils/ArgValue.java b/src/qz/utils/ArgValue.java
index f73b7eb3f..cfe0856d7 100644
--- a/src/qz/utils/ArgValue.java
+++ b/src/qz/utils/ArgValue.java
@@ -51,6 +51,8 @@ public enum ArgValue {
// Build stubs
JLINK(BUILD, "Download, compress and bundle a Java Runtime", "jlink [--platform mac|windows|linux] [--arch x64|aarch64] [--vendor bellsoft|eclipse|...] [--version ...] [--gc hotspot|openj9] [--gcversion ...]", null,
"jlink"),
+ PROVISION(BUILD, "Provision/bundle addition settings or resources into this installer", "provision --json file.json [--target-os windows --target-arch x86_64]", null,
+ "provision"),
// Parameter stubs
TRAY_NOTIFICATIONS(PREFERENCES, "Show verbose connect/disconnect notifications in the tray area", null, false,
diff --git a/src/qz/utils/ConnectionUtilities.java b/src/qz/utils/ConnectionUtilities.java
index 21221ce0c..466a02828 100644
--- a/src/qz/utils/ConnectionUtilities.java
+++ b/src/qz/utils/ConnectionUtilities.java
@@ -191,15 +191,16 @@ public String toString() {
}
private static String getArch() {
- switch(SystemUtilities.getJreArch()) {
+ switch(SystemUtilities.getArch()) {
case X86:
case X86_64:
return "x86";
case AARCH64:
return "arm";
- case RISCV:
+ case RISCV32:
+ case RISCV64:
return "riscv";
- case PPC:
+ case PPC64:
return "ppc";
default:
return "unknown";
@@ -213,13 +214,14 @@ private static String getBitness() {
return bitness;
}
// fallback on some sane, hard-coded values
- switch(SystemUtilities.getJreArch()) {
- case ARM:
+ switch(SystemUtilities.getArch()) {
+ case ARM32:
+ case RISCV32:
case X86:
return "32";
case X86_64:
- case RISCV:
- case PPC:
+ case RISCV64:
+ case PPC64:
default:
return "64";
}
@@ -278,7 +280,7 @@ private static String getUserAgentOS() {
private static String getUserAgentArch() {
String arch;
- switch (SystemUtilities.getJreArch()) {
+ switch (SystemUtilities.getArch()) {
case X86:
arch = "x86";
break;
@@ -289,7 +291,7 @@ private static String getUserAgentArch() {
arch = SystemUtilities.OS_ARCH;
}
- switch(SystemUtilities.getOsType()) {
+ switch(SystemUtilities.getOs()) {
case LINUX:
return "Linux " + arch;
case WINDOWS:
diff --git a/src/qz/utils/FileUtilities.java b/src/qz/utils/FileUtilities.java
index 51c54e7ad..52122d3ff 100644
--- a/src/qz/utils/FileUtilities.java
+++ b/src/qz/utils/FileUtilities.java
@@ -33,6 +33,7 @@
import qz.exception.NullCommandException;
import qz.installer.WindowsSpecialFolders;
import qz.installer.certificate.CertificateManager;
+import qz.installer.provision.ProvisionInstaller;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
@@ -47,7 +48,7 @@
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
-import static qz.common.Constants.ALLOW_FILE;
+import static qz.common.Constants.*;
/**
* Common static file i/o utilities
@@ -893,6 +894,29 @@ public static void setPermissionsRecursively(Path toRecurse, boolean worldWrite)
}
}
+ public static void setExecutableRecursively(Path toRecurse, boolean ownerOnly) {
+ File folder = toRecurse.toFile();
+ if(SystemUtilities.isWindows() || !folder.exists() || !folder.isDirectory()) {
+ return;
+ }
+
+ // "provision.json" found, assume we're in the provisioning directory, only process scripts and installers
+ boolean isProvision = toRecurse.resolve(PROVISION_FILE).toFile().exists();
+
+ try (Stream paths = Files.walk(toRecurse)) {
+ paths.forEach((path)->{
+ if (path.toFile().isDirectory()) {
+ // Executable bit in Unix allows listing files
+ path.toFile().setExecutable(true, ownerOnly);
+ } else if(!isProvision || ProvisionInstaller.shouldBeExecutable(path)) {
+ path.toFile().setExecutable(true, ownerOnly);
+ }
+ });
+ } catch (IOException e) {
+ log.warn("An error occurred setting permissions: {}", toRecurse);
+ }
+ }
+
public static void cleanup() {
if(FileUtilities.TEMP_DIR != null) {
FileUtils.deleteQuietly(FileUtilities.TEMP_DIR.toFile());
diff --git a/src/qz/utils/LibUtilities.java b/src/qz/utils/LibUtilities.java
index a6790dc5c..1fd172584 100644
--- a/src/qz/utils/LibUtilities.java
+++ b/src/qz/utils/LibUtilities.java
@@ -3,6 +3,7 @@
import com.sun.jna.Platform;
import javafx.application.Application;
import org.usb4java.Loader;
+import qz.build.provision.params.Os;
import qz.common.Constants;
import java.io.IOException;
@@ -36,12 +37,12 @@ public class LibUtilities {
private final Path basePath;
// Common native file extensions, by platform
- private static HashMap extensionMap = new HashMap<>();
+ private static HashMap extensionMap = new HashMap<>();
static {
- extensionMap.put(SystemUtilities.OsType.WINDOWS, new String[]{ "dll" });
- extensionMap.put(SystemUtilities.OsType.MAC, new String[]{ "jnilib", "dylib" });
- extensionMap.put(SystemUtilities.OsType.LINUX, new String[]{ "so" });
- extensionMap.put(SystemUtilities.OsType.UNKNOWN, new String[]{ "so" });
+ extensionMap.put(Os.WINDOWS, new String[]{ "dll" });
+ extensionMap.put(Os.MAC, new String[]{ "jnilib", "dylib" });
+ extensionMap.put(Os.LINUX, new String[]{ "so" });
+ extensionMap.put(Os.UNKNOWN, new String[]{ "so" });
}
public LibUtilities() {
@@ -78,7 +79,7 @@ public void bind() {
* Search recursively for a native library in the specified path
*/
private static Path findNativeLib(String libName, Path basePath) {
- String[] extensions = extensionMap.get(SystemUtilities.getOsType());
+ String[] extensions = extensionMap.get(SystemUtilities.getOs());
String prefix = !SystemUtilities.isWindows() ? "lib" : "";
List found = new ArrayList<>();
try (Stream walkStream = Files.walk(basePath)) {
@@ -170,7 +171,7 @@ private boolean detectJavaFxConflict() {
// If running from the IDE, make sure we're not using the wrong libs
URL url = Application.class.getResource("/" + Application.class.getName().replace('.', '/') + ".class");
String graphicsJar = url.toString().replaceAll("file:/|jar:", "").replaceAll("!.*", "");
- switch(SystemUtilities.getOsType()) {
+ switch(SystemUtilities.getOs()) {
case WINDOWS:
return !graphicsJar.contains("windows");
case MAC:
diff --git a/src/qz/utils/ShellUtilities.java b/src/qz/utils/ShellUtilities.java
index 6f8b61dea..0c90938de 100644
--- a/src/qz/utils/ShellUtilities.java
+++ b/src/qz/utils/ShellUtilities.java
@@ -18,10 +18,7 @@
import java.io.*;
import java.lang.reflect.Method;
import java.nio.file.Path;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.Locale;
-import java.util.Map;
+import java.util.*;
/**
* Utility class for managing all {@code Runtime.exec(...)} functions.
@@ -32,26 +29,25 @@ public class ShellUtilities {
private static final Logger log = LogManager.getLogger(ShellUtilities.class);
+ // Display envp values in errors and console logs
+ private static boolean debugEnvp = false;
+
// Shell environment overrides. null = don't override
- public static String[] envp = null;
+ private static Map env = new HashMap<>(System.getenv());
+ @Deprecated /* TODO: Make private, use getEnvp() instead */
+ public static String[] envp = getEnvp();
// Make sure all shell calls are LANG=en_US.UTF-8
static {
- if (!SystemUtilities.isWindows()) {
- // Cache existing; permit named overrides w/o full clobber
- Map env = new HashMap<>(System.getenv());
- if (SystemUtilities.isMac()) {
+ switch(SystemUtilities.getOs()) {
+ case WINDOWS:
+ break;
+ case MAC:
// Enable LANG overrides
- env.put("SOFTWARE", "");
- }
- // Functional equivalent of "export LANG=en_US.UTF-8"
- env.put("LANG", "C");
- String[] envp = new String[env.size()];
- int i = 0;
- for(Map.Entry o : env.entrySet())
- envp[i++] = o.getKey() + "=" + o.getValue();
-
- ShellUtilities.envp = envp;
+ addEnvp("SOFTWARE", "");
+ default:
+ // Functional equivalent of "export LANG=en_US.UTF-8"
+ addEnvp("LANG", "C");
}
}
@@ -94,14 +90,15 @@ public static boolean execute(String[] commandArray, boolean silent) {
Process p = Runtime.getRuntime().exec(commandArray, envp);
// Consume output to prevent deadlock
while (p.getInputStream().read() != -1) {}
+ while (p.getErrorStream().read() != -1) {}
p.waitFor();
return p.exitValue() == 0;
}
catch(InterruptedException ex) {
- log.warn("InterruptedException waiting for a return value: {} envp: {}", Arrays.toString(commandArray), Arrays.toString(envp), ex);
+ log.warn("InterruptedException waiting for a return value: {} envp: {}", Arrays.toString(commandArray), envpToString(), ex);
}
catch(IOException ex) {
- log.error("IOException executing: {} envp: {}", Arrays.toString(commandArray), Arrays.toString(envp), ex);
+ log.error("IOException executing: {} envp: {}", Arrays.toString(commandArray), envpToString(), ex);
}
return false;
@@ -156,7 +153,7 @@ public static String execute(String[] commandArray, String[] searchFor, boolean
}
}
catch(IOException ex) {
- log.error("IOException executing: {} envp: {}", Arrays.toString(commandArray), Arrays.toString(envp), ex);
+ log.error("IOException executing: {} envp: {}", Arrays.toString(commandArray), envpToString(), ex);
}
finally {
if (stdInput != null) {
@@ -199,7 +196,7 @@ public static String executeRaw(String[] commandArray, boolean silent) {
}
catch(IOException ex) {
if(!silent) {
- log.error("IOException executing: {} envp: {}", Arrays.toString(commandArray), Arrays.toString(envp), ex);
+ log.error("IOException executing: {} envp: {}", Arrays.toString(commandArray), envpToString(), ex);
}
}
finally {
@@ -277,4 +274,40 @@ public static void browseDirectory(File directory) {
}
}
}
+
+ /**
+ * Provides fast envp manipulation for starting processes with additional environmental variables
+ *
+ * @param paired Pairs of values, e.g. { "FOO", "BAR" } where the environment will set FOO=BAR
+ * @return
+ */
+ public static synchronized String[] addEnvp(Object ... paired) {
+ if(paired.length % 2 != 0) {
+ throw new UnsupportedOperationException("Values must be provided in pairs");
+ }
+
+ for(int i = 0; i < paired.length / 2; i++) {
+ env.put(paired[2 * i].toString(), paired[2 * i + 1].toString());
+ }
+ envp = null;
+ return getEnvp();
+ }
+
+ public static synchronized String[] getEnvp() {
+ if(envp == null) {
+ String[] temp = new String[env.size()];
+ int i = 0;
+ for(Map.Entry o : env.entrySet())
+ temp[i++] = o.getKey() + "=" + o.getValue();
+ envp = temp;
+ }
+ return envp;
+ }
+
+ public static String envpToString() {
+ if(debugEnvp) {
+ return Arrays.toString(envp);
+ }
+ return "(suppressed)";
+ }
}
diff --git a/src/qz/utils/SystemUtilities.java b/src/qz/utils/SystemUtilities.java
index 979e5c275..d821ad5f6 100644
--- a/src/qz/utils/SystemUtilities.java
+++ b/src/qz/utils/SystemUtilities.java
@@ -18,6 +18,8 @@
import org.joor.ReflectException;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import qz.build.provision.params.Arch;
+import qz.build.provision.params.Os;
import qz.common.Constants;
import qz.common.TrayManager;
import qz.installer.Installer;
@@ -50,8 +52,8 @@
public class SystemUtilities {
static final String OS_NAME = System.getProperty("os.name");
static final String OS_ARCH = System.getProperty("os.arch");
- private static final OsType OS_TYPE = getOsType(OS_NAME);
- private static final JreArch JRE_ARCH = getJreArch(OS_ARCH);
+ private static final Os OS_TYPE = Os.bestMatch(OS_NAME);
+ private static final Arch JRE_ARCH = Arch.bestMatch(OS_ARCH);
private static final Logger log = LogManager.getLogger(TrayManager.class);
private static final Locale defaultLocale = Locale.getDefault();
@@ -74,73 +76,14 @@ public class SystemUtilities {
private static Path jarPath;
private static Integer pid;
- public enum OsType {
- MAC,
- WINDOWS,
- LINUX,
- SOLARIS,
- UNKNOWN
- }
-
- public enum JreArch {
- X86,
- X86_64,
- ARM, // 32-bit
- AARCH64,
- RISCV,
- PPC,
- UNKNOWN
- }
-
- public static OsType getOsType() {
+ public static Os getOs() {
return OS_TYPE;
}
- public static OsType getOsType(String os) {
- if(os != null) {
- String osLower = os.toLowerCase(Locale.ENGLISH);
- if (osLower.contains("win")) {
- return OsType.WINDOWS;
- } else if (osLower.contains("mac")) {
- return OsType.MAC;
- } else if (osLower.contains("linux")) {
- return OsType.LINUX;
- } else if (osLower.contains("sunos")) {
- return OsType.SOLARIS;
- }
- }
- return OsType.UNKNOWN;
- }
-
- public static JreArch getJreArch() {
+ public static Arch getArch() {
return JRE_ARCH;
}
- public static JreArch getJreArch(String arch) {
- if(arch != null) {
- String archLower = arch.toLowerCase(Locale.ENGLISH);
- if (archLower.equals("arm")) {
- return JreArch.ARM;
- }
- if (archLower.contains("amd64") || archLower.contains("x86_64")) {
- return JreArch.X86_64;
- }
- if (archLower.contains("86")) { // x86, i386, i486, i586, i686
- return JreArch.X86;
- }
- if (archLower.startsWith("aarch") || archLower.startsWith("arm")) {
- return JreArch.AARCH64;
- }
- if (archLower.startsWith("riscv") || archLower.startsWith("rv")) {
- return JreArch.RISCV;
- }
- if (archLower.startsWith("ppc") || archLower.startsWith("power")) {
- return JreArch.PPC;
- }
- }
- return JreArch.UNKNOWN;
- }
-
/**
* Call to workaround Locale-specific bugs (See issue #680)
* Please call restoreLocale()
as soon as possible
@@ -393,7 +336,7 @@ public static Path getAppPath() {
* @return {@code true} if Windows, {@code false} otherwise
*/
public static boolean isWindows() {
- return OS_TYPE == OsType.WINDOWS;
+ return OS_TYPE == Os.WINDOWS;
}
/**
@@ -402,7 +345,7 @@ public static boolean isWindows() {
* @return {@code true} if Mac OS, {@code false} otherwise
*/
public static boolean isMac() {
- return OS_TYPE == OsType.MAC;
+ return OS_TYPE == Os.MAC;
}
/**
@@ -411,7 +354,7 @@ public static boolean isMac() {
* @return {@code true} if Linux, {@code false} otherwise
*/
public static boolean isLinux() {
- return OS_TYPE == OsType.LINUX;
+ return OS_TYPE == Os.LINUX;
}
/**
@@ -422,7 +365,7 @@ public static boolean isLinux() {
public static boolean isUnix() {
if(OS_NAME != null) {
String osLower = OS_NAME.toLowerCase(Locale.ENGLISH);
- return OS_TYPE == OsType.MAC || OS_TYPE == OsType.SOLARIS || OS_TYPE == OsType.LINUX ||
+ return OS_TYPE == Os.MAC || OS_TYPE == Os.SOLARIS || OS_TYPE == Os.LINUX ||
osLower.contains("nix") || osLower.indexOf("aix") > 0;
}
return false;
@@ -434,7 +377,7 @@ public static boolean isUnix() {
* @return {@code true} if Solaris, {@code false} otherwise
*/
public static boolean isSolaris() {
- return OS_TYPE == OsType.SOLARIS;
+ return OS_TYPE == Os.SOLARIS;
}
public static boolean isDarkTaskbar() {
@@ -829,7 +772,7 @@ public static boolean validateSaltedChallenge(String message) {
*/
public static boolean isSystemTraySupported(boolean headless) {
if(!headless) {
- switch(getOsType()) {
+ switch(getOs()) {
case WINDOWS:
if(WindowsUtilities.isHiddenSystemTray()) {
return false;
diff --git a/src/qz/utils/WindowsUtilities.java b/src/qz/utils/WindowsUtilities.java
index a6c15fa4f..714fe3042 100644
--- a/src/qz/utils/WindowsUtilities.java
+++ b/src/qz/utils/WindowsUtilities.java
@@ -15,6 +15,7 @@
import com.sun.jna.ptr.IntByReference;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import qz.build.provision.params.Arch;
import qz.common.Constants;
import java.awt.*;
@@ -449,7 +450,7 @@ public static boolean isWow64() {
if(isWow64 == null) {
isWow64 = false;
if (SystemUtilities.isWindows()) {
- if (SystemUtilities.getJreArch() != JreArch.X86_64) {
+ if (SystemUtilities.getArch() != Arch.X86_64) {
isWow64 = System.getenv("PROGRAMFILES(x86)") != null;
}
}
diff --git a/test/qz/installer/provision/ProvisionerInstallerTests.java b/test/qz/installer/provision/ProvisionerInstallerTests.java
new file mode 100644
index 000000000..69b15312d
--- /dev/null
+++ b/test/qz/installer/provision/ProvisionerInstallerTests.java
@@ -0,0 +1,20 @@
+package qz.installer.provision;
+
+import org.codehaus.jettison.json.JSONException;
+import qz.common.Constants;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+public class ProvisionerInstallerTests {
+
+ public static void main(String ... args) throws JSONException, IOException {
+ InputStream in = ProvisionerInstallerTests.class.getResourceAsStream("resources/" + Constants.PROVISION_FILE);
+
+ // Parse the JSON
+ ProvisionInstaller provisionInstaller = new ProvisionInstaller(ProvisionerInstallerTests.class, in);
+
+ // Invoke all parsed steps
+ provisionInstaller.invoke();
+ }
+}
diff --git a/test/qz/installer/provision/resources/cert1.crt b/test/qz/installer/provision/resources/cert1.crt
new file mode 100644
index 000000000..4ec319a04
--- /dev/null
+++ b/test/qz/installer/provision/resources/cert1.crt
@@ -0,0 +1,60 @@
+-----BEGIN CERTIFICATE-----
+MIIFAzCCAuugAwIBAgICEAIwDQYJKoZIhvcNAQEFBQAwgZgxCzAJBgNVBAYTAlVT
+MQswCQYDVQQIDAJOWTEbMBkGA1UECgwSUVogSW5kdXN0cmllcywgTExDMRswGQYD
+VQQLDBJRWiBJbmR1c3RyaWVzLCBMTEMxGTAXBgNVBAMMEHF6aW5kdXN0cmllcy5j
+b20xJzAlBgkqhkiG9w0BCQEWGHN1cHBvcnRAcXppbmR1c3RyaWVzLmNvbTAeFw0x
+NTAzMTkwMjM4NDVaFw0yNTAzMTkwMjM4NDVaMHMxCzAJBgNVBAYTAkFBMRMwEQYD
+VQQIDApTb21lIFN0YXRlMQ0wCwYDVQQKDAREZW1vMQ0wCwYDVQQLDAREZW1vMRIw
+EAYDVQQDDAlsb2NhbGhvc3QxHTAbBgkqhkiG9w0BCQEWDnJvb3RAbG9jYWxob3N0
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtFzbBDRTDHHmlSVQLqjY
+aoGax7ql3XgRGdhZlNEJPZDs5482ty34J4sI2ZK2yC8YkZ/x+WCSveUgDQIVJ8oK
+D4jtAPxqHnfSr9RAbvB1GQoiYLxhfxEp/+zfB9dBKDTRZR2nJm/mMsavY2DnSzLp
+t7PJOjt3BdtISRtGMRsWmRHRfy882msBxsYug22odnT1OdaJQ54bWJT5iJnceBV2
+1oOqWSg5hU1MupZRxxHbzI61EpTLlxXJQ7YNSwwiDzjaxGrufxc4eZnzGQ1A8h1u
+jTaG84S1MWvG7BfcPLW+sya+PkrQWMOCIgXrQnAsUgqQrgxQ8Ocq3G4X9UvBy5VR
+CwIDAQABo3sweTAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdl
+bmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQUpG420UhvfwAFMr+8vf3pJunQ
+gH4wHwYDVR0jBBgwFoAUkKZQt4TUuepf8gWEE3hF6Kl1VFwwDQYJKoZIhvcNAQEF
+BQADggIBAFXr6G1g7yYVHg6uGfh1nK2jhpKBAOA+OtZQLNHYlBgoAuRRNWdE9/v4
+J/3Jeid2DAyihm2j92qsQJXkyxBgdTLG+ncILlRElXvG7IrOh3tq/TttdzLcMjaR
+8w/AkVDLNL0z35shNXih2F9JlbNRGqbVhC7qZl+V1BITfx6mGc4ayke7C9Hm57X0
+ak/NerAC/QXNs/bF17b+zsUt2ja5NVS8dDSC4JAkM1dD64Y26leYbPybB+FgOxFu
+wou9gFxzwbdGLCGboi0lNLjEysHJBi90KjPUETbzMmoilHNJXw7egIo8yS5eq8RH
+i2lS0GsQjYFMvplNVMATDXUPm9MKpCbZ7IlJ5eekhWqvErddcHbzCuUBkDZ7wX/j
+unk/3DyXdTsSGuZk3/fLEsc4/YTujpAjVXiA1LCooQJ7SmNOpUa66TPz9O7Ufkng
++CoTSACmnlHdP7U9WLr5TYnmL9eoHwtb0hwENe1oFC5zClJoSX/7DRexSJfB7YBf
+vn6JA2xy4C6PqximyCPisErNp85GUcZfo33Np1aywFv9H+a83rSUcV6kpE/jAZio
+5qLpgIOisArj1HTM6goDWzKhLiR/AeG3IJvgbpr9Gr7uZmfFyQzUjvkJ9cybZRd+
+G8azmpBBotmKsbtbAU/I/LVk8saeXznshOVVpDRYtVnjZeAneso7
+-----END CERTIFICATE-----
+--START INTERMEDIATE CERT--
+-----BEGIN CERTIFICATE-----
+MIIFEjCCA/qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgawxCzAJBgNVBAYTAlVT
+MQswCQYDVQQIDAJOWTESMBAGA1UEBwwJQ2FuYXN0b3RhMRswGQYDVQQKDBJRWiBJ
+bmR1c3RyaWVzLCBMTEMxGzAZBgNVBAsMElFaIEluZHVzdHJpZXMsIExMQzEZMBcG
+A1UEAwwQcXppbmR1c3RyaWVzLmNvbTEnMCUGCSqGSIb3DQEJARYYc3VwcG9ydEBx
+emluZHVzdHJpZXMuY29tMB4XDTE1MDMwMjAwNTAxOFoXDTM1MDMwMjAwNTAxOFow
+gZgxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJOWTEbMBkGA1UECgwSUVogSW5kdXN0
+cmllcywgTExDMRswGQYDVQQLDBJRWiBJbmR1c3RyaWVzLCBMTEMxGTAXBgNVBAMM
+EHF6aW5kdXN0cmllcy5jb20xJzAlBgkqhkiG9w0BCQEWGHN1cHBvcnRAcXppbmR1
+c3RyaWVzLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANTDgNLU
+iohl/rQoZ2bTMHVEk1mA020LYhgfWjO0+GsLlbg5SvWVFWkv4ZgffuVRXLHrwz1H
+YpMyo+Zh8ksJF9ssJWCwQGO5ciM6dmoryyB0VZHGY1blewdMuxieXP7Kr6XD3GRM
+GAhEwTxjUzI3ksuRunX4IcnRXKYkg5pjs4nLEhXtIZWDLiXPUsyUAEq1U1qdL1AH
+EtdK/L3zLATnhPB6ZiM+HzNG4aAPynSA38fpeeZ4R0tINMpFThwNgGUsxYKsP9kh
+0gxGl8YHL6ZzC7BC8FXIB/0Wteng0+XLAVto56Pyxt7BdxtNVuVNNXgkCi9tMqVX
+xOk3oIvODDt0UoQUZ/umUuoMuOLekYUpZVk4utCqXXlB4mVfS5/zWB6nVxFX8Io1
+9FOiDLTwZVtBmzmeikzb6o1QLp9F2TAvlf8+DIGDOo0DpPQUtOUyLPCh5hBaDGFE
+ZhE56qPCBiQIc4T2klWX/80C5NZnd/tJNxjyUyk7bjdDzhzT10CGRAsqxAnsjvMD
+2KcMf3oXN4PNgyfpbfq2ipxJ1u777Gpbzyf0xoKwH9FYigmqfRH2N2pEdiYawKrX
+6pyXzGM4cvQ5X1Yxf2x/+xdTLdVaLnZgwrdqwFYmDejGAldXlYDl3jbBHVM1v+uY
+5ItGTjk+3vLrxmvGy5XFVG+8fF/xaVfo5TW5AgMBAAGjUDBOMB0GA1UdDgQWBBSQ
+plC3hNS56l/yBYQTeEXoqXVUXDAfBgNVHSMEGDAWgBQDRcZNwPqOqQvagw9BpW0S
+BkOpXjAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAJIO8SiNr9jpLQ
+eUsFUmbueoxyI5L+P5eV92ceVOJ2tAlBA13vzF1NWlpSlrMmQcVUE/K4D01qtr0k
+gDs6LUHvj2XXLpyEogitbBgipkQpwCTJVfC9bWYBwEotC7Y8mVjjEV7uXAT71GKT
+x8XlB9maf+BTZGgyoulA5pTYJ++7s/xX9gzSWCa+eXGcjguBtYYXaAjjAqFGRAvu
+pz1yrDWcA6H94HeErJKUXBakS0Jm/V33JDuVXY+aZ8EQi2kV82aZbNdXll/R6iGw
+2ur4rDErnHsiphBgZB71C5FD4cdfSONTsYxmPmyUb5T+KLUouxZ9B0Wh28ucc1Lp
+rbO7BnjW
+-----END CERTIFICATE-----
\ No newline at end of file
diff --git a/test/qz/installer/provision/resources/provision.json b/test/qz/installer/provision/resources/provision.json
new file mode 100644
index 000000000..f596df548
--- /dev/null
+++ b/test/qz/installer/provision/resources/provision.json
@@ -0,0 +1,117 @@
+
+[
+ {
+ "description": "[ERROR EXPECTED] missing 'type' and 'phase'",
+ "os": "*",
+ "data": "foo.bar=1"
+ },
+ {
+ "description": "[ERROR EXPECTED] invalid 'remover' id",
+ "type": "remover",
+ "os": "*",
+ "phase": "install",
+ "data": "bbb"
+ },
+ {
+ "description": "[ERROR EXPECTED] missing 'type'",
+ "os": "*",
+ "phase": "install",
+ "data": "this_file_does_not_exist"
+ },
+ {
+ "description": "[ERROR EXPECTED] 'data' file missing",
+ "type": "script",
+ "os": "*",
+ "phase": "install",
+ "data": "this_file_does_not_exist"
+ },
+ {
+ "description": "[ERROR EXPECTED] 'arch' is invalid",
+ "type": "property",
+ "os": "*",
+ "arch": "sparc",
+ "data": "bar.foo=2"
+ },
+ {
+ "description": "[ERROR EXPECTED] 'os' is invalid",
+ "type": "property",
+ "os": "quake",
+ "arch": "*",
+ "data": "bar.foo=2"
+ },
+ {
+ "description": "[WINDOWS SCRIPT] powershell at 'install'",
+ "type": "script",
+ "os": "windows",
+ "phase": "install",
+ "data": "script1.ps1"
+ },
+ {
+ "description": "[MAC SCRIPT] powershell at 'install'",
+ "type": "script",
+ "os": "mac",
+ "phase": "install",
+ "data": "script1.ps1"
+ },
+ {
+ "description": "[LINUX SCRIPT] python at 'startup'",
+ "type": "script",
+ "os": "linux",
+ "phase": "startup",
+ "data": "script4.py"
+ },
+ {
+ "description": "[LINUX & MAC SCRIPT] bash without extension at 'install'",
+ "type": "script",
+ "os": "linux|mac",
+ "phase": "install",
+ "data": "script2"
+ },
+ {
+ "description": "[ALL OS SCRIPT] with '.sh' extension at 'install'",
+ "type": "script",
+ "os": "*",
+ "phase": "install",
+ "data": "script3.sh"
+ },
+ {
+ "description": "[AARCH64 ONLY SCRIPT] with '.sh' extension at 'install'",
+ "type": "script",
+ "os": "*",
+ "arch": "aarch64",
+ "phase": "install",
+ "data": "script2"
+ },
+ {
+ "description": "[CERTIFICATE] at 'startup' (allowed.dat)",
+ "type": "cert",
+ "os": "*",
+ "data": "cert1.crt"
+ },
+ {
+ "description": "[PROPERTY] at 'certgen' (qz-tray.properties)",
+ "type": "property",
+ "os": "*",
+ "data": "log.size=2097152"
+ },
+ {
+ "description": "[PREFERENCE] at 'startup' (prefs.properties)",
+ "type": "preference",
+ "os": "*",
+ "data": "tray.notifications=true"
+ },
+ {
+ "description": "[REMOVER] at 'install' ('QZ Tray' rebranded 'Cherry Connect')",
+ "type": "remover",
+ "os": "*",
+ "phase": "install",
+ "data": "Cherry Connect,cc-util,cc"
+ },
+ {
+ "description": "[REMOVER] at 'install' QZ-branded version",
+ "type": "remover",
+ "os": "*",
+ "phase": "install",
+ "data": "qz"
+ }
+]
\ No newline at end of file
diff --git a/test/qz/installer/provision/resources/script1.ps1 b/test/qz/installer/provision/resources/script1.ps1
new file mode 100644
index 000000000..3a7c68e62
--- /dev/null
+++ b/test/qz/installer/provision/resources/script1.ps1
@@ -0,0 +1,5 @@
+$shell="PowerShell"
+$date="$(Get-Date -format "yyyy-MM-dd HH:mm:ss")"
+$script="$($myInvocation.MyCommand.Name)"
+# FIXME: ~/Desktop may try to write to /root/Desktop on Linux
+echo "$date Successful provisioning test from '$shell': $script" >> ~/Desktop/provision.log
\ No newline at end of file
diff --git a/test/qz/installer/provision/resources/script2 b/test/qz/installer/provision/resources/script2
new file mode 100644
index 000000000..81165cb6e
--- /dev/null
+++ b/test/qz/installer/provision/resources/script2
@@ -0,0 +1,9 @@
+#!/bin/bash
+
+shell=$(ps -p $$ -oargs=|awk '{print $1}')
+date=$(date "+%F %T")
+script=$(basename "$0")
+user="$(eval echo ~$(logname))"
+
+echo "$date Successful provisioning test from '$shell': $script" >> "$user/Desktop/provision.log"
+chmod 555 "$user/Desktop/provision.log"
\ No newline at end of file
diff --git a/test/qz/installer/provision/resources/script3.sh b/test/qz/installer/provision/resources/script3.sh
new file mode 100644
index 000000000..f05f99a60
--- /dev/null
+++ b/test/qz/installer/provision/resources/script3.sh
@@ -0,0 +1,7 @@
+shell=$(ps -p $$ -oargs=|awk '{print $1}')
+date=$(date "+%F %T")
+script=$(basename "$0")
+user="$(eval echo ~$(logname))"
+
+echo "$date Successful provisioning test from '$shell': $script" >> "$user/Desktop/provision.log"
+chmod 555 "$user/Desktop/provision.log"
\ No newline at end of file
diff --git a/test/qz/installer/provision/resources/script4.py b/test/qz/installer/provision/resources/script4.py
new file mode 100644
index 000000000..e34d47bd9
--- /dev/null
+++ b/test/qz/installer/provision/resources/script4.py
@@ -0,0 +1,15 @@
+#!/usr/bin/env python3
+
+import os
+
+def notify(title, message):
+ os.system(f"notify-send '{title}' '{message}'")
+
+title=os.getenv('APP_TITLE')
+version=os.getenv('APP_VERSION')
+printer="\U0001F5A8"
+tada="\U0001F389"
+
+notify("{} {}".format(printer, title), """{} This is a sample message from {} {}.
+
+This message indicates that provisioning startup tasks are working.""".format(tada, title, version))
\ No newline at end of file