Add custom QZ Tray fork with pairing key authentication
- Custom fork of QZ Tray 2.2.x with certificate validation bypassed - Implemented pairing key (HMAC) authentication as replacement - Modified files: PrintSocketClient.java (certificate check disabled) - New files: PairingAuth.java, PairingConfigDialog.java - Excluded build artifacts (out/, lib/javafx*) from repository - Library JARs included for dependency management
This commit is contained in:
133
tray/src/qz/App.java
Normal file
133
tray/src/qz/App.java
Normal file
@@ -0,0 +1,133 @@
|
||||
package qz;
|
||||
|
||||
|
||||
import org.apache.logging.log4j.Level;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.apache.logging.log4j.core.Filter;
|
||||
import org.apache.logging.log4j.core.appender.RollingFileAppender;
|
||||
import org.apache.logging.log4j.core.appender.rolling.DefaultRolloverStrategy;
|
||||
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.ui.PairingConfigDialog;
|
||||
import qz.utils.*;
|
||||
import qz.ws.PrintSocketServer;
|
||||
import qz.ws.SingleInstanceChecker;
|
||||
import qz.ws.substitutions.Substitutions;
|
||||
|
||||
import java.io.File;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Properties;
|
||||
|
||||
public class App {
|
||||
private static final Logger log = LogManager.getLogger(App.class);
|
||||
private static Properties trayProperties = null;
|
||||
|
||||
public static void main(String ... args) {
|
||||
ArgParser parser = new ArgParser(args);
|
||||
LibUtilities.getInstance().bind();
|
||||
if(parser.intercept()) {
|
||||
FileUtilities.cleanup();
|
||||
System.exit(parser.getExitCode());
|
||||
}
|
||||
SingleInstanceChecker.stealWebsocket = parser.hasFlag(ArgValue.STEAL);
|
||||
setupFileLogging();
|
||||
log.info(Constants.ABOUT_TITLE + " version: {}", Constants.VERSION);
|
||||
log.info(Constants.ABOUT_TITLE + " vendor: {}", Constants.ABOUT_COMPANY);
|
||||
log.info("Java version: {}", Constants.JAVA_VERSION.toString());
|
||||
log.info("Java vendor: {}", Constants.JAVA_VENDOR);
|
||||
Substitutions.setEnabled(PrefsSearch.getBoolean(ArgValue.SECURITY_SUBSTITUTIONS_ENABLE));
|
||||
Substitutions.setStrict(PrefsSearch.getBoolean(ArgValue.SECURITY_SUBSTITUTIONS_STRICT));
|
||||
|
||||
CertificateManager certManager = null;
|
||||
try {
|
||||
// Gets and sets the SSL info, properties file
|
||||
certManager = Installer.getInstance().certGen(false);
|
||||
trayProperties = certManager.getProperties();
|
||||
// Reoccurring (e.g. hourly) cert expiration check
|
||||
new ExpiryTask(certManager).schedule();
|
||||
} catch(Exception e) {
|
||||
log.error("Something went critically wrong loading HTTPS", e);
|
||||
}
|
||||
Installer.getInstance().addUserSettings();
|
||||
|
||||
// Load overridable preferences set in qz-tray.properties file
|
||||
NetworkUtilities.setPreferences(certManager.getProperties());
|
||||
SingleInstanceChecker.setPreferences(certManager.getProperties());
|
||||
|
||||
// Linux needs the cert installed in user-space on every launch for Chrome SSL to work
|
||||
if(!SystemUtilities.isWindows() && !SystemUtilities.isMac()) {
|
||||
X509Certificate caCert = certManager.getKeyPair(KeyPairWrapper.Type.CA).getCert();
|
||||
// Only install if a CA cert exists (e.g. one we generated)
|
||||
if(caCert != null) {
|
||||
NativeCertificateInstaller.getInstance().install(certManager.getKeyPair(KeyPairWrapper.Type.CA).getCert());
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
PrintSocketServer.runServer(certManager, parser.isHeadless());
|
||||
}
|
||||
catch(Exception e) {
|
||||
log.error("Could not start tray manager", e);
|
||||
}
|
||||
FileUtilities.cleanup();
|
||||
log.warn("The web socket server is no longer running");
|
||||
|
||||
// Show pairing config dialog if needed
|
||||
PairingConfigDialog.showIfNeeded(null); // null for no parent frame, or pass your main frame if available
|
||||
}
|
||||
|
||||
public static Properties getTrayProperties() {
|
||||
return trayProperties;
|
||||
}
|
||||
|
||||
private static void setupFileLogging() {
|
||||
//disable jetty logging
|
||||
System.setProperty("org.eclipse.jetty.util.log.class", "org.eclipse.jetty.util.log.StdErrLog");
|
||||
System.setProperty("org.eclipse.jetty.LEVEL", "OFF");
|
||||
|
||||
if(PrefsSearch.getBoolean(ArgValue.LOG_DISABLE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
int logSize = PrefsSearch.getInt(ArgValue.LOG_SIZE);
|
||||
int logRotate = PrefsSearch.getInt(ArgValue.LOG_ROTATE);
|
||||
Installer.getInstance().cleanupLegacyLogs(Math.max(logRotate, 5));
|
||||
RollingFileAppender fileAppender = RollingFileAppender.newBuilder()
|
||||
.setName("log-file")
|
||||
.withAppend(true)
|
||||
.setLayout(PatternLayout.newBuilder().withPattern("%d{ISO8601} [%p] %m%n").build())
|
||||
.setFilter(ThresholdFilter.createFilter(Level.DEBUG, Filter.Result.ACCEPT, Filter.Result.DENY))
|
||||
.withFileName(FileUtilities.USER_DIR + File.separator + Constants.LOG_FILE + ".log")
|
||||
.withFilePattern(FileUtilities.USER_DIR + File.separator + Constants.LOG_FILE + ".%i.log")
|
||||
.withStrategy(DefaultRolloverStrategy.newBuilder()
|
||||
.withMax(String.valueOf(logRotate))
|
||||
.withFileIndex("min")
|
||||
.build())
|
||||
.withPolicy(SizeBasedTriggeringPolicy.createPolicy(String.valueOf(logSize)))
|
||||
.withImmediateFlush(true)
|
||||
.build();
|
||||
fileAppender.start();
|
||||
|
||||
LoggerUtilities.getRootLogger().addAppender(fileAppender);
|
||||
}
|
||||
}
|
||||
70
tray/src/qz/auth/CRL.java
Normal file
70
tray/src/qz/auth/CRL.java
Normal file
@@ -0,0 +1,70 @@
|
||||
package qz.auth;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import qz.utils.ConnectionUtilities;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.util.ArrayList;
|
||||
|
||||
/**
|
||||
* Wrapper class for the Certificate Revocation List
|
||||
* Created by Steven on 2/4/2015. Package: qz.auth Project: qz-print
|
||||
*/
|
||||
public class CRL {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(CRL.class);
|
||||
|
||||
/** The URL to the QZ CRL. Should not be changed except for dev tests */
|
||||
public static final String CRL_URL = "https://crl.qz.io";
|
||||
|
||||
private static CRL instance = null;
|
||||
|
||||
private ArrayList<String> revokedHashes = new ArrayList<String>();
|
||||
private boolean loaded = false;
|
||||
|
||||
|
||||
private CRL() {}
|
||||
|
||||
public static CRL getInstance() {
|
||||
if (instance == null) {
|
||||
instance = new CRL();
|
||||
|
||||
new Thread() {
|
||||
@Override
|
||||
public void run() {
|
||||
log.info("Loading CRL from {}", CRL_URL);
|
||||
|
||||
try(BufferedReader br = new BufferedReader(new InputStreamReader(ConnectionUtilities.getInputStream(CRL_URL, false)))) {
|
||||
String line;
|
||||
while((line = br.readLine()) != null) {
|
||||
//Ignore empty and commented lines
|
||||
if (!line.isEmpty() && line.charAt(0) != '#') {
|
||||
instance.revokedHashes.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
instance.loaded = true;
|
||||
log.info("Successfully loaded {} CRL entries from {}", instance.revokedHashes.size(), CRL_URL);
|
||||
}
|
||||
catch(IOException e) {
|
||||
log.warn("Unable to access CRL from {}, {}", CRL_URL, e.toString());
|
||||
}
|
||||
}
|
||||
}.start();
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
public boolean isRevoked(String fingerprint) {
|
||||
return revokedHashes.contains(fingerprint);
|
||||
}
|
||||
|
||||
public boolean isLoaded() {
|
||||
return loaded;
|
||||
}
|
||||
}
|
||||
536
tray/src/qz/auth/Certificate.java
Normal file
536
tray/src/qz/auth/Certificate.java
Normal file
@@ -0,0 +1,536 @@
|
||||
package qz.auth;
|
||||
|
||||
import org.apache.commons.codec.binary.StringUtils;
|
||||
import org.apache.commons.codec.digest.DigestUtils;
|
||||
import org.apache.commons.io.Charsets;
|
||||
import org.apache.commons.ssl.Base64;
|
||||
import org.apache.commons.ssl.X509CertificateChainBuilder;
|
||||
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
|
||||
import org.bouncycastle.asn1.x500.style.BCStyle;
|
||||
import org.bouncycastle.jce.PrincipalUtil;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.App;
|
||||
import qz.common.Constants;
|
||||
import qz.utils.*;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.*;
|
||||
import java.security.cert.*;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Created by Steven on 1/27/2015. Package: qz.auth Project: qz-print
|
||||
* Wrapper to store certificate objects from
|
||||
*/
|
||||
public class Certificate {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(Certificate.class);
|
||||
private static final String QUIETLY_FAIL = "quiet";
|
||||
|
||||
public enum Algorithm {
|
||||
SHA1("SHA1withRSA"),
|
||||
SHA256("SHA256withRSA"),
|
||||
SHA512("SHA512withRSA");
|
||||
|
||||
String name;
|
||||
|
||||
Algorithm(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
public static ArrayList<Certificate> rootCAs = new ArrayList<>();
|
||||
public static Certificate builtIn;
|
||||
private static CertPathValidator validator;
|
||||
private static CertificateFactory factory;
|
||||
private static boolean trustBuiltIn = false;
|
||||
// id-at-description used for storing renewal information
|
||||
private static ASN1ObjectIdentifier RENEWAL_OF = new ASN1ObjectIdentifier("2.5.4.13");
|
||||
|
||||
public static final String[] saveFields = new String[] {"fingerprint", "commonName", "organization", "validFrom", "validTo", "valid"};
|
||||
|
||||
public static final String SPONSORED_CN_PREFIX = "Sponsored:";
|
||||
|
||||
// Valid date range allows UI to only show "Expired" text for valid certificates
|
||||
private static final Instant UNKNOWN_MIN = LocalDateTime.MIN.toInstant(ZoneOffset.UTC);
|
||||
private static final Instant UNKNOWN_MAX = LocalDateTime.MAX.toInstant(ZoneOffset.UTC);
|
||||
public static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
public static final DateTimeFormatter DATE_PARSE = DateTimeFormatter.ofPattern("uuuu-MM-dd['T'][ ]HH:mm:ss[.n]['Z']"); //allow parsing of both ISO and custom formatted dates
|
||||
|
||||
private X509Certificate theCertificate;
|
||||
private boolean sponsored;
|
||||
private String fingerprint;
|
||||
private String commonName;
|
||||
private String organization;
|
||||
private Instant validFrom;
|
||||
private Instant validTo;
|
||||
|
||||
//used by review sites UI only
|
||||
private boolean expired = false;
|
||||
private boolean valid = false;
|
||||
private boolean rootCA = false; // TODO: Move to constructor?
|
||||
|
||||
|
||||
//Pre-set certificate for use when missing
|
||||
public static final Certificate UNKNOWN;
|
||||
|
||||
static {
|
||||
HashMap<String,String> map = new HashMap<>();
|
||||
map.put("fingerprint", "UNKNOWN REQUEST");
|
||||
map.put("commonName", "An anonymous request");
|
||||
map.put("organization", "Unknown");
|
||||
map.put("validFrom", UNKNOWN_MIN.toString());
|
||||
map.put("validTo", UNKNOWN_MAX.toString());
|
||||
map.put("valid", "false");
|
||||
UNKNOWN = Certificate.loadCertificate(map);
|
||||
}
|
||||
|
||||
static {
|
||||
try {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
validator = CertPathValidator.getInstance("PKIX");
|
||||
factory = CertificateFactory.getInstance("X.509");
|
||||
builtIn = new Certificate("-----BEGIN CERTIFICATE-----\n" +
|
||||
"MIIELzCCAxegAwIBAgIJALm151zCHDxiMA0GCSqGSIb3DQEBCwUAMIGsMQswCQYD\n" +
|
||||
"VQQGEwJVUzELMAkGA1UECAwCTlkxEjAQBgNVBAcMCUNhbmFzdG90YTEbMBkGA1UE\n" +
|
||||
"CgwSUVogSW5kdXN0cmllcywgTExDMRswGQYDVQQLDBJRWiBJbmR1c3RyaWVzLCBM\n" +
|
||||
"TEMxGTAXBgNVBAMMEHF6aW5kdXN0cmllcy5jb20xJzAlBgkqhkiG9w0BCQEWGHN1\n" +
|
||||
"cHBvcnRAcXppbmR1c3RyaWVzLmNvbTAgFw0xNTAzMDEyMzM4MjlaGA8yMTE1MDMw\n" +
|
||||
"MjIzMzgyOVowgawxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJOWTESMBAGA1UEBwwJ\n" +
|
||||
"Q2FuYXN0b3RhMRswGQYDVQQKDBJRWiBJbmR1c3RyaWVzLCBMTEMxGzAZBgNVBAsM\n" +
|
||||
"ElFaIEluZHVzdHJpZXMsIExMQzEZMBcGA1UEAwwQcXppbmR1c3RyaWVzLmNvbTEn\n" +
|
||||
"MCUGCSqGSIb3DQEJARYYc3VwcG9ydEBxemluZHVzdHJpZXMuY29tMIIBIjANBgkq\n" +
|
||||
"hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuWsBa6uk+RM4OKBZTRfIIyqaaFD71FAS\n" +
|
||||
"7kojAQ+ySMpYuqLjIVZuCh92o1FGBvyBKUFc6knAHw5749yhLCYLXhzWwiNW2ri1\n" +
|
||||
"Jwx/d83Wnaw6qA3lt++u3tmiA8tsFtss0QZW0YBpFsIqhamvB3ypwu0bdUV/oH7g\n" +
|
||||
"/s8TFR5LrDfnfxlLFYhTUVWuWzMqEFAGnFG3uw/QMWZnQgkGbx0LMcYzdqFb7/vz\n" +
|
||||
"rTSHfjJsisUTWPjo7SBnAtNYCYaGj0YH5RFUdabnvoTdV2XpA5IPYa9Q597g/M0z\n" +
|
||||
"icAjuaK614nKXDaAUCbjki8RL3OK9KY920zNFboq/jKG6rKW2t51ZQIDAQABo1Aw\n" +
|
||||
"TjAdBgNVHQ4EFgQUA0XGTcD6jqkL2oMPQaVtEgZDqV4wHwYDVR0jBBgwFoAUA0XG\n" +
|
||||
"TcD6jqkL2oMPQaVtEgZDqV4wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC\n" +
|
||||
"AQEAijcT5QMVqrWWqpNEe1DidzQfSnKo17ZogHW+BfUbxv65JbDIntnk1XgtLTKB\n" +
|
||||
"VAdIWUtGZbXxrp16NEsh96V2hjDIoiAaEpW+Cp6AHhIVgVh7Q9Knq9xZ1t6H8PL5\n" +
|
||||
"QiYQKQgJ0HapdCxlPKBfUm/Mj1ppNl9mPFJwgHmzORexbxrzU/M5i2jlies+CXNq\n" +
|
||||
"cvmF2l33QNHnLwpFGwYKs08pyHwUPp6+bfci6lRvavztgvnKroWWIRq9ZPlC0yVK\n" +
|
||||
"FFemhbCd7ZVbrTo0NcWZM1PTAbvlOikV9eh3i1Vot+3dJ8F27KwUTtnV0B9Jrxum\n" +
|
||||
"W9P3C48mvwTxYZJFOu0N9UBLLg==\n" +
|
||||
"-----END CERTIFICATE-----");
|
||||
|
||||
builtIn.valid = true;
|
||||
setTrustBuiltIn(true);
|
||||
scanAdditionalCAs();
|
||||
}
|
||||
catch(NoSuchAlgorithmException | CertificateException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public static void scanAdditionalCAs() {
|
||||
ArrayList<Map.Entry<Path, String>> certPaths = new ArrayList<>();
|
||||
// First, look for "authcert.override", "-DtrustedRootCert"
|
||||
certPaths.addAll(FileUtilities.parseDelimitedPaths(PrefsSearch.getString(ArgValue.AUTHCERT_OVERRIDE, App.getTrayProperties())));
|
||||
|
||||
// Second, look for "override.crt" within App directory
|
||||
certPaths.add(new AbstractMap.SimpleEntry<>(SystemUtilities.getJarParentPath().resolve(Constants.OVERRIDE_CERT), QUIETLY_FAIL));
|
||||
|
||||
for(Map.Entry<Path, String> certPath : certPaths) {
|
||||
if(certPath.getKey() != null) {
|
||||
if (certPath.getKey().toFile().exists()) {
|
||||
try {
|
||||
Certificate caCert = new Certificate(FileUtilities.readLocalFile(certPath.getKey()));
|
||||
caCert.rootCA = true;
|
||||
caCert.valid = true;
|
||||
if(!rootCAs.contains(caCert)) {
|
||||
log.debug("Adding CA certificate: CN={}, O={} ({})",
|
||||
caCert.getCommonName(), caCert.getOrganization(), caCert.getFingerprint());
|
||||
rootCAs.add(caCert);
|
||||
} else {
|
||||
log.warn("CA cert exists, skipping: {}", certPath.getKey());
|
||||
}
|
||||
}
|
||||
catch(Exception e) {
|
||||
log.error("Error loading CA cert: {}", certPath.getKey(), e);
|
||||
}
|
||||
} else if(!certPath.getValue().equals(QUIETLY_FAIL)) {
|
||||
log.warn("CA cert \"{}\" was provided, but could not be found, skipping.", certPath.getKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Certificate(Path path) throws IOException, CertificateException {
|
||||
this(new String(Files.readAllBytes(path), Charsets.UTF_8));
|
||||
}
|
||||
|
||||
/** Decodes a certificate and intermediate certificate from the given string */
|
||||
public Certificate(String in) throws CertificateException {
|
||||
try {
|
||||
//Strip beginning and end
|
||||
String[] split = in.split("--START INTERMEDIATE CERT--");
|
||||
byte[] serverCertificate = Base64.decodeBase64(split[0].replaceAll(X509Constants.BEGIN_CERT, "").replaceAll(X509Constants.END_CERT, ""));
|
||||
|
||||
X509Certificate theIntermediateCertificate;
|
||||
if (split.length == 2) {
|
||||
byte[] intermediateCertificate = Base64.decodeBase64(split[1].replaceAll(X509Constants.BEGIN_CERT, "").replaceAll(X509Constants.END_CERT, ""));
|
||||
theIntermediateCertificate = (X509Certificate)factory.generateCertificate(new ByteArrayInputStream(intermediateCertificate));
|
||||
} else {
|
||||
theIntermediateCertificate = null; //Self-signed
|
||||
}
|
||||
|
||||
//Generate cert
|
||||
theCertificate = (X509Certificate)factory.generateCertificate(new ByteArrayInputStream(serverCertificate));
|
||||
commonName = getSubjectX509Principal(theCertificate, BCStyle.CN);
|
||||
if(commonName.isEmpty()) {
|
||||
throw new CertificateException("Common Name cannot be blank.");
|
||||
}
|
||||
// Remove "Sponsored: " from CN, we'll swap the trusted icon instead <3
|
||||
if(commonName.startsWith(SPONSORED_CN_PREFIX)) {
|
||||
commonName = commonName.split(SPONSORED_CN_PREFIX)[1].trim();
|
||||
sponsored = true;
|
||||
} else {
|
||||
sponsored = false;
|
||||
}
|
||||
fingerprint = makeThumbPrint(theCertificate);
|
||||
organization = getSubjectX509Principal(theCertificate, BCStyle.O);
|
||||
validFrom = theCertificate.getNotBefore().toInstant();
|
||||
validTo = theCertificate.getNotAfter().toInstant();
|
||||
|
||||
// Check trust anchor against all root certs
|
||||
Certificate foundRoot = null;
|
||||
if(!this.rootCA) {
|
||||
for(Certificate rootCA : rootCAs) {
|
||||
HashSet<X509Certificate> chain = new HashSet<>();
|
||||
try {
|
||||
chain.add(rootCA.theCertificate);
|
||||
if (theIntermediateCertificate != null) { chain.add(theIntermediateCertificate); }
|
||||
X509Certificate[] x509Certificates = X509CertificateChainBuilder.buildPath(theCertificate, chain);
|
||||
|
||||
Set<TrustAnchor> anchor = new HashSet<>();
|
||||
anchor.add(new TrustAnchor(rootCA.theCertificate, null));
|
||||
PKIXParameters params = new PKIXParameters(anchor);
|
||||
params.setRevocationEnabled(false); // TODO: Re-enable, remove proprietary CRL
|
||||
validator.validate(factory.generateCertPath(Arrays.asList(x509Certificates)), params);
|
||||
foundRoot = rootCA;
|
||||
valid = true;
|
||||
log.debug("Successfully chained certificate: CN={}, O={} ({})", getCommonName(), getOrganization(), getFingerprint());
|
||||
break; // if successful, don't attempt another chain
|
||||
}
|
||||
catch(Exception e) {
|
||||
log.warn("Problem building certificate chain (normal if multiple CAs are in use)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for expiration
|
||||
Instant now = Instant.now();
|
||||
if (expired = (validFrom.isAfter(now) || validTo.isBefore(now))) {
|
||||
log.warn("Certificate is expired: CN={}, O={} ({})", getCommonName(), getOrganization(), getFingerprint());
|
||||
valid = false;
|
||||
}
|
||||
|
||||
// If cert matches a rootCA trust it blindly
|
||||
// If cert is chained to a 3rd party rootCA, trust it blindly as well
|
||||
Iterator<Certificate> allCerts = rootCAs.iterator();
|
||||
while(allCerts.hasNext()) {
|
||||
Certificate cert = allCerts.next();
|
||||
if(cert.equals(this) || (cert.equals(foundRoot) && !cert.equals(builtIn))) {
|
||||
log.debug("Adding {} to {} list", cert.toString(), Constants.ALLOW_FILE);
|
||||
if(!isSaved()) {
|
||||
FileUtilities.printLineToFile(Constants.ALLOW_FILE, data());
|
||||
}
|
||||
valid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
readRenewalInfo();
|
||||
CRL qzCrl = CRL.getInstance();
|
||||
if (qzCrl.isLoaded()) {
|
||||
if (qzCrl.isRevoked(getFingerprint()) || (theIntermediateCertificate != null && qzCrl.isRevoked(makeThumbPrint(theIntermediateCertificate)))) {
|
||||
log.error("Certificate has been revoked and can no longer be used: CN={}, O={} ({})", getCommonName(), getOrganization(), getFingerprint());
|
||||
valid = false;
|
||||
}
|
||||
} else {
|
||||
//Assume nothing is revoked, because we can't get the CRL
|
||||
log.warn("Failed to retrieve QZ CRL, skipping CRL check");
|
||||
}
|
||||
}
|
||||
catch(Exception e) {
|
||||
CertificateException certificateException = new CertificateException();
|
||||
certificateException.initCause(e);
|
||||
throw certificateException;
|
||||
}
|
||||
}
|
||||
|
||||
private void readRenewalInfo() throws Exception {
|
||||
Vector values = PrincipalUtil.getSubjectX509Principal(theCertificate).getValues(RENEWAL_OF);
|
||||
Iterator renewals = values.iterator();
|
||||
|
||||
while(renewals.hasNext()) {
|
||||
String renewalInfo = String.valueOf(renewals.next());
|
||||
|
||||
String renewalPrefix = "renewal-of-";
|
||||
if (!renewalInfo.startsWith(renewalPrefix)) {
|
||||
log.warn("Malformed renewal info: {}", renewalInfo);
|
||||
continue;
|
||||
}
|
||||
String previousFingerprint = renewalInfo.substring(renewalPrefix.length());
|
||||
if (previousFingerprint.length() != 40) {
|
||||
log.warn("Malformed renewal fingerprint: {}", previousFingerprint);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add this certificate to the whitelist if the previous certificate was whitelisted
|
||||
|
||||
// First, handle shared directory
|
||||
File sharedFile = FileUtilities.getFile(Constants.ALLOW_FILE, false);
|
||||
if (existsInAnyFile(previousFingerprint, sharedFile) && !isSaved(false)) {
|
||||
if(!FileUtilities.printLineToFile(Constants.ALLOW_FILE, data(), false)) {
|
||||
// Fallback to local directory if shared is not writable
|
||||
FileUtilities.printLineToFile(Constants.ALLOW_FILE, data(), /* fallback */ true);
|
||||
}
|
||||
}
|
||||
|
||||
// Second, handle local directory
|
||||
File localFile = FileUtilities.getFile(Constants.ALLOW_FILE, true);
|
||||
if (existsInAnyFile(previousFingerprint, localFile) && !isSaved(true)) {
|
||||
FileUtilities.printLineToFile(Constants.ALLOW_FILE, data(), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Certificate() {}
|
||||
|
||||
|
||||
/**
|
||||
* Used to rebuild a certificate for the 'Saved Sites' screen without having to decrypt the certificates again
|
||||
*/
|
||||
public static Certificate loadCertificate(HashMap<String,String> data) {
|
||||
Certificate cert = new Certificate();
|
||||
|
||||
cert.fingerprint = data.get("fingerprint");
|
||||
cert.commonName = data.get("commonName");
|
||||
cert.organization = data.get("organization");
|
||||
|
||||
try {
|
||||
cert.validFrom = Instant.from(LocalDateTime.from(DATE_PARSE.parse(data.get("validFrom"))).atZone(ZoneOffset.UTC));
|
||||
cert.validTo = Instant.from(LocalDateTime.from(DATE_PARSE.parse(data.get("validTo"))).atZone(ZoneOffset.UTC));
|
||||
}
|
||||
catch(DateTimeException e) {
|
||||
cert.validFrom = UNKNOWN_MIN;
|
||||
cert.validTo = UNKNOWN_MAX;
|
||||
|
||||
log.warn("Unable to parse certificate date: {}", e.getMessage());
|
||||
}
|
||||
|
||||
cert.valid = Boolean.parseBoolean(data.get("valid"));
|
||||
|
||||
return cert;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks given signature for given data against this certificate,
|
||||
* ensuring it is properly signed
|
||||
*
|
||||
* @param signature the signature appended to the data, base64 encoded
|
||||
* @param data the data to check
|
||||
* @return true if signature valid, false if not
|
||||
*/
|
||||
public boolean isSignatureValid(Algorithm algorithm, String signature, String data) {
|
||||
if (!signature.isEmpty()) {
|
||||
//On errors, assume failure.
|
||||
try {
|
||||
Signature verifier = Signature.getInstance(algorithm.name);
|
||||
verifier.initVerify(theCertificate.getPublicKey());
|
||||
verifier.update(StringUtils.getBytesUtf8(DigestUtils.sha256Hex(data)));
|
||||
|
||||
return verifier.verify(Base64.decodeBase64(signature));
|
||||
}
|
||||
catch(GeneralSecurityException e) {
|
||||
log.error("Unable to verify signature", e);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Checks if the certificate has been added to the specified allow file */
|
||||
public boolean isSaved(boolean local) {
|
||||
File allowed = FileUtilities.getFile(Constants.ALLOW_FILE, local);
|
||||
return existsInAnyFile(getFingerprint(), allowed);
|
||||
}
|
||||
|
||||
/** Checks if the certificate has been added to any allow file */
|
||||
public boolean isSaved() {
|
||||
return isSaved(false) || isSaved(true);
|
||||
}
|
||||
|
||||
/** Checks if the certificate has been added to the local block file */
|
||||
public boolean isBlocked() {
|
||||
File blocks = FileUtilities.getFile(Constants.BLOCK_FILE, true);
|
||||
File blocksShared = FileUtilities.getFile(Constants.BLOCK_FILE, false);
|
||||
return existsInAnyFile(getFingerprint(), blocksShared, blocks);
|
||||
}
|
||||
|
||||
private static boolean existsInAnyFile(String fingerprint, File... files) {
|
||||
for(File file : files) {
|
||||
if (file == null) { continue; }
|
||||
|
||||
try(BufferedReader br = new BufferedReader(new FileReader(file))) {
|
||||
String line;
|
||||
while((line = br.readLine()) != null) {
|
||||
if (line.contains("\t")) {
|
||||
String print = line.substring(0, line.indexOf("\t"));
|
||||
if (print.equals(fingerprint)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
public String getFingerprint() {
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
public String getCommonName() {
|
||||
return commonName;
|
||||
}
|
||||
|
||||
public String getOrganization() {
|
||||
return organization;
|
||||
}
|
||||
|
||||
public String getValidFrom() {
|
||||
if (validFrom.isAfter(UNKNOWN_MIN)) {
|
||||
return DATE_FORMAT.format(validFrom.atZone(ZoneOffset.UTC));
|
||||
} else {
|
||||
return "Not Provided";
|
||||
}
|
||||
}
|
||||
|
||||
public String getValidTo() {
|
||||
if (validTo.isBefore(UNKNOWN_MAX)) {
|
||||
return DATE_FORMAT.format(validTo.atZone(ZoneOffset.UTC));
|
||||
} else {
|
||||
return "Not Provided";
|
||||
}
|
||||
}
|
||||
|
||||
public Instant getValidFromDate() {
|
||||
return validFrom;
|
||||
}
|
||||
|
||||
public Instant getValidToDate() {
|
||||
return validTo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates certificate against embedded cert.
|
||||
*/
|
||||
public boolean isTrusted() {
|
||||
return isValid() && !isExpired();
|
||||
}
|
||||
|
||||
public boolean isSponsored() {
|
||||
return sponsored;
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
return valid;
|
||||
}
|
||||
|
||||
public boolean isExpired() {
|
||||
return expired;
|
||||
}
|
||||
|
||||
|
||||
public static String makeThumbPrint(X509Certificate cert) throws NoSuchAlgorithmException, CertificateEncodingException {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-1");
|
||||
md.update(cert.getEncoded());
|
||||
return ByteUtilities.bytesToHex(md.digest(), false);
|
||||
}
|
||||
|
||||
private String data(boolean assumeTrusted) {
|
||||
return getFingerprint() + "\t" +
|
||||
getCommonName() + "\t" +
|
||||
getOrganization() + "\t" +
|
||||
getValidFrom() + "\t" +
|
||||
getValidTo() + "\t" +
|
||||
// Used by equals(), may fail if it hasn't been trusted yet
|
||||
(assumeTrusted ? true : isTrusted());
|
||||
}
|
||||
|
||||
public String data() {
|
||||
return data(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return getOrganization() + " (" + getCommonName() + ")";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (obj instanceof Certificate) {
|
||||
return ((Certificate)obj).data(true).equals(data(true));
|
||||
}
|
||||
return super.equals(obj);
|
||||
}
|
||||
|
||||
public static void setTrustBuiltIn(boolean trustBuiltIn) {
|
||||
if(trustBuiltIn) {
|
||||
if (!rootCAs.contains(builtIn)) {
|
||||
log.debug("Adding internal CA certificate: CN={}, O={} ({})",
|
||||
builtIn.getCommonName(), builtIn.getOrganization(), builtIn.getFingerprint());
|
||||
builtIn.rootCA = true;
|
||||
builtIn.valid = true;
|
||||
rootCAs.add(0, builtIn);
|
||||
}
|
||||
} else {
|
||||
if (rootCAs.contains(builtIn)) {
|
||||
log.debug("Removing internal CA certificate: CN={}, O={} ({})",
|
||||
builtIn.getCommonName(), builtIn.getOrganization(), builtIn.getFingerprint());
|
||||
rootCAs.remove(builtIn);
|
||||
}
|
||||
}
|
||||
Certificate.trustBuiltIn = trustBuiltIn;
|
||||
}
|
||||
|
||||
public static boolean isTrustBuiltIn() {
|
||||
return trustBuiltIn;
|
||||
}
|
||||
|
||||
public static boolean hasAdditionalCAs() {
|
||||
return rootCAs.size() > (isTrustBuiltIn() ? 1 : 0);
|
||||
}
|
||||
|
||||
private static String getSubjectX509Principal(X509Certificate cert, ASN1ObjectIdentifier key) {
|
||||
try {
|
||||
Vector v = PrincipalUtil.getSubjectX509Principal(cert).getValues(key);
|
||||
if(v.size() > 0) {
|
||||
return String.valueOf(v.get(0));
|
||||
}
|
||||
} catch(CertificateEncodingException e) {
|
||||
log.warn("Certificate encoding exception occurred", e);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
}
|
||||
87
tray/src/qz/auth/PairingAuth.java
Normal file
87
tray/src/qz/auth/PairingAuth.java
Normal file
@@ -0,0 +1,87 @@
|
||||
package qz.auth;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import qz.utils.FileUtilities;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.File;
|
||||
import java.io.FileReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Base64;
|
||||
|
||||
public class PairingAuth {
|
||||
private static String site = null;
|
||||
private static String pairingKey = null;
|
||||
private static final String HMAC_ALGO = "HmacSHA256";
|
||||
private static File configFile = null;
|
||||
|
||||
static {
|
||||
loadConfig();
|
||||
}
|
||||
|
||||
private static void loadConfig() {
|
||||
try {
|
||||
// Use QZ Tray's user directory - same location as PairingConfigDialog
|
||||
configFile = new File(FileUtilities.USER_DIR.toFile(), "pairing-config.json");
|
||||
|
||||
if (!configFile.exists()) {
|
||||
System.out.println("Pairing config file not found at: " + configFile.getAbsolutePath());
|
||||
site = null;
|
||||
pairingKey = null;
|
||||
return;
|
||||
}
|
||||
|
||||
String content = new String(Files.readAllBytes(configFile.toPath()));
|
||||
JSONObject config = new JSONObject(content);
|
||||
site = config.optString("site", null);
|
||||
pairingKey = config.optString("pairing_key", null);
|
||||
|
||||
System.out.println("Pairing config loaded from: " + configFile.getAbsolutePath());
|
||||
System.out.println("Site configured: " + site);
|
||||
} catch (Exception e) {
|
||||
System.err.println("Error loading pairing config: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
site = null;
|
||||
pairingKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
public static String getSite() {
|
||||
return site;
|
||||
}
|
||||
|
||||
public static String getPairingKey() {
|
||||
return pairingKey;
|
||||
}
|
||||
|
||||
public static boolean isConfigured() {
|
||||
return site != null && !site.isEmpty() && pairingKey != null && !pairingKey.isEmpty();
|
||||
}
|
||||
|
||||
public static String getConfigFilePath() {
|
||||
return configFile != null ? configFile.getAbsolutePath() : "unknown";
|
||||
}
|
||||
|
||||
public static void reload() {
|
||||
loadConfig();
|
||||
}
|
||||
|
||||
public static String hmacSignature(String message) {
|
||||
if (pairingKey == null || pairingKey.isEmpty()) {
|
||||
System.err.println("Cannot compute HMAC: pairing key is not configured");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HMAC_ALGO);
|
||||
SecretKeySpec keySpec = new SecretKeySpec(pairingKey.getBytes(StandardCharsets.UTF_8), HMAC_ALGO);
|
||||
mac.init(keySpec);
|
||||
byte[] hmac = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(hmac);
|
||||
} catch (Exception e) {
|
||||
System.err.println("HMAC computation failed: " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
118
tray/src/qz/auth/RequestState.java
Normal file
118
tray/src/qz/auth/RequestState.java
Normal file
@@ -0,0 +1,118 @@
|
||||
package qz.auth;
|
||||
|
||||
import org.codehaus.jettison.json.JSONObject;
|
||||
import qz.common.Constants;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
|
||||
public class RequestState {
|
||||
|
||||
public enum Validity {
|
||||
TRUSTED("Valid"),
|
||||
EXPIRED("Expired Signature"),
|
||||
UNSIGNED("Invalid Signature"),
|
||||
EXPIRED_CERT("Expired Certificate"),
|
||||
FUTURE_CERT("Future Certificate"),
|
||||
INVALID_CERT("Invalid Certificate"),
|
||||
UNKNOWN("Invalid");
|
||||
|
||||
private String formatted;
|
||||
|
||||
Validity(String formatted) {
|
||||
this.formatted = formatted;
|
||||
}
|
||||
|
||||
public String getFormatted() {
|
||||
return formatted;
|
||||
}
|
||||
}
|
||||
|
||||
Certificate certUsed;
|
||||
JSONObject requestData;
|
||||
|
||||
boolean initialConnect;
|
||||
Validity status;
|
||||
|
||||
public RequestState(Certificate cert, JSONObject data) {
|
||||
certUsed = cert;
|
||||
requestData = data;
|
||||
status = Validity.UNKNOWN;
|
||||
}
|
||||
|
||||
public Certificate getCertUsed() {
|
||||
return certUsed;
|
||||
}
|
||||
|
||||
public JSONObject getRequestData() {
|
||||
return requestData;
|
||||
}
|
||||
|
||||
public boolean isInitialConnect() {
|
||||
return initialConnect;
|
||||
}
|
||||
|
||||
public void markNewConnection(Certificate cert) {
|
||||
certUsed = cert;
|
||||
initialConnect = true;
|
||||
|
||||
checkCertificateState(cert);
|
||||
}
|
||||
|
||||
public void checkCertificateState(Certificate cert) {
|
||||
if (cert.isTrusted()) {
|
||||
status = Validity.TRUSTED;
|
||||
} else if (cert.getValidToDate().isBefore(Instant.now())) {
|
||||
status = Validity.EXPIRED_CERT;
|
||||
} else if (cert.getValidFromDate().isAfter(Instant.now())) {
|
||||
status = Validity.FUTURE_CERT;
|
||||
} else if (!cert.isValid()) {
|
||||
status = Validity.INVALID_CERT;
|
||||
} else {
|
||||
status = Validity.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
public Validity getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(Validity state) {
|
||||
status = state;
|
||||
}
|
||||
|
||||
public boolean hasCertificate() {
|
||||
return certUsed != null && certUsed != Certificate.UNKNOWN;
|
||||
}
|
||||
|
||||
public boolean hasSavedCert() {
|
||||
return isVerified() && certUsed.isSaved();
|
||||
}
|
||||
|
||||
public boolean hasBlockedCert() {
|
||||
return certUsed == null || certUsed.isBlocked();
|
||||
}
|
||||
|
||||
public String getCertName() {
|
||||
return certUsed.getCommonName();
|
||||
}
|
||||
|
||||
public boolean isVerified() {
|
||||
return certUsed.isTrusted() && status == Validity.TRUSTED;
|
||||
}
|
||||
|
||||
public boolean isSponsored() {
|
||||
return certUsed.isSponsored();
|
||||
}
|
||||
|
||||
public String getValidityInfo() {
|
||||
if (status == Validity.TRUSTED) {
|
||||
return Constants.TRUSTED_CERT;
|
||||
} else if (Arrays.asList(Validity.UNSIGNED, Validity.EXPIRED, Validity.EXPIRED_CERT, Validity.FUTURE_CERT).contains(status)) {
|
||||
return Constants.NO_TRUST + " - " + status.getFormatted();
|
||||
} else {
|
||||
return Constants.UNTRUSTED_CERT;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
12
tray/src/qz/auth/X509Constants.java
Normal file
12
tray/src/qz/auth/X509Constants.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package qz.auth;
|
||||
|
||||
/**
|
||||
* Created by Tres on 3/3/2015.
|
||||
*/
|
||||
public class X509Constants {
|
||||
public static final String BEGIN_CERT = "-----BEGIN CERTIFICATE-----";
|
||||
public static final String END_CERT = "-----END CERTIFICATE-----";
|
||||
public static final String INTERMEDIATE_CERT = "--START INTERMEDIATE CERT--";
|
||||
public static final String BEGIN_CRL = "-----BEGIN X509 CRL-----";
|
||||
public static final String END_CRL = "-----END X509 CRL-----";
|
||||
}
|
||||
129
tray/src/qz/build/Fetcher.java
Normal file
129
tray/src/qz/build/Fetcher.java
Normal file
@@ -0,0 +1,129 @@
|
||||
package qz.build;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.utils.ShellUtilities;
|
||||
import qz.utils.SystemUtilities;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
/**
|
||||
* Fetches a zip or tarball from URL and decompresses it
|
||||
*/
|
||||
public class Fetcher {
|
||||
public enum Format {
|
||||
ZIP(".zip"),
|
||||
TARBALL(".tar.gz"),
|
||||
UNKNOWN(null);
|
||||
|
||||
String suffix;
|
||||
Format(String suffix) {
|
||||
this.suffix = suffix;
|
||||
}
|
||||
|
||||
public String getSuffix() {
|
||||
return suffix;
|
||||
}
|
||||
|
||||
public static Format parse(String url) {
|
||||
for(Format format : Format.values()) {
|
||||
if (url.endsWith(format.getSuffix())) {
|
||||
return format;
|
||||
}
|
||||
}
|
||||
return UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
private static final Logger log = LogManager.getLogger(Fetcher.class);
|
||||
|
||||
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;
|
||||
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) {
|
||||
this.resourceName = resourceName;
|
||||
this.url = url;
|
||||
this.format = format;
|
||||
this.rootDir = Paths.get(rootDir);
|
||||
}
|
||||
|
||||
public Fetcher fetch() throws IOException {
|
||||
extracted = new File(rootDir.toString(), resourceName);
|
||||
if(extracted.isDirectory() && extracted.exists()) {
|
||||
log.info("Resource '{}' from [{}] has already been downloaded and extracted. Using: [{}]", resourceName, url, extracted);
|
||||
} else {
|
||||
tempExtracted = new File(rootDir.toString(), resourceName + "~tmp");
|
||||
if(tempExtracted.exists()) {
|
||||
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);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public String uncompress() throws IOException {
|
||||
if(tempArchive != null) {
|
||||
log.info("Unzipping '{}' from [{}] to [{}]", resourceName, tempArchive, tempExtracted);
|
||||
if(format == Format.ZIP) {
|
||||
unzip(tempArchive.getAbsolutePath(), tempExtracted);
|
||||
} else {
|
||||
untar(tempArchive.getAbsolutePath(), tempExtracted);
|
||||
}
|
||||
log.info("Moving [{}] to [{}]", tempExtracted, extracted);
|
||||
tempExtracted.renameTo(extracted);
|
||||
}
|
||||
return extracted.toString();
|
||||
}
|
||||
|
||||
public static void untar(String sourceFile, File targetDir) throws IOException {
|
||||
// TODO: Switch to TarArchiveInputStream from Apache Commons Compress
|
||||
if (!ShellUtilities.execute("tar", "-xzf", sourceFile, "-C", targetDir.getPath())) {
|
||||
throw new IOException("Something went wrong extracting " + sourceFile +", check logs for details");
|
||||
}
|
||||
}
|
||||
|
||||
public static void unzip(String sourceFile, File targetDir) throws IOException {
|
||||
try (ZipInputStream zipIn = new ZipInputStream(new FileInputStream(sourceFile))) {
|
||||
for (ZipEntry ze; (ze = zipIn.getNextEntry()) != null; ) {
|
||||
Path resolvedPath = targetDir.toPath().resolve(ze.getName());
|
||||
if (ze.isDirectory()) {
|
||||
Files.createDirectories(resolvedPath);
|
||||
} else {
|
||||
Files.createDirectories(resolvedPath.getParent());
|
||||
Files.copy(zipIn, resolvedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
341
tray/src/qz/build/JLink.java
Normal file
341
tray/src/qz/build/JLink.java
Normal file
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2020 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.build;
|
||||
|
||||
import com.github.zafarkhaja.semver.Version;
|
||||
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 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.*;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
|
||||
public class JLink {
|
||||
private static final Logger log = LogManager.getLogger(JLink.class);
|
||||
public static final Vendor JAVA_DEFAULT_VENDOR = Vendor.BELLSOFT;
|
||||
private static final String JAVA_DEFAULT_VERSION = "11.0.17+7";
|
||||
private static final String JAVA_DEFAULT_GC_ENGINE = "hotspot"; // or "openj9"
|
||||
private static final String JAVA_DEFAULT_GC_VERSION = "0.35.0"; // openj9 gc only
|
||||
|
||||
private Path jarPath;
|
||||
private Path jdepsPath;
|
||||
private Path jlinkPath;
|
||||
private Path jmodsPath;
|
||||
private Path outPath;
|
||||
private Version jdepsVersion;
|
||||
private Platform hostPlatform;
|
||||
private Platform targetPlatform;
|
||||
private Arch hostArch;
|
||||
private Arch targetArch;
|
||||
private Vendor javaVendor;
|
||||
private String gcEngine;
|
||||
private String javaVersion;
|
||||
private String gcVersion;
|
||||
|
||||
private Path targetJdk;
|
||||
|
||||
private Version javaSemver;
|
||||
|
||||
private LinkedHashSet<String> depList;
|
||||
|
||||
public JLink(String targetPlatform, String targetArch, String javaVendor, String javaVersion, String gcEngine, String gcVersion, String targetJdk) throws IOException {
|
||||
this.hostPlatform = Platform.getCurrentPlatform();
|
||||
this.hostArch = SystemUtilities.getArch();
|
||||
|
||||
this.targetPlatform = Platform.parse(targetPlatform, this.hostPlatform);
|
||||
this.targetArch = Arch.parse(targetArch, this.hostArch);
|
||||
this.javaVendor = Vendor.parse(javaVendor, JAVA_DEFAULT_VENDOR);
|
||||
this.gcEngine = getParam("gcEngine", gcEngine, JAVA_DEFAULT_GC_ENGINE);
|
||||
this.javaVersion = getParam("javaVersion", javaVersion, JAVA_DEFAULT_VERSION);
|
||||
this.gcVersion = getParam("gcVersion", gcVersion, JAVA_DEFAULT_GC_VERSION);
|
||||
|
||||
this.javaSemver = SystemUtilities.getJavaVersion(this.javaVersion);
|
||||
|
||||
// Optional: Provide the location of a custom JDK on the local filesystem
|
||||
if(!StringUtils.isEmpty(targetJdk)) {
|
||||
Path jdkPath = Paths.get(targetJdk);
|
||||
Properties jdkProps = new Properties();
|
||||
jdkProps.load(new FileInputStream(jdkPath.resolve("release").toFile()));
|
||||
String customVersion = jdkProps.getProperty("JAVA_VERSION");
|
||||
if(customVersion.contains("\"")) {
|
||||
customVersion = customVersion.split("\"")[1];
|
||||
}
|
||||
Version customSemver = SystemUtilities.getJavaVersion(customVersion);
|
||||
if(needsDownload(javaSemver, customSemver)) {
|
||||
// The "release" file doesn't have build info, so we can't auto-download :(
|
||||
if(javaSemver.getMajorVersion() != customSemver.getMajorVersion()) {
|
||||
log.error("Error: jlink version {}.0 does not match target java.base version {}.0", javaSemver.getMajorVersion(), customSemver.getMajorVersion());
|
||||
} else {
|
||||
// Handle edge-cases (e.g. JDK-8240734)
|
||||
log.error("Error: jlink version {} is incompatible with target java.base version {}", javaSemver.getMajorVersion(), customSemver.getMajorVersion());
|
||||
}
|
||||
System.exit(2);
|
||||
}
|
||||
this.targetJdk = Paths.get(targetJdk);
|
||||
calculateToolPaths(Paths.get(targetJdk));
|
||||
} else {
|
||||
// Determine if the version we're building with is compatible with the target version
|
||||
if (needsDownload(javaSemver, Constants.JAVA_VERSION)) {
|
||||
log.warn("Java versions are incompatible, locating a suitable runtime for Java " + javaSemver.getMajorVersion() + "...");
|
||||
String hostJdk = downloadJdk(this.hostArch, this.hostPlatform);
|
||||
calculateToolPaths(Paths.get(hostJdk));
|
||||
} else {
|
||||
calculateToolPaths(null);
|
||||
}
|
||||
}
|
||||
|
||||
if(this.targetJdk == null) {
|
||||
targetJdk = downloadJdk(this.targetArch, this.targetPlatform);
|
||||
jmodsPath = Paths.get(targetJdk, "jmods");
|
||||
} else {
|
||||
log.info("\"targetjdk\" was provided {}, skipping download", targetJdk);
|
||||
jmodsPath = this.targetJdk.resolve("jmods");
|
||||
}
|
||||
|
||||
log.info("Selecting jmods folder: {}", jmodsPath);
|
||||
|
||||
calculateJarPath()
|
||||
.calculateOutPath()
|
||||
.calculateDepList()
|
||||
.deployJre();
|
||||
}
|
||||
|
||||
public static void main(String ... args) throws IOException {
|
||||
new JLink(null, null, null, null, null, null, null).calculateJarPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incompatibilities between JDKs, download a fresh one if needed
|
||||
*/
|
||||
private static boolean needsDownload(Version want, Version installed) {
|
||||
// jdeps and jlink historically require matching major JDK versions. Download if needed.
|
||||
boolean downloadJdk = installed.getMajorVersion() != want.getMajorVersion();
|
||||
|
||||
// Per JDK-8240734: Major versions checks aren't enough starting with 11.0.16+8
|
||||
// see also https://github.com/adoptium/adoptium-support/issues/557
|
||||
Version bad = SystemUtilities.getJavaVersion("11.0.16+8");
|
||||
if(want.greaterThanOrEqualTo(bad) && installed.lessThan(bad) ||
|
||||
installed.greaterThanOrEqualTo(bad) && want.lessThan(bad)) {
|
||||
// Force download
|
||||
// Fixes "Hash of java.rmi differs from expected hash"
|
||||
downloadJdk = true;
|
||||
}
|
||||
return downloadJdk;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// 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)
|
||||
.fetch()
|
||||
.uncompress();
|
||||
|
||||
// Get first subfolder, e.g. jdk-11.0.7+10
|
||||
for(File subfolder : new File(extractedJdk).listFiles(pathname -> pathname.isDirectory())) {
|
||||
extractedJdk = subfolder.getPath();
|
||||
if(platform == Platform.MAC && Paths.get(extractedJdk, "/Contents/Home").toFile().isDirectory()) {
|
||||
extractedJdk += "/Contents/Home";
|
||||
}
|
||||
log.info("Selecting JDK home: {}", extractedJdk);
|
||||
break;
|
||||
}
|
||||
|
||||
return extractedJdk;
|
||||
}
|
||||
|
||||
private JLink calculateJarPath() {
|
||||
if(SystemUtilities.isJar()) {
|
||||
jarPath = SystemUtilities.getJarPath();
|
||||
} else {
|
||||
// Detect out/dist/qz-tray.jar for IDE usage
|
||||
jarPath = SystemUtilities.getJarParentPath()
|
||||
.resolve("dist")
|
||||
.resolve(Constants.PROPS_FILE + ".jar");
|
||||
}
|
||||
log.info("Assuming jar path: {}", jarPath);
|
||||
return this;
|
||||
}
|
||||
|
||||
private JLink calculateOutPath() {
|
||||
switch(targetPlatform) {
|
||||
case MAC:
|
||||
outPath = jarPath.resolve("../Java.runtime/Contents/Home").normalize();
|
||||
break;
|
||||
default:
|
||||
outPath = jarPath.resolve("../runtime").normalize();
|
||||
}
|
||||
log.info("Assuming output path: {}", outPath);
|
||||
return this;
|
||||
}
|
||||
|
||||
private JLink calculateToolPaths(Path javaHome) throws IOException {
|
||||
if(javaHome == null) {
|
||||
javaHome = Paths.get(System.getProperty("java.home"));
|
||||
}
|
||||
log.info("Using JAVA_HOME: {}", javaHome);
|
||||
jdepsPath = javaHome.resolve("bin").resolve(SystemUtilities.isWindows() ? "jdeps.exe" : "jdeps").normalize();
|
||||
jlinkPath = javaHome.resolve("bin").resolve(SystemUtilities.isWindows() ? "jlink.exe" : "jlink").normalize();
|
||||
log.info("Assuming jdeps path: {}", jdepsPath);
|
||||
log.info("Assuming jlink path: {}", jlinkPath);
|
||||
jdepsPath.toFile().setExecutable(true, false);
|
||||
jlinkPath.toFile().setExecutable(true, false);
|
||||
jdepsVersion = SystemUtilities.getJavaVersion(jdepsPath);
|
||||
return this;
|
||||
}
|
||||
|
||||
private JLink calculateDepList() throws IOException {
|
||||
log.info("Calling jdeps to determine runtime dependencies");
|
||||
depList = new LinkedHashSet<>();
|
||||
|
||||
// JDK11.0.11+requires suppressing of missing deps
|
||||
String raw = jdepsVersion.compareTo(Version.valueOf("11.0.10")) > 0 ?
|
||||
ShellUtilities.executeRaw(jdepsPath.toString(), "--multi-release", "9", "--list-deps", "--ignore-missing-deps", jarPath.toString()) :
|
||||
ShellUtilities.executeRaw(jdepsPath.toString(), "--multi-release", "9", "--list-deps", jarPath.toString());
|
||||
if (raw == null || raw.trim().isEmpty() || raw.trim().startsWith("Warning") ) {
|
||||
throw new IOException("An unexpected error occurred calling jdeps. Please check the logs for details.\n" + raw);
|
||||
}
|
||||
for(String item : raw.split("\\r?\\n")) {
|
||||
item = item.trim();
|
||||
if(!item.isEmpty()) {
|
||||
if(item.startsWith("JDK") || item.startsWith("jdk8internals")) {
|
||||
// Remove e.g. "JDK removed internal API/sun.reflect"
|
||||
log.trace("Removing dependency: '{}'", item);
|
||||
continue;
|
||||
}
|
||||
if(item.contains("/")) {
|
||||
// Isolate base name e.g. "java.base/com.sun.net.ssl"
|
||||
item = item.split("/")[0];
|
||||
}
|
||||
depList.add(item);
|
||||
}
|
||||
}
|
||||
switch(targetPlatform) {
|
||||
case WINDOWS:
|
||||
// Java accessibility bridge dependency, see https://github.com/qzind/tray/issues/1234
|
||||
depList.add("jdk.accessibility");
|
||||
default:
|
||||
// Adds "bin/jcmd"
|
||||
depList.add("jdk.jcmd");
|
||||
// "jar:" URLs create transient zipfs dependency, see https://stackoverflow.com/a/57846672/3196753
|
||||
depList.add("jdk.zipfs");
|
||||
// fix for https://github.com/qzind/tray/issues/894 solution from https://github.com/adoptium/adoptium-support/issues/397
|
||||
depList.add("jdk.crypto.ec");
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
private JLink deployJre() throws IOException {
|
||||
if(targetPlatform == Platform.MAC) {
|
||||
// Deploy Contents/MacOS/libjli.dylib
|
||||
Path macOS = Files.createDirectories(outPath.resolve("../MacOS").normalize());
|
||||
Path jliLib = macOS.resolve("libjli.dylib");
|
||||
log.info("Deploying {}", macOS);
|
||||
try {
|
||||
// Not all jdks use a bundle format, but try this first
|
||||
Files.copy(jmodsPath.resolve("../../MacOS/libjli.dylib").normalize(), jliLib, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch(IOException ignore) {
|
||||
// Fallback to flat format
|
||||
String libjli = "../lib/jli/libjli.dylib";
|
||||
if(javaSemver.getMajorVersion() >= 21) {
|
||||
libjli = "../lib/libjli.dylib";
|
||||
}
|
||||
Files.copy(jmodsPath.resolve(libjli).normalize(), jliLib, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
// Deploy Contents/Info.plist
|
||||
HashMap<String, String> fieldMap = new HashMap<>();
|
||||
fieldMap.put("%BUNDLE_ID%", MacUtilities.getBundleId() + ".jre"); // e.g. io.qz.qz-tray.jre
|
||||
fieldMap.put("%BUNDLE_VERSION%", String.format("%s.%s.%s", javaSemver.getMajorVersion(), javaSemver.getMinorVersion(), javaSemver.getPatchVersion()));
|
||||
fieldMap.put("%BUNDLE_VERSION_FULL%", javaSemver.toString());
|
||||
fieldMap.put("%BUNDLE_VENDOR%", javaVendor.getVendorName());
|
||||
fieldMap.put("%BUNDLE_PRODUCT%", javaVendor.getProductName());
|
||||
log.info("Deploying {}/Info.plist", macOS.getParent());
|
||||
FileUtilities.configureAssetFile("assets/mac-runtime.plist.in", macOS.getParent().resolve("Info.plist"), fieldMap, JLink.class);
|
||||
}
|
||||
|
||||
FileUtils.deleteQuietly(outPath.toFile());
|
||||
|
||||
if(ShellUtilities.execute(jlinkPath.toString(),
|
||||
"--strip-debug",
|
||||
"--compress=2",
|
||||
"--no-header-files",
|
||||
"--no-man-pages",
|
||||
"--exclude-files=glob:**/legal/**",
|
||||
"--module-path", jmodsPath.toString(),
|
||||
"--add-modules", String.join(",", depList),
|
||||
"--output", outPath.toString())) {
|
||||
log.info("Successfully deployed a jre to {}", outPath);
|
||||
|
||||
// Remove all but java/javaw
|
||||
List<String> keepFiles = new ArrayList<>();
|
||||
//String[] keepFiles;
|
||||
String keepExt;
|
||||
switch(targetPlatform) {
|
||||
case WINDOWS:
|
||||
keepFiles.add("java.exe");
|
||||
keepFiles.add("javaw.exe");
|
||||
keepFiles.add("jcmd.exe");
|
||||
if(depList.contains("jdk.accessibility")) {
|
||||
// Java accessibility bridge switching tool
|
||||
keepFiles.add("jabswitch.exe");
|
||||
}
|
||||
// Windows stores ".dll" files in bin
|
||||
keepExt = ".dll";
|
||||
break;
|
||||
default:
|
||||
keepFiles.add("java");
|
||||
keepFiles.add("jcmd");
|
||||
keepExt = null;
|
||||
}
|
||||
|
||||
Files.list(outPath.resolve("bin")).forEach(binFile -> {
|
||||
if(Files.isDirectory(binFile) || (keepExt != null && binFile.toString().endsWith(keepExt))) {
|
||||
log.info("Keeping {}", binFile);
|
||||
return; // iterate forEach
|
||||
}
|
||||
for(String name : keepFiles) {
|
||||
if (binFile.endsWith(name)) {
|
||||
log.info("Keeping {}", binFile);
|
||||
return; // iterate forEach
|
||||
}
|
||||
}
|
||||
log.info("Deleting {}", binFile);
|
||||
binFile.toFile().delete();
|
||||
});
|
||||
|
||||
return this;
|
||||
|
||||
}
|
||||
throw new IOException("An error occurred deploying the jre. Please check the logs for details.");
|
||||
}
|
||||
|
||||
public static String getParam(String paramName, String value, String fallback) {
|
||||
if(value != null && !value.isEmpty() && !value.trim().isEmpty()) {
|
||||
return value;
|
||||
}
|
||||
log.info("No {} specified, assuming '{}'", paramName, fallback);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
45
tray/src/qz/build/assets/mac-runtime.plist.in
Normal file
45
tray/src/qz/build/assets/mac-runtime.plist.in
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>English</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>libjli.dylib</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>%BUNDLE_VENDOR% %BUNDLE_PRODUCT% %BUNDLE_VERSION_FULL%</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>%BUNDLE_ID%</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>7.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Java Runtime Image</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>%BUNDLE_VERSION%</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>%BUNDLE_VERSION%</string>
|
||||
<key>JavaVM</key>
|
||||
<dict>
|
||||
<key>JVMCapabilities</key>
|
||||
<array>
|
||||
<string>CommandLine</string>
|
||||
<string>JNI</string>
|
||||
<string>BundledApp</string>
|
||||
</array>
|
||||
<key>JVMMinimumFrameworkVersion</key>
|
||||
<string>17.0.0</string>
|
||||
<key>JVMMinimumSystemVersion</key>
|
||||
<string>10.6.0</string>
|
||||
<key>JVMPlatformVersion</key>
|
||||
<string>%BUNDLE_VERSION_FULL%</string>
|
||||
<key>JVMVendor</key>
|
||||
<string>%BUNDLE_VENDOR%</string>
|
||||
<key>JVMVersion</key>
|
||||
<string>%BUNDLE_VERSION%</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
0
tray/src/qz/build/jlink/Arch.java
Normal file
0
tray/src/qz/build/jlink/Arch.java
Normal file
68
tray/src/qz/build/jlink/Parsable.java
Normal file
68
tray/src/qz/build/jlink/Parsable.java
Normal file
@@ -0,0 +1,68 @@
|
||||
package qz.build.jlink;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* A special template class for handling enums with varargs needing string matches.
|
||||
*
|
||||
* Parsable enums must declare <code>public static void String[] matches;</code>
|
||||
* in the constructor, which <code>parse(Class enumType, </T>String value) will
|
||||
* call using reflection.
|
||||
*
|
||||
* Enums are inherently static in Java and cannot extend superclasses. The
|
||||
* workaround to avoid code duplication is to leverage reflection and generics in
|
||||
* static utility functions.
|
||||
*
|
||||
* The downsides of this are:
|
||||
* - Reflection is slow
|
||||
* - Static helpers must be explicitly class-type-aware*
|
||||
*
|
||||
* *Non-static methods may be implicit, but create anti-patterns for static helpers
|
||||
* such as <code>parse(String value)</code> as they would exist at
|
||||
* <code>ENUM_ENTRY.parse(...)</code> rather than <code>EnumClass.parse(...)</code>.
|
||||
*/
|
||||
public interface Parsable<T extends Enum> {
|
||||
Logger log = LogManager.getLogger(Parsable.class);
|
||||
|
||||
static <T extends Enum<T>> T parse(Class<T> enumType, String value) {
|
||||
if(value != null && !value.trim().isEmpty()) {
|
||||
for(T parsable : enumType.getEnumConstants()) {
|
||||
try {
|
||||
Field matchesField = parsable.getClass().getDeclaredField("matches");
|
||||
String[] matches = (String[])matchesField.get(parsable);
|
||||
for(String match : matches) {
|
||||
if (match.equalsIgnoreCase(value)) {
|
||||
return parsable;
|
||||
}
|
||||
}
|
||||
} catch(NoSuchFieldException | IllegalAccessException | ClassCastException e) {
|
||||
log.warn("Parsable enums must have a 'public String[] matches' field", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
log.warn("Could not parse {} as a valid {} value", value, enumType.getSimpleName());
|
||||
return null;
|
||||
}
|
||||
|
||||
static <T extends Enum<T>> T parse(Class<T> enumType, String value, T fallback, boolean silent) {
|
||||
if(value != null && !value.trim().isEmpty()) {
|
||||
return parse(enumType, value);
|
||||
}
|
||||
if(!silent) {
|
||||
log.warn("No {} specified, assuming '{}'", enumType.getSimpleName(), ((Parsable)fallback).value());
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
static <T extends Enum<T>> T parse(Class<T> enumType, String value, T fallback) {
|
||||
return parse(enumType, value, fallback, false);
|
||||
}
|
||||
|
||||
default String value() {
|
||||
return ((T)this).toString().toLowerCase(Locale.ENGLISH);
|
||||
}
|
||||
}
|
||||
37
tray/src/qz/build/jlink/Platform.java
Normal file
37
tray/src/qz/build/jlink/Platform.java
Normal file
@@ -0,0 +1,37 @@
|
||||
package qz.build.jlink;
|
||||
|
||||
|
||||
import qz.utils.SystemUtilities;
|
||||
|
||||
/**
|
||||
* Handling of platform names as they would appear in a URL
|
||||
* Values added must also be added to <code>ArgValue.JLINK --platform</code> values
|
||||
*/
|
||||
public enum Platform implements Parsable {
|
||||
MAC("mac"),
|
||||
WINDOWS("windows"),
|
||||
LINUX("linux");
|
||||
|
||||
public final String[] matches;
|
||||
Platform(String ... matches) { this.matches = matches; }
|
||||
|
||||
public static Platform parse(String value, Platform fallback) {
|
||||
return Parsable.parse(Platform.class, value, fallback);
|
||||
}
|
||||
|
||||
public static Platform parse(String value) {
|
||||
return Parsable.parse(Platform.class, value);
|
||||
}
|
||||
|
||||
public static Platform getCurrentPlatform() {
|
||||
switch(SystemUtilities.getOs()) {
|
||||
case MAC:
|
||||
return Platform.MAC;
|
||||
case WINDOWS:
|
||||
return Platform.WINDOWS;
|
||||
case LINUX:
|
||||
default:
|
||||
return Platform.LINUX;
|
||||
}
|
||||
}
|
||||
}
|
||||
72
tray/src/qz/build/jlink/Url.java
Normal file
72
tray/src/qz/build/jlink/Url.java
Normal file
@@ -0,0 +1,72 @@
|
||||
package qz.build.jlink;
|
||||
|
||||
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;
|
||||
import java.util.HashMap;
|
||||
|
||||
import static qz.build.jlink.Vendor.*;
|
||||
|
||||
/**
|
||||
* Each JDK provider uses their own url format
|
||||
*/
|
||||
public class Url {
|
||||
static HashMap<Vendor, String> VENDOR_URL_MAP = new HashMap<>();
|
||||
static {
|
||||
VENDOR_URL_MAP.put(BELLSOFT, "https://download.bell-sw.com/java/%s/bellsoft-jdk%s-%s-%s.%s");
|
||||
VENDOR_URL_MAP.put(ECLIPSE, "https://github.com/adoptium/temurin%s-binaries/releases/download/jdk-%s/OpenJDK%sU-jdk_%s_%s_%s_%s.%s");
|
||||
VENDOR_URL_MAP.put(IBM, "https://github.com/ibmruntimes/semeru%s-binaries/releases/download/jdk-%s_%s-%s/ibm-semeru-open-jdk_%s_%s_%s_%s-%s.%s");
|
||||
VENDOR_URL_MAP.put(MICROSOFT, "https://aka.ms/download-jdk/microsoft-jdk-%s-%s-%s.%s");
|
||||
VENDOR_URL_MAP.put(AMAZON, "https://corretto.aws/downloads/resources/%s/amazon-corretto-%s-%s-%s.%s");
|
||||
VENDOR_URL_MAP.put(AZUL, "https://cdn.azul.com/zulu%s/bin/zulu%s-ca-jdk%s-%s_%s.%s");
|
||||
}
|
||||
|
||||
private static final Logger log = LogManager.getLogger(Url.class);
|
||||
|
||||
Vendor vendor;
|
||||
String pattern;
|
||||
public Url(Vendor vendor) {
|
||||
this.vendor = vendor;
|
||||
if(!VENDOR_URL_MAP.containsKey(vendor)) {
|
||||
throw new UnsupportedOperationException(String.format("Vendor provided '%s' couldn't be matched to a URL pattern, aborting.", vendor));
|
||||
}
|
||||
pattern = VENDOR_URL_MAP.get(vendor);
|
||||
}
|
||||
|
||||
public String format(Arch arch, Platform platform, String gcEngine, Version javaSemver, String javaVersion, String gcVer) throws UnsupportedEncodingException {
|
||||
Url pattern = new Url(vendor);
|
||||
String urlArch = vendor.getUrlArch(arch);
|
||||
String fileExt = vendor.getUrlExtension(platform);
|
||||
String urlPlatform = vendor.getUrlPlatform(platform);
|
||||
String urlJavaVersion = vendor.getUrlJavaVersion(javaSemver);
|
||||
|
||||
// Convert "+" to "%2B"
|
||||
String urlJavaVersionEncode = URLEncoder.encode(javaVersion, "UTF-8");
|
||||
|
||||
int javaMajor = javaSemver.getMajorVersion();
|
||||
|
||||
switch(vendor) {
|
||||
case BELLSOFT:
|
||||
return String.format(pattern.pattern, urlJavaVersionEncode, urlJavaVersionEncode, urlPlatform, urlArch, fileExt);
|
||||
case ECLIPSE:
|
||||
return String.format(pattern.pattern, javaMajor, urlJavaVersionEncode, javaMajor, urlArch, urlPlatform, gcEngine, urlJavaVersion, fileExt);
|
||||
case IBM:
|
||||
return String.format(pattern.pattern, javaMajor, urlJavaVersionEncode, gcEngine, gcVer, urlArch, urlPlatform, urlJavaVersion, gcEngine, gcVer, fileExt);
|
||||
case MICROSOFT:
|
||||
return String.format(pattern.pattern, urlJavaVersion, urlPlatform, urlArch, fileExt);
|
||||
case AMAZON:
|
||||
return String.format(pattern.pattern, urlJavaVersion, urlJavaVersion, urlPlatform, urlArch, fileExt);
|
||||
case AZUL:
|
||||
// Special handling of Linux aarch64
|
||||
String embedded = platform == Platform.LINUX ? "-embedded" : "";
|
||||
return String.format(pattern.pattern, embedded, gcVer, urlJavaVersion, urlPlatform, urlArch, fileExt);
|
||||
default:
|
||||
throw new UnsupportedOperationException(String.format("URL pattern for '%s' (%s) is missing a format implementation.", vendor, pattern));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
155
tray/src/qz/build/jlink/Vendor.java
Normal file
155
tray/src/qz/build/jlink/Vendor.java
Normal file
@@ -0,0 +1,155 @@
|
||||
package qz.build.jlink;
|
||||
|
||||
import com.github.zafarkhaja.semver.Version;
|
||||
import qz.build.provision.params.Arch;
|
||||
|
||||
/**
|
||||
* Handling of java vendors
|
||||
*/
|
||||
public enum Vendor implements Parsable {
|
||||
ECLIPSE("Eclipse", "Adoptium", "adoptium", "temurin", "adoptopenjdk"),
|
||||
BELLSOFT("BellSoft", "Liberica", "bellsoft", "liberica"),
|
||||
IBM("IBM", "Semeru", "ibm", "semeru"),
|
||||
MICROSOFT("Microsoft", "OpenJDK", "microsoft"),
|
||||
AMAZON("Amazon", "Corretto", "amazon", "corretto"),
|
||||
AZUL("Azul", "Zulu", "azul", "zulu");
|
||||
|
||||
public String vendorName;
|
||||
public String productName;
|
||||
public final String[] matches;
|
||||
Vendor(String vendorName, String productName, String ... matches) {
|
||||
this.matches = matches;
|
||||
this.vendorName = vendorName;
|
||||
this.productName = productName;
|
||||
}
|
||||
|
||||
public static Vendor parse(String value, Vendor fallback) {
|
||||
return Parsable.parse(Vendor.class, value, fallback, true);
|
||||
}
|
||||
|
||||
public static Vendor parse(String value) {
|
||||
return Parsable.parse(Vendor.class, value);
|
||||
}
|
||||
|
||||
public String getVendorName() {
|
||||
return vendorName;
|
||||
}
|
||||
|
||||
public String getProductName() {
|
||||
return productName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Vendor to Arch value
|
||||
*/
|
||||
public String getUrlArch(Arch arch) {
|
||||
switch(arch) {
|
||||
case AARCH64:
|
||||
// All vendors seem to use "aarch64" universally
|
||||
return "aarch64";
|
||||
case ARM32:
|
||||
switch(this) {
|
||||
case BELLSOFT:
|
||||
return "arm32-vfp-hflt";
|
||||
case AZUL:
|
||||
return "aarch32hf";
|
||||
case MICROSOFT:
|
||||
case IBM:
|
||||
throw new UnsupportedOperationException("Vendor does not provide builds for this architecture");
|
||||
case AMAZON:
|
||||
case ECLIPSE:
|
||||
default:
|
||||
return "arm";
|
||||
}
|
||||
case RISCV64:
|
||||
return "riscv64";
|
||||
case X86:
|
||||
switch(this) {
|
||||
case AZUL:
|
||||
return "i686";
|
||||
case BELLSOFT:
|
||||
return "i586";
|
||||
case ECLIPSE:
|
||||
case IBM:
|
||||
return "x86-32";
|
||||
case AMAZON:
|
||||
default:
|
||||
return "x86";
|
||||
}
|
||||
case X86_64:
|
||||
default:
|
||||
switch(this) {
|
||||
// BellSoft uses "amd64"
|
||||
case BELLSOFT:
|
||||
return "amd64";
|
||||
}
|
||||
return "x64";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Vendor to Platform name
|
||||
*/
|
||||
public String getUrlPlatform(Platform platform) {
|
||||
switch(platform) {
|
||||
case MAC:
|
||||
switch(this) {
|
||||
case BELLSOFT:
|
||||
return "macos";
|
||||
case MICROSOFT:
|
||||
return "macOS";
|
||||
case AMAZON:
|
||||
case AZUL:
|
||||
return "macosx";
|
||||
}
|
||||
default:
|
||||
return platform.value();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Vendor and Platform to file extension
|
||||
*/
|
||||
public String getUrlExtension(Platform platform) {
|
||||
switch(this) {
|
||||
case BELLSOFT:
|
||||
switch(platform) {
|
||||
case LINUX:
|
||||
return "tar.gz";
|
||||
default:
|
||||
// BellSoft uses "zip" for mac and windows platforms
|
||||
return "zip";
|
||||
}
|
||||
default:
|
||||
switch(platform) {
|
||||
case WINDOWS:
|
||||
return "zip";
|
||||
default:
|
||||
return "tar.gz";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String getUrlJavaVersion(Version javaSemver) {
|
||||
switch(this) {
|
||||
case MICROSOFT:
|
||||
case AZUL:
|
||||
// Return shorted version (Microsoft, Azul suppresses the build information from URLs)
|
||||
return javaSemver.toString().split("\\+")[0];
|
||||
case AMAZON:
|
||||
// Return lengthened version (Corretto formats major.minor.patch.build.number, e.g. 11.0.17.8.1)
|
||||
String[] parts = javaSemver.toString().split("\\+");
|
||||
String javaVersion = parts[0];
|
||||
//
|
||||
String buildAndNumber = parts[1];
|
||||
if(!buildAndNumber.contains(".")) {
|
||||
// Append ".1" if ".number" is missing
|
||||
buildAndNumber += ".1";
|
||||
}
|
||||
return String.format("%s.%s", javaVersion, buildAndNumber);
|
||||
}
|
||||
// All others seem to prefer "+" replaced with "_"
|
||||
return javaSemver.toString().replaceAll("\\+", "_");
|
||||
}
|
||||
}
|
||||
|
||||
328
tray/src/qz/build/provision/ProvisionBuilder.java
Normal file
328
tray/src/qz/build/provision/ProvisionBuilder.java
Normal file
@@ -0,0 +1,328 @@
|
||||
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.Phase;
|
||||
import qz.build.provision.params.Type;
|
||||
import qz.common.Constants;
|
||||
import qz.installer.provision.invoker.PropertyInvoker;
|
||||
import qz.utils.ArgValue;
|
||||
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;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
|
||||
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 <code>provision</code> 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;
|
||||
}
|
||||
|
||||
// Inject any special inferences (such as inferring resources from args)
|
||||
inferAdditionalSteps(step);
|
||||
|
||||
if(copyResource(step)) {
|
||||
log.info("[SUCCESS] Step successfully processed '{}'", step);
|
||||
jsonSteps.put(step.toJSON());
|
||||
// Special case for custom websocket ports
|
||||
if(step.getType() == Type.PROPERTY && step.getPhase() == Phase.CERTGEN) {
|
||||
HashMap<String, String> pairs = PropertyInvoker.parsePropertyPairs(step);
|
||||
if(pairs.get(ArgValue.WEBSOCKET_SECURE_PORTS.getMatch()) != null ||
|
||||
pairs.get(ArgValue.WEBSOCKET_INSECURE_PORTS.getMatch()) != null) {
|
||||
// Clone to install step
|
||||
jsonSteps.put(step.cloneTo(Phase.INSTALL).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 CA:
|
||||
case CERT:
|
||||
case SCRIPT:
|
||||
case RESOURCE:
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first index of the specified arg prefix pattern(s)
|
||||
*
|
||||
* e.g. if pattern is "/f1", it will return 1 from args { "/s", "/f1C:\foo" }
|
||||
*/
|
||||
private int argPrefixIndex(Step step, String ... prefixes) {
|
||||
for(int i = 0; i < step.args.size() ; i++){
|
||||
for(String prefix : prefixes) {
|
||||
if (step.args.get(i).toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the "value" of the specified arg prefix pattern(s)
|
||||
*
|
||||
* e.g. if pattern is "/f1", it will return "C:\foo" from args { "/s", "/f1C:\foo" }
|
||||
*
|
||||
*/
|
||||
private String argPrefixValue(Step step, int index, String ... prefixes) {
|
||||
String arg = step.args.get(index);
|
||||
String value = null;
|
||||
for(String prefix : prefixes) {
|
||||
if (arg.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
value = arg.substring(prefix.length());
|
||||
if((value.startsWith("\"") && value.endsWith("\"")) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
// Remove surrounding quotes
|
||||
value = value.substring(1, value.length() - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones the provided step into a new step that performs a prerequisite task.
|
||||
*
|
||||
* This is "magic" in the sense that it's highly specific to <code>Type</code>
|
||||
* <code>Os</code> and <code>Step.args</code>.
|
||||
*
|
||||
* For example:
|
||||
*
|
||||
* Older InstallShield installers supported the <code>/f1</code> parameter which
|
||||
* implies an answer file of which we need to bundle for a successful deployment.
|
||||
*/
|
||||
private void inferAdditionalSteps(Step orig) throws JSONException, IOException {
|
||||
// Infer resource step for InstallShield .iss answer files
|
||||
if(orig.getType() == Type.SOFTWARE && Os.WINDOWS.matches(orig.getOs())) {
|
||||
String[] patterns = { "/f1", "-f1" };
|
||||
int index = argPrefixIndex(orig, patterns);
|
||||
if(index > 0) {
|
||||
String resource = argPrefixValue(orig, index, patterns);
|
||||
if(resource != null) {
|
||||
// Clone to copy the Phase, Os and Description
|
||||
Step step = orig.clone();
|
||||
|
||||
// Swap Type, clear args and update the data
|
||||
step.setType(Type.RESOURCE);
|
||||
step.setArgs(new ArrayList<>());
|
||||
step.setData(resource);
|
||||
|
||||
if(copyResource(step)) {
|
||||
File resourceFile = new File(resource);
|
||||
jsonSteps.put(step.toJSON());
|
||||
orig.getArgs().set(index, String.format("/f1\"%s\"", resourceFile.getName()));
|
||||
log.info("[SUCCESS] Step successfully inferred and appended '{}'", step);
|
||||
} else {
|
||||
log.error("[SKIPPED] Resources could not be saved '{}'", step);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
337
tray/src/qz/build/provision/Step.java
Normal file
337
tray/src/qz/build/provision/Step.java
Normal file
@@ -0,0 +1,337 @@
|
||||
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<String> args; // Type.SCRIPT or Type.INSTALLER or Type.CONF only
|
||||
HashSet<Os> os;
|
||||
HashSet<Arch> arch;
|
||||
Phase phase;
|
||||
String data;
|
||||
|
||||
Path relativePath;
|
||||
Class relativeClass;
|
||||
|
||||
public Step(Path relativePath, String description, Type type, HashSet<Os> os, HashSet<Arch> arch, Phase phase, String data, List<String> 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> os, HashSet<Arch> arch, Phase phase, String data, List<String> 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<String> getArgs() {
|
||||
return args;
|
||||
}
|
||||
|
||||
public void setArgs(List<String> args) {
|
||||
this.args = args;
|
||||
}
|
||||
|
||||
public HashSet<Os> getOs() {
|
||||
return os;
|
||||
}
|
||||
|
||||
public void setOs(HashSet<Os> os) {
|
||||
this.os = os;
|
||||
}
|
||||
|
||||
public HashSet<Arch> getArch() {
|
||||
return arch;
|
||||
}
|
||||
|
||||
public void setArch(HashSet<Arch> 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<String> args = new LinkedList<>();
|
||||
if(type == Type.SOFTWARE || type == Type.CONF) {
|
||||
// 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());
|
||||
} else {
|
||||
// stop searching if the next incremental arg (e.g. "arg2") isn't found
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mandate "args" as the CONF path
|
||||
if(type == Type.CONF) {
|
||||
// Honor "path" first, if provided
|
||||
String path = jsonStep.optString("path", "");
|
||||
if(!path.isEmpty()) {
|
||||
args.add(0, path);
|
||||
}
|
||||
|
||||
// Keep only the first value
|
||||
if(args.size() > 0) {
|
||||
args = args.subList(0, 1);
|
||||
} else {
|
||||
throw formatted("Conf path value cannot be blank.");
|
||||
}
|
||||
}
|
||||
|
||||
HashSet<Os> 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> 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.CA, Phase.CERTGEN)
|
||||
.enforcePhase(Type.CERT, Phase.STARTUP)
|
||||
.enforcePhase(Type.CONF, Phase.CERTGEN)
|
||||
.enforcePhase(Type.SOFTWARE, Phase.INSTALL)
|
||||
.enforcePhase(Type.REMOVER, Phase.INSTALL)
|
||||
.enforcePhase(Type.PROPERTY, Phase.CERTGEN, Phase.INSTALL)
|
||||
.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 ... requiredPhases) {
|
||||
if(requiredPhases.length == 0) {
|
||||
throw new UnsupportedOperationException("At least one Phase must be specified");
|
||||
}
|
||||
if(type == matchType) {
|
||||
for(Phase requiredPhase : requiredPhases) {
|
||||
if (phase == null) {
|
||||
phase = requiredPhase;
|
||||
log.debug("Phase is null, defaulting to '{}' based on Type '{}'", phase, type);
|
||||
return this;
|
||||
} else if (phase == requiredPhase) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
log.debug("Phase '{}' is unsupported for Type '{}', defaulting to '{}'", phase, type, phase = requiredPhases[0]);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
private static UnsupportedOperationException formatted(String message, Object ... args) {
|
||||
String formatted = String.format(message, args);
|
||||
return new UnsupportedOperationException(formatted);
|
||||
}
|
||||
Step cloneTo(Phase phase) {
|
||||
return relativePath != null ?
|
||||
new Step(relativePath, description, type, os, arch, phase, data, args) :
|
||||
new Step(relativeClass, description, type, os, arch, phase, data, args);
|
||||
}
|
||||
|
||||
public Step clone() {
|
||||
return cloneTo(this.phase);
|
||||
}
|
||||
}
|
||||
70
tray/src/qz/build/provision/params/Arch.java
Normal file
70
tray/src/qz/build/provision/params/Arch.java
Normal file
@@ -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<String> 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<Arch> 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<Arch> archList) {
|
||||
return this == ALL || archList.contains(ALL) || (this != UNKNOWN && archList.contains(this));
|
||||
}
|
||||
|
||||
public static String serialize(HashSet<Arch> archList) {
|
||||
if(archList.contains(ALL)) {
|
||||
return "*";
|
||||
}
|
||||
return StringUtils.join(archList, "|");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return super.toString().toLowerCase(Locale.ENGLISH);
|
||||
}
|
||||
}
|
||||
64
tray/src/qz/build/provision/params/EnumParser.java
Normal file
64
tray/src/qz/build/provision/params/EnumParser.java
Normal file
@@ -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 extends Enum<T>> T parse(Class<T> clazz, String s) {
|
||||
return parse(clazz, s, null);
|
||||
}
|
||||
|
||||
static <T extends Enum<T>> T parse(Class<T> 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 extends Enum<T>> T parseStrict(Class<T> clazz, String s, T ... blocklist) throws UnsupportedOperationException {
|
||||
if(s != null) {
|
||||
HashSet<T> 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 <T extends Enum<T>> HashSet<T> parseSet(Class<T> clazz, T all, String s) {
|
||||
HashSet<T> 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;
|
||||
}
|
||||
}
|
||||
67
tray/src/qz/build/provision/params/Os.java
Normal file
67
tray/src/qz/build/provision/params/Os.java
Normal file
@@ -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<Os> osList) {
|
||||
return this == ALL || osList.contains(ALL) || (this != UNKNOWN && osList.contains(this));
|
||||
}
|
||||
|
||||
public static boolean matchesHost(HashSet<Os> 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<Os> parse(String input) {
|
||||
return EnumParser.parseSet(Os.class, Os.ALL, input);
|
||||
}
|
||||
|
||||
public static String serialize(HashSet<Os> osList) {
|
||||
if(osList.contains(ALL)) {
|
||||
return "*";
|
||||
}
|
||||
return StringUtils.join(osList, "|");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return super.toString().toLowerCase(Locale.ENGLISH);
|
||||
}
|
||||
}
|
||||
19
tray/src/qz/build/provision/params/Phase.java
Normal file
19
tray/src/qz/build/provision/params/Phase.java
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
24
tray/src/qz/build/provision/params/Type.java
Normal file
24
tray/src/qz/build/provision/params/Type.java
Normal file
@@ -0,0 +1,24 @@
|
||||
package qz.build.provision.params;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public enum Type {
|
||||
SCRIPT,
|
||||
SOFTWARE,
|
||||
RESOURCE,
|
||||
REMOVER, // QZ Tray remover
|
||||
CA,
|
||||
CERT,
|
||||
CONF,
|
||||
PROPERTY,
|
||||
PREFERENCE;
|
||||
|
||||
public static Type parse(String input) {
|
||||
return EnumParser.parse(Type.class, input);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return super.toString().toLowerCase(Locale.ENGLISH);
|
||||
}
|
||||
}
|
||||
67
tray/src/qz/build/provision/params/types/Remover.java
Normal file
67
tray/src/qz/build/provision/params/types/Remover.java
Normal file
@@ -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<Remover> 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);
|
||||
}
|
||||
}
|
||||
31
tray/src/qz/build/provision/params/types/Script.java
Normal file
31
tray/src/qz/build/provision/params/types/Script.java
Normal file
@@ -0,0 +1,31 @@
|
||||
package qz.build.provision.params.types;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
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 && !input.isEmpty()) {
|
||||
return EnumParser.parse(Script.class, FilenameUtils.getExtension(input), SH);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Script parse(Path path) {
|
||||
return parse(path.toAbsolutePath().toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return super.toString().toLowerCase(Locale.ENGLISH);
|
||||
}
|
||||
}
|
||||
63
tray/src/qz/build/provision/params/types/Software.java
Normal file
63
tray/src/qz/build/provision/params/types/Software.java
Normal file
@@ -0,0 +1,63 @@
|
||||
package qz.build.provision.params.types;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
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, FilenameUtils.getExtension(input), UNKNOWN);
|
||||
}
|
||||
|
||||
public static Software parse(Path path) {
|
||||
return parse(path.toString());
|
||||
}
|
||||
|
||||
public static List<String> parseArgs(String input) {
|
||||
List<String> 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<Os> defaultOs() {
|
||||
HashSet<Os> 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);
|
||||
}
|
||||
}
|
||||
205
tray/src/qz/common/AboutInfo.java
Normal file
205
tray/src/qz/common/AboutInfo.java
Normal file
@@ -0,0 +1,205 @@
|
||||
package qz.common;
|
||||
|
||||
import com.github.zafarkhaja.semver.Version;
|
||||
import org.apache.commons.lang3.time.DurationFormatUtils;
|
||||
import org.apache.commons.ssl.Base64;
|
||||
import org.bouncycastle.asn1.ASN1Primitive;
|
||||
import org.bouncycastle.asn1.x509.BasicConstraints;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.x509.extension.X509ExtensionUtil;
|
||||
import org.codehaus.jettison.json.JSONArray;
|
||||
import org.codehaus.jettison.json.JSONException;
|
||||
import org.codehaus.jettison.json.JSONObject;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.installer.certificate.KeyPairWrapper;
|
||||
import qz.installer.certificate.CertificateManager;
|
||||
import qz.utils.MacUtilities;
|
||||
import qz.utils.StringUtilities;
|
||||
import qz.utils.SystemUtilities;
|
||||
import qz.ws.PrintSocketServer;
|
||||
import qz.ws.WebsocketPorts;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.*;
|
||||
|
||||
public class AboutInfo {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(AboutInfo.class);
|
||||
|
||||
private static String preferredHostname = "localhost";
|
||||
|
||||
public static JSONObject gatherAbout(String domain, CertificateManager certificateManager) {
|
||||
JSONObject about = new JSONObject();
|
||||
|
||||
try {
|
||||
about.put("product", product());
|
||||
about.put("socket", socket(certificateManager, domain));
|
||||
about.put("environment", environment());
|
||||
about.put("ssl", ssl(certificateManager));
|
||||
about.put("libraries", libraries());
|
||||
about.put("charsets", charsets());
|
||||
}
|
||||
catch(JSONException | GeneralSecurityException e) {
|
||||
log.error("Failed to write JSON data", e);
|
||||
}
|
||||
|
||||
return about;
|
||||
}
|
||||
|
||||
private static JSONObject product() throws JSONException {
|
||||
JSONObject product = new JSONObject();
|
||||
|
||||
product
|
||||
.put("title", Constants.ABOUT_TITLE)
|
||||
.put("version", Constants.VERSION)
|
||||
.put("vendor", Constants.ABOUT_COMPANY)
|
||||
.put("url", Constants.ABOUT_URL);
|
||||
|
||||
return product;
|
||||
}
|
||||
|
||||
private static JSONObject socket(CertificateManager certificateManager, String domain) throws JSONException {
|
||||
JSONObject socket = new JSONObject();
|
||||
String sanitizeDomain = StringUtilities.escapeHtmlEntities(domain);
|
||||
WebsocketPorts websocketPorts = PrintSocketServer.getWebsocketPorts();
|
||||
|
||||
// Gracefully handle XSS per https://github.com/qzind/tray/issues/1099
|
||||
if(sanitizeDomain.contains("<") || sanitizeDomain.contains(">")) {
|
||||
log.warn("Something smells fishy about this domain: \"{}\", skipping", domain);
|
||||
sanitizeDomain = "unknown";
|
||||
}
|
||||
|
||||
socket
|
||||
.put("domain", sanitizeDomain)
|
||||
.put("secureProtocol", "wss")
|
||||
.put("securePort", certificateManager.isSslActive() ? websocketPorts.getSecurePort() : "none")
|
||||
.put("insecureProtocol", "ws")
|
||||
.put("insecurePort", websocketPorts.getInsecurePort());
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
private static JSONObject environment() throws JSONException {
|
||||
JSONObject environment = new JSONObject();
|
||||
|
||||
long uptime = ManagementFactory.getRuntimeMXBean().getUptime();
|
||||
|
||||
environment
|
||||
.put("os", SystemUtilities.getOsDisplayName())
|
||||
.put("os version", SystemUtilities.getOsDisplayVersion())
|
||||
.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))
|
||||
.put("uptimeMillis", uptime)
|
||||
.put("sandbox", SystemUtilities.isMac() && MacUtilities.isSandboxed());
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
private static JSONObject ssl(CertificateManager certificateManager) throws JSONException, CertificateEncodingException {
|
||||
JSONObject ssl = new JSONObject();
|
||||
|
||||
JSONArray certs = new JSONArray();
|
||||
|
||||
for (KeyPairWrapper keyPair : new KeyPairWrapper[]{certificateManager.getCaKeyPair(), certificateManager.getSslKeyPair() }) {
|
||||
X509Certificate x509 = keyPair.getCert();
|
||||
if (x509 != null) {
|
||||
JSONObject cert = new JSONObject();
|
||||
cert.put("alias", keyPair.getAlias());
|
||||
try {
|
||||
ASN1Primitive ext = X509ExtensionUtil.fromExtensionValue(x509.getExtensionValue(Extension.basicConstraints.getId()));
|
||||
cert.put("rootca", BasicConstraints.getInstance(ext).isCA());
|
||||
}
|
||||
catch(IOException | NullPointerException e) {
|
||||
cert.put("rootca", false);
|
||||
}
|
||||
cert.put("subject", x509.getSubjectX500Principal().getName());
|
||||
cert.put("expires", SystemUtilities.toISO(x509.getNotAfter()));
|
||||
cert.put("data", formatCert(x509.getEncoded()));
|
||||
certs.put(cert);
|
||||
}
|
||||
}
|
||||
ssl.put("certificates", certs);
|
||||
|
||||
return ssl;
|
||||
}
|
||||
|
||||
public static String formatCert(byte[] encoding) {
|
||||
return "-----BEGIN CERTIFICATE-----\r\n" +
|
||||
new String(Base64.encodeBase64(encoding, true), StandardCharsets.UTF_8) +
|
||||
"-----END CERTIFICATE-----\r\n";
|
||||
}
|
||||
|
||||
private static JSONObject libraries() throws JSONException {
|
||||
JSONObject libraries = new JSONObject();
|
||||
|
||||
SortedMap<String,String> libs = SecurityInfo.getLibVersions();
|
||||
for(Map.Entry<String,String> entry : libs.entrySet()) {
|
||||
String version = entry.getValue();
|
||||
if (version == null) { version = "unknown"; }
|
||||
|
||||
libraries.put(entry.getKey(), version);
|
||||
}
|
||||
|
||||
return libraries;
|
||||
}
|
||||
|
||||
private static JSONObject charsets() throws JSONException {
|
||||
JSONObject charsets = new JSONObject();
|
||||
|
||||
SortedMap<String,Charset> avail = Charset.availableCharsets();
|
||||
ArrayList<String> names = new ArrayList<>();
|
||||
for(Map.Entry<String,Charset> entry : avail.entrySet()) {
|
||||
names.add(entry.getValue().name());
|
||||
}
|
||||
|
||||
charsets.put("charsets", Arrays.toString(names.toArray()));
|
||||
return charsets;
|
||||
}
|
||||
|
||||
public static String getPreferredHostname() {
|
||||
return preferredHostname;
|
||||
}
|
||||
|
||||
public static Version findLatestVersion() {
|
||||
log.trace("Looking for newer versions of {} online", Constants.ABOUT_TITLE);
|
||||
try {
|
||||
URL api = new URL(Constants.VERSION_CHECK_URL);
|
||||
BufferedReader br = new BufferedReader(new InputStreamReader(api.openStream()));
|
||||
|
||||
StringBuilder rawJson = new StringBuilder();
|
||||
String line;
|
||||
while((line = br.readLine()) != null) {
|
||||
rawJson.append(line);
|
||||
}
|
||||
|
||||
JSONArray versions = new JSONArray(rawJson.toString());
|
||||
for(int i = 0; i < versions.length(); i++) {
|
||||
JSONObject versionData = versions.getJSONObject(i);
|
||||
if(versionData.getString("target_commitish").equals("master")) {
|
||||
Version latestVersion = Version.valueOf(versionData.getString("name"));
|
||||
log.trace("Found latest version of {} online: {}", Constants.ABOUT_TITLE, latestVersion);
|
||||
return latestVersion;
|
||||
}
|
||||
}
|
||||
throw new Exception("Could not find valid json version information online.");
|
||||
}
|
||||
catch(Exception e) {
|
||||
log.error("Failed to get latest version of {} online", Constants.ABOUT_TITLE, e);
|
||||
}
|
||||
|
||||
return Constants.VERSION;
|
||||
}
|
||||
|
||||
}
|
||||
156
tray/src/qz/common/ByteArrayBuilder.java
Normal file
156
tray/src/qz/common/ByteArrayBuilder.java
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @author Antoni Ten Monro's
|
||||
*
|
||||
* Copyright (C) 2016 Tres Finocchiaro, QZ Industries, LLC
|
||||
* Copyright (C) 2013 Antoni Ten Monro's
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*
|
||||
*/
|
||||
|
||||
package qz.common;
|
||||
|
||||
import org.apache.commons.lang3.ArrayUtils;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Provides a simple and efficient way for concatenating byte arrays, similar
|
||||
* in purpose to <code>StringBuilder</code>. Objects of this class are not
|
||||
* thread safe and include no synchronization
|
||||
*
|
||||
* @author Antoni Ten Monro's
|
||||
*/
|
||||
@SuppressWarnings("UnusedDeclaration") //Library class
|
||||
public final class ByteArrayBuilder {
|
||||
|
||||
private List<Byte> buffer;
|
||||
|
||||
|
||||
/**
|
||||
* Creates a new <code>ByteArrayBuilder</code> and sets initial capacity to 10
|
||||
*/
|
||||
public ByteArrayBuilder() {
|
||||
this(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new <code>ByteArrayBuilder</code> and sets initial capacity to
|
||||
* <code>initialCapacity</code>
|
||||
*
|
||||
* @param initialCapacity the initial capacity of the <code>ByteArrayBuilder</code>
|
||||
*/
|
||||
public ByteArrayBuilder(int initialCapacity) {
|
||||
this(null, initialCapacity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new <code>ByteArrayBuilder</code>, sets initial capacity to 10
|
||||
* and appends <code>initialContents</code>
|
||||
*
|
||||
* @param initialContents the initial contents of the ByteArrayBuilder
|
||||
*/
|
||||
public ByteArrayBuilder(byte[] initialContents) {
|
||||
this(initialContents, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new <code>ByteArrayBuilder</code>, sets initial capacity to
|
||||
* <code>initialContents</code> and appends <code>initialContents</code>
|
||||
*
|
||||
* @param initialContents the initial contents of the <code>ByteArrayBuilder</code>
|
||||
* @param initialCapacity the initial capacity of the <code>ByteArrayBuilder</code>
|
||||
*/
|
||||
public ByteArrayBuilder(byte[] initialContents, int initialCapacity) {
|
||||
buffer = new ArrayList<>(initialCapacity);
|
||||
if (initialContents != null) {
|
||||
append(initialContents);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Empties the <code>ByteArrayBuilder</code>
|
||||
*/
|
||||
public void clear() {
|
||||
buffer.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a portion of the <code>ByteArrayBuilder</code>
|
||||
*
|
||||
* @param startIndex Starting index, inclusive
|
||||
* @param endIndex Ending index, exclusive
|
||||
*/
|
||||
public final void clearRange(int startIndex, int endIndex) {
|
||||
buffer.subList(startIndex, endIndex).clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives the number of bytes currently stored in this <code>ByteArrayBuilder</code>
|
||||
*
|
||||
* @return the number of bytes in the <code>ByteArrayBuilder</code>
|
||||
*/
|
||||
public int getLength() {
|
||||
return buffer.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a new byte array to this <code>ByteArrayBuilder</code>.
|
||||
* Returns this same object to allow chaining calls
|
||||
*
|
||||
* @param bytes the byte array to append
|
||||
* @return this <code>ByteArrayBuilder</code>
|
||||
*/
|
||||
public final ByteArrayBuilder append(byte[] bytes) {
|
||||
for(byte b : bytes) {
|
||||
buffer.add(b);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public final ByteArrayBuilder append(List<Byte> bytes) {
|
||||
for(byte b : bytes) {
|
||||
buffer.add(b);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for append(byte[]) combined with a StringBuffer of specified
|
||||
* charset
|
||||
*
|
||||
* @param string the String to append
|
||||
* @param charset the Charset of the String
|
||||
* @return this <code>ByteArrayBuilder</code>
|
||||
*/
|
||||
public final ByteArrayBuilder append(String string, Charset charset) throws UnsupportedEncodingException {
|
||||
return append(string.getBytes(charset.name()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for append(byte[]) combined with a String of specified
|
||||
* charset
|
||||
*
|
||||
* @param stringBuilder the StringBuilder to append
|
||||
* @param charset the Charset of the StringBuilder
|
||||
* @return this <code>ByteArrayBuilder</code>
|
||||
*/
|
||||
public final ByteArrayBuilder append(StringBuilder stringBuilder, Charset charset) throws UnsupportedEncodingException {
|
||||
return append(stringBuilder.toString(), charset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full contents of this <code>ByteArrayBuilder</code> as
|
||||
* a single <code>byte</code> array.
|
||||
*
|
||||
* @return The contents of this <code>ByteArrayBuilder</code> as a single <code>byte</code> array
|
||||
*/
|
||||
public byte[] getByteArray() {
|
||||
return ArrayUtils.toPrimitive(buffer.toArray(new Byte[buffer.size()]));
|
||||
}
|
||||
}
|
||||
97
tray/src/qz/common/CachedObject.java
Normal file
97
tray/src/qz/common/CachedObject.java
Normal file
@@ -0,0 +1,97 @@
|
||||
package qz.common;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* A generic class that encapsulates an object for caching. The cached object
|
||||
* will be refreshed automatically when accessed after its lifespan has expired.
|
||||
*
|
||||
* @param <T> The type of object to be cached.
|
||||
*/
|
||||
public class CachedObject<T> {
|
||||
public static final long DEFAULT_LIFESPAN = 5000; // in milliseconds
|
||||
T lastObject;
|
||||
Supplier<T> supplier;
|
||||
private long timestamp;
|
||||
private long lifespan;
|
||||
|
||||
/**
|
||||
* Creates a new CachedObject with a default lifespan of 5000 milliseconds
|
||||
*
|
||||
* @param supplier The function to pull new values from
|
||||
*/
|
||||
public CachedObject(Supplier<T> supplier) {
|
||||
this(supplier, DEFAULT_LIFESPAN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new CachedObject
|
||||
*
|
||||
* @param supplier The function to pull new values from
|
||||
* @param lifespan The lifespan of the cached object in milliseconds
|
||||
*/
|
||||
public CachedObject(Supplier<T> supplier, long lifespan) {
|
||||
this.supplier = supplier;
|
||||
setLifespan(lifespan);
|
||||
timestamp = Long.MIN_VALUE; // System.nanoTime() can be negative, MIN_VALUE guarantees a first-run.
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new supplier for the CachedObject
|
||||
*
|
||||
* @param supplier The function to pull new values from
|
||||
*/
|
||||
@SuppressWarnings("unused")
|
||||
public void registerSupplier(Supplier<T> supplier) {
|
||||
this.supplier = supplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the lifespan of the cached object
|
||||
*
|
||||
* @param milliseconds The lifespan of the cached object in milliseconds
|
||||
*/
|
||||
public void setLifespan(long milliseconds) {
|
||||
lifespan = Math.max(0, milliseconds); // prevent overflow
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the cached object.
|
||||
* If the cached object's lifespan has expired, it gets refreshed before being returned.
|
||||
*
|
||||
* @return The cached object
|
||||
*/
|
||||
public T get() {
|
||||
return get(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the cached object.
|
||||
* If the cached object's lifespan is expired or forceRefresh is true, it gets refreshed before being returned.
|
||||
*
|
||||
* @param forceRefresh If true, the cached object will be refreshed before being returned regardless of its lifespan
|
||||
* @return The cached object
|
||||
*/
|
||||
public T get(boolean forceRefresh) {
|
||||
long now = TimeUnit.NANOSECONDS.toMillis(System.nanoTime());
|
||||
// check lifespan
|
||||
if (forceRefresh || (timestamp + lifespan <= now)) {
|
||||
timestamp = now;
|
||||
lastObject = supplier.get();
|
||||
}
|
||||
return lastObject;
|
||||
}
|
||||
|
||||
// Test
|
||||
public static void main(String ... args) throws InterruptedException {
|
||||
final AtomicInteger testInt = new AtomicInteger(0);
|
||||
|
||||
CachedObject<Integer> cachedString = new CachedObject<>(testInt::incrementAndGet);
|
||||
for(int i = 0; i < 100; i++) {
|
||||
Thread.sleep(1500);
|
||||
System.out.println(cachedString.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
103
tray/src/qz/common/Constants.java
Normal file
103
tray/src/qz/common/Constants.java
Normal file
@@ -0,0 +1,103 @@
|
||||
package qz.common;
|
||||
|
||||
import com.github.zafarkhaja.semver.Version;
|
||||
import qz.utils.SystemUtilities;
|
||||
|
||||
import java.awt.*;
|
||||
|
||||
import static qz.ws.SingleInstanceChecker.STEAL_WEBSOCKET_PROPERTY;
|
||||
|
||||
/**
|
||||
* Created by robert on 7/9/2014.
|
||||
*/
|
||||
public class Constants {
|
||||
public static final String HEXES = "0123456789ABCDEF";
|
||||
public static final char[] HEXES_ARRAY = HEXES.toCharArray();
|
||||
public static final int BYTE_BUFFER_SIZE = 8192;
|
||||
public static final Version VERSION = Version.valueOf("2.2.6-SNAPSHOT");
|
||||
public static final Version JAVA_VERSION = SystemUtilities.getJavaVersion();
|
||||
public static final String JAVA_VENDOR = System.getProperty("java.vendor");
|
||||
|
||||
/* QZ-Tray Constants */
|
||||
public static final String BLOCK_FILE = "blocked";
|
||||
public static final String ALLOW_FILE = "allowed";
|
||||
public static final String TEMP_FILE = "temp";
|
||||
public static final String LOG_FILE = "debug";
|
||||
public static final String PROPS_FILE = "qz-tray"; // .properties extension is assumed
|
||||
public static final String PREFS_FILE = "prefs"; // .properties extension is assumed
|
||||
public static final String[] PERSIST_PROPS = {"file.whitelist", "file.allow", "networking.hostname", "networking.port", STEAL_WEBSOCKET_PROPERTY };
|
||||
public static final String AUTOSTART_FILE = ".autostart";
|
||||
public static final String DATA_DIR = "qz";
|
||||
|
||||
public static final int BORDER_PADDING = 10;
|
||||
|
||||
public static final String ABOUT_TITLE = "QZ Tray";
|
||||
public static final String ABOUT_EMAIL = "support@qz.io";
|
||||
public static final String ABOUT_URL = "https://qz.io";
|
||||
public static final String ABOUT_COMPANY = "QZ Industries, LLC";
|
||||
public static final String ABOUT_CITY = "Canastota";
|
||||
public static final String ABOUT_STATE = "NY";
|
||||
public static final String ABOUT_COUNTRY = "US";
|
||||
|
||||
public static final String ABOUT_LICENSING_URL = Constants.ABOUT_URL + "/licensing";
|
||||
public static final String ABOUT_SUPPORT_URL = Constants.ABOUT_URL + "/support";
|
||||
public static final String ABOUT_PRIVACY_URL = Constants.ABOUT_URL + "/privacy";
|
||||
public static final String ABOUT_DOWNLOAD_URL = Constants.ABOUT_URL + "/download";
|
||||
|
||||
public static final String VERSION_CHECK_URL = "https://api.github.com/repos/qzind/tray/releases";
|
||||
public static final String VERSION_DOWNLOAD_URL = "https://github.com/qzind/tray/releases";
|
||||
public static final boolean ENABLE_DIAGNOSTICS = true; // Diagnostics menu (logs, etc)
|
||||
|
||||
public static final String TRUSTED_CERT = String.format("Verified by %s", Constants.ABOUT_COMPANY);
|
||||
public static final String SPONSORED_CERT = String.format("Sponsored by %s", Constants.ABOUT_COMPANY);
|
||||
public static final String SPONSORED_TOOLTIP = "Sponsored organization";
|
||||
public static final String UNTRUSTED_CERT = "Untrusted website";
|
||||
public static final String NO_TRUST = "Cannot verify trust";
|
||||
|
||||
public static final String PROBE_REQUEST = "getProgramName";
|
||||
public static final String PROBE_RESPONSE = ABOUT_TITLE;
|
||||
|
||||
public static final String ALLOW_SITES_TEXT = "Permanently allowed \"%s\" to access local resources";
|
||||
public static final String BLOCK_SITES_TEXT = "Permanently blocked \"%s\" from accessing local resources";
|
||||
|
||||
public static final String REMEMBER_THIS_DECISION = "Remember this decision";
|
||||
public static final String STRICT_MODE_LABEL = "Use strict certificate mode";
|
||||
public static final String STRICT_MODE_TOOLTIP = String.format("Prevents the ability to select \"%s\" for most websites", REMEMBER_THIS_DECISION);
|
||||
public static final String STRICT_MODE_CONFIRM = String.format("Set strict certificate mode? Most websites will stop working with %s.", ABOUT_TITLE);
|
||||
public static final String ALLOW_SITES_LABEL = "Sites permanently allowed access";
|
||||
public static final String BLOCK_SITES_LABEL = "Sites permanently blocked from access";
|
||||
|
||||
|
||||
public static final String ALLOWED = "Allowed";
|
||||
public static final String BLOCKED = "Blocked";
|
||||
|
||||
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";
|
||||
|
||||
public static final long VALID_SIGNING_PERIOD = 15 * 60 * 1000; //millis
|
||||
public static final int EXPIRY_WARN = 30; // days
|
||||
public static final Color WARNING_COLOR_LITE = Color.RED;
|
||||
public static final Color TRUSTED_COLOR_LITE = Color.BLUE;
|
||||
public static final Color WARNING_COLOR_DARK = Color.decode("#EB6261");
|
||||
public static final Color TRUSTED_COLOR_DARK = Color.decode("#589DF6");
|
||||
public static Color WARNING_COLOR = WARNING_COLOR_LITE;
|
||||
public static Color TRUSTED_COLOR = TRUSTED_COLOR_LITE;
|
||||
|
||||
public static boolean MASK_TRAY_SUPPORTED = true;
|
||||
|
||||
public static final long MEMORY_PER_PRINT = 512; //MB
|
||||
|
||||
public static final String RAW_PRINT = ABOUT_TITLE + " Raw Print";
|
||||
public static final String IMAGE_PRINT = ABOUT_TITLE + " Pixel Print";
|
||||
public static final String PDF_PRINT = ABOUT_TITLE + " PDF Print";
|
||||
public static final String HTML_PRINT = ABOUT_TITLE + " HTML Print";
|
||||
|
||||
public static final Integer[] DEFAULT_WSS_PORTS = {8181, 8282, 8383, 8484};
|
||||
public static final Integer[] DEFAULT_WS_PORTS = {8182, 8283, 8384, 8485};
|
||||
public static final Integer[] CUPS_RSS_PORTS = {8586, 8687, 8788, 8889};
|
||||
}
|
||||
100
tray/src/qz/common/PropertyHelper.java
Normal file
100
tray/src/qz/common/PropertyHelper.java
Normal file
@@ -0,0 +1,100 @@
|
||||
package qz.common;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.utils.ArgValue;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
/**
|
||||
* Created by Tres on 12/16/2015.
|
||||
*/
|
||||
public class PropertyHelper extends Properties {
|
||||
private static final Logger log = LogManager.getLogger(PropertyHelper.class);
|
||||
private String file;
|
||||
|
||||
/**
|
||||
* Default constructor
|
||||
*/
|
||||
public PropertyHelper() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Default constructor
|
||||
* @param p Initial Properties
|
||||
*/
|
||||
public PropertyHelper(Properties p) {
|
||||
super(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom constructor, attempts to load from file
|
||||
* @param file File to load properties from
|
||||
*/
|
||||
public PropertyHelper(String file) {
|
||||
super();
|
||||
this.file = file;
|
||||
load(file);
|
||||
}
|
||||
|
||||
public PropertyHelper(File file) {
|
||||
this(file == null ? null : file.getAbsolutePath());
|
||||
}
|
||||
|
||||
public boolean getBoolean(String key, boolean defaultVal) {
|
||||
String prop = getProperty(key);
|
||||
if (prop != null) {
|
||||
return Boolean.parseBoolean(prop);
|
||||
} else {
|
||||
return defaultVal;
|
||||
}
|
||||
}
|
||||
|
||||
public void setProperty(ArgValue arg, boolean value) {
|
||||
setProperty(arg.getMatch(), "" + value);
|
||||
}
|
||||
|
||||
public void load(File file) {
|
||||
load(file == null ? null : file.getAbsolutePath());
|
||||
}
|
||||
|
||||
public void load(String file) {
|
||||
FileInputStream f = null;
|
||||
try {
|
||||
f = new FileInputStream(file);
|
||||
load(f);
|
||||
} catch (IOException e) {
|
||||
log.warn("Could not load file: {}, reason: {}", file, e.getLocalizedMessage());
|
||||
} finally {
|
||||
if (f != null) {
|
||||
try { f.close(); } catch(Throwable ignore) {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean save() {
|
||||
boolean success = false;
|
||||
FileOutputStream f = null;
|
||||
try {
|
||||
f = new FileOutputStream(file);
|
||||
this.store(f, null);
|
||||
success = true;
|
||||
} catch (IOException e) {
|
||||
log.error("Error saving file: {}", file, e);
|
||||
} finally {
|
||||
if (f != null) {
|
||||
try { f.close(); } catch(Throwable ignore) {};
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
public synchronized Object setProperty(Map.Entry<String, String> pair) {
|
||||
return super.setProperty(pair.getKey(), pair.getValue());
|
||||
}
|
||||
}
|
||||
169
tray/src/qz/common/SecurityInfo.java
Normal file
169
tray/src/qz/common/SecurityInfo.java
Normal file
@@ -0,0 +1,169 @@
|
||||
package qz.common;
|
||||
|
||||
import com.sun.jna.Native;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.eclipse.jetty.util.Jetty;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.usb4java.LibUsb;
|
||||
import purejavahidapi.PureJavaHidApi;
|
||||
import qz.utils.SystemUtilities;
|
||||
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyStore;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Created by Kyle B. on 10/27/2017.
|
||||
*/
|
||||
public class SecurityInfo {
|
||||
/**
|
||||
* Wrap throwable operations into a try/catch
|
||||
*/
|
||||
private static class CheckedTreeMap<K, V> extends TreeMap<K, V> {
|
||||
private static final Logger log = LogManager.getLogger(CheckedTreeMap.class);
|
||||
|
||||
interface CheckedValue {
|
||||
Object check() throws Throwable;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public V put(K key, CheckedValue value) {
|
||||
try {
|
||||
return put(key, (V)value.check());
|
||||
} catch(Throwable t) {
|
||||
log.warn("A checked exception was suppressed adding key \"{}\"", key, t);
|
||||
return put(key, (V)"missing");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final Logger log = LogManager.getLogger(SecurityInfo.class);
|
||||
|
||||
public static KeyStore getKeyStore(Properties props) {
|
||||
if (props != null) {
|
||||
String store = props.getProperty("wss.keystore", "");
|
||||
char[] pass = props.getProperty("wss.storepass", "").toCharArray();
|
||||
|
||||
try {
|
||||
KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
keystore.load(new FileInputStream(store), pass);
|
||||
return keystore;
|
||||
}
|
||||
catch(GeneralSecurityException | IOException e) {
|
||||
log.warn("Unable to create keystore from properties file: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static SortedMap<String,String> getLibVersions() {
|
||||
CheckedTreeMap<String,String> libVersions = new CheckedTreeMap<>();
|
||||
|
||||
// Use API-provided mechanism if available
|
||||
libVersions.put("jna (native)", () -> Native.VERSION_NATIVE);
|
||||
libVersions.put("jna (location)", () -> {
|
||||
@SuppressWarnings("unused")
|
||||
int ignore = Native.BOOL_SIZE;
|
||||
return System.getProperty("jnidispatch.path");
|
||||
});
|
||||
libVersions.put("jna", Native.VERSION);
|
||||
libVersions.put("jssc", () -> jssc.SerialNativeInterface.getLibraryVersion());
|
||||
libVersions.put("jssc (native)", () -> jssc.SerialNativeInterface.getNativeLibraryVersion());
|
||||
libVersions.put("jetty", Jetty.VERSION);
|
||||
libVersions.put("pdfbox", org.apache.pdfbox.util.Version.getVersion());
|
||||
libVersions.put("purejavahidapi", () -> PureJavaHidApi.getVersion());
|
||||
libVersions.put("usb-api", javax.usb.Version.getApiVersion());
|
||||
libVersions.put("not-yet-commons-ssl", org.apache.commons.ssl.Version.VERSION);
|
||||
libVersions.put("mslinks", mslinks.ShellLink.VERSION);
|
||||
libVersions.put("bouncycastle", "" + new BouncyCastleProvider().getVersion());
|
||||
libVersions.put("usb4java (native)", () -> LibUsb.getVersion().toString());
|
||||
|
||||
libVersions.put("jre", Constants.JAVA_VERSION.toString());
|
||||
libVersions.put("jre (vendor)", Constants.JAVA_VENDOR);
|
||||
|
||||
//JFX info, if it exists
|
||||
try {
|
||||
// "DO NOT LINK JAVAFX EVER" - JavaFX may not exist, use reflection to avoid compilation errors
|
||||
Class<?> VersionInfo = Class.forName("com.sun.javafx.runtime.VersionInfo");
|
||||
Path fxPath = Paths.get(VersionInfo.getProtectionDomain().getCodeSource().getLocation().toURI());
|
||||
Method method = VersionInfo.getMethod("getVersion");
|
||||
Object version = method.invoke(null);
|
||||
libVersions.put("javafx", (String)version);
|
||||
libVersions.put("javafx (location)", fxPath.toString());
|
||||
} catch(Throwable e) {
|
||||
libVersions.put("javafx", "missing");
|
||||
libVersions.put("javafx (location)", "missing");
|
||||
}
|
||||
|
||||
// Fallback to maven manifest information
|
||||
HashMap<String,String> mavenVersions = getMavenVersions();
|
||||
|
||||
String[] mavenLibs = {"jetty-servlet", "jetty-io", "websocket-common",
|
||||
"usb4java-javax", "java-semver", "commons-pool2",
|
||||
"websocket-server", "jettison", "commons-codec", "log4j-api", "log4j-core",
|
||||
"websocket-servlet", "jetty-http", "commons-lang3", "javax-websocket-server-impl",
|
||||
"javax.servlet-api", "hid4java", "usb4java", "websocket-api", "jetty-util", "websocket-client",
|
||||
"javax.websocket-api", "commons-io", "jetty-security"};
|
||||
|
||||
for(String lib : mavenLibs) {
|
||||
libVersions.put(lib, mavenVersions.get(lib));
|
||||
}
|
||||
|
||||
return libVersions;
|
||||
}
|
||||
|
||||
public static void printLibInfo() {
|
||||
String format = "%-40s%s%n";
|
||||
System.out.printf(format, "LIBRARY NAME:", "VERSION:");
|
||||
SortedMap<String,String> libVersions = SecurityInfo.getLibVersions();
|
||||
for(Map.Entry<String,String> entry : libVersions.entrySet()) {
|
||||
if (entry.getValue() == null) {
|
||||
System.out.printf(format, entry.getKey(), "(unknown)");
|
||||
} else {
|
||||
System.out.printf(format, entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches embedded version information based on maven properties
|
||||
*
|
||||
* @return HashMap of library name, version
|
||||
*/
|
||||
private static HashMap<String,String> getMavenVersions() {
|
||||
final HashMap<String,String> mavenVersions = new HashMap<>();
|
||||
String jar = "jar:" + SecurityInfo.class.getProtectionDomain().getCodeSource().getLocation().toString();
|
||||
try(FileSystem fs = FileSystems.newFileSystem(new URI(jar), new HashMap<String,String>())) {
|
||||
Files.walkFileTree(fs.getPath("/META-INF/maven"), new HashSet<>(), 3, new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||
if (file.toString().endsWith(".properties")) {
|
||||
try {
|
||||
Properties props = new Properties();
|
||||
props.load(Files.newInputStream(file, StandardOpenOption.READ));
|
||||
mavenVersions.put(props.getProperty("artifactId"), props.getProperty("version"));
|
||||
}
|
||||
catch(Exception e) {
|
||||
log.warn("Error reading properties from {}", file, e);
|
||||
}
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
catch(Exception ignore) {
|
||||
log.warn("Could not open {} for version information. Most libraries will list as (unknown)", jar);
|
||||
}
|
||||
return mavenVersions;
|
||||
}
|
||||
|
||||
}
|
||||
693
tray/src/qz/common/TrayManager.java
Normal file
693
tray/src/qz/common/TrayManager.java
Normal file
@@ -0,0 +1,693 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2016 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.common;
|
||||
|
||||
import com.github.zafarkhaja.semver.Version;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.eclipse.jetty.server.Server;
|
||||
import qz.App;
|
||||
import qz.auth.Certificate;
|
||||
import qz.auth.RequestState;
|
||||
import qz.installer.shortcut.ShortcutCreator;
|
||||
import qz.printer.PrintServiceMatcher;
|
||||
import qz.printer.action.html.WebApp;
|
||||
import qz.ui.*;
|
||||
import qz.ui.component.IconCache;
|
||||
import qz.ui.tray.TrayType;
|
||||
import qz.utils.*;
|
||||
import qz.ws.PrintSocketServer;
|
||||
import qz.ws.SingleInstanceChecker;
|
||||
import qz.ws.WebsocketPorts;
|
||||
import qz.ws.substitutions.Substitutions;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.event.ActionEvent;
|
||||
import java.awt.event.ActionListener;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.TimerTask;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static qz.ui.component.IconCache.Icon.*;
|
||||
import static qz.utils.ArgValue.*;
|
||||
|
||||
/**
|
||||
* Manages the icons and actions associated with the TrayIcon
|
||||
*
|
||||
* @author Tres Finocchiaro
|
||||
*/
|
||||
public class TrayManager {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(TrayManager.class);
|
||||
|
||||
private boolean headless;
|
||||
|
||||
// The cached icons
|
||||
private final IconCache iconCache;
|
||||
|
||||
// Custom swing pop-up menu
|
||||
private TrayType tray;
|
||||
|
||||
private ConfirmDialog confirmDialog;
|
||||
private GatewayDialog gatewayDialog;
|
||||
private AboutDialog aboutDialog;
|
||||
private LogDialog logDialog;
|
||||
private SiteManagerDialog sitesDialog;
|
||||
private ArrayList<Component> componentList;
|
||||
private IconCache.Icon shownIcon;
|
||||
|
||||
// Need a class reference to this so we can set it from the request dialog window
|
||||
private JCheckBoxMenuItem anonymousItem;
|
||||
|
||||
// The name this UI component will use, i.e "QZ Print 1.9.0"
|
||||
private final String name;
|
||||
|
||||
// The shortcut and startup helper
|
||||
private final ShortcutCreator shortcutCreator;
|
||||
|
||||
private final PropertyHelper prefs;
|
||||
|
||||
// Action to run when reload is triggered
|
||||
private Thread reloadThread;
|
||||
|
||||
// Actions to run if idle after startup
|
||||
private java.util.Timer idleTimer = new java.util.Timer();
|
||||
|
||||
public TrayManager() {
|
||||
this(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a AutoHideJSystemTray with the specified name/text
|
||||
*/
|
||||
public TrayManager(boolean isHeadless) {
|
||||
name = Constants.ABOUT_TITLE + " " + Constants.VERSION;
|
||||
|
||||
prefs = new PropertyHelper(FileUtilities.USER_DIR + File.separator + Constants.PREFS_FILE + ".properties");
|
||||
prefs.remove(SECURITY_FILE_STRICT.getMatch()); // per https://github.com/qzind/tray/issues/1337
|
||||
|
||||
// Set strict certificate mode preference
|
||||
Certificate.setTrustBuiltIn(!getPref(TRAY_STRICTMODE));
|
||||
|
||||
// Configures JSON websocket messages
|
||||
Substitutions.getInstance();
|
||||
|
||||
// Set FileIO security
|
||||
FileUtilities.setFileIoEnabled(getPref(SECURITY_FILE_ENABLED));
|
||||
FileUtilities.setFileIoStrict(getPref(SECURITY_FILE_STRICT));
|
||||
|
||||
// Headless if turned on by user or unsupported by environment
|
||||
headless = isHeadless || getPref(HEADLESS) || GraphicsEnvironment.isHeadless();
|
||||
if (headless) {
|
||||
log.info("Running in headless mode");
|
||||
}
|
||||
|
||||
// Set up the shortcut name so that the UI components can use it
|
||||
shortcutCreator = ShortcutCreator.getInstance();
|
||||
|
||||
SystemUtilities.setSystemLookAndFeel(headless);
|
||||
iconCache = new IconCache();
|
||||
|
||||
if (SystemUtilities.isSystemTraySupported(headless)) { // UI mode with tray
|
||||
switch(SystemUtilities.getOs()) {
|
||||
case WINDOWS:
|
||||
tray = TrayType.JX.init(iconCache);
|
||||
// Undocumented HiDPI behavior
|
||||
tray.setImageAutoSize(true);
|
||||
break;
|
||||
case MAC:
|
||||
tray = TrayType.CLASSIC.init(iconCache);
|
||||
break;
|
||||
default:
|
||||
tray = TrayType.MODERN.init(iconCache);
|
||||
}
|
||||
|
||||
// OS-specific tray icon handling
|
||||
if (SystemTray.isSupported()) {
|
||||
iconCache.fixTrayIcons(SystemUtilities.isDarkTaskbar());
|
||||
}
|
||||
|
||||
// Iterates over all images denoted by IconCache.getTypes() and caches them
|
||||
tray.setIcon(DANGER_ICON);
|
||||
tray.setToolTip(name);
|
||||
|
||||
try {
|
||||
SystemTray.getSystemTray().add(tray.tray());
|
||||
}
|
||||
catch(AWTException awt) {
|
||||
log.error("Could not attach tray, forcing headless mode", awt);
|
||||
headless = true;
|
||||
}
|
||||
} else if (!headless) { // UI mode without tray
|
||||
tray = TrayType.TASKBAR.init(exitListener, iconCache);
|
||||
tray.setIcon(DANGER_ICON);
|
||||
tray.setToolTip(name);
|
||||
tray.showTaskbar();
|
||||
}
|
||||
|
||||
// TODO: Remove when fixed upstream. See issue #393
|
||||
if (SystemUtilities.isUnix() && !isHeadless) {
|
||||
// Update printer list in CUPS immediately (normally 2min)
|
||||
System.setProperty("sun.java2d.print.polling", "false");
|
||||
}
|
||||
|
||||
if (!headless) {
|
||||
componentList = new ArrayList<>();
|
||||
|
||||
// The allow/block dialog
|
||||
gatewayDialog = new GatewayDialog(null, "Action Required", iconCache);
|
||||
componentList.add(gatewayDialog);
|
||||
|
||||
// The ok/cancel dialog
|
||||
confirmDialog = new ConfirmDialog(null, "Please Confirm", iconCache);
|
||||
componentList.add(confirmDialog);
|
||||
|
||||
// Detect theme changes
|
||||
new Thread(() -> {
|
||||
boolean darkDesktopMode = SystemUtilities.isDarkDesktop();
|
||||
boolean darkTaskbarMode = SystemUtilities.isDarkTaskbar();
|
||||
while(true) {
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
if (darkDesktopMode != SystemUtilities.isDarkDesktop(true) ||
|
||||
darkTaskbarMode != SystemUtilities.isDarkTaskbar(true)) {
|
||||
darkDesktopMode = SystemUtilities.isDarkDesktop();
|
||||
darkTaskbarMode = SystemUtilities.isDarkTaskbar();
|
||||
iconCache.fixTrayIcons(darkTaskbarMode);
|
||||
refreshIcon(null);
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
SystemUtilities.setSystemLookAndFeel(headless);
|
||||
for(Component c : componentList) {
|
||||
SwingUtilities.updateComponentTreeUI(c);
|
||||
if (c instanceof Themeable) {
|
||||
((Themeable)c).refresh();
|
||||
}
|
||||
if (c instanceof JDialog) {
|
||||
((JDialog)c).pack();
|
||||
} else if (c instanceof JPopupMenu) {
|
||||
((JPopupMenu)c).pack();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
catch(InterruptedException ignore) {}
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
if (tray != null) {
|
||||
addMenuItems();
|
||||
}
|
||||
|
||||
// Initialize idle actions
|
||||
// Slow to start JavaFX the first time
|
||||
if (getPref(TRAY_IDLE_JAVAFX)) {
|
||||
performIfIdle((int)TimeUnit.SECONDS.toMillis(60), evt -> {
|
||||
log.debug("IDLE: Starting up JFX for HTML printing");
|
||||
try {
|
||||
WebApp.initialize();
|
||||
}
|
||||
catch(IOException e) {
|
||||
log.error("Idle runner failed to preemptively start JavaFX service");
|
||||
}
|
||||
});
|
||||
}
|
||||
// Slow to find printers the first time if a lot of printers are installed
|
||||
// Must run after JavaFX per https://github.com/qzind/tray/issues/924
|
||||
if (getPref(TRAY_IDLE_PRINTERS)) {
|
||||
performIfIdle((int)TimeUnit.SECONDS.toMillis(120), evt -> {
|
||||
log.debug("IDLE: Performing first run of find printers");
|
||||
PrintServiceMatcher.getNativePrinterList(false, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand-alone invocation of TrayManager
|
||||
*
|
||||
* @param args arguments to pass to main
|
||||
*/
|
||||
public static void main(String args[]) {
|
||||
SwingUtilities.invokeLater(TrayManager::new);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the swing pop-up menu with the specified items
|
||||
*/
|
||||
private void addMenuItems() {
|
||||
JPopupMenu popup = new JPopupMenu();
|
||||
componentList.add(popup);
|
||||
|
||||
JMenu advancedMenu = new JMenu("Advanced");
|
||||
advancedMenu.setMnemonic(KeyEvent.VK_A);
|
||||
advancedMenu.setIcon(iconCache.getIcon(SETTINGS_ICON));
|
||||
|
||||
JMenuItem sitesItem = new JMenuItem("Site Manager...", iconCache.getIcon(SAVED_ICON));
|
||||
sitesItem.setMnemonic(KeyEvent.VK_M);
|
||||
sitesItem.addActionListener(savedListener);
|
||||
sitesDialog = new SiteManagerDialog(sitesItem, iconCache, prefs);
|
||||
componentList.add(sitesDialog);
|
||||
|
||||
JMenuItem pairingConfigItem = new JMenuItem("Pairing Configuration...", iconCache.getIcon(SETTINGS_ICON));
|
||||
pairingConfigItem.setMnemonic(KeyEvent.VK_P);
|
||||
pairingConfigItem.addActionListener(e -> new qz.ui.PairingConfigDialog(null).setVisible(true));
|
||||
advancedMenu.add(pairingConfigItem);
|
||||
|
||||
JMenuItem diagnosticMenu = new JMenu("Diagnostic");
|
||||
|
||||
JMenuItem browseApp = new JMenuItem("Browse App folder...", iconCache.getIcon(FOLDER_ICON));
|
||||
browseApp.setToolTipText(SystemUtilities.getJarParentPath().toString());
|
||||
browseApp.setMnemonic(KeyEvent.VK_O);
|
||||
browseApp.addActionListener(e -> ShellUtilities.browseAppDirectory());
|
||||
diagnosticMenu.add(browseApp);
|
||||
|
||||
JMenuItem browseUser = new JMenuItem("Browse User folder...", iconCache.getIcon(FOLDER_ICON));
|
||||
browseUser.setToolTipText(FileUtilities.USER_DIR.toString());
|
||||
browseUser.setMnemonic(KeyEvent.VK_U);
|
||||
browseUser.addActionListener(e -> ShellUtilities.browseDirectory(FileUtilities.USER_DIR));
|
||||
diagnosticMenu.add(browseUser);
|
||||
|
||||
JMenuItem browseShared = new JMenuItem("Browse Shared folder...", iconCache.getIcon(FOLDER_ICON));
|
||||
browseShared.setToolTipText(FileUtilities.SHARED_DIR.toString());
|
||||
browseShared.setMnemonic(KeyEvent.VK_S);
|
||||
browseShared.addActionListener(e -> ShellUtilities.browseDirectory(FileUtilities.SHARED_DIR));
|
||||
diagnosticMenu.add(browseShared);
|
||||
|
||||
diagnosticMenu.add(new JSeparator());
|
||||
|
||||
JCheckBoxMenuItem notificationsItem = new JCheckBoxMenuItem("Show all notifications");
|
||||
notificationsItem.setToolTipText("Shows all connect/disconnect messages, useful for debugging purposes");
|
||||
notificationsItem.setMnemonic(KeyEvent.VK_S);
|
||||
notificationsItem.setState(getPref(TRAY_NOTIFICATIONS));
|
||||
notificationsItem.addActionListener(notificationsListener);
|
||||
diagnosticMenu.add(notificationsItem);
|
||||
|
||||
JCheckBoxMenuItem monocleItem = new JCheckBoxMenuItem("Use Monocle for HTML");
|
||||
monocleItem.setToolTipText("Use monocle platform for HTML printing (restart required)");
|
||||
monocleItem.setMnemonic(KeyEvent.VK_U);
|
||||
monocleItem.setState(getPref(TRAY_MONOCLE));
|
||||
if(!SystemUtilities.hasMonocle()) {
|
||||
log.warn("Monocle engine was not detected");
|
||||
monocleItem.setEnabled(false);
|
||||
monocleItem.setToolTipText("Monocle HTML engine was not detected");
|
||||
}
|
||||
monocleItem.addActionListener(monocleListener);
|
||||
|
||||
if (Constants.JAVA_VERSION.greaterThanOrEqualTo(Version.valueOf("11.0.0"))) { //only include if it can be used
|
||||
diagnosticMenu.add(monocleItem);
|
||||
}
|
||||
|
||||
diagnosticMenu.add(new JSeparator());
|
||||
|
||||
JMenuItem logItem = new JMenuItem("View logs (live feed)...", iconCache.getIcon(LOG_ICON));
|
||||
logItem.setMnemonic(KeyEvent.VK_L);
|
||||
logItem.addActionListener(logListener);
|
||||
diagnosticMenu.add(logItem);
|
||||
logDialog = new LogDialog(logItem, iconCache);
|
||||
componentList.add(logDialog);
|
||||
|
||||
JMenuItem zipLogs = new JMenuItem("Zip logs (to Desktop)");
|
||||
zipLogs.setToolTipText("Zip diagnostic logs, place on Desktop");
|
||||
zipLogs.setMnemonic(KeyEvent.VK_Z);
|
||||
zipLogs.addActionListener(e -> FileUtilities.zipLogs());
|
||||
diagnosticMenu.add(zipLogs);
|
||||
|
||||
JMenuItem desktopItem = new JMenuItem("Create Desktop shortcut", iconCache.getIcon(DESKTOP_ICON));
|
||||
desktopItem.setMnemonic(KeyEvent.VK_D);
|
||||
desktopItem.addActionListener(desktopListener());
|
||||
|
||||
anonymousItem = new JCheckBoxMenuItem("Block anonymous requests");
|
||||
anonymousItem.setToolTipText("Blocks all requests that do not contain a valid certificate/signature");
|
||||
anonymousItem.setMnemonic(KeyEvent.VK_K);
|
||||
anonymousItem.setState(Certificate.UNKNOWN.isBlocked());
|
||||
anonymousItem.addActionListener(anonymousListener);
|
||||
|
||||
if(Constants.ENABLE_DIAGNOSTICS) {
|
||||
advancedMenu.add(diagnosticMenu);
|
||||
advancedMenu.add(new JSeparator());
|
||||
}
|
||||
advancedMenu.add(sitesItem);
|
||||
advancedMenu.add(desktopItem);
|
||||
advancedMenu.add(new JSeparator());
|
||||
advancedMenu.add(anonymousItem);
|
||||
|
||||
JMenuItem reloadItem = new JMenuItem("Reload", iconCache.getIcon(RELOAD_ICON));
|
||||
reloadItem.setMnemonic(KeyEvent.VK_R);
|
||||
reloadItem.addActionListener(reloadListener);
|
||||
|
||||
JMenuItem aboutItem = new JMenuItem("About...", iconCache.getIcon(ABOUT_ICON));
|
||||
aboutItem.setMnemonic(KeyEvent.VK_B);
|
||||
aboutItem.addActionListener(aboutListener);
|
||||
aboutDialog = new AboutDialog(aboutItem, iconCache);
|
||||
componentList.add(aboutDialog);
|
||||
|
||||
if (SystemUtilities.isMac()) {
|
||||
MacUtilities.registerAboutDialog(aboutDialog);
|
||||
MacUtilities.registerQuitHandler(this);
|
||||
}
|
||||
|
||||
JSeparator separator = new JSeparator();
|
||||
|
||||
JCheckBoxMenuItem startupItem = new JCheckBoxMenuItem("Automatically start");
|
||||
startupItem.setMnemonic(KeyEvent.VK_S);
|
||||
startupItem.setState(FileUtilities.isAutostart());
|
||||
startupItem.addActionListener(startupListener());
|
||||
if (!shortcutCreator.canAutoStart()) {
|
||||
startupItem.setEnabled(false);
|
||||
startupItem.setState(false);
|
||||
startupItem.setToolTipText("Autostart has been disabled by the administrator");
|
||||
}
|
||||
|
||||
JMenuItem exitItem = new JMenuItem("Exit", iconCache.getIcon(EXIT_ICON));
|
||||
exitItem.addActionListener(exitListener);
|
||||
|
||||
popup.add(advancedMenu);
|
||||
popup.add(reloadItem);
|
||||
popup.add(aboutItem);
|
||||
popup.add(startupItem);
|
||||
popup.add(separator);
|
||||
popup.add(exitItem);
|
||||
|
||||
if (tray != null) {
|
||||
tray.setJPopupMenu(popup);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private final ActionListener notificationsListener = new ActionListener() {
|
||||
@Override
|
||||
public void actionPerformed(ActionEvent e) {
|
||||
prefs.setProperty(TRAY_NOTIFICATIONS, ((JCheckBoxMenuItem)e.getSource()).getState());
|
||||
}
|
||||
};
|
||||
|
||||
private final ActionListener monocleListener = new ActionListener() {
|
||||
@Override
|
||||
public void actionPerformed(ActionEvent e) {
|
||||
JCheckBoxMenuItem j = (JCheckBoxMenuItem)e.getSource();
|
||||
prefs.setProperty(TRAY_MONOCLE, j.getState());
|
||||
displayWarningMessage(String.format("A restart of %s is required to ensure this feature is %sabled.",
|
||||
Constants.ABOUT_TITLE, j.getState()? "en":"dis"));
|
||||
}
|
||||
};
|
||||
|
||||
private final ActionListener desktopListener() {
|
||||
return e -> {
|
||||
shortcutCreator.createDesktopShortcut();
|
||||
};
|
||||
}
|
||||
|
||||
private final ActionListener savedListener = new ActionListener() {
|
||||
public void actionPerformed(ActionEvent e) {
|
||||
sitesDialog.setVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
private final ActionListener anonymousListener = e -> {
|
||||
boolean checkBoxState = true;
|
||||
if (e.getSource() instanceof JCheckBoxMenuItem) {
|
||||
checkBoxState = ((JCheckBoxMenuItem)e.getSource()).getState();
|
||||
}
|
||||
|
||||
log.debug("Block unsigned: {}", checkBoxState);
|
||||
|
||||
if (checkBoxState) {
|
||||
blackList(Certificate.UNKNOWN);
|
||||
} else {
|
||||
FileUtilities.deleteFromFile(Constants.BLOCK_FILE, Certificate.UNKNOWN.data(), true);
|
||||
FileUtilities.deleteFromFile(Constants.BLOCK_FILE, Certificate.UNKNOWN.data(), false);
|
||||
}
|
||||
};
|
||||
|
||||
private final ActionListener logListener = new ActionListener() {
|
||||
@Override
|
||||
public void actionPerformed(ActionEvent e) {
|
||||
logDialog.setVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
private ActionListener startupListener() {
|
||||
return e -> {
|
||||
JCheckBoxMenuItem source = (JCheckBoxMenuItem)e.getSource();
|
||||
if (!source.getState() && !confirmDialog.prompt("Remove " + name + " from startup?")) {
|
||||
source.setState(true);
|
||||
return;
|
||||
}
|
||||
if (FileUtilities.setAutostart(source.getState())) {
|
||||
displayInfoMessage("Successfully " + (source.getState() ? "enabled" : "disabled") + " autostart");
|
||||
} else {
|
||||
displayErrorMessage("Error " + (source.getState() ? "enabling" : "disabling") + " autostart");
|
||||
}
|
||||
source.setState(FileUtilities.isAutostart());
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the default reload action (in this case, <code>Thread.start()</code>) to be fired
|
||||
*
|
||||
* @param reloadThread The Thread to call when reload is clicked
|
||||
*/
|
||||
public void setReloadThread(Thread reloadThread) {
|
||||
this.reloadThread = reloadThread;
|
||||
}
|
||||
|
||||
private ActionListener reloadListener = new ActionListener() {
|
||||
public void actionPerformed(ActionEvent e) {
|
||||
if (reloadThread == null) {
|
||||
showErrorDialog("Sorry, Reload has not yet been implemented.");
|
||||
} else {
|
||||
reloadThread.start();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private final ActionListener aboutListener = new ActionListener() {
|
||||
public void actionPerformed(ActionEvent e) {
|
||||
aboutDialog.setVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
private final ActionListener exitListener = new ActionListener() {
|
||||
public void actionPerformed(ActionEvent e) {
|
||||
boolean showAllNotifications = getPref(TRAY_NOTIFICATIONS);
|
||||
if (!showAllNotifications || confirmDialog.prompt("Exit " + name + "?")) { exit(0); }
|
||||
}
|
||||
};
|
||||
|
||||
public void exit(int returnCode) {
|
||||
prefs.save();
|
||||
FileUtilities.cleanup();
|
||||
System.exit(returnCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a basic error dialog.
|
||||
*/
|
||||
private void showErrorDialog(String message) {
|
||||
JOptionPane.showMessageDialog(null, message, name, JOptionPane.ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
public boolean showGatewayDialog(final RequestState request, final String prompt, final Point position) {
|
||||
if (!headless) {
|
||||
try {
|
||||
SwingUtilities.invokeAndWait(() -> gatewayDialog.prompt("%s wants to " + prompt, request, position));
|
||||
}
|
||||
catch(Exception ignore) {}
|
||||
|
||||
if (gatewayDialog.isApproved()) {
|
||||
log.info("Allowed {} to {}", request.getCertName(), prompt);
|
||||
if (gatewayDialog.isPersistent()) {
|
||||
whiteList(request.getCertUsed());
|
||||
}
|
||||
} else {
|
||||
log.info("Denied {} to {}", request.getCertName(), prompt);
|
||||
if (gatewayDialog.isPersistent()) {
|
||||
if (!request.hasCertificate()) {
|
||||
anonymousItem.doClick(); // if always block anonymous requests -> flag menu item
|
||||
} else {
|
||||
blackList(request.getCertUsed());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gatewayDialog.isApproved();
|
||||
} else {
|
||||
return request.hasSavedCert();
|
||||
}
|
||||
}
|
||||
|
||||
private void whiteList(Certificate cert) {
|
||||
if (FileUtilities.printLineToFile(Constants.ALLOW_FILE, cert.data())) {
|
||||
displayInfoMessage(String.format(Constants.ALLOW_SITES_TEXT, cert.getOrganization()));
|
||||
} else {
|
||||
displayErrorMessage("Failed to write to file (Insufficient user privileges)");
|
||||
}
|
||||
}
|
||||
|
||||
private void blackList(Certificate cert) {
|
||||
if (FileUtilities.printLineToFile(Constants.BLOCK_FILE, cert.data())) {
|
||||
displayInfoMessage(String.format(Constants.BLOCK_SITES_TEXT, cert.getOrganization()));
|
||||
} else {
|
||||
displayErrorMessage("Failed to write to file (Insufficient user privileges)");
|
||||
}
|
||||
}
|
||||
|
||||
public void setServer(Server server, WebsocketPorts websocketPorts) {
|
||||
if (server != null && server.getConnectors().length > 0) {
|
||||
singleInstanceCheck(websocketPorts);
|
||||
|
||||
displayInfoMessage("Server started on port(s) " + PrintSocketServer.getPorts(server));
|
||||
|
||||
if (!headless) {
|
||||
aboutDialog.setServer(server);
|
||||
setDefaultIcon();
|
||||
}
|
||||
} else {
|
||||
displayErrorMessage("Invalid server");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thread safe method for setting a fine status message. Messages are suppressed unless "Show all
|
||||
* notifications" is checked.
|
||||
*/
|
||||
public void displayInfoMessage(String text) {
|
||||
displayMessage(name, text, TrayIcon.MessageType.INFO);
|
||||
}
|
||||
|
||||
/**
|
||||
* Thread safe method for setting the default icon
|
||||
*/
|
||||
public void setDefaultIcon() {
|
||||
// Workaround for JDK-8252015
|
||||
if(SystemUtilities.isMac() && Constants.MASK_TRAY_SUPPORTED && !MacUtilities.jdkSupportsTemplateIcon()) {
|
||||
setIcon(DEFAULT_ICON, () -> MacUtilities.toggleTemplateIcon(tray.tray()));
|
||||
} else {
|
||||
setIcon(DEFAULT_ICON);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thread safe method for setting the error status message */
|
||||
public void displayErrorMessage(String text) {
|
||||
displayMessage(name, text, TrayIcon.MessageType.ERROR);
|
||||
}
|
||||
|
||||
/** Thread safe method for setting the danger icon */
|
||||
public void setDangerIcon() {
|
||||
setIcon(DANGER_ICON);
|
||||
}
|
||||
|
||||
/** Thread safe method for setting the warning status message */
|
||||
public void displayWarningMessage(String text) {
|
||||
displayMessage(name, text, TrayIcon.MessageType.WARNING);
|
||||
}
|
||||
|
||||
/** Thread safe method for setting the warning icon */
|
||||
public void setWarningIcon() {
|
||||
setIcon(WARNING_ICON);
|
||||
}
|
||||
|
||||
/** Thread safe method for setting the specified icon */
|
||||
private void setIcon(final IconCache.Icon i, Runnable whenDone) {
|
||||
if (tray != null && i != shownIcon) {
|
||||
shownIcon = i;
|
||||
refreshIcon(whenDone);
|
||||
}
|
||||
}
|
||||
|
||||
private void setIcon(final IconCache.Icon i) {
|
||||
setIcon(i, null);
|
||||
}
|
||||
|
||||
public void refreshIcon(final Runnable whenDone) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
tray.setIcon(shownIcon);
|
||||
if(whenDone != null) {
|
||||
whenDone.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Thread safe method for setting the specified status message
|
||||
*
|
||||
* @param caption The title of the tray message
|
||||
* @param text The text body of the tray message
|
||||
* @param level The message type: Level.INFO, .WARN, .SEVERE
|
||||
*/
|
||||
private void displayMessage(final String caption, final String text, final TrayIcon.MessageType level) {
|
||||
if (!headless) {
|
||||
if (tray != null) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
boolean showAllNotifications = getPref(TRAY_NOTIFICATIONS);
|
||||
if (showAllNotifications || level != TrayIcon.MessageType.INFO) {
|
||||
tray.displayMessage(caption, text, level);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
log.info("{}: [{}] {}", caption, level, text);
|
||||
}
|
||||
}
|
||||
|
||||
public void singleInstanceCheck(WebsocketPorts websocketPorts) {
|
||||
// Secure
|
||||
for(int port : websocketPorts.getUnusedSecurePorts()) {
|
||||
new SingleInstanceChecker(this, port, true);
|
||||
}
|
||||
// Insecure
|
||||
for(int port : websocketPorts.getUnusedInsecurePorts()) {
|
||||
new SingleInstanceChecker(this, port, false);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isMonoclePreferred() {
|
||||
return getPref(TRAY_MONOCLE);
|
||||
}
|
||||
|
||||
public boolean isHeadless() {
|
||||
return headless;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get boolean user pref: Searching "user", "app" and <code>System.getProperty(...)</code>.
|
||||
*/
|
||||
private boolean getPref(ArgValue argValue) {
|
||||
return PrefsSearch.getBoolean(argValue, prefs, App.getTrayProperties());
|
||||
}
|
||||
|
||||
private void performIfIdle(int idleQualifier, ActionListener performer) {
|
||||
if (idleTimer != null) {
|
||||
idleTimer.schedule(new TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
performer.actionPerformed(null);
|
||||
}
|
||||
}, idleQualifier);
|
||||
} else {
|
||||
log.warn("Idle actions have already been cleared due to activity, task not scheduled.");
|
||||
}
|
||||
}
|
||||
|
||||
public void voidIdleActions() {
|
||||
if (idleTimer != null) {
|
||||
log.trace("Not idle, stopping any actions that haven't ran yet");
|
||||
idleTimer.cancel();
|
||||
idleTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
13
tray/src/qz/communication/DeviceException.java
Normal file
13
tray/src/qz/communication/DeviceException.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package qz.communication;
|
||||
|
||||
public class DeviceException extends Exception {
|
||||
|
||||
public DeviceException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public DeviceException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
}
|
||||
29
tray/src/qz/communication/DeviceIO.java
Normal file
29
tray/src/qz/communication/DeviceIO.java
Normal file
@@ -0,0 +1,29 @@
|
||||
package qz.communication;
|
||||
|
||||
public interface DeviceIO extends DeviceListener {
|
||||
|
||||
String getVendorId();
|
||||
|
||||
String getProductId();
|
||||
|
||||
|
||||
void open() throws DeviceException;
|
||||
|
||||
boolean isOpen();
|
||||
|
||||
void close();
|
||||
|
||||
void setStreaming(boolean streaming);
|
||||
|
||||
boolean isStreaming();
|
||||
|
||||
|
||||
byte[] readData(int responseSize, Byte exchangeConfig) throws DeviceException;
|
||||
|
||||
void sendData(byte[] data, Byte exchangeConfig) throws DeviceException;
|
||||
|
||||
|
||||
byte[] getFeatureReport(int responseSize, Byte reportId) throws DeviceException;
|
||||
|
||||
void sendFeatureReport(byte[] data, Byte reportId) throws DeviceException;
|
||||
}
|
||||
9
tray/src/qz/communication/DeviceListener.java
Normal file
9
tray/src/qz/communication/DeviceListener.java
Normal file
@@ -0,0 +1,9 @@
|
||||
package qz.communication;
|
||||
|
||||
public interface DeviceListener {
|
||||
/**
|
||||
* Cleanup task for when a socket closes while a device is still streaming
|
||||
*/
|
||||
void close();
|
||||
|
||||
}
|
||||
130
tray/src/qz/communication/DeviceOptions.java
Normal file
130
tray/src/qz/communication/DeviceOptions.java
Normal file
@@ -0,0 +1,130 @@
|
||||
package qz.communication;
|
||||
|
||||
import org.apache.commons.lang3.builder.HashCodeBuilder;
|
||||
import org.codehaus.jettison.json.JSONObject;
|
||||
import qz.utils.UsbUtilities;
|
||||
|
||||
public class DeviceOptions {
|
||||
|
||||
public enum DeviceMode {
|
||||
HID,
|
||||
USB,
|
||||
UNKNOWN;
|
||||
public static DeviceMode parse(String callName) {
|
||||
if (callName != null) {
|
||||
if (callName.startsWith("usb")) {
|
||||
return USB;
|
||||
} else if (callName.startsWith("hid")) {
|
||||
return HID;
|
||||
}
|
||||
}
|
||||
return UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
private DeviceMode deviceMode;
|
||||
|
||||
private Integer vendorId;
|
||||
private Integer productId;
|
||||
|
||||
//usb specific
|
||||
private Byte interfaceId;
|
||||
private Byte endpoint;
|
||||
private int interval;
|
||||
private int responseSize;
|
||||
|
||||
//hid specific
|
||||
private Integer usagePage;
|
||||
private String serial;
|
||||
|
||||
public DeviceOptions(JSONObject parameters, DeviceMode deviceMode) {
|
||||
this.deviceMode = deviceMode;
|
||||
|
||||
vendorId = UsbUtilities.hexToInt(parameters.optString("vendorId"));
|
||||
productId = UsbUtilities.hexToInt(parameters.optString("productId"));
|
||||
|
||||
if (!parameters.isNull("interface")) {
|
||||
interfaceId = UsbUtilities.hexToByte(parameters.optString("interface"));
|
||||
}
|
||||
if (!parameters.isNull("endpoint")) {
|
||||
endpoint = UsbUtilities.hexToByte(parameters.optString("endpoint"));
|
||||
} else if (!parameters.isNull("reportId")) {
|
||||
endpoint = UsbUtilities.hexToByte(parameters.optString("reportId"));
|
||||
}
|
||||
interval = parameters.optInt("interval", 100);
|
||||
responseSize = parameters.optInt("responseSize");
|
||||
|
||||
if (!parameters.isNull("usagePage")) {
|
||||
usagePage = UsbUtilities.hexToInt(parameters.optString("usagePage"));
|
||||
}
|
||||
if (!parameters.isNull("serial")) {
|
||||
serial = parameters.optString("serial", "");
|
||||
serial = serial.isEmpty() ? null : serial;
|
||||
}
|
||||
}
|
||||
|
||||
public Integer getVendorId() {
|
||||
return vendorId;
|
||||
}
|
||||
|
||||
public Integer getProductId() {
|
||||
return productId;
|
||||
}
|
||||
|
||||
public Byte getInterfaceId() {
|
||||
return interfaceId;
|
||||
}
|
||||
|
||||
public Byte getEndpoint() {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
public int getInterval() {
|
||||
return interval;
|
||||
}
|
||||
|
||||
public int getResponseSize() {
|
||||
return responseSize;
|
||||
}
|
||||
|
||||
public Integer getUsagePage() {
|
||||
return usagePage;
|
||||
}
|
||||
|
||||
public String getSerial() {
|
||||
return serial;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (obj == null || !(obj instanceof DeviceOptions)) { return false; }
|
||||
|
||||
DeviceOptions that = (DeviceOptions)obj;
|
||||
|
||||
if (this.getVendorId().equals(that.getVendorId()) && this.getProductId().equals(that.getProductId())) {
|
||||
if (deviceMode == DeviceMode.USB
|
||||
&& (this.getInterfaceId() == null || that.getInterfaceId() == null || this.getInterfaceId().equals(that.getInterfaceId()))
|
||||
&& (this.getEndpoint() == null || that.getEndpoint() == null || this.getEndpoint().equals(that.getEndpoint()))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (deviceMode == DeviceMode.HID
|
||||
&& (this.getUsagePage() == null || that.getUsagePage() == null || this.getUsagePage().equals(that.getUsagePage()))
|
||||
&& (this.getSerial() == null || that.getSerial() == null || this.getSerial().equals(that.getSerial()))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return new HashCodeBuilder()
|
||||
.append(deviceMode)
|
||||
.append(vendorId)
|
||||
.append(productId)
|
||||
.toHashCode();
|
||||
}
|
||||
|
||||
}
|
||||
173
tray/src/qz/communication/FileIO.java
Normal file
173
tray/src/qz/communication/FileIO.java
Normal file
@@ -0,0 +1,173 @@
|
||||
package qz.communication;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.commons.io.IOCase;
|
||||
import org.codehaus.jettison.json.JSONArray;
|
||||
import org.codehaus.jettison.json.JSONException;
|
||||
import org.codehaus.jettison.json.JSONObject;
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import qz.ws.PrintSocketClient;
|
||||
import qz.ws.StreamEvent;
|
||||
|
||||
import java.nio.channels.ClosedChannelException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.WatchKey;
|
||||
import java.util.ArrayList;
|
||||
|
||||
public class FileIO implements DeviceListener {
|
||||
public static final String SANDBOX_DATA_SUFFIX = "sandbox";
|
||||
public static final String GLOBAL_DATA_SUFFIX = "shared";
|
||||
|
||||
// Pesky breadcrumb files that only the OS cares about
|
||||
public static final String[] DEFAULT_EXCLUSIONS = { ".DS_Store", "Thumbs.db" };
|
||||
|
||||
public static final int FILE_LISTENER_DEFAULT_LINES = 10;
|
||||
|
||||
public enum ReadType {
|
||||
BYTES, LINES
|
||||
}
|
||||
|
||||
private Session session;
|
||||
|
||||
private Path originalPath;
|
||||
private Path absolutePath;
|
||||
|
||||
private WatchKey wk;
|
||||
|
||||
private ReadType readType;
|
||||
private boolean reversed;
|
||||
private long bytes;
|
||||
private int lines;
|
||||
|
||||
private IOCase caseSensitivity;
|
||||
|
||||
private ArrayList<String> inclusions;
|
||||
private ArrayList<String> exclusions;
|
||||
|
||||
public FileIO(Session session, JSONObject params, Path originalPath, Path absolutePath) throws JSONException {
|
||||
this.session = session;
|
||||
this.originalPath = originalPath;
|
||||
this.absolutePath = absolutePath;
|
||||
|
||||
inclusions = new ArrayList<>();
|
||||
exclusions = new ArrayList<>();
|
||||
|
||||
JSONArray inc = params.optJSONArray("include");
|
||||
JSONArray exc = params.optJSONArray("exclude");
|
||||
caseSensitivity = params.optBoolean("ignoreCase", true) ? IOCase.INSENSITIVE : IOCase.SENSITIVE;
|
||||
|
||||
if (inc != null) {
|
||||
for (int i = 0; i < inc.length(); i++) {
|
||||
inclusions.add(inc.getString(i));
|
||||
}
|
||||
}
|
||||
if (exc != null) {
|
||||
for(int i = 0; i < exc.length(); i++) {
|
||||
exclusions.add(exc.getString(i));
|
||||
}
|
||||
}
|
||||
|
||||
JSONObject options = params.optJSONObject("listener");
|
||||
if (options != null) {
|
||||
// Setup defaults
|
||||
bytes = options.optLong("bytes", -1);
|
||||
if (bytes > 0) {
|
||||
readType = ReadType.BYTES;
|
||||
} else {
|
||||
readType = ReadType.LINES;
|
||||
}
|
||||
|
||||
lines = options.optInt("lines", readType == ReadType.LINES? FILE_LISTENER_DEFAULT_LINES:-1);
|
||||
reversed = options.optBoolean("reverse", readType == ReadType.LINES);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isMatch(String fileName) {
|
||||
boolean match = inclusions.isEmpty();
|
||||
for (String inclusion : inclusions) {
|
||||
if(FilenameUtils.wildcardMatch(fileName, inclusion, caseSensitivity)) {
|
||||
match = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(match) {
|
||||
// Never match on DEFAULT_EXCLUSIONS
|
||||
for(String exclusion : DEFAULT_EXCLUSIONS) {
|
||||
if (FilenameUtils.wildcardMatch(fileName, exclusion, IOCase.INSENSITIVE)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for(String exclusion : exclusions) {
|
||||
if (FilenameUtils.wildcardMatch(fileName, exclusion, caseSensitivity)) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
public boolean returnsContents() {
|
||||
return bytes > 0 || lines > 0;
|
||||
}
|
||||
|
||||
public ReadType getReadType() {
|
||||
return readType;
|
||||
}
|
||||
|
||||
public boolean isReversed() {
|
||||
return reversed;
|
||||
}
|
||||
|
||||
public long getBytes() {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public int getLines() {
|
||||
return lines;
|
||||
}
|
||||
|
||||
public Path getOriginalPath() {
|
||||
return originalPath;
|
||||
}
|
||||
|
||||
public Path getAbsolutePath() {
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
public boolean isWatching() {
|
||||
return wk != null && wk.isValid();
|
||||
}
|
||||
|
||||
public void setWk(WatchKey wk) {
|
||||
this.wk = wk;
|
||||
}
|
||||
|
||||
public void fileChanged(String fileName, String type, String fileData) throws ClosedChannelException {
|
||||
StreamEvent evt = new StreamEvent(StreamEvent.Stream.FILE, StreamEvent.Type.ACTION)
|
||||
.withData("file", getOriginalPath().resolve(fileName))
|
||||
.withData("eventType", type);
|
||||
|
||||
if (fileData != null) {
|
||||
evt.withData("fileData", fileData);
|
||||
}
|
||||
|
||||
PrintSocketClient.sendStream(session, evt);
|
||||
}
|
||||
|
||||
public void sendError(String message) throws ClosedChannelException {
|
||||
StreamEvent eventErr = new StreamEvent(StreamEvent.Stream.FILE, StreamEvent.Type.ERROR)
|
||||
.withData("message", message);
|
||||
PrintSocketClient.sendStream(session, eventErr);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (wk != null) {
|
||||
wk.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
67
tray/src/qz/communication/FileParams.java
Normal file
67
tray/src/qz/communication/FileParams.java
Normal file
@@ -0,0 +1,67 @@
|
||||
package qz.communication;
|
||||
|
||||
import org.codehaus.jettison.json.JSONException;
|
||||
import org.codehaus.jettison.json.JSONObject;
|
||||
import qz.utils.ByteUtilities;
|
||||
import qz.utils.PrintingUtilities.Flavor;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.OpenOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
|
||||
/**
|
||||
* Created by Kyle on 2/28/2018.
|
||||
*/
|
||||
public class FileParams {
|
||||
private Path path;
|
||||
private String data;
|
||||
private Flavor flavor;
|
||||
|
||||
private boolean shared;
|
||||
private boolean sandbox;
|
||||
|
||||
private OpenOption appendMode;
|
||||
|
||||
|
||||
public FileParams(JSONObject params) throws JSONException {
|
||||
path = Paths.get(params.getString("path"));
|
||||
data = params.optString("data", "");
|
||||
flavor = Flavor.parse(params, Flavor.PLAIN);
|
||||
|
||||
shared = params.optBoolean("shared", true);
|
||||
sandbox = params.optBoolean("sandbox", true);
|
||||
|
||||
appendMode = params.optBoolean("append")? StandardOpenOption.APPEND:StandardOpenOption.TRUNCATE_EXISTING;
|
||||
}
|
||||
|
||||
public Path getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public String toString(byte[] bytes) {
|
||||
return ByteUtilities.toString(flavor, bytes);
|
||||
}
|
||||
|
||||
public byte[] getData() throws IOException {
|
||||
return flavor.read(data);
|
||||
}
|
||||
|
||||
public Flavor getFlavor() {
|
||||
return flavor;
|
||||
}
|
||||
|
||||
public boolean isShared() {
|
||||
return shared;
|
||||
}
|
||||
|
||||
public boolean isSandbox() {
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
public OpenOption getAppendMode() {
|
||||
return appendMode;
|
||||
}
|
||||
|
||||
}
|
||||
110
tray/src/qz/communication/H4J_HidIO.java
Normal file
110
tray/src/qz/communication/H4J_HidIO.java
Normal file
@@ -0,0 +1,110 @@
|
||||
package qz.communication;
|
||||
|
||||
import org.hid4java.HidDevice;
|
||||
import qz.ws.SocketConnection;
|
||||
|
||||
import javax.usb.util.UsbUtil;
|
||||
|
||||
public class H4J_HidIO implements DeviceIO, DeviceListener {
|
||||
|
||||
private HidDevice device;
|
||||
|
||||
private boolean streaming;
|
||||
|
||||
private DeviceOptions dOpts;
|
||||
private SocketConnection websocket;
|
||||
|
||||
|
||||
public H4J_HidIO(DeviceOptions dOpts, SocketConnection websocket) throws DeviceException {
|
||||
this(H4J_HidUtilities.findDevice(dOpts), dOpts, websocket);
|
||||
}
|
||||
|
||||
private H4J_HidIO(HidDevice device, DeviceOptions dOpts, SocketConnection websocket) throws DeviceException {
|
||||
this.dOpts = dOpts;
|
||||
this.websocket = websocket;
|
||||
if (device == null) {
|
||||
throw new DeviceException("HID device could not be found");
|
||||
}
|
||||
|
||||
this.device = device;
|
||||
}
|
||||
|
||||
public void open() {
|
||||
if (!isOpen()) {
|
||||
device.open();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isOpen() {
|
||||
return !device.isClosed();
|
||||
}
|
||||
|
||||
public void setStreaming(boolean active) {
|
||||
streaming = active;
|
||||
}
|
||||
|
||||
public boolean isStreaming() {
|
||||
return streaming;
|
||||
}
|
||||
|
||||
public String getVendorId() {
|
||||
return UsbUtil.toHexString(device.getVendorId());
|
||||
}
|
||||
|
||||
public String getProductId() {
|
||||
return UsbUtil.toHexString(device.getProductId());
|
||||
}
|
||||
|
||||
public byte[] readData(int responseSize, Byte unused) throws DeviceException {
|
||||
byte[] response = new byte[responseSize];
|
||||
|
||||
int read = device.read(response);
|
||||
if (read == -1) {
|
||||
throw new DeviceException("Failed to read from device");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public void sendData(byte[] data, Byte reportId) throws DeviceException {
|
||||
if (reportId == null) { reportId = (byte)0x00; }
|
||||
|
||||
int wrote = device.write(data, data.length, reportId);
|
||||
if (wrote == -1) {
|
||||
throw new DeviceException("Failed to write to device");
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] getFeatureReport(int responseSize, Byte reportId) throws DeviceException {
|
||||
if (reportId == null) { reportId = (byte)0x00; }
|
||||
byte[] response = new byte[responseSize];
|
||||
|
||||
int read = device.getFeatureReport(response, reportId);
|
||||
if (read == -1) {
|
||||
throw new DeviceException("Failed to read from device");
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
}
|
||||
|
||||
public void sendFeatureReport(byte[] data, Byte reportId) throws DeviceException {
|
||||
if (reportId == null) { reportId = (byte)0x00; }
|
||||
|
||||
int wrote = device.sendFeatureReport(data, reportId);
|
||||
if (wrote == -1) {
|
||||
throw new DeviceException("Failed to write to device");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
setStreaming(false);
|
||||
// Remove orphaned reference
|
||||
websocket.removeDevice(dOpts);
|
||||
if (isOpen()) {
|
||||
device.close();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
83
tray/src/qz/communication/H4J_HidListener.java
Normal file
83
tray/src/qz/communication/H4J_HidListener.java
Normal file
@@ -0,0 +1,83 @@
|
||||
package qz.communication;
|
||||
|
||||
import org.codehaus.jettison.json.JSONArray;
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.hid4java.HidDevice;
|
||||
import org.hid4java.HidManager;
|
||||
import org.hid4java.HidServicesListener;
|
||||
import org.hid4java.event.HidServicesEvent;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.ws.PrintSocketClient;
|
||||
import qz.ws.StreamEvent;
|
||||
|
||||
import javax.usb.util.UsbUtil;
|
||||
|
||||
public class H4J_HidListener implements DeviceListener, HidServicesListener {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(H4J_HidListener.class);
|
||||
|
||||
private Session session;
|
||||
|
||||
|
||||
public H4J_HidListener(Session session) {
|
||||
HidManager.getHidServices().addHidServicesListener(this);
|
||||
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void hidFailure(HidServicesEvent hidServicesEvent) {
|
||||
log.debug("Device failure: {}", hidServicesEvent.getHidDevice().getProduct());
|
||||
PrintSocketClient.sendStream(session, createStreamAction(hidServicesEvent.getHidDevice(), "Device Failure"), this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hidDataReceived(HidServicesEvent hidServicesEvent) {
|
||||
log.debug("Data received: {}", hidServicesEvent.getDataReceived().length + " bytes");
|
||||
|
||||
JSONArray hex = new JSONArray();
|
||||
for(byte b : hidServicesEvent.getDataReceived()) {
|
||||
hex.put(UsbUtil.toHexString(b));
|
||||
}
|
||||
|
||||
PrintSocketClient.sendStream(session, createStreamAction(hidServicesEvent.getHidDevice(), "Data Received", hex), this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hidDeviceDetached(HidServicesEvent hidServicesEvent) {
|
||||
log.debug("Device detached: {}", hidServicesEvent.getHidDevice().getProduct());
|
||||
PrintSocketClient.sendStream(session, createStreamAction(hidServicesEvent.getHidDevice(), "Device Detached"), this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hidDeviceAttached(HidServicesEvent hidServicesEvent) {
|
||||
log.debug("Device attached: {}", hidServicesEvent.getHidDevice().getProduct());
|
||||
PrintSocketClient.sendStream(session, createStreamAction(hidServicesEvent.getHidDevice(), "Device Attached"), this);
|
||||
}
|
||||
|
||||
private StreamEvent createStreamAction(HidDevice device, String action) {
|
||||
return createStreamAction(device, action, null);
|
||||
}
|
||||
|
||||
private StreamEvent createStreamAction(HidDevice device, String action, JSONArray dataArr) {
|
||||
StreamEvent event = new StreamEvent(StreamEvent.Stream.HID, StreamEvent.Type.ACTION)
|
||||
.withData("vendorId", UsbUtil.toHexString(device.getVendorId()))
|
||||
.withData("productId", UsbUtil.toHexString(device.getProductId()))
|
||||
.withData("actionType", action);
|
||||
|
||||
if (dataArr != null) {
|
||||
event.withData("data", dataArr);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
HidManager.getHidServices().removeHidServicesListener(this);
|
||||
}
|
||||
|
||||
}
|
||||
65
tray/src/qz/communication/H4J_HidUtilities.java
Normal file
65
tray/src/qz/communication/H4J_HidUtilities.java
Normal file
@@ -0,0 +1,65 @@
|
||||
package qz.communication;
|
||||
|
||||
|
||||
import org.codehaus.jettison.json.JSONArray;
|
||||
import org.codehaus.jettison.json.JSONException;
|
||||
import org.codehaus.jettison.json.JSONObject;
|
||||
import org.hid4java.HidDevice;
|
||||
import org.hid4java.HidManager;
|
||||
import org.hid4java.HidServices;
|
||||
|
||||
import javax.usb.util.UsbUtil;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
public class H4J_HidUtilities {
|
||||
private static final HidServices service = HidManager.getHidServices();
|
||||
|
||||
public static List<HidDevice> getHidDevices() {
|
||||
return service.getAttachedHidDevices();
|
||||
}
|
||||
|
||||
public static JSONArray getHidDevicesJSON() throws JSONException {
|
||||
List<HidDevice> devices = getHidDevices();
|
||||
JSONArray devicesJSON = new JSONArray();
|
||||
|
||||
HashSet<String> unique = new HashSet<>();
|
||||
for(HidDevice device : devices) {
|
||||
JSONObject deviceJSON = new JSONObject();
|
||||
|
||||
deviceJSON.put("vendorId", UsbUtil.toHexString(device.getVendorId()))
|
||||
.put("productId", UsbUtil.toHexString(device.getProductId()))
|
||||
.put("usagePage", UsbUtil.toHexString((short)device.getUsagePage()))
|
||||
.put("serial", device.getSerialNumber())
|
||||
.put("manufacturer", device.getManufacturer())
|
||||
.put("product", device.getProduct());
|
||||
|
||||
String uid = String.format("v%sp%su%ss%s", deviceJSON.optString("vendorId"), deviceJSON.optString("productId"), deviceJSON.optString("usagePage"), deviceJSON.optString("serial"));
|
||||
if (!unique.contains(uid)) {
|
||||
devicesJSON.put(deviceJSON);
|
||||
unique.add(uid);
|
||||
}
|
||||
}
|
||||
|
||||
return devicesJSON;
|
||||
}
|
||||
|
||||
public static HidDevice findDevice(DeviceOptions dOpts) {
|
||||
if (dOpts.getVendorId() == null) {
|
||||
throw new IllegalArgumentException("Vendor ID cannot be null");
|
||||
}
|
||||
if (dOpts.getProductId() == null) {
|
||||
throw new IllegalArgumentException("Product ID cannot be null");
|
||||
}
|
||||
|
||||
List<HidDevice> devices = getHidDevices();
|
||||
for(HidDevice device : devices) {
|
||||
if (device.isVidPidSerial(dOpts.getVendorId(), dOpts.getProductId(), dOpts.getSerial())
|
||||
&& (dOpts.getUsagePage() == null || dOpts.getUsagePage() == device.getUsagePage())) {
|
||||
return device;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
154
tray/src/qz/communication/PJHA_HidIO.java
Normal file
154
tray/src/qz/communication/PJHA_HidIO.java
Normal file
@@ -0,0 +1,154 @@
|
||||
package qz.communication;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import purejavahidapi.HidDevice;
|
||||
import purejavahidapi.HidDeviceInfo;
|
||||
import purejavahidapi.InputReportListener;
|
||||
import purejavahidapi.PureJavaHidApi;
|
||||
import qz.utils.SystemUtilities;
|
||||
import qz.ws.SocketConnection;
|
||||
|
||||
import javax.usb.util.UsbUtil;
|
||||
import java.io.IOException;
|
||||
import java.util.Vector;
|
||||
|
||||
public class PJHA_HidIO implements DeviceIO {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(PJHA_HidIO.class);
|
||||
|
||||
private HidDeviceInfo deviceInfo;
|
||||
private HidDevice device;
|
||||
|
||||
private static final int BUFFER_SIZE = 32;
|
||||
private Vector<byte[]> dataBuffer;
|
||||
private boolean streaming;
|
||||
private DeviceOptions dOpts;
|
||||
private SocketConnection websocket;
|
||||
|
||||
public PJHA_HidIO(DeviceOptions dOpts, SocketConnection websocket) throws DeviceException {
|
||||
this(PJHA_HidUtilities.findDevice(dOpts), dOpts, websocket);
|
||||
}
|
||||
|
||||
private PJHA_HidIO(HidDeviceInfo deviceInfo, DeviceOptions dOpts, SocketConnection websocket) throws DeviceException {
|
||||
this.dOpts = dOpts;
|
||||
this.websocket = websocket;
|
||||
if (deviceInfo == null) {
|
||||
throw new DeviceException("HID device could not be found");
|
||||
}
|
||||
|
||||
this.deviceInfo = deviceInfo;
|
||||
|
||||
dataBuffer = new Vector<byte[]>() {
|
||||
@Override
|
||||
public synchronized boolean add(byte[] e) {
|
||||
while(this.size() >= BUFFER_SIZE) {
|
||||
this.remove(0);
|
||||
}
|
||||
return super.add(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public void open() throws DeviceException {
|
||||
if (!isOpen()) {
|
||||
try {
|
||||
device = PureJavaHidApi.openDevice(deviceInfo);
|
||||
device.setInputReportListener(new InputReportListener() {
|
||||
@Override
|
||||
public void onInputReport(HidDevice source, byte id, byte[] data, int len) {
|
||||
byte[] dataCopy = new byte[len];
|
||||
System.arraycopy(data, 0, dataCopy, 0, len);
|
||||
dataBuffer.add(dataCopy);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch(IOException ex) {
|
||||
throw new DeviceException(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isOpen() {
|
||||
return device != null;
|
||||
}
|
||||
|
||||
public void setStreaming(boolean active) {
|
||||
streaming = active;
|
||||
}
|
||||
|
||||
public boolean isStreaming() {
|
||||
return streaming;
|
||||
}
|
||||
|
||||
public String getVendorId() {
|
||||
return UsbUtil.toHexString(deviceInfo.getVendorId());
|
||||
}
|
||||
|
||||
public String getProductId() {
|
||||
return UsbUtil.toHexString(deviceInfo.getProductId());
|
||||
}
|
||||
|
||||
public byte[] readData(int responseSize, Byte unused) throws DeviceException {
|
||||
byte[] response = new byte[responseSize];
|
||||
if (dataBuffer.isEmpty()) {
|
||||
return new byte[0]; //no data received yet
|
||||
}
|
||||
|
||||
byte[] latestData = dataBuffer.remove(0);
|
||||
if (SystemUtilities.isWindows()) {
|
||||
//windows missing the leading byte
|
||||
System.arraycopy(latestData, 0, response, 1, Math.min(responseSize - 1, latestData.length));
|
||||
} else {
|
||||
System.arraycopy(latestData, 0, response, 0, Math.min(responseSize - 1, latestData.length));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
public void sendData(byte[] data, Byte reportId) throws DeviceException {
|
||||
if (reportId == null) { reportId = (byte)0x00; }
|
||||
|
||||
int wrote = device.setOutputReport(reportId, data, data.length);
|
||||
if (wrote == -1) {
|
||||
throw new DeviceException("Failed to write to device");
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] getFeatureReport(int responseSize, Byte unused) throws DeviceException {
|
||||
byte[] response = new byte[responseSize];
|
||||
int read = device.getFeatureReport(response, responseSize);
|
||||
if (read == -1) {
|
||||
throw new DeviceException("Failed to read from device");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
public void sendFeatureReport(byte[] data, Byte reportId) throws DeviceException {
|
||||
if (reportId == null) { reportId = (byte)0x00; }
|
||||
int wrote = device.setFeatureReport(reportId, data, data.length);
|
||||
|
||||
if (wrote == -1) {
|
||||
throw new DeviceException("Failed to write to device");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
setStreaming(false);
|
||||
// Remove orphaned reference
|
||||
websocket.removeDevice(dOpts);
|
||||
if (isOpen()) {
|
||||
try {
|
||||
device.setInputReportListener(null);
|
||||
device.close();
|
||||
}
|
||||
catch(IllegalStateException e) {
|
||||
log.warn("Device already closed");
|
||||
}
|
||||
}
|
||||
|
||||
device = null;
|
||||
}
|
||||
|
||||
}
|
||||
50
tray/src/qz/communication/PJHA_HidListener.java
Normal file
50
tray/src/qz/communication/PJHA_HidListener.java
Normal file
@@ -0,0 +1,50 @@
|
||||
package qz.communication;
|
||||
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import purejavahidapi.DeviceRemovalListener;
|
||||
import purejavahidapi.HidDevice;
|
||||
import qz.ws.PrintSocketClient;
|
||||
import qz.ws.StreamEvent;
|
||||
|
||||
import javax.usb.util.UsbUtil;
|
||||
|
||||
public class PJHA_HidListener implements DeviceListener, DeviceRemovalListener {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(PJHA_HidListener.class);
|
||||
|
||||
private Session session;
|
||||
private HidDevice device;
|
||||
|
||||
|
||||
public PJHA_HidListener(Session session) {
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
public void setDevice(HidDevice device) {
|
||||
this.device = device;
|
||||
device.setDeviceRemovalListener(this);
|
||||
}
|
||||
|
||||
private StreamEvent createStreamAction(HidDevice device, String action) {
|
||||
return new StreamEvent(StreamEvent.Stream.HID, StreamEvent.Type.ACTION)
|
||||
.withData("vendorId", UsbUtil.toHexString(device.getHidDeviceInfo().getVendorId()))
|
||||
.withData("productId", UsbUtil.toHexString(device.getHidDeviceInfo().getProductId()))
|
||||
.withData("actionType", action);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (device != null) {
|
||||
device.setDeviceRemovalListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDeviceRemoval(HidDevice device) {
|
||||
log.debug("Device detached: {}", device.getHidDeviceInfo().getProductString());
|
||||
PrintSocketClient.sendStream(session, createStreamAction(device, "Device Detached"), this);
|
||||
}
|
||||
}
|
||||
56
tray/src/qz/communication/PJHA_HidUtilities.java
Normal file
56
tray/src/qz/communication/PJHA_HidUtilities.java
Normal file
@@ -0,0 +1,56 @@
|
||||
package qz.communication;
|
||||
|
||||
|
||||
import org.codehaus.jettison.json.JSONArray;
|
||||
import org.codehaus.jettison.json.JSONException;
|
||||
import org.codehaus.jettison.json.JSONObject;
|
||||
import purejavahidapi.HidDeviceInfo;
|
||||
import purejavahidapi.PureJavaHidApi;
|
||||
|
||||
import javax.usb.util.UsbUtil;
|
||||
import java.util.List;
|
||||
|
||||
public class PJHA_HidUtilities {
|
||||
|
||||
public static JSONArray getHidDevicesJSON() throws JSONException {
|
||||
List<HidDeviceInfo> devices = PureJavaHidApi.enumerateDevices();
|
||||
JSONArray devicesJSON = new JSONArray();
|
||||
|
||||
for(HidDeviceInfo device : devices) {
|
||||
JSONObject deviceJSON = new JSONObject();
|
||||
|
||||
deviceJSON.put("vendorId", UsbUtil.toHexString(device.getVendorId()))
|
||||
.put("productId", UsbUtil.toHexString(device.getProductId()))
|
||||
.put("usagePage", UsbUtil.toHexString(device.getUsagePage()))
|
||||
.put("serial", device.getSerialNumberString())
|
||||
.put("manufacturer", device.getManufacturerString())
|
||||
.put("product", device.getProductString());
|
||||
|
||||
devicesJSON.put(deviceJSON);
|
||||
}
|
||||
|
||||
return devicesJSON;
|
||||
}
|
||||
|
||||
public static HidDeviceInfo findDevice(DeviceOptions dOpts) {
|
||||
if (dOpts.getVendorId() == null) {
|
||||
throw new IllegalArgumentException("Vendor ID cannot be null");
|
||||
}
|
||||
if (dOpts.getProductId() == null) {
|
||||
throw new IllegalArgumentException("Product ID cannot be null");
|
||||
}
|
||||
|
||||
|
||||
List<HidDeviceInfo> devList = PureJavaHidApi.enumerateDevices();
|
||||
for(HidDeviceInfo device : devList) {
|
||||
if (device.getVendorId() == dOpts.getVendorId().shortValue() && device.getProductId() == dOpts.getProductId().shortValue()
|
||||
&& (dOpts.getUsagePage() == null || dOpts.getUsagePage().shortValue() == device.getUsagePage())
|
||||
&& (dOpts.getSerial() == null || dOpts.getSerial().equals(device.getSerialNumberString()))) {
|
||||
return device;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
297
tray/src/qz/communication/SerialIO.java
Normal file
297
tray/src/qz/communication/SerialIO.java
Normal file
@@ -0,0 +1,297 @@
|
||||
package qz.communication;
|
||||
|
||||
import jssc.*;
|
||||
import org.apache.commons.codec.binary.StringUtils;
|
||||
import org.codehaus.jettison.json.JSONException;
|
||||
import org.codehaus.jettison.json.JSONObject;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.common.ByteArrayBuilder;
|
||||
import qz.utils.ByteUtilities;
|
||||
import qz.utils.DeviceUtilities;
|
||||
import qz.ws.SocketConnection;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* @author Tres
|
||||
*/
|
||||
public class SerialIO implements DeviceListener {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(SerialIO.class);
|
||||
|
||||
// Timeout to wait before giving up on reading the specified amount of bytes
|
||||
private static final int TIMEOUT = 1200;
|
||||
|
||||
private String portName;
|
||||
private SerialPort port;
|
||||
private SerialOptions serialOpts;
|
||||
|
||||
private ByteArrayBuilder data = new ByteArrayBuilder();
|
||||
|
||||
private SocketConnection websocket;
|
||||
|
||||
|
||||
/**
|
||||
* Controller for serial communications
|
||||
*
|
||||
* @param portName Port name to open, such as "COM1" or "/dev/tty0/"
|
||||
*/
|
||||
public SerialIO(String portName, SocketConnection websocket) {
|
||||
this.portName = portName;
|
||||
this.websocket = websocket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the specified port name.
|
||||
*
|
||||
* @param opts Parsed serial options
|
||||
* @return Boolean indicating success.
|
||||
* @throws SerialPortException If the port fails to open.
|
||||
*/
|
||||
public boolean open(SerialOptions opts) throws SerialPortException {
|
||||
if (isOpen()) {
|
||||
log.warn("Serial port [{}] is already open", portName);
|
||||
return false;
|
||||
}
|
||||
|
||||
port = new SerialPort(portName);
|
||||
port.openPort();
|
||||
|
||||
serialOpts = new SerialOptions();
|
||||
setOptions(opts);
|
||||
|
||||
return port.isOpened();
|
||||
}
|
||||
|
||||
public void applyPortListener(SerialPortEventListener listener) throws SerialPortException {
|
||||
port.addEventListener(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Boolean indicating if port is currently open.
|
||||
*/
|
||||
public boolean isOpen() {
|
||||
return port != null && port.isOpened();
|
||||
}
|
||||
|
||||
public String processSerialEvent(SerialPortEvent event) {
|
||||
SerialOptions.ResponseFormat format = serialOpts.getResponseFormat();
|
||||
|
||||
try {
|
||||
// Receive data
|
||||
if (event.isRXCHAR()) {
|
||||
data.append(port.readBytes(event.getEventValue(), TIMEOUT));
|
||||
|
||||
String response = null;
|
||||
if (format.isBoundNewline()) {
|
||||
//process as line delimited
|
||||
|
||||
// check for CR AND NL
|
||||
Integer endIdx = ByteUtilities.firstMatchingIndex(data.getByteArray(), new byte[] {'\r', '\n'});
|
||||
int delimSize = 2;
|
||||
|
||||
// check for CR OR NL
|
||||
if(endIdx == null) {
|
||||
endIdx = min(
|
||||
ByteUtilities.firstMatchingIndex(data.getByteArray(), new byte[] {'\r'}),
|
||||
ByteUtilities.firstMatchingIndex(data.getByteArray(), new byte[] {'\n'}));
|
||||
delimSize = 1;
|
||||
}
|
||||
if (endIdx != null) {
|
||||
log.trace("Reading newline-delimited response");
|
||||
byte[] output = new byte[endIdx];
|
||||
System.arraycopy(data.getByteArray(), 0, output, 0, endIdx);
|
||||
String buffer = new String(output, format.getEncoding());
|
||||
|
||||
if (!buffer.isEmpty()) {
|
||||
//send non-empty string
|
||||
response = buffer;
|
||||
}
|
||||
|
||||
data.clearRange(0, endIdx + delimSize);
|
||||
}
|
||||
} else if (format.getBoundStart() != null && format.getBoundStart().length > 0) {
|
||||
//process as formatted response
|
||||
Integer startIdx = ByteUtilities.firstMatchingIndex(data.getByteArray(), format.getBoundStart());
|
||||
|
||||
if (startIdx != null) {
|
||||
int startOffset = startIdx + format.getBoundStart().length;
|
||||
|
||||
int copyLength = 0;
|
||||
int endIdx = 0;
|
||||
|
||||
if (format.getBoundEnd() != null && format.getBoundEnd().length > 0) {
|
||||
//process as bounded response
|
||||
Integer boundEnd = ByteUtilities.firstMatchingIndex(data.getByteArray(), format.getBoundEnd(), startIdx);
|
||||
|
||||
if (boundEnd != null) {
|
||||
log.trace("Reading bounded response");
|
||||
|
||||
copyLength = boundEnd - startOffset;
|
||||
endIdx = boundEnd + 1;
|
||||
if (format.isIncludeStart()) {
|
||||
//also include the ending bytes
|
||||
copyLength += format.getBoundEnd().length;
|
||||
}
|
||||
}
|
||||
} else if (format.getFixedWidth() > 0) {
|
||||
//process as fixed length prefixed response
|
||||
log.trace("Reading fixed length prefixed response");
|
||||
|
||||
copyLength = format.getFixedWidth();
|
||||
endIdx = startOffset + format.getFixedWidth();
|
||||
} else if (format.getLength() != null) {
|
||||
//process as dynamic formatted response
|
||||
SerialOptions.ByteParam lengthParam = format.getLength();
|
||||
|
||||
if (data.getLength() > startOffset + lengthParam.getIndex() + lengthParam.getLength()) { //ensure there's length bytes to read
|
||||
log.trace("Reading dynamic formatted response");
|
||||
|
||||
int expectedLength = ByteUtilities.parseBytes(data.getByteArray(), startOffset + lengthParam.getIndex(), lengthParam.getLength(), lengthParam.getEndian());
|
||||
log.trace("Found length byte, expecting {} bytes", expectedLength);
|
||||
|
||||
startOffset += lengthParam.getIndex() + lengthParam.getLength(); // don't include the length byte(s) in the response
|
||||
copyLength = expectedLength;
|
||||
endIdx = startOffset + copyLength;
|
||||
|
||||
if (format.getCrc() != null) {
|
||||
SerialOptions.ByteParam crcParam = format.getCrc();
|
||||
|
||||
log.trace("Expecting {} crc bytes", crcParam.getLength());
|
||||
int expand = crcParam.getIndex() + crcParam.getLength();
|
||||
|
||||
//include crc in copy
|
||||
copyLength += expand;
|
||||
endIdx += expand;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
//process as header formatted raw response - high risk of lost data, likely unintended settings
|
||||
log.warn("Reading header formatted raw response, are you missing an rx option?");
|
||||
|
||||
copyLength = data.getLength() - startOffset;
|
||||
endIdx = data.getLength();
|
||||
}
|
||||
|
||||
|
||||
if (copyLength > 0 && data.getLength() >= endIdx) {
|
||||
log.debug("Response format readable, starting copy");
|
||||
|
||||
if (format.isIncludeStart()) {
|
||||
//increase length to account for header bytes and bump offset back to include in copy
|
||||
copyLength += (startOffset - startIdx);
|
||||
startOffset = startIdx;
|
||||
}
|
||||
|
||||
byte[] responseData = new byte[copyLength];
|
||||
System.arraycopy(data.getByteArray(), startOffset, responseData, 0, copyLength);
|
||||
|
||||
response = new String(responseData, format.getEncoding());
|
||||
data.clearRange(startIdx, endIdx);
|
||||
}
|
||||
}
|
||||
} else if (format.getFixedWidth() > 0) {
|
||||
if (data.getLength() >= format.getFixedWidth()) {
|
||||
//process as fixed width response
|
||||
log.trace("Reading fixed length response");
|
||||
|
||||
byte[] output = new byte[format.getFixedWidth()];
|
||||
System.arraycopy(data.getByteArray(), 0, output, 0, format.getFixedWidth());
|
||||
|
||||
response = StringUtils.newStringUtf8(output);
|
||||
data.clearRange(0, format.getFixedWidth());
|
||||
}
|
||||
} else {
|
||||
//no processing, return raw
|
||||
log.trace("Reading raw response");
|
||||
|
||||
response = new String(data.getByteArray(), format.getEncoding());
|
||||
data.clear();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
catch(SerialPortException e) {
|
||||
log.error("Exception occurred while reading data from port.", e);
|
||||
}
|
||||
catch(SerialPortTimeoutException e) {
|
||||
log.error("Timeout occurred waiting for port to respond.", e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets and caches the properties as to not set them every data call
|
||||
*
|
||||
* @throws SerialPortException If the properties fail to set
|
||||
*/
|
||||
private void setOptions(SerialOptions opts) throws SerialPortException {
|
||||
if (opts == null) { return; }
|
||||
|
||||
SerialOptions.PortSettings ps = opts.getPortSettings();
|
||||
if (ps != null && !ps.equals(serialOpts.getPortSettings())) {
|
||||
log.debug("Applying new port settings");
|
||||
port.setParams(ps.getBaudRate(), ps.getDataBits(), ps.getStopBits(), ps.getParity());
|
||||
port.setFlowControlMode(ps.getFlowControl());
|
||||
serialOpts.setPortSettings(ps);
|
||||
}
|
||||
|
||||
SerialOptions.ResponseFormat rf = opts.getResponseFormat();
|
||||
if (rf != null) {
|
||||
log.debug("Applying new response formatting");
|
||||
serialOpts.setResponseFormat(rf);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the port parameters and writes the buffered data to the serial port.
|
||||
*/
|
||||
public void sendData(JSONObject params, SerialOptions opts) throws JSONException, IOException, SerialPortException {
|
||||
if (opts != null) {
|
||||
setOptions(opts);
|
||||
}
|
||||
|
||||
log.debug("Sending data over [{}]", portName);
|
||||
port.writeBytes(DeviceUtilities.getDataBytes(params, serialOpts.getPortSettings().getEncoding()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the serial port, if open.
|
||||
*
|
||||
* @throws SerialPortException If the port fails to close.
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
// Remove orphaned reference
|
||||
websocket.removeSerialPort(portName);
|
||||
|
||||
if (!isOpen()) {
|
||||
log.warn("Serial port [{}] is not open.", portName);
|
||||
}
|
||||
|
||||
try {
|
||||
boolean closed = port.closePort();
|
||||
if (closed) {
|
||||
log.info("Serial port [{}] closed successfully.", portName);
|
||||
} else {
|
||||
// Handle ambiguity in JSSCs API
|
||||
throw new SerialPortException(portName, "closePort", "Port not closed");
|
||||
}
|
||||
} catch(SerialPortException e) {
|
||||
log.warn("Serial port [{}] was not closed properly.", portName);
|
||||
}
|
||||
|
||||
port = null;
|
||||
portName = null;
|
||||
}
|
||||
|
||||
private Integer min(Integer a, Integer b) {
|
||||
if (a == null) { return b; }
|
||||
if (b == null) { return a; }
|
||||
return Math.min(a, b);
|
||||
}
|
||||
|
||||
}
|
||||
350
tray/src/qz/communication/SerialOptions.java
Normal file
350
tray/src/qz/communication/SerialOptions.java
Normal file
@@ -0,0 +1,350 @@
|
||||
package qz.communication;
|
||||
|
||||
import jssc.SerialPort;
|
||||
import org.apache.commons.lang3.ArrayUtils;
|
||||
import org.codehaus.jettison.json.JSONArray;
|
||||
import org.codehaus.jettison.json.JSONException;
|
||||
import org.codehaus.jettison.json.JSONObject;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.utils.ByteUtilities;
|
||||
import qz.utils.DeviceUtilities;
|
||||
import qz.utils.LoggerUtilities;
|
||||
import qz.utils.SerialUtilities;
|
||||
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Locale;
|
||||
|
||||
public class SerialOptions {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(SerialOptions.class);
|
||||
|
||||
private static final String DEFAULT_BEGIN = "0x0002";
|
||||
private static final String DEFAULT_END = "0x000D";
|
||||
|
||||
private PortSettings portSettings = null;
|
||||
private ResponseFormat responseFormat = null;
|
||||
|
||||
/**
|
||||
* Creates an empty/default options object
|
||||
*/
|
||||
public SerialOptions() {
|
||||
portSettings = new PortSettings();
|
||||
responseFormat = new ResponseFormat();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the provided JSON object into relevant SerialPort constants
|
||||
*/
|
||||
public SerialOptions(JSONObject serialOpts, boolean isOpening) {
|
||||
if (serialOpts == null) { return; }
|
||||
|
||||
//only apply port settings if opening or explicitly set in a send data call
|
||||
if (isOpening || serialOpts.has("baudRate") || serialOpts.has("dataBits") || serialOpts.has("stopBits") || serialOpts.has("parity") || serialOpts.has("flowControl")) {
|
||||
portSettings = new PortSettings();
|
||||
|
||||
if (!serialOpts.isNull("baudRate")) {
|
||||
try { portSettings.baudRate = SerialUtilities.parseBaudRate(serialOpts.getString("baudRate")); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "string", "baudRate", serialOpts.opt("baudRate")); }
|
||||
}
|
||||
|
||||
if (!serialOpts.isNull("dataBits")) {
|
||||
try { portSettings.dataBits = SerialUtilities.parseDataBits(serialOpts.getString("dataBits")); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "string", "dataBits", serialOpts.opt("dataBits")); }
|
||||
}
|
||||
|
||||
if (!serialOpts.isNull("stopBits")) {
|
||||
try { portSettings.stopBits = SerialUtilities.parseStopBits(serialOpts.getString("stopBits")); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "string", "stopBits", serialOpts.opt("stopBits")); }
|
||||
}
|
||||
|
||||
if (!serialOpts.isNull("parity")) {
|
||||
try { portSettings.parity = SerialUtilities.parseParity(serialOpts.getString("parity")); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "string", "parity", serialOpts.opt("parity")); }
|
||||
}
|
||||
|
||||
if (!serialOpts.isNull("flowControl")) {
|
||||
try { portSettings.flowControl = SerialUtilities.parseFlowControl(serialOpts.getString("flowControl")); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "string", "flowControl", serialOpts.opt("flowControl")); }
|
||||
}
|
||||
|
||||
if (!serialOpts.isNull("encoding") && !serialOpts.optString("encoding").isEmpty()) {
|
||||
try { portSettings.encoding = Charset.forName(serialOpts.getString("encoding")); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "string", "encoding", serialOpts.opt("encoding")); }
|
||||
}
|
||||
}
|
||||
|
||||
if (!serialOpts.isNull("rx")) {
|
||||
responseFormat = new ResponseFormat();
|
||||
//Make the response encoding default to the port encoding. If this is removed it will default to UTF-8
|
||||
responseFormat.encoding = portSettings.encoding;
|
||||
|
||||
JSONObject respOpts = serialOpts.optJSONObject("rx");
|
||||
if (respOpts != null) {
|
||||
if (!respOpts.isNull("start")) {
|
||||
try {
|
||||
JSONArray startBits = respOpts.getJSONArray("start");
|
||||
ArrayList<Byte> bytes = new ArrayList<>();
|
||||
for(int i = 0; i < startBits.length(); i++) {
|
||||
byte[] charByte = DeviceUtilities.characterBytes(startBits.getString(i), responseFormat.encoding);
|
||||
for(byte b : charByte) { bytes.add(b); }
|
||||
}
|
||||
responseFormat.boundStart = ArrayUtils.toPrimitive(bytes.toArray(new Byte[0]));
|
||||
}
|
||||
catch(JSONException e) {
|
||||
try { responseFormat.boundStart = DeviceUtilities.characterBytes(respOpts.getString("start"), responseFormat.encoding); }
|
||||
catch(JSONException e2) { LoggerUtilities.optionWarn(log, "string", "start", respOpts.opt("start")); }
|
||||
}
|
||||
}
|
||||
|
||||
if (!respOpts.isNull("includeHeader")) {
|
||||
try { responseFormat.includeStart = respOpts.getBoolean("includeHeader"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "boolean", "includeHeader", respOpts.opt("includeHeader")); }
|
||||
}
|
||||
|
||||
if (!respOpts.isNull("end")) {
|
||||
try { responseFormat.boundEnd = DeviceUtilities.characterBytes(respOpts.getString("end"), responseFormat.encoding); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "string", "end", respOpts.opt("end")); }
|
||||
|
||||
if (responseFormat.boundStart == null || responseFormat.boundStart.length == 0) {
|
||||
log.warn("End bound set without start bound defined");
|
||||
}
|
||||
}
|
||||
|
||||
if (!respOpts.isNull("untilNewline")) {
|
||||
try { responseFormat.boundNewline = respOpts.getBoolean("untilNewline"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "boolean", "untilNewline", respOpts.opt("untilNewline")); }
|
||||
}
|
||||
|
||||
if (!respOpts.isNull("width")) {
|
||||
try { responseFormat.fixedWidth = respOpts.getInt("width"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "integer", "width", respOpts.opt("width")); }
|
||||
}
|
||||
|
||||
if (!respOpts.isNull("lengthBytes")) {
|
||||
try {
|
||||
JSONObject lengthOpts = respOpts.optJSONObject("lengthBytes");
|
||||
responseFormat.length = new ByteParam();
|
||||
|
||||
if (lengthOpts != null) {
|
||||
if (!lengthOpts.isNull("index")) {
|
||||
try { responseFormat.length.index = lengthOpts.getInt("index"); }
|
||||
catch(JSONException se) { LoggerUtilities.optionWarn(log, "integer", "lengthBytes.index", lengthOpts.opt("index")); }
|
||||
}
|
||||
|
||||
if (!lengthOpts.isNull("length")) {
|
||||
try { responseFormat.length.length = lengthOpts.getInt("length"); }
|
||||
catch(JSONException se) { LoggerUtilities.optionWarn(log, "integer", "lengthBytes.length", lengthOpts.opt("length")); }
|
||||
}
|
||||
|
||||
if (!lengthOpts.isNull("endian")) {
|
||||
try { responseFormat.length.endian = ByteUtilities.Endian.valueOf(lengthOpts.getString("endian").toUpperCase(Locale.ENGLISH)); }
|
||||
catch(JSONException se) { LoggerUtilities.optionWarn(log, "string", "lengthBytes.endian", lengthOpts.opt("endian")); }
|
||||
}
|
||||
} else {
|
||||
responseFormat.length.index = respOpts.getInt("lengthBytes");
|
||||
}
|
||||
}
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "integer", "lengthBytes", respOpts.opt("lengthBytes")); }
|
||||
|
||||
if (responseFormat.boundStart == null || responseFormat.boundStart.length == 0) {
|
||||
log.warn("Length byte(s) defined without start bound defined");
|
||||
}
|
||||
}
|
||||
|
||||
if (!respOpts.isNull("crcBytes")) {
|
||||
try {
|
||||
JSONObject crcOpts = respOpts.optJSONObject("crcBytes");
|
||||
responseFormat.crc = new ByteParam();
|
||||
|
||||
if (crcOpts != null) {
|
||||
if (!crcOpts.isNull("index")) {
|
||||
try { responseFormat.crc.index = crcOpts.getInt("index"); }
|
||||
catch(JSONException se) { LoggerUtilities.optionWarn(log, "integer", "crcBytes.index", crcOpts.opt("index")); }
|
||||
}
|
||||
|
||||
if (!crcOpts.isNull("length")) {
|
||||
try { responseFormat.crc.length = crcOpts.getInt("length"); }
|
||||
catch(JSONException se) { LoggerUtilities.optionWarn(log, "integer", "crcBytes.length", crcOpts.opt("length")); }
|
||||
}
|
||||
} else {
|
||||
responseFormat.crc.length = respOpts.getInt("crcBytes");
|
||||
}
|
||||
}
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "integer", "crcBytes", respOpts.opt("crcBytes")); }
|
||||
|
||||
if (responseFormat.boundStart == null || responseFormat.boundStart.length == 0) {
|
||||
log.warn("CRC byte(s) defined without start bound defined");
|
||||
}
|
||||
}
|
||||
|
||||
if (!respOpts.isNull("encoding") && !respOpts.optString("encoding").isEmpty()) {
|
||||
try { responseFormat.encoding = Charset.forName(respOpts.getString("encoding")); }
|
||||
catch(JSONException | IllegalArgumentException e) { LoggerUtilities.optionWarn(log, "charset", "encoding", respOpts.opt("encoding")); }
|
||||
}
|
||||
} else {
|
||||
LoggerUtilities.optionWarn(log, "JSONObject", "rx", serialOpts.opt("rx"));
|
||||
}
|
||||
} else if (isOpening) {
|
||||
// legacy support - only applies on port open
|
||||
responseFormat = new ResponseFormat();
|
||||
|
||||
// legacy start only supports string, not an array
|
||||
if (!serialOpts.isNull("start")) {
|
||||
responseFormat.boundStart = DeviceUtilities.characterBytes(serialOpts.optString("start", DEFAULT_BEGIN), responseFormat.encoding);
|
||||
} else {
|
||||
responseFormat.boundStart = DeviceUtilities.characterBytes(DEFAULT_BEGIN, responseFormat.encoding);
|
||||
}
|
||||
|
||||
if (!serialOpts.isNull("end")) {
|
||||
responseFormat.boundEnd = DeviceUtilities.characterBytes(serialOpts.optString("end", DEFAULT_END), responseFormat.encoding);
|
||||
} else {
|
||||
responseFormat.boundEnd = DeviceUtilities.characterBytes(DEFAULT_END, responseFormat.encoding);
|
||||
}
|
||||
|
||||
if (!serialOpts.isNull("width")) {
|
||||
try {
|
||||
responseFormat.fixedWidth = serialOpts.getInt("width");
|
||||
if (responseFormat.boundEnd.length > 0) {
|
||||
log.warn("Combining 'width' property with 'end' property has undefined behavior and should not be used");
|
||||
}
|
||||
}
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "integer", "width", serialOpts.opt("width")); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public PortSettings getPortSettings() {
|
||||
return portSettings;
|
||||
}
|
||||
|
||||
public ResponseFormat getResponseFormat() {
|
||||
return responseFormat;
|
||||
}
|
||||
|
||||
public void setPortSettings(PortSettings portSettings) {
|
||||
this.portSettings = portSettings;
|
||||
}
|
||||
|
||||
public void setResponseFormat(ResponseFormat responseFormat) {
|
||||
this.responseFormat = responseFormat;
|
||||
}
|
||||
|
||||
public class PortSettings {
|
||||
|
||||
private Charset encoding = Charset.forName("UTF-8");
|
||||
private int baudRate = SerialPort.BAUDRATE_9600;
|
||||
private int dataBits = SerialPort.DATABITS_8;
|
||||
private int stopBits = SerialPort.STOPBITS_1;
|
||||
private int parity = SerialPort.PARITY_NONE;
|
||||
private int flowControl = SerialPort.FLOWCONTROL_NONE;
|
||||
|
||||
|
||||
public Charset getEncoding() {
|
||||
return encoding;
|
||||
}
|
||||
|
||||
public int getBaudRate() {
|
||||
return baudRate;
|
||||
}
|
||||
|
||||
public int getDataBits() {
|
||||
return dataBits;
|
||||
}
|
||||
|
||||
public int getStopBits() {
|
||||
return stopBits;
|
||||
}
|
||||
|
||||
public int getParity() {
|
||||
return parity;
|
||||
}
|
||||
|
||||
public int getFlowControl() {
|
||||
return flowControl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (o instanceof PortSettings) {
|
||||
PortSettings that = (PortSettings)o;
|
||||
|
||||
return getEncoding().equals(that.getEncoding()) &&
|
||||
getBaudRate() == that.getBaudRate() &&
|
||||
getDataBits() == that.getDataBits() &&
|
||||
getStopBits() == that.getStopBits() &&
|
||||
getParity() == that.getParity() &&
|
||||
getFlowControl() == that.getFlowControl();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class ResponseFormat {
|
||||
|
||||
private Charset encoding = Charset.forName("UTF-8"); //Response charset
|
||||
private byte[] boundStart; //Character(s) denoting start of new response
|
||||
private byte[] boundEnd; //Character denoting end of a response
|
||||
private boolean boundNewline; //If the response should be split on \r?\n
|
||||
private int fixedWidth; //Fixed length response bounds
|
||||
private ByteParam length; //Info about the data length byte(s)
|
||||
private ByteParam crc; //Info about the data crc byte(s)
|
||||
private boolean includeStart; //If the response headers should be sent as well
|
||||
|
||||
|
||||
public Charset getEncoding() {
|
||||
return encoding;
|
||||
}
|
||||
|
||||
public byte[] getBoundStart() {
|
||||
return boundStart;
|
||||
}
|
||||
|
||||
public byte[] getBoundEnd() {
|
||||
return boundEnd;
|
||||
}
|
||||
|
||||
public int getFixedWidth() {
|
||||
return fixedWidth;
|
||||
}
|
||||
|
||||
public ByteParam getLength() {
|
||||
return length;
|
||||
}
|
||||
|
||||
public ByteParam getCrc() {
|
||||
return crc;
|
||||
}
|
||||
|
||||
public boolean isIncludeStart() {
|
||||
return includeStart;
|
||||
}
|
||||
|
||||
public boolean isBoundNewline() {
|
||||
return boundNewline;
|
||||
}
|
||||
}
|
||||
|
||||
public class ByteParam {
|
||||
|
||||
private int index = 0;
|
||||
private int length = 1;
|
||||
private ByteUtilities.Endian endian = ByteUtilities.Endian.BIG;
|
||||
|
||||
|
||||
public int getIndex() {
|
||||
return index;
|
||||
}
|
||||
|
||||
public int getLength() {
|
||||
return length;
|
||||
}
|
||||
|
||||
public ByteUtilities.Endian getEndian() {
|
||||
return endian;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
100
tray/src/qz/communication/SocketIO.java
Normal file
100
tray/src/qz/communication/SocketIO.java
Normal file
@@ -0,0 +1,100 @@
|
||||
package qz.communication;
|
||||
|
||||
import org.apache.commons.lang3.ArrayUtils;
|
||||
import org.codehaus.jettison.json.JSONException;
|
||||
import org.codehaus.jettison.json.JSONObject;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.utils.DeviceUtilities;
|
||||
import qz.utils.NetworkUtilities;
|
||||
import qz.ws.SocketConnection;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.ArrayList;
|
||||
|
||||
public class SocketIO implements DeviceListener {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(SocketIO.class);
|
||||
|
||||
private String host;
|
||||
private int port;
|
||||
private Charset encoding;
|
||||
|
||||
private Socket socket;
|
||||
private DataOutputStream dataOut;
|
||||
private DataInputStream dataIn;
|
||||
|
||||
private SocketConnection websocket;
|
||||
|
||||
public SocketIO(String host, int port, Charset encoding, SocketConnection websocket) {
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.encoding = encoding;
|
||||
this.websocket = websocket;
|
||||
}
|
||||
|
||||
public boolean open() throws IOException {
|
||||
socket = new Socket(host, port);
|
||||
socket.setSoTimeout(NetworkUtilities.SOCKET_TIMEOUT);
|
||||
dataOut = new DataOutputStream(socket.getOutputStream());
|
||||
dataIn = new DataInputStream(socket.getInputStream());
|
||||
|
||||
return socket.isConnected();
|
||||
}
|
||||
|
||||
public boolean isOpen() {
|
||||
return socket.isConnected();
|
||||
}
|
||||
|
||||
public void sendData(JSONObject params) throws JSONException, IOException {
|
||||
log.debug("Sending data over [{}:{}]", host, port);
|
||||
dataOut.write(DeviceUtilities.getDataBytes(params, encoding));
|
||||
dataOut.flush();
|
||||
}
|
||||
|
||||
public String processSocketResponse() throws IOException {
|
||||
byte[] response = new byte[1024];
|
||||
ArrayList<Byte> fullResponse = new ArrayList<>();
|
||||
do {
|
||||
int size = dataIn.read(response);
|
||||
for(int i = 0; i < size; i++) {
|
||||
fullResponse.add(response[i]);
|
||||
}
|
||||
}
|
||||
while(dataIn.available() > 0);
|
||||
if(fullResponse.size() > 0) {
|
||||
return new String(ArrayUtils.toPrimitive(fullResponse.toArray(new Byte[0])), encoding);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// Remove orphaned reference
|
||||
websocket.removeNetworkSocket(String.format("%s:%s", host, port));
|
||||
|
||||
try {
|
||||
dataOut.close();
|
||||
} catch(IOException e) {
|
||||
log.warn("Could not close socket output stream", e);
|
||||
}
|
||||
try {
|
||||
socket.close();
|
||||
} catch(IOException e) {
|
||||
log.warn("Could not close socket", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String getHost() {
|
||||
return host;
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
return port;
|
||||
}
|
||||
|
||||
}
|
||||
153
tray/src/qz/communication/UsbIO.java
Normal file
153
tray/src/qz/communication/UsbIO.java
Normal file
@@ -0,0 +1,153 @@
|
||||
package qz.communication;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.utils.UsbUtilities;
|
||||
import qz.ws.SocketConnection;
|
||||
|
||||
import javax.usb.*;
|
||||
import javax.usb.util.UsbUtil;
|
||||
|
||||
public class UsbIO implements DeviceIO {
|
||||
private static final Logger log = LogManager.getLogger(UsbIO.class);
|
||||
private UsbDevice device;
|
||||
private UsbInterface iface;
|
||||
|
||||
private boolean streaming;
|
||||
|
||||
private DeviceOptions dOpts;
|
||||
private SocketConnection websocket;
|
||||
|
||||
public UsbIO(DeviceOptions dOpts, SocketConnection websocket) throws DeviceException {
|
||||
this.dOpts = dOpts;
|
||||
this.websocket = websocket;
|
||||
UsbDevice device = UsbUtilities.findDevice(dOpts.getVendorId().shortValue(), dOpts.getProductId().shortValue());
|
||||
if (device == null) {
|
||||
throw new DeviceException("USB device could not be found");
|
||||
}
|
||||
if (dOpts.getInterfaceId() == null) {
|
||||
throw new IllegalArgumentException("Device interface cannot be null");
|
||||
}
|
||||
this.iface = device.getActiveUsbConfiguration().getUsbInterface(dOpts.getInterfaceId());
|
||||
if (iface == null) {
|
||||
throw new DeviceException(String.format("Could not find USB interface matching [ vendorId: '%s', productId: '%s', interface: '%s' ]",
|
||||
"0x" + UsbUtil.toHexString(dOpts.getVendorId()),
|
||||
"0x" + UsbUtil.toHexString(dOpts.getProductId()),
|
||||
"0x" + UsbUtil.toHexString(dOpts.getInterfaceId())));
|
||||
}
|
||||
this.device = device;
|
||||
|
||||
}
|
||||
|
||||
public void open() throws DeviceException {
|
||||
try {
|
||||
iface.claim(new UsbInterfacePolicy() {
|
||||
@Override
|
||||
public boolean forceClaim(UsbInterface usbInterface) {
|
||||
// Releases kernel driver for systems that auto-claim usb devices
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
catch(UsbException e) {
|
||||
throw new DeviceException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isOpen() {
|
||||
return iface.isClaimed();
|
||||
}
|
||||
|
||||
public void setStreaming(boolean active) {
|
||||
streaming = active;
|
||||
}
|
||||
|
||||
public boolean isStreaming() {
|
||||
return streaming;
|
||||
}
|
||||
|
||||
public String getVendorId() {
|
||||
return UsbUtil.toHexString(device.getUsbDeviceDescriptor().idVendor());
|
||||
}
|
||||
|
||||
public String getProductId() {
|
||||
return UsbUtil.toHexString(device.getUsbDeviceDescriptor().idProduct());
|
||||
}
|
||||
|
||||
public String getInterface() {
|
||||
return UsbUtil.toHexString(iface.getUsbInterfaceDescriptor().iInterface());
|
||||
}
|
||||
|
||||
public byte[] readData(int responseSize, Byte endpoint) throws DeviceException {
|
||||
try {
|
||||
byte[] response = new byte[responseSize];
|
||||
exchangeData(endpoint, response);
|
||||
return response;
|
||||
}
|
||||
catch(UsbException e) {
|
||||
throw new DeviceException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public void sendData(byte[] data, Byte endpoint) throws DeviceException {
|
||||
try {
|
||||
exchangeData(endpoint, data);
|
||||
}
|
||||
catch(UsbException e) {
|
||||
throw new DeviceException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] getFeatureReport(int responseSize, Byte reportId) throws DeviceException {
|
||||
throw new DeviceException("USB feature reports are not supported");
|
||||
}
|
||||
|
||||
public void sendFeatureReport(byte[] data, Byte reportId) throws DeviceException {
|
||||
throw new DeviceException("USB feature reports are not supported");
|
||||
}
|
||||
|
||||
/**
|
||||
* Data will be sent to or received from the open usb device, depending on the {@code endpoint} used.
|
||||
*
|
||||
* @param endpoint Endpoint on the usb device interface to pass data across
|
||||
* @param data Byte array of data to send, or to be written from a receive
|
||||
*/
|
||||
private synchronized void exchangeData(Byte endpoint, byte[] data) throws UsbException, DeviceException {
|
||||
if (endpoint == null) {
|
||||
throw new IllegalArgumentException("Interface endpoint cannot be null");
|
||||
}
|
||||
|
||||
UsbEndpoint usbEndpoint = iface.getUsbEndpoint(endpoint);
|
||||
if(usbEndpoint == null) {
|
||||
throw new DeviceException(String.format("Could not find USB endpoint matching [ endpoint: '%s' ]",
|
||||
"0x" + UsbUtil.toHexString(endpoint)));
|
||||
}
|
||||
UsbPipe pipe = usbEndpoint.getUsbPipe();
|
||||
if (!pipe.isOpen()) { pipe.open(); }
|
||||
|
||||
try {
|
||||
pipe.syncSubmit(data);
|
||||
}
|
||||
finally {
|
||||
if(pipe != null) {
|
||||
pipe.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
setStreaming(false);
|
||||
// Remove orphaned reference
|
||||
websocket.removeDevice(dOpts);
|
||||
if (iface.isClaimed()) {
|
||||
try {
|
||||
iface.release();
|
||||
}
|
||||
catch(UsbException e) {
|
||||
log.error("Unable to close USB device", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
28
tray/src/qz/communication/WinspoolEx.java
Normal file
28
tray/src/qz/communication/WinspoolEx.java
Normal file
@@ -0,0 +1,28 @@
|
||||
package qz.communication;
|
||||
|
||||
import com.sun.jna.Native;
|
||||
import com.sun.jna.Pointer;
|
||||
import com.sun.jna.platform.win32.WinNT;
|
||||
import com.sun.jna.platform.win32.Winspool;
|
||||
import com.sun.jna.win32.W32APIOptions;
|
||||
|
||||
/**
|
||||
* TODO: Remove when JNA 5.14.0+ is bundled
|
||||
*/
|
||||
@SuppressWarnings("unused")
|
||||
public interface WinspoolEx extends Winspool {
|
||||
WinspoolEx INSTANCE = Native.load("Winspool.drv", WinspoolEx.class, W32APIOptions.DEFAULT_OPTIONS);
|
||||
|
||||
int JOB_CONTROL_NONE = 0x00000000; // Perform no additional action.
|
||||
int JOB_CONTROL_PAUSE = 0x00000001; // Pause the print job.
|
||||
int JOB_CONTROL_RESUME = 0x00000002; // Resume a paused print job.
|
||||
int JOB_CONTROL_CANCEL = 0x00000003; // Delete a print job.
|
||||
int JOB_CONTROL_RESTART = 0x00000004; // Restart a print job.
|
||||
int JOB_CONTROL_DELETE = 0x00000005; // Delete a print job.
|
||||
int JOB_CONTROL_SENT_TO_PRINTER = 0x00000006; // Used by port monitors to signal that a print job has been sent to the printer. This value SHOULD NOT be used remotely.
|
||||
int JOB_CONTROL_LAST_PAGE_EJECTED = 0x00000007; // Used by language monitors to signal that the last page of a print job has been ejected from the printer. This value SHOULD NOT be used remotely.
|
||||
int JOB_CONTROL_RETAIN = 0x00000008; // Keep the print job in the print queue after it prints.
|
||||
int JOB_CONTROL_RELEASE = 0x00000009; // Release the print job, undoing the effect of a JOB_CONTROL_RETAIN action.
|
||||
|
||||
boolean SetJob(WinNT.HANDLE hPrinter, int JobId, int Level, Pointer pJob, int Command);
|
||||
}
|
||||
11
tray/src/qz/exception/InvalidRawImageException.java
Normal file
11
tray/src/qz/exception/InvalidRawImageException.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package qz.exception;
|
||||
|
||||
public class InvalidRawImageException extends Exception {
|
||||
public InvalidRawImageException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
|
||||
public InvalidRawImageException(String msg, Throwable cause) {
|
||||
super(msg, cause);
|
||||
}
|
||||
}
|
||||
3
tray/src/qz/exception/MissingArgException.java
Normal file
3
tray/src/qz/exception/MissingArgException.java
Normal file
@@ -0,0 +1,3 @@
|
||||
package qz.exception;
|
||||
|
||||
public class MissingArgException extends Exception {}
|
||||
10
tray/src/qz/exception/NullCommandException.java
Normal file
10
tray/src/qz/exception/NullCommandException.java
Normal file
@@ -0,0 +1,10 @@
|
||||
package qz.exception;
|
||||
|
||||
public class NullCommandException extends javax.print.PrintException {
|
||||
public NullCommandException() {
|
||||
super();
|
||||
}
|
||||
public NullCommandException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
7
tray/src/qz/exception/NullPrintServiceException.java
Normal file
7
tray/src/qz/exception/NullPrintServiceException.java
Normal file
@@ -0,0 +1,7 @@
|
||||
package qz.exception;
|
||||
|
||||
public class NullPrintServiceException extends javax.print.PrintException {
|
||||
public NullPrintServiceException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
413
tray/src/qz/installer/Installer.java
Normal file
413
tray/src/qz/installer/Installer.java
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.installer;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
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;
|
||||
import qz.ws.WebsocketPorts;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.*;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.*;
|
||||
|
||||
import static qz.common.Constants.*;
|
||||
import static qz.installer.certificate.KeyPairWrapper.Type.CA;
|
||||
import static qz.utils.FileUtilities.*;
|
||||
|
||||
/**
|
||||
* Cross-platform wrapper for install steps
|
||||
* - Used by CommandParser via command line
|
||||
* - Used by PrintSocketServer at startup to ensure SSL is functioning
|
||||
*/
|
||||
public abstract class Installer {
|
||||
protected static final Logger log = LogManager.getLogger(Installer.class);
|
||||
|
||||
// Silence prompts within our control
|
||||
public static boolean IS_SILENT = "1".equals(System.getenv(DATA_DIR + "_silent"));
|
||||
public static String JRE_LOCATION = SystemUtilities.isMac() ? "Contents/PlugIns/Java.runtime/Contents/Home" : "runtime";
|
||||
|
||||
WebsocketPorts websocketPorts;
|
||||
|
||||
public enum PrivilegeLevel {
|
||||
USER,
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
public abstract Installer removeLegacyStartup();
|
||||
public abstract Installer addAppLauncher();
|
||||
public abstract Installer addStartupEntry();
|
||||
public abstract Installer addSystemSettings();
|
||||
public abstract Installer removeSystemSettings();
|
||||
public abstract void spawn(List<String> args) throws Exception;
|
||||
|
||||
public abstract void setDestination(String destination);
|
||||
public abstract String getDestination();
|
||||
|
||||
private static Installer instance;
|
||||
|
||||
public static Installer getInstance() {
|
||||
if(instance == null) {
|
||||
switch(SystemUtilities.getOs()) {
|
||||
case WINDOWS:
|
||||
instance = new WindowsInstaller();
|
||||
break;
|
||||
case MAC:
|
||||
instance = new MacInstaller();
|
||||
break;
|
||||
default:
|
||||
instance = new LinuxInstaller();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public static void install(String destination, boolean silent) throws Exception {
|
||||
IS_SILENT |= silent; // preserve environmental variable if possible
|
||||
getInstance();
|
||||
if (destination != null) {
|
||||
instance.setDestination(destination);
|
||||
}
|
||||
install();
|
||||
}
|
||||
|
||||
public static boolean preinstall() {
|
||||
getInstance();
|
||||
log.info("Fixing runtime permissions...");
|
||||
instance.setJrePermissions(SystemUtilities.getAppPath().toString());
|
||||
log.info("Stopping running instances...");
|
||||
return TaskKiller.killAll();
|
||||
}
|
||||
|
||||
public static void install() throws Exception {
|
||||
getInstance();
|
||||
log.info("Installing to {}", instance.getDestination());
|
||||
instance.removeLibs()
|
||||
.removeProvisioning()
|
||||
.deployApp()
|
||||
.removeLegacyStartup()
|
||||
.removeLegacyFiles()
|
||||
.addSharedDirectory()
|
||||
.addAppLauncher()
|
||||
.addStartupEntry()
|
||||
.invokeProvisioning(Phase.INSTALL)
|
||||
.addSystemSettings();
|
||||
}
|
||||
|
||||
public static void uninstall() {
|
||||
log.info("Stopping running instances...");
|
||||
TaskKiller.killAll();
|
||||
getInstance();
|
||||
log.info("Uninstalling from {}", instance.getDestination());
|
||||
instance.removeSharedDirectory()
|
||||
.removeSystemSettings()
|
||||
.removeCerts()
|
||||
.invokeProvisioning(Phase.UNINSTALL);
|
||||
}
|
||||
|
||||
public Installer deployApp() throws IOException {
|
||||
Path src = SystemUtilities.getAppPath();
|
||||
Path dest = Paths.get(getDestination());
|
||||
|
||||
if(!Files.exists(dest)) {
|
||||
Files.createDirectories(dest);
|
||||
}
|
||||
|
||||
// Delete the JDK blindly
|
||||
FileUtils.deleteDirectory(dest.resolve(JRE_LOCATION).toFile());
|
||||
// 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);
|
||||
return setJrePermissions(getDestination());
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
private Installer setJrePermissions(String dest) {
|
||||
File jreLocation = new File(dest, JRE_LOCATION);
|
||||
File jreBin = new File(jreLocation, "bin");
|
||||
File jreLib = new File(jreLocation, "lib");
|
||||
|
||||
// Set jre/bin/java and friends executable
|
||||
File[] files = jreBin.listFiles(pathname -> !pathname.isDirectory());
|
||||
if(files != null) {
|
||||
for(File file : files) {
|
||||
file.setExecutable(true, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Set jspawnhelper executable
|
||||
new File(jreLib, "jspawnhelper" + (SystemUtilities.isWindows() ? ".exe" : "")).setExecutable(true, false);
|
||||
return this;
|
||||
}
|
||||
|
||||
private void setExecutable(String relativePath) {
|
||||
new File(getDestination(), relativePath).setExecutable(true, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly purge libs to notify system cache per https://github.com/qzind/tray/issues/662
|
||||
*/
|
||||
public Installer removeLibs() {
|
||||
String[] dirs = { "libs" };
|
||||
for (String dir : dirs) {
|
||||
try {
|
||||
FileUtils.deleteDirectory(new File(instance.getDestination() + File.separator + dir));
|
||||
} catch(IOException ignore) {}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public Installer cleanupLegacyLogs(int rolloverCount) {
|
||||
// Convert old < 2.2.3 log file format
|
||||
Path logLocation = USER_DIR;
|
||||
int oldIndex = 0;
|
||||
int newIndex = 0;
|
||||
File oldFile;
|
||||
do {
|
||||
// Old: debug.log.1
|
||||
oldFile = logLocation.resolve("debug.log." + ++oldIndex).toFile();
|
||||
if(oldFile.exists()) {
|
||||
// New: debug.1.log
|
||||
File newFile;
|
||||
do {
|
||||
newFile = logLocation.resolve("debug." + ++newIndex + ".log").toFile();
|
||||
} while(newFile.exists());
|
||||
|
||||
oldFile.renameTo(newFile);
|
||||
log.info("Migrated log file {} to new location {}", oldFile, newFile);
|
||||
}
|
||||
} while(oldFile.exists() || oldIndex <= rolloverCount);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public Installer removeLegacyFiles() {
|
||||
ArrayList<String> dirs = new ArrayList<>();
|
||||
ArrayList<String> files = new ArrayList<>();
|
||||
HashMap<String, String> move = new HashMap<>();
|
||||
|
||||
// QZ Tray 2.0 files
|
||||
dirs.add("demo/js/3rdparty");
|
||||
dirs.add("utils");
|
||||
dirs.add("auth");
|
||||
files.add("demo/js/qz-websocket.js");
|
||||
files.add("windows-icon.ico");
|
||||
|
||||
// QZ Tray 2.2.3-SNAPSHOT accidentally wrote certs in the wrong place
|
||||
dirs.add("ssl");
|
||||
|
||||
// QZ Tray 2.1 files
|
||||
if(SystemUtilities.isMac()) {
|
||||
// Moved to macOS Application Bundle standard https://developer.apple.com/go/?id=bundle-structure
|
||||
dirs.add("demo");
|
||||
dirs.add("libs");
|
||||
files.add(PROPS_FILE + ".jar");
|
||||
files.add("LICENSE.txt");
|
||||
files.add("uninstall");
|
||||
move.put(PROPS_FILE + ".properties", "Contents/Resources/" + PROPS_FILE + ".properties");
|
||||
}
|
||||
|
||||
dirs.forEach(dir -> {
|
||||
try {
|
||||
FileUtils.deleteDirectory(new File(instance.getDestination() + File.separator + dir));
|
||||
} catch(IOException ignore) {}
|
||||
});
|
||||
|
||||
files.forEach(file -> {
|
||||
new File(instance.getDestination() + File.separator + file).delete();
|
||||
});
|
||||
|
||||
move.forEach((src, dest) -> {
|
||||
try {
|
||||
FileUtils.moveFile(new File(instance.getDestination() + File.separator + src),
|
||||
new File(instance.getDestination() + File.separator + dest));
|
||||
} catch(IOException ignore) {}
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public Installer addSharedDirectory() {
|
||||
try {
|
||||
Files.createDirectories(SHARED_DIR);
|
||||
FileUtilities.setPermissionsRecursively(SHARED_DIR, true);
|
||||
Path ssl = Paths.get(SHARED_DIR.toString(), "ssl");
|
||||
Files.createDirectories(ssl);
|
||||
FileUtilities.setPermissionsRecursively(ssl, true);
|
||||
|
||||
log.info("Created shared directory: {}", SHARED_DIR);
|
||||
} catch(IOException e) {
|
||||
log.warn("Could not create shared directory: {}", SHARED_DIR);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public Installer removeSharedDirectory() {
|
||||
try {
|
||||
FileUtils.deleteDirectory(SHARED_DIR.toFile());
|
||||
log.info("Deleted shared directory: {}", SHARED_DIR);
|
||||
} catch(IOException e) {
|
||||
log.warn("Could not delete shared directory: {}", SHARED_DIR);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks, and if needed generates an SSL for the system
|
||||
*/
|
||||
public CertificateManager certGen(boolean forceNew, String... hostNames) throws Exception {
|
||||
CertificateManager certificateManager = new CertificateManager(forceNew, hostNames);
|
||||
boolean needsInstall = certificateManager.needsInstall();
|
||||
try {
|
||||
// Check that the CA cert is installed
|
||||
X509Certificate caCert = certificateManager.getKeyPair(CA).getCert();
|
||||
NativeCertificateInstaller installer = NativeCertificateInstaller.getInstance();
|
||||
|
||||
if (forceNew || needsInstall) {
|
||||
// Remove installed certs per request (usually the desktop installer, or failure to write properties)
|
||||
// Skip if running from IDE, this may accidentally remove sandboxed certs
|
||||
if(SystemUtilities.isJar()) {
|
||||
List<String> matchingCerts = installer.find();
|
||||
installer.remove(matchingCerts);
|
||||
}
|
||||
installer.install(caCert);
|
||||
FirefoxCertificateInstaller.install(caCert, hostNames);
|
||||
} else {
|
||||
// Make sure the certificate is recognized by the system
|
||||
if(caCert == null) {
|
||||
log.info("CA cert is empty, skipping installation checks. This is normal for trusted/3rd-party SSL certificates.");
|
||||
} else {
|
||||
File tempCert = File.createTempFile(KeyPairWrapper.getAlias(KeyPairWrapper.Type.CA) + "-", CertificateManager.DEFAULT_CERTIFICATE_EXTENSION);
|
||||
CertificateManager.writeCert(caCert, tempCert); // temp cert
|
||||
if (!installer.verify(tempCert)) {
|
||||
installer.install(caCert);
|
||||
FirefoxCertificateInstaller.install(caCert, hostNames);
|
||||
}
|
||||
if(!tempCert.delete()) {
|
||||
tempCert.deleteOnExit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e) {
|
||||
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 Installer removeCerts() {
|
||||
// System certs
|
||||
NativeCertificateInstaller instance = NativeCertificateInstaller.getInstance();
|
||||
instance.remove(instance.find());
|
||||
// Firefox certs
|
||||
FirefoxCertificateInstaller.uninstall();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add user-specific settings
|
||||
* Note: See override usage for platform-specific tasks
|
||||
*/
|
||||
public Installer addUserSettings() {
|
||||
// Check for whitelisted certificates in <install>/whitelist/
|
||||
Path whiteList = SystemUtilities.getJarParentPath().resolve(WHITELIST_CERT_DIR);
|
||||
if(Files.exists(whiteList) && Files.isDirectory(whiteList)) {
|
||||
for(File file : whiteList.toFile().listFiles()) {
|
||||
try {
|
||||
Certificate cert = new Certificate(FileUtilities.readLocalFile(file.getPath()));
|
||||
if (!cert.isSaved()) {
|
||||
FileUtilities.addToCertList(ALLOW_FILE, file);
|
||||
}
|
||||
} catch(Exception e) {
|
||||
log.warn("Could not add {} to {}", file, ALLOW_FILE, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
// Special case for custom websocket ports
|
||||
if(phase == Phase.INSTALL) {
|
||||
websocketPorts = WebsocketPorts.parseFromSteps(provisionInstaller.getSteps());
|
||||
}
|
||||
} catch(Exception e) {
|
||||
log.warn("An error occurred invoking provision \"phase\": \"{}\"", 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();
|
||||
try(Reader reader = new FileReader(oldFile)) {
|
||||
oldProps.load(reader);
|
||||
for(String key : PERSIST_PROPS) {
|
||||
if (oldProps.containsKey(key)) {
|
||||
String value = oldProps.getProperty(key);
|
||||
log.info("Preserving {}={} for install", key, value);
|
||||
newProps.put(key, value);
|
||||
}
|
||||
}
|
||||
} catch(IOException e) {
|
||||
log.warn("Warning, an error occurred reading the old properties file {}", oldFile, e);
|
||||
}
|
||||
}
|
||||
return newProps;
|
||||
}
|
||||
|
||||
public void spawn(String ... args) throws Exception {
|
||||
spawn(new ArrayList(Arrays.asList(args)));
|
||||
}
|
||||
}
|
||||
371
tray/src/qz/installer/LinuxInstaller.java
Normal file
371
tray/src/qz/installer/LinuxInstaller.java
Normal file
@@ -0,0 +1,371 @@
|
||||
package qz.installer;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.utils.FileUtilities;
|
||||
import qz.utils.ShellUtilities;
|
||||
import qz.utils.SystemUtilities;
|
||||
import qz.utils.UnixUtilities;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static qz.common.Constants.*;
|
||||
|
||||
public class LinuxInstaller extends Installer {
|
||||
protected static final Logger log = LogManager.getLogger(LinuxInstaller.class);
|
||||
|
||||
public static final String SHORTCUT_NAME = PROPS_FILE + ".desktop";
|
||||
public static final String STARTUP_DIR = "/etc/xdg/autostart/";
|
||||
public static final String STARTUP_LAUNCHER = STARTUP_DIR + SHORTCUT_NAME;
|
||||
public static final String APP_DIR = "/usr/share/applications/";
|
||||
public static final String APP_LAUNCHER = APP_DIR + SHORTCUT_NAME;
|
||||
public static final String UDEV_RULES = "/lib/udev/rules.d/99-udev-override.rules";
|
||||
public static final String[] CHROME_POLICY_DIRS = {"/etc/chromium/policies/managed", "/etc/opt/chrome/policies/managed" };
|
||||
public static final String CHROME_POLICY = "{ \"URLAllowlist\": [\"" + DATA_DIR + "://*\"] }";
|
||||
|
||||
private String destination = "/opt/" + PROPS_FILE;
|
||||
private String sudoer;
|
||||
|
||||
public LinuxInstaller() {
|
||||
super();
|
||||
sudoer = getSudoer();
|
||||
}
|
||||
|
||||
public void setDestination(String destination) {
|
||||
this.destination = destination;
|
||||
}
|
||||
|
||||
public String getDestination() {
|
||||
return destination;
|
||||
}
|
||||
|
||||
public Installer addAppLauncher() {
|
||||
addLauncher(APP_LAUNCHER, false);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Installer addStartupEntry() {
|
||||
addLauncher(STARTUP_LAUNCHER, true);
|
||||
return this;
|
||||
}
|
||||
|
||||
private void addLauncher(String location, boolean isStartup) {
|
||||
HashMap<String, String> fieldMap = new HashMap<>();
|
||||
// Dynamic fields
|
||||
fieldMap.put("%DESTINATION%", destination);
|
||||
fieldMap.put("%LINUX_ICON%", String.format("%s.svg", PROPS_FILE));
|
||||
fieldMap.put("%COMMAND%", String.format("%s/%s", destination, PROPS_FILE));
|
||||
fieldMap.put("%PARAM%", isStartup ? "--honorautostart" : "%u");
|
||||
|
||||
File launcher = new File(location);
|
||||
try {
|
||||
FileUtilities.configureAssetFile("assets/linux-shortcut.desktop.in", launcher, fieldMap, LinuxInstaller.class);
|
||||
launcher.setReadable(true, false);
|
||||
launcher.setExecutable(true, false);
|
||||
} catch(IOException e) {
|
||||
log.warn("Unable to write {} file: {}", isStartup ? "startup":"launcher", location, e);
|
||||
}
|
||||
}
|
||||
|
||||
public Installer removeLegacyStartup() {
|
||||
log.info("Removing legacy autostart entries for all users matching {} or {}", ABOUT_TITLE, PROPS_FILE);
|
||||
// assume users are in /home
|
||||
String[] shortcutNames = {ABOUT_TITLE, PROPS_FILE};
|
||||
for(File file : new File("/home").listFiles()) {
|
||||
if (file.isDirectory()) {
|
||||
File userStart = new File(file.getPath() + "/.config/autostart");
|
||||
if (userStart.exists() && userStart.isDirectory()) {
|
||||
for (String shortcutName : shortcutNames) {
|
||||
File legacyStartup = new File(userStart.getPath() + File.separator + shortcutName + ".desktop");
|
||||
if(legacyStartup.exists()) {
|
||||
legacyStartup.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public Installer addSystemSettings() {
|
||||
// Legacy Ubuntu versions only: Patch Unity to show the System Tray
|
||||
if(UnixUtilities.isUbuntu()) {
|
||||
ShellUtilities.execute("gsettings", "set", "com.canonical.Unity.Panel", "systray", "-whitelist", "\"['all']\"");
|
||||
|
||||
if(ShellUtilities.execute("killall", "-w", "unity", "-panel")) {
|
||||
ShellUtilities.execute("nohup", "unity", "-panel");
|
||||
}
|
||||
|
||||
if(ShellUtilities.execute("killall", "-w", "unity", "-2d")) {
|
||||
ShellUtilities.execute("nohup", "unity", "-2d");
|
||||
}
|
||||
}
|
||||
|
||||
// Chrome protocol handler
|
||||
for (String policyDir : CHROME_POLICY_DIRS) {
|
||||
log.info("Installing chrome protocol handler {}/{}...", policyDir, PROPS_FILE + ".json");
|
||||
try {
|
||||
FileUtilities.setPermissionsParentally(Files.createDirectories(Paths.get(policyDir)), false);
|
||||
} catch(IOException e) {
|
||||
log.warn("An error occurred creating {}", policyDir);
|
||||
}
|
||||
|
||||
Path policy = Paths.get(policyDir, PROPS_FILE + ".json");
|
||||
try (BufferedWriter writer = new BufferedWriter(new FileWriter(policy.toFile()))){
|
||||
writer.write(CHROME_POLICY);
|
||||
policy.toFile().setReadable(true, false);
|
||||
}
|
||||
catch(IOException e) {
|
||||
log.warn("Unable to write chrome policy: {} ({}:launch will fail)", policy, DATA_DIR);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// USB permissions
|
||||
try {
|
||||
File udev = new File(UDEV_RULES);
|
||||
if (udev.exists()) {
|
||||
udev.delete();
|
||||
}
|
||||
FileUtilities.configureAssetFile("assets/linux-udev.rules.in", new File(UDEV_RULES), new HashMap<>(), LinuxInstaller.class);
|
||||
// udev rules should be -rw-r--r--
|
||||
udev.setReadable(true, false);
|
||||
ShellUtilities.execute("udevadm", "control", "--reload-rules");
|
||||
} catch(IOException e) {
|
||||
log.warn("Could not install udev rules, usb support may fail {}", UDEV_RULES, e);
|
||||
}
|
||||
|
||||
// Cleanup incorrectly placed files
|
||||
File badFirefoxJs = new File("/usr/bin/defaults/pref/" + PROPS_FILE + ".js");
|
||||
File badFirefoxCfg = new File("/usr/bin/" + PROPS_FILE + ".cfg");
|
||||
|
||||
if(badFirefoxCfg.exists()) {
|
||||
log.info("Removing incorrectly placed Firefox configuration {}, {}...", badFirefoxJs, badFirefoxCfg);
|
||||
badFirefoxCfg.delete();
|
||||
new File("/usr/bin/defaults").delete();
|
||||
}
|
||||
|
||||
// Cleanup incorrectly placed files
|
||||
File badFirefoxPolicy = new File("/usr/bin/distribution/policies.json");
|
||||
if(badFirefoxPolicy.exists()) {
|
||||
log.info("Removing incorrectly placed Firefox policy {}", badFirefoxPolicy);
|
||||
badFirefoxPolicy.delete();
|
||||
// Delete the distribution folder too, as long as it's empty
|
||||
File badPolicyFolder = badFirefoxPolicy.getParentFile();
|
||||
if(badPolicyFolder.isDirectory() && badPolicyFolder.listFiles().length == 0) {
|
||||
badPolicyFolder.delete();
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
log.info("Cleaning up any remaining files...");
|
||||
new File(destination + File.separator + "install").delete();
|
||||
return this;
|
||||
}
|
||||
|
||||
public Installer removeSystemSettings() {
|
||||
// Chrome protocol handler
|
||||
for (String policyDir : CHROME_POLICY_DIRS) {
|
||||
log.info("Removing chrome protocol handler {}/{}...", policyDir, PROPS_FILE + ".json");
|
||||
Path policy = Paths.get(policyDir, PROPS_FILE + ".json");
|
||||
policy.toFile().delete();
|
||||
}
|
||||
|
||||
// USB permissions
|
||||
File udev = new File(UDEV_RULES);
|
||||
if (udev.exists()) {
|
||||
udev.delete();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// Environmental variables for spawning a task using sudo. Order is important.
|
||||
static String[] SUDO_EXPORTS = {"USER", "HOME", "UPSTART_SESSION", "DISPLAY", "DBUS_SESSION_BUS_ADDRESS", "XDG_CURRENT_DESKTOP", "GNOME_DESKTOP_SESSION_ID" };
|
||||
|
||||
/**
|
||||
* Spawns the process as the underlying regular user account, preserving the environment
|
||||
*/
|
||||
public void spawn(List<String> args) throws Exception {
|
||||
if(!SystemUtilities.isAdmin()) {
|
||||
// Not admin, just run as the existing user
|
||||
ShellUtilities.execute(args.toArray(new String[args.size()]));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user's environment from dbus, etc
|
||||
HashMap<String, String> env = getUserEnv(sudoer);
|
||||
if(env.size() == 0) {
|
||||
throw new Exception("Unable to get dbus info; can't spawn instance");
|
||||
}
|
||||
|
||||
// Prepare the environment
|
||||
String[] envp = new String[env.size() + ShellUtilities.envp.length];
|
||||
int i = 0;
|
||||
// Keep existing env
|
||||
for(String keep : ShellUtilities.envp) {
|
||||
envp[i++] = keep;
|
||||
}
|
||||
for(String key :env.keySet()) {
|
||||
envp[i++] = String.format("%s=%s", key, env.get(key));
|
||||
}
|
||||
|
||||
// Concat "sudo|su", sudoer, "nohup", args
|
||||
ArrayList<String> argsList = sudoCommand(sudoer, true, args);
|
||||
|
||||
// Spawn
|
||||
log.info("Executing: {}", Arrays.toString(argsList.toArray()));
|
||||
Runtime.getRuntime().exec(argsList.toArray(new String[argsList.size()]), envp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a command to help running as another user using "sudo" or "su"
|
||||
*/
|
||||
public static ArrayList<String> sudoCommand(String sudoer, boolean async, List<String> cmds) {
|
||||
ArrayList<String> sudo = new ArrayList<>();
|
||||
if(StringUtils.isEmpty(sudoer) || !userExists(sudoer)) {
|
||||
throw new UnsupportedOperationException(String.format("Parameter [sudoer: %s] is empty or the provided user was not found", sudoer));
|
||||
}
|
||||
if(ShellUtilities.execute("which", "sudo") // check if sudo exists
|
||||
|| ShellUtilities.execute("sudo", "-u", sudoer, "-v")) { // check if user can login
|
||||
// Pass directly into "sudo"
|
||||
log.info("Guessing that this system prefers \"sudo\" over \"su\".");
|
||||
sudo.add("sudo");
|
||||
|
||||
// Add calling user
|
||||
sudo.add("-E"); // preserve environment
|
||||
sudo.add("-u");
|
||||
sudo.add(sudoer);
|
||||
|
||||
// Add "background" task support
|
||||
if(async) {
|
||||
sudo.add("nohup");
|
||||
}
|
||||
if(cmds != null && cmds.size() > 0) {
|
||||
// Add additional commands
|
||||
sudo.addAll(cmds);
|
||||
}
|
||||
} else {
|
||||
// Build and escape for "su"
|
||||
log.info("Guessing that this system prefers \"su\" over \"sudo\".");
|
||||
sudo.add("su");
|
||||
|
||||
// Add calling user
|
||||
sudo.add(sudoer);
|
||||
|
||||
sudo.add("-c");
|
||||
|
||||
// Add "background" task support
|
||||
if(async) {
|
||||
sudo.add("nohup");
|
||||
}
|
||||
if(cmds != null && cmds.size() > 0) {
|
||||
// Add additional commands
|
||||
sudo.addAll(Arrays.asList(StringUtils.join(cmds, "\" \"") + "\""));
|
||||
}
|
||||
}
|
||||
return sudo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the most likely non-root user account that the installer is running from
|
||||
*/
|
||||
private static String getSudoer() {
|
||||
String sudoer = ShellUtilities.executeRaw("logname").trim();
|
||||
if(sudoer.isEmpty() || SystemUtilities.isSolaris()) {
|
||||
sudoer = System.getenv("SUDO_USER");
|
||||
}
|
||||
return sudoer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses two common POSIX techniques for testing if the provided user account exists
|
||||
*/
|
||||
private static boolean userExists(String user) {
|
||||
return ShellUtilities.execute("id", "-u", user) ||
|
||||
ShellUtilities.execute("getent", "passwd", user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to extract user environment variables from the dbus process to
|
||||
* allow starting a graphical application as the current user.
|
||||
*
|
||||
* If this fails, items such as the user's desktop theme may not be known to Java
|
||||
* at runtime resulting in the Swing L&F instead of the Gtk L&F.
|
||||
*/
|
||||
private static HashMap<String, String> getUserEnv(String matchingUser) {
|
||||
if(!SystemUtilities.isAdmin()) {
|
||||
throw new UnsupportedOperationException("Administrative access is required");
|
||||
}
|
||||
|
||||
String[] dbusMatches = { "ibus-daemon.*--panel", "dbus-daemon.*--config-file="};
|
||||
|
||||
ArrayList<String> pids = new ArrayList<>();
|
||||
for(String dbusMatch : dbusMatches) {
|
||||
pids.addAll(Arrays.asList(ShellUtilities.executeRaw("pgrep", "-f", dbusMatch).split("\\r?\\n")));
|
||||
}
|
||||
|
||||
HashMap<String, String> env = new HashMap<>();
|
||||
HashMap<String, String> tempEnv = new HashMap<>();
|
||||
ArrayList<String> toExport = new ArrayList<>(Arrays.asList(SUDO_EXPORTS));
|
||||
for(String pid : pids) {
|
||||
if(pid.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
String[] vars;
|
||||
if(SystemUtilities.isSolaris()) {
|
||||
// Use pargs -e $$ to get environment
|
||||
log.info("Reading environment info from [pargs, -e, {}]", pid);
|
||||
String pargs = ShellUtilities.executeRaw("pargs", "-e", pid);
|
||||
vars = pargs.split("\\r?\\n");
|
||||
String delim = "]: ";
|
||||
for(int i = 0; i < vars.length; i++) {
|
||||
if(vars[i].contains(delim)) {
|
||||
vars[i] = vars[i].substring(vars[i].indexOf(delim) + delim.length()).trim();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Assume /proc/$$/environ
|
||||
String environ = String.format("/proc/%s/environ", pid);
|
||||
String delim = Pattern.compile("\0").pattern();
|
||||
log.info("Reading environment info from {}", environ);
|
||||
vars = new String(Files.readAllBytes(Paths.get(environ))).split(delim);
|
||||
}
|
||||
for(String var : vars) {
|
||||
String[] parts = var.split("=", 2);
|
||||
if(parts.length == 2) {
|
||||
String key = parts[0].trim();
|
||||
String val = parts[1].trim();
|
||||
if(toExport.contains(key)) {
|
||||
tempEnv.put(key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(Exception e) {
|
||||
log.warn("An unexpected error occurred obtaining dbus info", e);
|
||||
}
|
||||
|
||||
// Only add vars for the current user
|
||||
if(matchingUser.trim().equals(tempEnv.get("USER"))) {
|
||||
env.putAll(tempEnv);
|
||||
} else {
|
||||
log.debug("Expected USER={} but got USER={}, skipping results for {}", matchingUser, tempEnv.get("USER"), pid);
|
||||
}
|
||||
|
||||
// Use gtk theme
|
||||
if(env.containsKey("XDG_CURRENT_DESKTOP") && !env.containsKey("GNOME_DESKTOP_SESSION_ID")) {
|
||||
if(env.get("XDG_CURRENT_DESKTOP").toLowerCase(Locale.ENGLISH).contains("gnome")) {
|
||||
env.put("GNOME_DESKTOP_SESSION_ID", "this-is-deprecated");
|
||||
}
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
}
|
||||
125
tray/src/qz/installer/MacInstaller.java
Normal file
125
tray/src/qz/installer/MacInstaller.java
Normal file
@@ -0,0 +1,125 @@
|
||||
package qz.installer;
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.utils.FileUtilities;
|
||||
import qz.utils.ShellUtilities;
|
||||
import qz.utils.SystemUtilities;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
||||
import static qz.common.Constants.*;
|
||||
|
||||
public class MacInstaller extends Installer {
|
||||
protected static final Logger log = LogManager.getLogger(MacInstaller.class);
|
||||
private static final String PACKAGE_NAME = getPackageName();
|
||||
public static final String LAUNCH_AGENT_PATH = String.format("/Library/LaunchAgents/%s.plist", MacInstaller.PACKAGE_NAME);
|
||||
private String destination = "/Applications/" + ABOUT_TITLE + ".app";
|
||||
|
||||
public Installer addAppLauncher() {
|
||||
// not needed; registered when "QZ Tray.app" is copied
|
||||
return this;
|
||||
}
|
||||
|
||||
public Installer addStartupEntry() {
|
||||
File dest = new File(LAUNCH_AGENT_PATH);
|
||||
HashMap<String, String> fieldMap = new HashMap<>();
|
||||
// Dynamic fields
|
||||
fieldMap.put("%PACKAGE_NAME%", PACKAGE_NAME);
|
||||
fieldMap.put("%COMMAND%", String.format("%s/Contents/MacOS/%s", destination, ABOUT_TITLE));
|
||||
fieldMap.put("%PARAM%", "--honorautostart");
|
||||
|
||||
try {
|
||||
FileUtilities.configureAssetFile("assets/mac-launchagent.plist.in", dest, fieldMap, MacInstaller.class);
|
||||
// Disable service until reboot
|
||||
if(SystemUtilities.isMac()) {
|
||||
ShellUtilities.execute("/bin/launchctl", "unload", MacInstaller.LAUNCH_AGENT_PATH);
|
||||
}
|
||||
} catch(IOException e) {
|
||||
log.warn("Unable to write startup file: {}", dest, e);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public void setDestination(String destination) {
|
||||
this.destination = destination;
|
||||
}
|
||||
|
||||
public String getDestination() {
|
||||
return destination;
|
||||
}
|
||||
|
||||
public Installer addSystemSettings() {
|
||||
// Chrome protocol handler
|
||||
String plist = "/Library/Preferences/com.google.Chrome.plist";
|
||||
if(ShellUtilities.execute(new String[] { "/usr/bin/defaults", "write", plist }, new String[] {DATA_DIR + "://*" }).isEmpty()) {
|
||||
ShellUtilities.execute("/usr/bin/defaults", "write", plist, "URLAllowlist", "-array-add", DATA_DIR +"://*");
|
||||
}
|
||||
return this;
|
||||
}
|
||||
public Installer removeSystemSettings() {
|
||||
// Remove startup entry
|
||||
File dest = new File(LAUNCH_AGENT_PATH);
|
||||
dest.delete();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes legacy (<= 2.0) startup entries
|
||||
*/
|
||||
public Installer removeLegacyStartup() {
|
||||
log.info("Removing startup entries for all users matching " + ABOUT_TITLE);
|
||||
String script = "tell application \"System Events\" to delete "
|
||||
+ "every login item where name is \"" + ABOUT_TITLE + "\""
|
||||
+ " or name is \"" + PROPS_FILE + ".jar\"";
|
||||
|
||||
// Run on background thread in case System Events is hung or slow to respond
|
||||
final String finalScript = script;
|
||||
new Thread(() -> {
|
||||
ShellUtilities.executeAppleScript(finalScript);
|
||||
}).run();
|
||||
return this;
|
||||
}
|
||||
|
||||
public static String getPackageName() {
|
||||
String packageName;
|
||||
String[] parts = ABOUT_URL.split("\\W");
|
||||
if (parts.length >= 2) {
|
||||
// Parse io.qz.qz-print from Constants
|
||||
packageName = String.format("%s.%s.%s", parts[parts.length - 1], parts[parts.length - 2], PROPS_FILE);
|
||||
} else {
|
||||
// Fallback on something sane
|
||||
packageName = "local." + PROPS_FILE;
|
||||
}
|
||||
return packageName;
|
||||
}
|
||||
|
||||
public void spawn(List<String> args) throws Exception {
|
||||
if(SystemUtilities.isAdmin()) {
|
||||
// macOS unconventionally uses "$USER" during its install process
|
||||
String sudoer = System.getenv("USER");
|
||||
if(sudoer == null || sudoer.isEmpty() || sudoer.equals("root")) {
|
||||
// Fallback, should only fire via Terminal + sudo
|
||||
sudoer = ShellUtilities.executeRaw("logname").trim();
|
||||
}
|
||||
// Start directly without waitFor(...), avoids deadlocking
|
||||
Runtime.getRuntime().exec(new String[] { "su", sudoer, "-c", "\"" + StringUtils.join(args, "\" \"") + "\""});
|
||||
} else {
|
||||
Runtime.getRuntime().exec(args.toArray(new String[args.size()]));
|
||||
}
|
||||
}
|
||||
}
|
||||
227
tray/src/qz/installer/TaskKiller.java
Normal file
227
tray/src/qz/installer/TaskKiller.java
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2021 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
package qz.installer;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.utils.ShellUtilities;
|
||||
import qz.utils.SystemUtilities;
|
||||
import qz.utils.WindowsUtilities;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashSet;
|
||||
|
||||
import static qz.common.Constants.PROPS_FILE;
|
||||
|
||||
public class TaskKiller {
|
||||
protected static final Logger log = LogManager.getLogger(TaskKiller.class);
|
||||
private static final String[] JAR_NAMES = {
|
||||
PROPS_FILE + ".jar",
|
||||
"qz.App", // v2.2.0...
|
||||
"qz.ws.PrintSocketServer" // v2.0.0...v2.1.6
|
||||
};
|
||||
private static final String[] KILL_PID_CMD_POSIX = { "kill", "-9" };
|
||||
private static final String[] KILL_PID_CMD_WIN32 = { "taskkill.exe", "/F", "/PID" };
|
||||
private static final String[] KILL_PID_CMD = SystemUtilities.isWindows() ? KILL_PID_CMD_WIN32 : KILL_PID_CMD_POSIX;
|
||||
|
||||
/**
|
||||
* Kills all QZ Tray processes, being careful not to kill itself
|
||||
*/
|
||||
public static boolean killAll() {
|
||||
boolean success = true;
|
||||
|
||||
// Disable service until reboot
|
||||
if(SystemUtilities.isMac()) {
|
||||
ShellUtilities.execute("/bin/launchctl", "unload", MacInstaller.LAUNCH_AGENT_PATH);
|
||||
}
|
||||
|
||||
// Use jcmd to get all java processes
|
||||
HashSet<Integer> pids = findPidsJcmd();
|
||||
if(!SystemUtilities.isWindows()) {
|
||||
// Fallback to pgrep, needed for macOS (See JDK-8319589, JDK-8197387)
|
||||
pids.addAll(findPidsPgrep());
|
||||
} else if(WindowsUtilities.isSystemAccount()) {
|
||||
// Fallback to powershell, needed for Windows
|
||||
pids.addAll(findPidsPwsh());
|
||||
}
|
||||
|
||||
// Careful not to kill ourselves ;)
|
||||
pids.remove(SystemUtilities.getProcessId());
|
||||
|
||||
// Kill each PID
|
||||
String[] killPid = new String[KILL_PID_CMD.length + 1];
|
||||
System.arraycopy(KILL_PID_CMD, 0, killPid, 0, KILL_PID_CMD.length);
|
||||
for (Integer pid : pids) {
|
||||
killPid[killPid.length - 1] = pid.toString();
|
||||
success = success && ShellUtilities.execute(killPid);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private static Path getJcmdPath() throws IOException {
|
||||
Path jcmd;
|
||||
if(SystemUtilities.isWindows()) {
|
||||
jcmd = SystemUtilities.getJarParentPath().resolve("runtime/bin/jcmd.exe");
|
||||
} else if (SystemUtilities.isMac()) {
|
||||
jcmd = SystemUtilities.getJarParentPath().resolve("../PlugIns/Java.runtime/Contents/Home/bin/jcmd");
|
||||
} else {
|
||||
jcmd = SystemUtilities.getJarParentPath().resolve("runtime/bin/jcmd");
|
||||
}
|
||||
if(!jcmd.toFile().exists()) {
|
||||
log.error("Could not find {}", jcmd);
|
||||
throw new IOException("Could not find jcmd, we can't use it for detecting running instances");
|
||||
}
|
||||
return jcmd;
|
||||
}
|
||||
|
||||
|
||||
static final String[] PWSH_QUERY = { "powershell.exe", "-Command", "\"(Get-CimInstance Win32_Process -Filter \\\"Name = 'java.exe' OR Name = 'javaw.exe'\\\").Where({$_.CommandLine -like '*%s*'}).ProcessId\"" };
|
||||
|
||||
/**
|
||||
* Leverage powershell.exe when run as SYSTEM to workaround https://github.com/qzind/tray/issues/1360
|
||||
* TODO: Remove when jcmd is patched to work as SYSTEM account
|
||||
*/
|
||||
private static HashSet<Integer> findPidsPwsh() {
|
||||
HashSet<Integer> foundPids = new HashSet<>();
|
||||
|
||||
for(String jarName : JAR_NAMES) {
|
||||
String[] pwshQuery = PWSH_QUERY.clone();
|
||||
int lastIndex = pwshQuery.length - 1;
|
||||
// Format the last element to contain the jarName
|
||||
pwshQuery[lastIndex] = String.format(pwshQuery[lastIndex], jarName);
|
||||
String stdout = ShellUtilities.executeRaw(pwshQuery);
|
||||
String[] lines = stdout.split("\\s*\\r?\\n");
|
||||
for(String line : lines) {
|
||||
if(line.trim().isEmpty()) {
|
||||
// Don't try to process blank lines
|
||||
continue;
|
||||
}
|
||||
|
||||
int pid = parsePid(line);
|
||||
if (pid >= 0) {
|
||||
foundPids.add(pid);
|
||||
} else {
|
||||
log.warn("Could not parse PID value. Full line: '{}', Full output: '{}'", line, stdout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return foundPids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use pgrep to fetch all PIDs to workaround https://github.com/openjdk/jdk/pull/25824
|
||||
* TODO: Remove when jcmd is patched to work properly on macOS
|
||||
*/
|
||||
private static HashSet<Integer> findPidsPgrep() {
|
||||
HashSet<Integer> foundPids = new HashSet<>();
|
||||
|
||||
for(String jarName : JAR_NAMES) {
|
||||
String stdout = ShellUtilities.executeRaw("pgrep", "-f", jarName);
|
||||
String[] lines = stdout.split("\\s*\\r?\\n");
|
||||
for(String line : lines) {
|
||||
if(line.trim().isEmpty()) {
|
||||
// Don't try to process blank lines
|
||||
continue;
|
||||
}
|
||||
|
||||
int pid = parsePid(line);
|
||||
if (pid >= 0) {
|
||||
foundPids.add(pid);
|
||||
} else {
|
||||
log.warn("Could not parse PID value. Full line: '{}', Full output: '{}'", line, stdout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return foundPids;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Uses jcmd to fetch all PIDs that match this product
|
||||
*/
|
||||
private static HashSet<Integer> findPidsJcmd() {
|
||||
HashSet<Integer> foundPids = new HashSet<>();
|
||||
|
||||
String stdout;
|
||||
String[] lines;
|
||||
try {
|
||||
stdout = ShellUtilities.executeRaw(getJcmdPath().toString(), "-l");
|
||||
if(stdout == null) {
|
||||
log.error("Error calling '{}' {}", getJcmdPath(), "-l");
|
||||
return foundPids;
|
||||
}
|
||||
lines = stdout.split("\\r?\\n");
|
||||
} catch(Exception e) {
|
||||
log.error(e);
|
||||
return foundPids;
|
||||
}
|
||||
|
||||
for(String line : lines) {
|
||||
if (line.trim().isEmpty()) {
|
||||
// Don't try to process blank lines
|
||||
continue;
|
||||
}
|
||||
// e.g. "35446 C:\Program Files\QZ Tray\qz-tray.jar"
|
||||
String[] parts = line.split(" ", 2);
|
||||
int pid = parsePid(parts);
|
||||
if (pid >= 0) {
|
||||
String args = parseArgs(parts);
|
||||
if (args == null) {
|
||||
log.warn("Found PID value '{}' but no args to match. Full line: '{}', Full output: '{}'", pid, line, stdout);
|
||||
continue;
|
||||
}
|
||||
for(String jarName : JAR_NAMES) {
|
||||
if (args.contains(jarName)) {
|
||||
foundPids.add(pid);
|
||||
break; // continue parent loop
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.warn("Could not parse PID value. Full line: '{}', Full output: '{}'", line, stdout);
|
||||
}
|
||||
}
|
||||
|
||||
return foundPids;
|
||||
}
|
||||
|
||||
// Returns the second index of a String[], trimmed
|
||||
private static String parseArgs(String[] input) {
|
||||
if(input != null) {
|
||||
if(input.length == 2) {
|
||||
return input[1].trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parses an int value form the first index of a String[], returning -1 if something went wrong
|
||||
private static int parsePid(String[] input) {
|
||||
if(input != null) {
|
||||
if(input.length == 2) {
|
||||
return parsePid(input[0]);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Parses an int value form the provided string, returning -1 if something went wrong
|
||||
private static int parsePid(String input) {
|
||||
String pidString = input.trim();
|
||||
if(StringUtils.isNumeric(pidString)) {
|
||||
return Integer.parseInt(pidString);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
208
tray/src/qz/installer/WindowsInstaller.java
Normal file
208
tray/src/qz/installer/WindowsInstaller.java
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.installer;
|
||||
|
||||
import com.sun.jna.platform.win32.*;
|
||||
import mslinks.ShellLink;
|
||||
import mslinks.ShellLinkException;
|
||||
import mslinks.ShellLinkHelper;
|
||||
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 qz.utils.ShellUtilities;
|
||||
import qz.utils.SystemUtilities;
|
||||
import qz.utils.WindowsUtilities;
|
||||
import qz.ws.PrintSocketServer;
|
||||
|
||||
import javax.swing.*;
|
||||
|
||||
import static qz.common.Constants.*;
|
||||
import static qz.installer.WindowsSpecialFolders.*;
|
||||
import static com.sun.jna.platform.win32.WinReg.*;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
public class WindowsInstaller extends Installer {
|
||||
protected static final Logger log = LogManager.getLogger(WindowsInstaller.class);
|
||||
private String destination = getDefaultDestination();
|
||||
private String destinationExe = getDefaultDestination() + File.separator + PROPS_FILE + ".exe";
|
||||
|
||||
public void setDestination(String destination) {
|
||||
this.destination = destination;
|
||||
this.destinationExe = destination + File.separator + PROPS_FILE + ".exe";
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycles through registry keys removing legacy (<= 2.0) startup entries
|
||||
*/
|
||||
public Installer removeLegacyStartup() {
|
||||
log.info("Removing legacy startup entries for all users matching " + ABOUT_TITLE);
|
||||
for (String user : Advapi32Util.registryGetKeys(HKEY_USERS)) {
|
||||
WindowsUtilities.deleteRegValue(HKEY_USERS, user.trim() + "\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", ABOUT_TITLE);
|
||||
}
|
||||
|
||||
try {
|
||||
FileUtils.deleteQuietly(new File(STARTUP + File.separator + ABOUT_TITLE + ".lnk"));
|
||||
} catch(Win32Exception ignore) {}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public Installer addAppLauncher() {
|
||||
try {
|
||||
// Delete old 2.0 launcher
|
||||
FileUtils.deleteQuietly(new File(COMMON_START_MENU + File.separator + "Programs" + File.separator + ABOUT_TITLE + ".lnk"));
|
||||
Path loc = Paths.get(COMMON_START_MENU.toString(), "Programs", ABOUT_TITLE);
|
||||
loc.toFile().mkdirs();
|
||||
String lnk = loc + File.separator + ABOUT_TITLE + ".lnk";
|
||||
String exe = destination + File.separator + PROPS_FILE+ ".exe";
|
||||
log.info("Creating launcher \"{}\" -> \"{}\"", lnk, exe);
|
||||
ShellLinkHelper.createLink(exe, lnk);
|
||||
} catch(ShellLinkException | IOException | Win32Exception e) {
|
||||
log.warn("Could not create launcher", e);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public Installer addStartupEntry() {
|
||||
try {
|
||||
String lnk = WindowsSpecialFolders.COMMON_STARTUP + File.separator + ABOUT_TITLE + ".lnk";
|
||||
String exe = destination + File.separator + PROPS_FILE+ ".exe";
|
||||
log.info("Creating startup entry \"{}\" -> \"{}\"", lnk, exe);
|
||||
ShellLink link = ShellLinkHelper.createLink(exe, lnk).getLink();
|
||||
link.setCMDArgs("--honorautostart"); // honors auto-start preferences
|
||||
} catch(ShellLinkException | IOException | Win32Exception e) {
|
||||
log.warn("Could not create startup launcher", e);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
public Installer removeSystemSettings() {
|
||||
// Cleanup registry
|
||||
WindowsUtilities.deleteRegKey(HKEY_LOCAL_MACHINE, "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\" + ABOUT_TITLE);
|
||||
WindowsUtilities.deleteRegKey(HKEY_LOCAL_MACHINE, "Software\\" + ABOUT_TITLE);
|
||||
WindowsUtilities.deleteRegKey(HKEY_LOCAL_MACHINE, DATA_DIR);
|
||||
// Chrome protocol handler
|
||||
WindowsUtilities.deleteRegData(HKEY_LOCAL_MACHINE, "SOFTWARE\\Policies\\Google\\Chrome\\URLAllowlist", String.format("%s://*", DATA_DIR));
|
||||
// Deprecated Chrome protocol handler
|
||||
WindowsUtilities.deleteRegData(HKEY_LOCAL_MACHINE, "SOFTWARE\\Policies\\Google\\Chrome\\URLWhitelist", String.format("%s://*", DATA_DIR));
|
||||
|
||||
// Cleanup launchers
|
||||
for(WindowsSpecialFolders folder : new WindowsSpecialFolders[] { START_MENU, COMMON_START_MENU, DESKTOP, PUBLIC_DESKTOP, COMMON_STARTUP, RECENT }) {
|
||||
try {
|
||||
new File(folder + File.separator + ABOUT_TITLE + ".lnk").delete();
|
||||
// Since 2.1, start menus use subfolder
|
||||
if (folder.equals(COMMON_START_MENU) || folder.equals(START_MENU)) {
|
||||
FileUtils.deleteQuietly(new File(folder + File.separator + "Programs" + File.separator + ABOUT_TITLE + ".lnk"));
|
||||
FileUtils.deleteDirectory(new File(folder + File.separator + "Programs" + File.separator + ABOUT_TITLE));
|
||||
}
|
||||
} catch(InvalidPathException | IOException | Win32Exception ignore) {}
|
||||
}
|
||||
|
||||
// Cleanup firewall rules
|
||||
ShellUtilities.execute("netsh.exe", "advfirewall", "firewall", "delete", "rule", String.format("name=%s", ABOUT_TITLE));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Installer addSystemSettings() {
|
||||
/**
|
||||
* TODO: Upgrade JNA!
|
||||
* 64-bit registry view is currently invoked by nsis (windows-installer.nsi.in) using SetRegView 64
|
||||
* However, newer version of JNA offer direct WinNT.KEY_WOW64_64KEY registry support, safeguarding
|
||||
* against direct calls to "java -jar qz-tray.jar install|keygen|etc", which will be needed moving forward
|
||||
* for support and troubleshooting.
|
||||
*/
|
||||
|
||||
// Mime-type support e.g. qz:launch
|
||||
WindowsUtilities.addRegValue(HKEY_CLASSES_ROOT, DATA_DIR, "", String.format("URL:%s Protocol", ABOUT_TITLE));
|
||||
WindowsUtilities.addRegValue(HKEY_CLASSES_ROOT, DATA_DIR, "URL Protocol", "");
|
||||
WindowsUtilities.addRegValue(HKEY_CLASSES_ROOT, String.format("%s\\DefaultIcon", DATA_DIR), "", String.format("\"%s\",1", destinationExe));
|
||||
WindowsUtilities.addRegValue(HKEY_CLASSES_ROOT, String.format("%s\\shell\\open\\command", DATA_DIR), "", String.format("\"%s\" \"%%1\"", destinationExe));
|
||||
|
||||
/// Uninstall info
|
||||
String uninstallKey = String.format("Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\%s", ABOUT_TITLE);
|
||||
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, String.format("Software\\%s", ABOUT_TITLE), "", destination);
|
||||
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, uninstallKey, "DisplayName", String.format("%s %s", ABOUT_TITLE, VERSION));
|
||||
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, uninstallKey, "Publisher", ABOUT_COMPANY);
|
||||
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, uninstallKey, "UninstallString", destination + File.separator + "uninstall.exe");
|
||||
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, uninstallKey, "DisplayIcon", destinationExe);
|
||||
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, uninstallKey, "HelpLink", ABOUT_SUPPORT_URL );
|
||||
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, uninstallKey, "URLUpdateInfo", ABOUT_DOWNLOAD_URL);
|
||||
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, uninstallKey, "URLInfoAbout", ABOUT_SUPPORT_URL);
|
||||
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, uninstallKey, "DisplayVersion", VERSION.toString());
|
||||
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, uninstallKey, "EstimatedSize", FileUtils.sizeOfDirectoryAsBigInteger(new File(destination)).intValue() / 1024);
|
||||
|
||||
// Chrome protocol handler
|
||||
WindowsUtilities.addNumberedRegValue(HKEY_LOCAL_MACHINE, "SOFTWARE\\Policies\\Google\\Chrome\\URLAllowlist", String.format("%s://*", DATA_DIR));
|
||||
|
||||
// Firewall rules
|
||||
ShellUtilities.execute("netsh.exe", "advfirewall", "firewall", "delete", "rule", String.format("name=%s", ABOUT_TITLE));
|
||||
ShellUtilities.execute("netsh.exe", "advfirewall", "firewall", "add", "rule", String.format("name=%s", ABOUT_TITLE),
|
||||
"dir=in", "action=allow", "profile=any", String.format("localport=%s", websocketPorts.allPortsAsString()), "localip=any", "protocol=tcp");
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Installer addUserSettings() {
|
||||
// Whitelist loopback for IE/Edge
|
||||
if(ShellUtilities.execute("CheckNetIsolation.exe", "LoopbackExempt", "-a", "-n=Microsoft.MicrosoftEdge_8wekyb3d8bbwe")) {
|
||||
log.warn("Could not whitelist loopback connections for IE, Edge");
|
||||
}
|
||||
|
||||
try {
|
||||
// Intranet settings; uncheck "include sites not listed in other zones"
|
||||
String key = "Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings\\Zones\\1";
|
||||
String value = "Flags";
|
||||
if (Advapi32Util.registryKeyExists(HKEY_CURRENT_USER, key) && Advapi32Util.registryValueExists(HKEY_CURRENT_USER, key, value)) {
|
||||
int data = Advapi32Util.registryGetIntValue(HKEY_CURRENT_USER, key, value);
|
||||
// remove value using bitwise XOR
|
||||
Advapi32Util.registrySetIntValue(HKEY_CURRENT_USER, key, value, data ^ 16);
|
||||
}
|
||||
|
||||
// Legacy Edge loopback support
|
||||
key = "Software\\Classes\\Local Settings\\Software\\Microsoft\\Windows\\CurrentVersion\\AppContainer\\Storage\\microsoft.microsoftedge_8wekyb3d8bbwe\\MicrosoftEdge\\ExperimentalFeatures";
|
||||
value = "AllowLocalhostLoopback";
|
||||
if (Advapi32Util.registryKeyExists(HKEY_CURRENT_USER, key) && Advapi32Util.registryValueExists(HKEY_CURRENT_USER, key, value)) {
|
||||
int data = Advapi32Util.registryGetIntValue(HKEY_CURRENT_USER, key, value);
|
||||
// remove value using bitwise OR
|
||||
Advapi32Util.registrySetIntValue(HKEY_CURRENT_USER, key, value, data | 1);
|
||||
}
|
||||
} catch(Exception e) {
|
||||
log.warn("An error occurred configuring the \"Local Intranet Zone\"; connections to \"localhost\" may fail", e);
|
||||
}
|
||||
return super.addUserSettings();
|
||||
}
|
||||
|
||||
public static String getDefaultDestination() {
|
||||
String path = System.getenv("ProgramW6432");
|
||||
if (path == null || path.trim().isEmpty()) {
|
||||
path = System.getenv("ProgramFiles");
|
||||
}
|
||||
return path + File.separator + ABOUT_TITLE;
|
||||
}
|
||||
|
||||
public String getDestination() {
|
||||
return destination;
|
||||
}
|
||||
|
||||
public void spawn(List<String> args) throws Exception {
|
||||
if(SystemUtilities.isAdmin()) {
|
||||
log.warn("Spawning as user isn't implemented; starting process with elevation instead");
|
||||
}
|
||||
ShellUtilities.execute(args.toArray(new String[args.size()]));
|
||||
}
|
||||
}
|
||||
97
tray/src/qz/installer/WindowsSpecialFolders.java
Normal file
97
tray/src/qz/installer/WindowsSpecialFolders.java
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2016 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.installer;
|
||||
|
||||
import com.sun.jna.platform.win32.*;
|
||||
import qz.utils.WindowsUtilities;
|
||||
|
||||
/**
|
||||
* Windows XP-compatible special folder's wrapper for JNA
|
||||
*
|
||||
*/
|
||||
public enum WindowsSpecialFolders {
|
||||
ADMIN_TOOLS(ShlObj.CSIDL_ADMINTOOLS, KnownFolders.FOLDERID_AdminTools),
|
||||
STARTUP_ALT(ShlObj.CSIDL_ALTSTARTUP, KnownFolders.FOLDERID_Startup),
|
||||
ROAMING_APPDATA(ShlObj.CSIDL_APPDATA, KnownFolders.FOLDERID_RoamingAppData),
|
||||
RECYCLING_BIN(ShlObj.CSIDL_BITBUCKET, KnownFolders.FOLDERID_RecycleBinFolder),
|
||||
CD_BURNING(ShlObj.CSIDL_CDBURN_AREA, KnownFolders.FOLDERID_CDBurning),
|
||||
COMMON_ADMIN_TOOLS(ShlObj.CSIDL_COMMON_ADMINTOOLS, KnownFolders.FOLDERID_CommonAdminTools),
|
||||
COMMON_STARTUP_ALT(ShlObj.CSIDL_COMMON_ALTSTARTUP, KnownFolders.FOLDERID_CommonStartup),
|
||||
PROGRAM_DATA(ShlObj.CSIDL_COMMON_APPDATA, KnownFolders.FOLDERID_ProgramData),
|
||||
PUBLIC_DESKTOP(ShlObj.CSIDL_COMMON_DESKTOPDIRECTORY, KnownFolders.FOLDERID_PublicDesktop),
|
||||
PUBLIC_DOCUMENTS(ShlObj.CSIDL_COMMON_DOCUMENTS, KnownFolders.FOLDERID_PublicDocuments),
|
||||
COMMON_FAVORITES(ShlObj.CSIDL_COMMON_FAVORITES, KnownFolders.FOLDERID_Favorites),
|
||||
COMMON_MUSIC(ShlObj.CSIDL_COMMON_MUSIC, KnownFolders.FOLDERID_PublicMusic),
|
||||
COMMON_OEM_LINKS(ShlObj.CSIDL_COMMON_OEM_LINKS, KnownFolders.FOLDERID_CommonOEMLinks),
|
||||
COMMON_PICTURES(ShlObj.CSIDL_COMMON_PICTURES, KnownFolders.FOLDERID_PublicPictures),
|
||||
COMMON_PROGRAMS(ShlObj.CSIDL_COMMON_PROGRAMS, KnownFolders.FOLDERID_CommonPrograms),
|
||||
COMMON_START_MENU(ShlObj.CSIDL_COMMON_STARTMENU, KnownFolders.FOLDERID_CommonStartMenu),
|
||||
COMMON_STARTUP(ShlObj.CSIDL_COMMON_STARTUP, KnownFolders.FOLDERID_CommonStartup),
|
||||
COMMON_TEMPLATES(ShlObj.CSIDL_COMMON_TEMPLATES, KnownFolders.FOLDERID_CommonTemplates),
|
||||
COMMON_VIDEO(ShlObj.CSIDL_COMMON_VIDEO, KnownFolders.FOLDERID_PublicVideos),
|
||||
COMPUTERS_NEAR_ME(ShlObj.CSIDL_COMPUTERSNEARME, KnownFolders.FOLDERID_NetworkFolder),
|
||||
CONNECTIONS_FOLDER(ShlObj.CSIDL_CONNECTIONS, KnownFolders.FOLDERID_ConnectionsFolder),
|
||||
CONTROL_PANEL(ShlObj.CSIDL_CONTROLS, KnownFolders.FOLDERID_ControlPanelFolder),
|
||||
COOKIES(ShlObj.CSIDL_COOKIES, KnownFolders.FOLDERID_Cookies),
|
||||
DESKTOP_VIRTUAL(ShlObj.CSIDL_DESKTOP, KnownFolders.FOLDERID_Desktop),
|
||||
DESKTOP(ShlObj.CSIDL_DESKTOPDIRECTORY, KnownFolders.FOLDERID_Desktop),
|
||||
COMPUTER_FOLDER(ShlObj.CSIDL_DRIVES, KnownFolders.FOLDERID_ComputerFolder),
|
||||
FAVORITES(ShlObj.CSIDL_FAVORITES, KnownFolders.FOLDERID_Favorites),
|
||||
FONTS(ShlObj.CSIDL_FONTS, KnownFolders.FOLDERID_Fonts),
|
||||
HISTORY(ShlObj.CSIDL_HISTORY, KnownFolders.FOLDERID_History),
|
||||
INTERNET_FOLDER(ShlObj.CSIDL_INTERNET, KnownFolders.FOLDERID_InternetFolder),
|
||||
INTERNET_CACHE(ShlObj.CSIDL_INTERNET_CACHE, KnownFolders.FOLDERID_InternetCache),
|
||||
LOCAL_APPDATA(ShlObj.CSIDL_LOCAL_APPDATA, KnownFolders.FOLDERID_LocalAppData),
|
||||
MY_DOCUMENTS(ShlObj.CSIDL_MYDOCUMENTS, KnownFolders.FOLDERID_Documents),
|
||||
MY_MUSIC(ShlObj.CSIDL_MYMUSIC, KnownFolders.FOLDERID_Music),
|
||||
MY_PICTURES(ShlObj.CSIDL_MYPICTURES, KnownFolders.FOLDERID_Pictures),
|
||||
MY_VIDEOS(ShlObj.CSIDL_MYVIDEO, KnownFolders.FOLDERID_Videos),
|
||||
NETWORK_NEIGHBORHOOD(ShlObj.CSIDL_NETHOOD, KnownFolders.FOLDERID_NetHood),
|
||||
NETWORK_FOLDER(ShlObj.CSIDL_NETWORK, KnownFolders.FOLDERID_NetworkFolder),
|
||||
PERSONAL_FOLDDER(ShlObj.CSIDL_PERSONAL, KnownFolders.FOLDERID_Documents),
|
||||
PRINTERS(ShlObj.CSIDL_PRINTERS, KnownFolders.FOLDERID_PrintersFolder),
|
||||
PRINTING_NEIGHBORHOODD(ShlObj.CSIDL_PRINTHOOD, KnownFolders.FOLDERID_PrintHood),
|
||||
PROFILE_FOLDER(ShlObj.CSIDL_PROFILE, KnownFolders.FOLDERID_Profile),
|
||||
PROGRAM_FILES(ShlObj.CSIDL_PROGRAM_FILES, KnownFolders.FOLDERID_ProgramFiles),
|
||||
PROGRAM_FILESX86(ShlObj.CSIDL_PROGRAM_FILESX86, KnownFolders.FOLDERID_ProgramFilesX86),
|
||||
PROGRAM_FILES_COMMON(ShlObj.CSIDL_PROGRAM_FILES_COMMON, KnownFolders.FOLDERID_ProgramFilesCommon),
|
||||
PROGRAM_FILES_COMMONX86(ShlObj.CSIDL_PROGRAM_FILES_COMMONX86, KnownFolders.FOLDERID_ProgramFilesCommonX86),
|
||||
PROGRAMS(ShlObj.CSIDL_PROGRAMS, KnownFolders.FOLDERID_Programs),
|
||||
RECENT(ShlObj.CSIDL_RECENT, KnownFolders.FOLDERID_Recent),
|
||||
RESOURCES(ShlObj.CSIDL_RESOURCES, KnownFolders.FOLDERID_ResourceDir),
|
||||
RESOURCES_LOCALIZED(ShlObj.CSIDL_RESOURCES_LOCALIZED, KnownFolders.FOLDERID_LocalizedResourcesDir),
|
||||
SEND_TO(ShlObj.CSIDL_SENDTO, KnownFolders.FOLDERID_SendTo),
|
||||
START_MENU(ShlObj.CSIDL_STARTMENU, KnownFolders.FOLDERID_StartMenu),
|
||||
STARTUP(ShlObj.CSIDL_STARTUP, KnownFolders.FOLDERID_Startup),
|
||||
SYSTEM(ShlObj.CSIDL_SYSTEM, KnownFolders.FOLDERID_System),
|
||||
SYSTEMX86(ShlObj.CSIDL_SYSTEMX86, KnownFolders.FOLDERID_SystemX86),
|
||||
TEMPLATES(ShlObj.CSIDL_TEMPLATES, KnownFolders.FOLDERID_Templates),
|
||||
WINDOWS(ShlObj.CSIDL_WINDOWS, KnownFolders.FOLDERID_Windows);
|
||||
|
||||
private int csidl;
|
||||
private Guid.GUID guid;
|
||||
WindowsSpecialFolders(int csidl, Guid.GUID guid) {
|
||||
this.csidl = csidl;
|
||||
this.guid = guid;
|
||||
}
|
||||
|
||||
public String getPath() {
|
||||
if(WindowsUtilities.isWindowsXP()) {
|
||||
return Shell32Util.getSpecialFolderPath(csidl, false);
|
||||
}
|
||||
return Shell32Util.getKnownFolderPath(guid);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return getPath();
|
||||
}
|
||||
}
|
||||
8
tray/src/qz/installer/assets/linux-shortcut.desktop.in
Normal file
8
tray/src/qz/installer/assets/linux-shortcut.desktop.in
Normal file
@@ -0,0 +1,8 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=%ABOUT_TITLE%
|
||||
Exec="%COMMAND%" %PARAM%
|
||||
Path=%DESTINATION%
|
||||
Icon=%DESTINATION%/%LINUX_ICON%
|
||||
MimeType=application/x-qz;x-scheme-handler/qz;
|
||||
Terminal=false
|
||||
2
tray/src/qz/installer/assets/linux-udev.rules.in
Normal file
2
tray/src/qz/installer/assets/linux-udev.rules.in
Normal file
@@ -0,0 +1,2 @@
|
||||
# %ABOUT_TITLE% usb override settings
|
||||
SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", MODE="0666"
|
||||
18
tray/src/qz/installer/assets/mac-launchagent.plist.in
Normal file
18
tray/src/qz/installer/assets/mac-launchagent.plist.in
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>%PACKAGE_NAME%</string>
|
||||
<key>KeepAlive</key>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key><false/>
|
||||
<key>AfterInitialDemand</key><false/>
|
||||
</dict>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>%COMMAND%</string>
|
||||
<string>%PARAM%</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
147
tray/src/qz/installer/certificate/CertificateChainBuilder.java
Normal file
147
tray/src/qz/installer/certificate/CertificateChainBuilder.java
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.installer.certificate;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.security.*;
|
||||
import java.util.Calendar;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.bouncycastle.asn1.*;
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x500.X500NameBuilder;
|
||||
import org.bouncycastle.asn1.x500.style.BCStyle;
|
||||
import org.bouncycastle.asn1.x509.*;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.X509v3CertificateBuilder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
import org.bouncycastle.operator.OperatorException;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import qz.common.Constants;
|
||||
import qz.utils.SystemUtilities;
|
||||
|
||||
import static qz.installer.certificate.KeyPairWrapper.Type.*;
|
||||
|
||||
public class CertificateChainBuilder {
|
||||
public static final String[] DEFAULT_HOSTNAMES = {"localhost", "localhost.qz.io" };
|
||||
|
||||
private static int KEY_SIZE = 2048;
|
||||
public static int CA_CERT_AGE = 7305; // 20 years
|
||||
public static int SSL_CERT_AGE = 825; // Per https://support.apple.com/HT210176
|
||||
|
||||
private String[] hostNames;
|
||||
|
||||
public CertificateChainBuilder(String ... hostNames) {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
if(hostNames.length > 0) {
|
||||
this.hostNames = hostNames;
|
||||
} else {
|
||||
this.hostNames = DEFAULT_HOSTNAMES;
|
||||
}
|
||||
}
|
||||
|
||||
public KeyPairWrapper createCaCert() throws IOException, GeneralSecurityException, OperatorException {
|
||||
KeyPair keyPair = createRsaKey();
|
||||
|
||||
X509v3CertificateBuilder builder = createX509Cert(keyPair, CA_CERT_AGE, hostNames);
|
||||
|
||||
builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(1))
|
||||
.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign + KeyUsage.cRLSign))
|
||||
.addExtension(Extension.subjectKeyIdentifier, false, new JcaX509ExtensionUtils().createSubjectKeyIdentifier(keyPair.getPublic()));
|
||||
|
||||
// Signing
|
||||
ContentSigner sign = new JcaContentSignerBuilder("SHA256withRSA").setProvider("BC").build(keyPair.getPrivate());
|
||||
X509CertificateHolder certHolder = builder.build(sign);
|
||||
|
||||
// Convert to java-friendly format
|
||||
return new KeyPairWrapper(CA, keyPair, new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder));
|
||||
}
|
||||
|
||||
public KeyPairWrapper createSslCert(KeyPairWrapper caKeyPairWrapper) throws IOException, GeneralSecurityException, OperatorException {
|
||||
KeyPair sslKeyPair = createRsaKey();
|
||||
X509v3CertificateBuilder builder = createX509Cert(sslKeyPair, SSL_CERT_AGE, hostNames);
|
||||
|
||||
JcaX509ExtensionUtils utils = new JcaX509ExtensionUtils();
|
||||
|
||||
builder.addExtension(Extension.authorityKeyIdentifier, false, utils.createAuthorityKeyIdentifier(caKeyPairWrapper.getCert()))
|
||||
.addExtension(Extension.basicConstraints, true, new BasicConstraints(false))
|
||||
.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature + KeyUsage.keyEncipherment))
|
||||
.addExtension(Extension.extendedKeyUsage, false, new ExtendedKeyUsage(new KeyPurposeId[]{KeyPurposeId.id_kp_serverAuth, KeyPurposeId.id_kp_clientAuth}))
|
||||
.addExtension(Extension.subjectAlternativeName, false, buildSan(hostNames))
|
||||
.addExtension(Extension.subjectKeyIdentifier, false, utils.createSubjectKeyIdentifier(sslKeyPair.getPublic()));
|
||||
|
||||
// Signing
|
||||
ContentSigner sign = new JcaContentSignerBuilder("SHA256withRSA").setProvider("BC").build(caKeyPairWrapper.getKey());
|
||||
X509CertificateHolder certHolder = builder.build(sign);
|
||||
|
||||
// Convert to java-friendly format
|
||||
return new KeyPairWrapper(SSL, sslKeyPair, new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder));
|
||||
}
|
||||
|
||||
private static KeyPair createRsaKey() throws GeneralSecurityException {
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
|
||||
keyPairGenerator.initialize(KEY_SIZE, new SecureRandom());
|
||||
return keyPairGenerator.generateKeyPair();
|
||||
}
|
||||
|
||||
private static X509v3CertificateBuilder createX509Cert(KeyPair keyPair, int age, String ... hostNames) {
|
||||
String cn = hostNames.length > 0? hostNames[0]:DEFAULT_HOSTNAMES[0];
|
||||
X500Name name = new X500NameBuilder()
|
||||
.addRDN(BCStyle.C, Constants.ABOUT_COUNTRY)
|
||||
.addRDN(BCStyle.ST, Constants.ABOUT_STATE)
|
||||
.addRDN(BCStyle.L, Constants.ABOUT_CITY)
|
||||
.addRDN(BCStyle.O, Constants.ABOUT_COMPANY)
|
||||
.addRDN(BCStyle.OU, Constants.ABOUT_COMPANY)
|
||||
.addRDN(BCStyle.EmailAddress, Constants.ABOUT_EMAIL)
|
||||
.addRDN(BCStyle.CN, cn)
|
||||
.build();
|
||||
BigInteger serial = BigInteger.valueOf(System.currentTimeMillis());
|
||||
Calendar notBefore = Calendar.getInstance(Locale.ENGLISH);
|
||||
Calendar notAfter = Calendar.getInstance(Locale.ENGLISH);
|
||||
notBefore.add(Calendar.DAY_OF_YEAR, -1);
|
||||
notAfter.add(Calendar.DAY_OF_YEAR, age - 1);
|
||||
|
||||
SystemUtilities.swapLocale();
|
||||
X509v3CertificateBuilder x509builder = new JcaX509v3CertificateBuilder(name, serial, notBefore.getTime(), notAfter.getTime(), name, keyPair.getPublic());
|
||||
SystemUtilities.restoreLocale();
|
||||
return x509builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds subjectAlternativeName extension; iterates and detects IPv4 or hostname
|
||||
*/
|
||||
private static GeneralNames buildSan(String ... hostNames) {
|
||||
GeneralName[] gn = new GeneralName[hostNames.length];
|
||||
for (int i = 0; i < hostNames.length; i++) {
|
||||
int gnType = isIp(hostNames[i]) ? GeneralName.iPAddress : GeneralName.dNSName;
|
||||
gn[i] = new GeneralName(gnType, hostNames[i]);
|
||||
}
|
||||
return GeneralNames.getInstance(new DERSequence(gn));
|
||||
}
|
||||
|
||||
private static boolean isIp(String ip) {
|
||||
try {
|
||||
String[] split = ip.split("\\.");
|
||||
if (split.length != 4) return false;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
int p = Integer.parseInt(split[i]);
|
||||
if (p > 255 || p < 0) return false;
|
||||
}
|
||||
return true;
|
||||
} catch (Exception ignore) {}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
478
tray/src/qz/installer/certificate/CertificateManager.java
Normal file
478
tray/src/qz/installer/certificate/CertificateManager.java
Normal file
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.installer.certificate;
|
||||
|
||||
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
|
||||
import org.bouncycastle.asn1.x500.AttributeTypeAndValue;
|
||||
import org.bouncycastle.asn1.x500.RDN;
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x500.style.BCStyle;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.openssl.PEMKeyPair;
|
||||
import org.bouncycastle.openssl.PEMParser;
|
||||
import org.bouncycastle.openssl.jcajce.JcaMiscPEMGenerator;
|
||||
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
|
||||
import org.bouncycastle.operator.OperatorException;
|
||||
import org.eclipse.jetty.util.ssl.SslContextFactory;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.common.Constants;
|
||||
import qz.installer.Installer;
|
||||
import qz.utils.ArgValue;
|
||||
import qz.utils.FileUtilities;
|
||||
import qz.utils.MacUtilities;
|
||||
import qz.utils.SystemUtilities;
|
||||
|
||||
import java.io.*;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.*;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.util.*;
|
||||
|
||||
import static qz.utils.FileUtilities.*;
|
||||
import static qz.installer.certificate.KeyPairWrapper.Type.*;
|
||||
|
||||
/**
|
||||
* Stores and maintains reading and writing of certificate related files
|
||||
*/
|
||||
public class CertificateManager {
|
||||
static List<Path> SAVE_LOCATIONS = new ArrayList<>();
|
||||
static {
|
||||
// Workaround for JDK-8266929
|
||||
// See also https://github.com/qzind/tray/issues/814
|
||||
SystemUtilities.clearAlgorithms();
|
||||
|
||||
// Skip shared location if running from IDE or build directory
|
||||
// Prevents corrupting the version installed per https://github.com/qzind/tray/issues/1200
|
||||
if(SystemUtilities.isJar() && SystemUtilities.isInstalled()) {
|
||||
// Skip install location if running from sandbox (must remain sealed)
|
||||
if(!SystemUtilities.isMac() || !MacUtilities.isSandboxed()) {
|
||||
SAVE_LOCATIONS.add(SystemUtilities.getJarParentPath());
|
||||
}
|
||||
SAVE_LOCATIONS.add(SHARED_DIR);
|
||||
}
|
||||
SAVE_LOCATIONS.add(USER_DIR);
|
||||
}
|
||||
private static final Logger log = LogManager.getLogger(CertificateManager.class);
|
||||
|
||||
public static String DEFAULT_KEYSTORE_FORMAT = "PKCS12";
|
||||
public static String DEFAULT_KEYSTORE_EXTENSION = ".p12";
|
||||
public static String DEFAULT_CERTIFICATE_EXTENSION = ".crt";
|
||||
private static int DEFAULT_PASSWORD_BITS = 100;
|
||||
|
||||
private boolean needsInstall;
|
||||
private SslContextFactory.Server sslContextFactory;
|
||||
private KeyPairWrapper sslKeyPair;
|
||||
private KeyPairWrapper caKeyPair;
|
||||
|
||||
private Properties properties;
|
||||
private char[] password;
|
||||
|
||||
/**
|
||||
* For internal certs
|
||||
*/
|
||||
public CertificateManager(boolean forceNew, String ... hostNames) throws IOException, GeneralSecurityException, OperatorException {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
sslKeyPair = new KeyPairWrapper(SSL);
|
||||
caKeyPair = new KeyPairWrapper(CA);
|
||||
|
||||
if (!forceNew) {
|
||||
// order is important: ssl, ca
|
||||
properties = loadProperties(sslKeyPair, caKeyPair);
|
||||
}
|
||||
|
||||
if(properties == null) {
|
||||
log.warn("Warning, SSL properties won't be loaded from disk... we'll try to create them...");
|
||||
|
||||
CertificateChainBuilder cb = new CertificateChainBuilder(hostNames);
|
||||
caKeyPair = cb.createCaCert();
|
||||
sslKeyPair = cb.createSslCert(caKeyPair);
|
||||
|
||||
// Create CA
|
||||
properties = createKeyStore(CA)
|
||||
.writeCert(CA)
|
||||
.writeKeystore(null, CA);
|
||||
|
||||
// Create SSL
|
||||
properties = createKeyStore(SSL)
|
||||
.writeCert(SSL)
|
||||
.writeKeystore(properties, SSL);
|
||||
|
||||
// Save properties
|
||||
saveProperties();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For trusted PEM-formatted certs
|
||||
*/
|
||||
public CertificateManager(File trustedPemKey, File trustedPemCert) throws Exception {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
needsInstall = false;
|
||||
sslKeyPair = new KeyPairWrapper(SSL);
|
||||
|
||||
// Assumes ssl/privkey.pem, ssl/fullchain.pem
|
||||
properties = createTrustedKeystore(trustedPemKey, trustedPemCert)
|
||||
.writeKeystore(properties, SSL);
|
||||
|
||||
// Save properties
|
||||
saveProperties();
|
||||
}
|
||||
|
||||
/**
|
||||
* For trusted PKCS12-formatted certs
|
||||
*/
|
||||
public CertificateManager(File pkcs12File, char[] password) throws Exception {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
needsInstall = false;
|
||||
sslKeyPair = new KeyPairWrapper(SSL);
|
||||
|
||||
// Assumes direct pkcs12 import
|
||||
this.password = password;
|
||||
sslKeyPair.init(pkcs12File, password);
|
||||
|
||||
// Save it back, but to a location we can find
|
||||
properties = writeKeystore(null, SSL);
|
||||
|
||||
// Save properties
|
||||
saveProperties();
|
||||
}
|
||||
|
||||
public void renewCertChain(String ... hostNames) throws Exception {
|
||||
CertificateChainBuilder cb = new CertificateChainBuilder(hostNames);
|
||||
sslKeyPair = cb.createSslCert(caKeyPair);
|
||||
createKeyStore(SSL).writeKeystore(properties, SSL);
|
||||
reloadSslContextFactory();
|
||||
}
|
||||
|
||||
public KeyPairWrapper getSslKeyPair() {
|
||||
return sslKeyPair;
|
||||
}
|
||||
|
||||
public KeyPairWrapper getCaKeyPair() {
|
||||
return caKeyPair;
|
||||
}
|
||||
|
||||
public KeyPairWrapper getKeyPair(KeyPairWrapper.Type type) {
|
||||
switch(type) {
|
||||
case SSL:
|
||||
return sslKeyPair;
|
||||
case CA:
|
||||
default:
|
||||
return caKeyPair;
|
||||
}
|
||||
}
|
||||
|
||||
public KeyPairWrapper getKeyPair(String alias) {
|
||||
for(KeyPairWrapper.Type type : KeyPairWrapper.Type.values()) {
|
||||
if (KeyPairWrapper.getAlias(type).equalsIgnoreCase(alias)) {
|
||||
return getKeyPair(type);
|
||||
}
|
||||
}
|
||||
return getKeyPair(KeyPairWrapper.Type.CA);
|
||||
}
|
||||
|
||||
public Properties getProperties() {
|
||||
return properties;
|
||||
}
|
||||
|
||||
private char[] getPassword() {
|
||||
if (password == null) {
|
||||
if(caKeyPair != null && caKeyPair.getPassword() != null) {
|
||||
// Reuse existing
|
||||
password = caKeyPair.getPassword();
|
||||
} else {
|
||||
// Create new
|
||||
BigInteger bi = new BigInteger(DEFAULT_PASSWORD_BITS, new SecureRandom());
|
||||
password = bi.toString(16).toCharArray();
|
||||
log.info("Created a random {} bit password: {}", DEFAULT_PASSWORD_BITS, new String(password));
|
||||
}
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
public SslContextFactory.Server configureSslContextFactory() {
|
||||
sslContextFactory = new SslContextFactory.Server();
|
||||
sslContextFactory.setKeyStore(sslKeyPair.getKeyStore());
|
||||
sslContextFactory.setKeyStorePassword(sslKeyPair.getPasswordString());
|
||||
sslContextFactory.setKeyManagerPassword(sslKeyPair.getPasswordString());
|
||||
return sslContextFactory;
|
||||
}
|
||||
|
||||
public void reloadSslContextFactory() throws Exception {
|
||||
if(isSslActive()) {
|
||||
sslContextFactory.reload(sslContextFactory -> {
|
||||
sslContextFactory.setKeyStore(sslKeyPair.getKeyStore());
|
||||
sslContextFactory.setKeyStorePassword(sslKeyPair.getPasswordString());
|
||||
sslContextFactory.setKeyManagerPassword(sslKeyPair.getPasswordString());
|
||||
});
|
||||
} else {
|
||||
log.warn("SSL isn't active, can't reload");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isSslActive() {
|
||||
return sslContextFactory != null;
|
||||
}
|
||||
|
||||
public boolean needsInstall() {
|
||||
return needsInstall;
|
||||
}
|
||||
|
||||
public CertificateManager createKeyStore(KeyPairWrapper.Type type) throws IOException, GeneralSecurityException {
|
||||
KeyPairWrapper keyPair = type == CA ? caKeyPair : sslKeyPair;
|
||||
KeyStore keyStore = KeyStore.getInstance(DEFAULT_KEYSTORE_FORMAT);
|
||||
keyStore.load(null, password);
|
||||
|
||||
List<X509Certificate> chain = new ArrayList<>();
|
||||
chain.add(keyPair.getCert());
|
||||
|
||||
// Add ca to ssl cert chain
|
||||
if (keyPair.getType() == SSL) {
|
||||
chain.add(caKeyPair.getCert());
|
||||
}
|
||||
keyStore.setEntry(caKeyPair.getAlias(), new KeyStore.TrustedCertificateEntry(caKeyPair.getCert()), null);
|
||||
keyStore.setKeyEntry(keyPair.getAlias(), keyPair.getKey(), getPassword(), chain.toArray(new X509Certificate[chain.size()]));
|
||||
keyPair.init(keyStore, getPassword());
|
||||
return this;
|
||||
}
|
||||
|
||||
public CertificateManager createTrustedKeystore(File p12Store, String password) throws Exception {
|
||||
sslKeyPair = new KeyPairWrapper(SSL);
|
||||
sslKeyPair.init(p12Store, password.toCharArray());
|
||||
return this;
|
||||
}
|
||||
|
||||
public CertificateManager createTrustedKeystore(File pemKey, File pemCert) throws Exception {
|
||||
sslKeyPair = new KeyPairWrapper(SSL);
|
||||
|
||||
// Private Key
|
||||
PEMParser pem = new PEMParser(new FileReader(pemKey));
|
||||
Object parsedObject = pem.readObject();
|
||||
|
||||
PrivateKeyInfo privateKeyInfo = parsedObject instanceof PEMKeyPair ? ((PEMKeyPair)parsedObject).getPrivateKeyInfo() : (PrivateKeyInfo)parsedObject;
|
||||
PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(privateKeyInfo.getEncoded());
|
||||
KeyFactory factory = KeyFactory.getInstance("RSA");
|
||||
PrivateKey key = factory.generatePrivate(privateKeySpec);
|
||||
|
||||
List<X509Certificate> certs = new ArrayList<>();
|
||||
X509CertificateHolder certHolder = (X509CertificateHolder)pem.readObject();
|
||||
if(certHolder != null) {
|
||||
certs.add(new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder));
|
||||
}
|
||||
|
||||
// Certificate
|
||||
pem = new PEMParser(new FileReader(pemCert));
|
||||
while((certHolder = (X509CertificateHolder)pem.readObject()) != null) {
|
||||
certs.add(new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder));
|
||||
}
|
||||
|
||||
// Keystore
|
||||
KeyStore ks = KeyStore.getInstance("PKCS12");
|
||||
ks.load(null);
|
||||
|
||||
for (int i = 0; i < certs.size(); i++) {
|
||||
ks.setCertificateEntry(sslKeyPair.getAlias() + "_" + i, certs.get(i));
|
||||
}
|
||||
|
||||
KeyStore keyStore = KeyStore.getInstance("PKCS12");
|
||||
keyStore.load(null);
|
||||
keyStore.setKeyEntry(sslKeyPair.getAlias(), key, getPassword(), certs.toArray(new X509Certificate[certs.size()]));
|
||||
|
||||
sslKeyPair.init(keyStore, getPassword());
|
||||
return this;
|
||||
}
|
||||
|
||||
public static void writeCert(X509Certificate data, File dest) throws IOException {
|
||||
// PEMWriter doesn't always clear the file, explicitly delete it, see issue #796
|
||||
if(dest.exists()) {
|
||||
dest.delete();
|
||||
}
|
||||
JcaMiscPEMGenerator cert = new JcaMiscPEMGenerator(data);
|
||||
JcaPEMWriter writer = new JcaPEMWriter(new OutputStreamWriter(Files.newOutputStream(dest.toPath(), StandardOpenOption.CREATE)));
|
||||
writer.writeObject(cert.generate());
|
||||
writer.close();
|
||||
FileUtilities.inheritParentPermissions(dest.toPath());
|
||||
log.info("Wrote Cert: \"{}\"", dest);
|
||||
}
|
||||
|
||||
public CertificateManager writeCert(KeyPairWrapper.Type type) throws IOException {
|
||||
KeyPairWrapper keyPair = type == CA ? caKeyPair : sslKeyPair;
|
||||
File certFile = new File(getWritableLocation("ssl"), keyPair.getAlias() + DEFAULT_CERTIFICATE_EXTENSION);
|
||||
|
||||
writeCert(keyPair.getCert(), certFile);
|
||||
FileUtilities.inheritParentPermissions(certFile.toPath());
|
||||
if(keyPair.getType() == CA) {
|
||||
needsInstall = true;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public Properties writeKeystore(Properties props, KeyPairWrapper.Type type) throws GeneralSecurityException, IOException {
|
||||
File sslDir = getWritableLocation("ssl");
|
||||
KeyPairWrapper keyPair = type == CA ? caKeyPair : sslKeyPair;
|
||||
|
||||
File keyFile = new File(sslDir, keyPair.getAlias() + DEFAULT_KEYSTORE_EXTENSION);
|
||||
keyPair.getKeyStore().store(Files.newOutputStream(keyFile.toPath(), StandardOpenOption.CREATE), getPassword());
|
||||
FileUtilities.inheritParentPermissions(keyFile.toPath());
|
||||
log.info("Wrote {} Key: \"{}\"", DEFAULT_KEYSTORE_FORMAT, keyFile);
|
||||
|
||||
if (props == null) {
|
||||
props = new Properties();
|
||||
}
|
||||
props.putIfAbsent(String.format("%s.keystore", keyPair.propsPrefix()), keyFile.toString());
|
||||
props.putIfAbsent(String.format("%s.storepass", keyPair.propsPrefix()), new String(getPassword()));
|
||||
props.putIfAbsent(String.format("%s.alias", keyPair.propsPrefix()), keyPair.getAlias());
|
||||
|
||||
if (keyPair.getType() == SSL) {
|
||||
props.putIfAbsent(String.format("%s.host", keyPair.propsPrefix()), ArgValue.SECURITY_WSS_HOST.getDefaultVal());
|
||||
}
|
||||
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
public static File getWritableLocation(String ... suffixes) throws IOException {
|
||||
// Get an array of preferred directories
|
||||
ArrayList<Path> locs = new ArrayList<>();
|
||||
|
||||
if (suffixes.length == 0) {
|
||||
locs.addAll(SAVE_LOCATIONS);
|
||||
// Last, fallback on a directory we won't ever see again :/
|
||||
locs.add(TEMP_DIR);
|
||||
} else {
|
||||
// Same as above, but with suffixes added (usually "ssl"), skipping the install location
|
||||
for(Path saveLocation : SAVE_LOCATIONS) {
|
||||
if(!saveLocation.equals(SystemUtilities.getJarParentPath())) {
|
||||
locs.add(Paths.get(saveLocation.toString(), suffixes));
|
||||
}
|
||||
}
|
||||
// Last, fallback on a directory we won't ever see again :/
|
||||
locs.add(Paths.get(TEMP_DIR.toString(), suffixes));
|
||||
}
|
||||
|
||||
// Find a suitable write location
|
||||
File path;
|
||||
for(Path loc : locs) {
|
||||
if (loc == null) continue;
|
||||
boolean isPreferred = locs.indexOf(loc) == 0;
|
||||
path = loc.toFile();
|
||||
path.mkdirs();
|
||||
if (path.canWrite()) {
|
||||
log.debug("Writing to {}", loc);
|
||||
if(!isPreferred) {
|
||||
log.warn("Warning, {} isn't the preferred write location, but we'll use it anyway", loc);
|
||||
}
|
||||
return path;
|
||||
} else {
|
||||
log.debug("Can't write to {}, trying the next...", loc);
|
||||
}
|
||||
}
|
||||
throw new IOException("Can't find a suitable write location. SSL will fail.");
|
||||
}
|
||||
|
||||
public static Properties loadProperties(KeyPairWrapper... keyPairs) {
|
||||
log.info("Try to find SSL properties file...");
|
||||
|
||||
|
||||
Properties props = null;
|
||||
for(Path loc : SAVE_LOCATIONS) {
|
||||
if (loc == null) continue;
|
||||
try {
|
||||
for(KeyPairWrapper keyPair : keyPairs) {
|
||||
props = loadKeyPair(keyPair, loc, props);
|
||||
}
|
||||
// We've loaded without Exception, return
|
||||
log.info("Found {}/{}.properties", loc, Constants.PROPS_FILE);
|
||||
return props;
|
||||
} catch(Exception ignore) {
|
||||
log.warn("Properties couldn't be loaded at {}, trying fallback...", loc, ignore);
|
||||
}
|
||||
}
|
||||
log.info("Could not get SSL properties from file.");
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Properties loadKeyPair(KeyPairWrapper keyPair, Path parent, Properties existing) throws Exception {
|
||||
Properties props;
|
||||
|
||||
if (existing == null) {
|
||||
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;
|
||||
}
|
||||
|
||||
String ks = props.getProperty(String.format("%s.keystore", keyPair.propsPrefix()));
|
||||
String pw = props.getProperty(String.format("%s.storepass", keyPair.propsPrefix()), "");
|
||||
|
||||
if(ks == null || ks.trim().isEmpty()) {
|
||||
if(keyPair.getType() == SSL) {
|
||||
throw new IOException("Missing wss.keystore entry");
|
||||
} else {
|
||||
// CA is only needed for internal certs, return
|
||||
return props;
|
||||
}
|
||||
}
|
||||
File ksFile = Paths.get(ks).isAbsolute()? new File(ks):new File(parent.toFile(), ks);
|
||||
if (ksFile.exists()) {
|
||||
keyPair.init(ksFile, pw.toCharArray());
|
||||
return props;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void saveProperties() throws IOException {
|
||||
File propsFile = new File(getWritableLocation(), Constants.PROPS_FILE + ".properties");
|
||||
Installer.persistProperties(propsFile, properties); // checks for props from previous install
|
||||
properties.store(new FileOutputStream(propsFile), null);
|
||||
FileUtilities.inheritParentPermissions(propsFile.toPath());
|
||||
log.info("Successfully created SSL properties file: {}", propsFile);
|
||||
}
|
||||
|
||||
public static boolean emailMatches(X509Certificate cert) {
|
||||
return emailMatches(cert, false);
|
||||
}
|
||||
|
||||
public static boolean emailMatches(X509Certificate cert, boolean quiet) {
|
||||
try {
|
||||
X500Name x500name = new JcaX509CertificateHolder(cert).getSubject();
|
||||
RDN[] emailNames = x500name.getRDNs(BCStyle.E);
|
||||
for(RDN emailName : emailNames) {
|
||||
AttributeTypeAndValue first = emailName.getFirst();
|
||||
if (first != null && first.getValue() != null && Constants.ABOUT_EMAIL.equals(first.getValue().toString())) {
|
||||
if(!quiet) {
|
||||
log.info("Email address {} found, assuming CertProvider is {}", Constants.ABOUT_EMAIL, ExpiryTask.CertProvider.INTERNAL);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception ignore) {}
|
||||
if(!quiet) {
|
||||
log.info("Email address {} was not found. Assuming the certificate is manually installed, we won't try to renew it.", Constants.ABOUT_EMAIL);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
295
tray/src/qz/installer/certificate/ExpiryTask.java
Normal file
295
tray/src/qz/installer/certificate/ExpiryTask.java
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.installer.certificate;
|
||||
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x500.style.BCStyle;
|
||||
import org.bouncycastle.asn1.x509.GeneralName;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.common.Constants;
|
||||
import qz.utils.ShellUtilities;
|
||||
import qz.utils.SystemUtilities;
|
||||
|
||||
import javax.naming.InvalidNameException;
|
||||
import javax.naming.ldap.LdapName;
|
||||
import javax.naming.ldap.Rdn;
|
||||
import java.io.File;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.*;
|
||||
|
||||
import static qz.utils.FileUtilities.*;
|
||||
|
||||
public class ExpiryTask extends TimerTask {
|
||||
private static final Logger log = LogManager.getLogger(CertificateManager.class);
|
||||
public static final int DEFAULT_INITIAL_DELAY = 60 * 1000; // 1 minute
|
||||
public static final int DEFAULT_CHECK_FREQUENCY = 3600 * 1000; // 1 hour
|
||||
private static final int DEFAULT_GRACE_PERIOD_DAYS = 5;
|
||||
private enum ExpiryState {VALID, EXPIRING, EXPIRED, MANAGED}
|
||||
|
||||
public enum CertProvider {
|
||||
INTERNAL(Constants.ABOUT_COMPANY + ".*"),
|
||||
LETS_ENCRYPT("Let's Encrypt.*"),
|
||||
CA_CERT_ORG("CA Cert Signing.*"),
|
||||
UNKNOWN;
|
||||
String[] patterns;
|
||||
CertProvider(String ... regexPattern) {
|
||||
this.patterns = regexPattern;
|
||||
}
|
||||
}
|
||||
|
||||
private Timer timer;
|
||||
private CertificateManager certificateManager;
|
||||
private String[] hostNames;
|
||||
private CertProvider certProvider;
|
||||
|
||||
public ExpiryTask(CertificateManager certificateManager) {
|
||||
super();
|
||||
this.certificateManager = certificateManager;
|
||||
this.hostNames = parseHostNames();
|
||||
this.certProvider = findCertProvider();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
// Check for expiration
|
||||
ExpiryState state = getExpiry(certificateManager.getSslKeyPair().getCert());
|
||||
switch(state) {
|
||||
case EXPIRING:
|
||||
case EXPIRED:
|
||||
log.info("Certificate ExpiryState {}, renewing/reloading...", state);
|
||||
switch(certProvider) {
|
||||
case INTERNAL:
|
||||
if(renewInternalCert()) {
|
||||
getExpiry();
|
||||
}
|
||||
break;
|
||||
case CA_CERT_ORG:
|
||||
case LETS_ENCRYPT:
|
||||
if(renewExternalCert(certProvider)) {
|
||||
getExpiry();
|
||||
}
|
||||
break;
|
||||
case UNKNOWN:
|
||||
default:
|
||||
log.warn("Certificate can't be renewed/reloaded; ExpiryState: {}, CertProvider: {}", state, certProvider);
|
||||
}
|
||||
case VALID:
|
||||
default:
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public boolean renewInternalCert() {
|
||||
try {
|
||||
log.info("Requesting a new SSL certificate from {} ...", certificateManager.getCaKeyPair().getAlias());
|
||||
certificateManager.renewCertChain(hostNames);
|
||||
log.info("New SSL certificate created. Reloading SslContextFactory...");
|
||||
certificateManager.reloadSslContextFactory();
|
||||
log.info("Reloaded SSL successfully.");
|
||||
return true;
|
||||
}
|
||||
catch(Exception e) {
|
||||
log.error("Could not reload SSL certificate", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public ExpiryState getExpiry() {
|
||||
return getExpiry(certificateManager.getSslKeyPair().getCert());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the SSL certificate is generated by QZ Tray and expires inside the GRACE_PERIOD.
|
||||
* GRACE_PERIOD is preferred for scheduling the renewals in advance, such as non-peak hours
|
||||
*/
|
||||
public static ExpiryState getExpiry(X509Certificate cert) {
|
||||
// Invalid
|
||||
if (cert == null) {
|
||||
log.error("Can't check for expiration, certificate is missing.");
|
||||
return ExpiryState.EXPIRED;
|
||||
}
|
||||
|
||||
Date expireDate = cert.getNotAfter();
|
||||
Calendar now = Calendar.getInstance(Locale.ENGLISH);
|
||||
Calendar expires = Calendar.getInstance(Locale.ENGLISH);
|
||||
expires.setTime(expireDate);
|
||||
|
||||
// Expired
|
||||
if (now.after(expires)) {
|
||||
log.info("SSL certificate has expired {}. It must be renewed immediately.", SystemUtilities.toISO(expireDate));
|
||||
return ExpiryState.EXPIRED;
|
||||
}
|
||||
|
||||
// Expiring
|
||||
expires.add(Calendar.DAY_OF_YEAR, -DEFAULT_GRACE_PERIOD_DAYS);
|
||||
if (now.after(expires)) {
|
||||
log.info("SSL certificate will expire in less than {} days: {}", DEFAULT_GRACE_PERIOD_DAYS, SystemUtilities.toISO(expireDate));
|
||||
return ExpiryState.EXPIRING;
|
||||
}
|
||||
|
||||
// Valid
|
||||
int days = (int)Math.round((expireDate.getTime() - new Date().getTime()) / (double)86400000);
|
||||
log.info("SSL certificate is still valid for {} more days: {}. We'll make a new one automatically when needed.", days, SystemUtilities.toISO(expireDate));
|
||||
return ExpiryState.VALID;
|
||||
}
|
||||
|
||||
public void schedule() {
|
||||
schedule(DEFAULT_INITIAL_DELAY, DEFAULT_CHECK_FREQUENCY);
|
||||
}
|
||||
|
||||
public void schedule(int delayMillis, int freqMillis) {
|
||||
if(timer != null) {
|
||||
timer.cancel();
|
||||
timer.purge();
|
||||
}
|
||||
timer = new Timer();
|
||||
timer.scheduleAtFixedRate(this, delayMillis, freqMillis);
|
||||
}
|
||||
|
||||
public String[] parseHostNames() {
|
||||
return parseHostNames(certificateManager.getSslKeyPair().getCert());
|
||||
}
|
||||
|
||||
public CertProvider findCertProvider() {
|
||||
return findCertProvider(certificateManager.getSslKeyPair().getCert());
|
||||
}
|
||||
|
||||
public static CertProvider findCertProvider(X509Certificate cert) {
|
||||
// Internal certs use CN=localhost, trust email instead
|
||||
if (CertificateManager.emailMatches(cert)) {
|
||||
return CertProvider.INTERNAL;
|
||||
}
|
||||
|
||||
String providerDN;
|
||||
|
||||
// check registered patterns to classify certificate
|
||||
if(cert.getIssuerDN() != null && (providerDN = cert.getIssuerDN().getName()) != null) {
|
||||
String cn = null;
|
||||
try {
|
||||
// parse issuer's DN
|
||||
LdapName ldapName = new LdapName(providerDN);
|
||||
for(Rdn rdn : ldapName.getRdns()) {
|
||||
if(rdn.getType().equalsIgnoreCase("CN")) {
|
||||
cn = (String)rdn.getValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// compare cn to our pattern
|
||||
if(cn != null) {
|
||||
for(CertProvider provider : CertProvider.values()) {
|
||||
for(String pattern : provider.patterns) {
|
||||
if (cn.matches(pattern)) {
|
||||
log.warn("Cert issuer detected as {}", provider.name());
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(InvalidNameException ignore) {}
|
||||
}
|
||||
|
||||
log.warn("A valid issuer couldn't be found, we won't know how to renew this cert when it expires");
|
||||
return CertProvider.UNKNOWN;
|
||||
}
|
||||
|
||||
public static String[] parseHostNames(X509Certificate cert) {
|
||||
// Cache the SAN hosts for recreation
|
||||
List<String> hostNameList = new ArrayList<>();
|
||||
try {
|
||||
Collection<List<?>> altNames = cert.getSubjectAlternativeNames();
|
||||
if (altNames != null) {
|
||||
for(List<?> altName : altNames) {
|
||||
if(altName.size()< 1) continue;
|
||||
switch((Integer)altName.get(0)) {
|
||||
case GeneralName.dNSName:
|
||||
case GeneralName.iPAddress:
|
||||
Object data = altName.get(1);
|
||||
if (data instanceof String) {
|
||||
hostNameList.add(((String)data));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.error("getSubjectAlternativeNames is null?");
|
||||
}
|
||||
log.debug("Parsed hostNames: {}", String.join(", ", hostNameList));
|
||||
} catch(CertificateException e) {
|
||||
log.warn("Can't parse hostNames from this cert. Cert renewals will contain default values instead");
|
||||
}
|
||||
return hostNameList.toArray(new String[hostNameList.size()]);
|
||||
}
|
||||
|
||||
public boolean renewExternalCert(CertProvider externalProvider) {
|
||||
switch(externalProvider) {
|
||||
case LETS_ENCRYPT:
|
||||
return renewLetsEncryptCert(externalProvider);
|
||||
case CA_CERT_ORG:
|
||||
default:
|
||||
log.error("Cert renewal for {} is not implemented", externalProvider);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean renewLetsEncryptCert(CertProvider externalProvider) {
|
||||
try {
|
||||
File storagePath = CertificateManager.getWritableLocation("ssl");
|
||||
|
||||
// cerbot is much simpler than acme, let's use it
|
||||
Path root = Paths.get(SHARED_DIR.toString(), "letsencrypt", "config");
|
||||
log.info("Attempting to renew {}. Assuming certs are installed in {}...", externalProvider, root);
|
||||
List<String> cmds = new ArrayList(Arrays.asList("certbot", "--force-renewal", "certonly"));
|
||||
|
||||
cmds.add("--standalone");
|
||||
|
||||
cmds.add("--config-dir");
|
||||
String config = Paths.get(SHARED_DIR.toString(), "ssl", "letsencrypt", "config").toString();
|
||||
cmds.add(config);
|
||||
|
||||
cmds.add("--logs-dir");
|
||||
cmds.add(Paths.get(SHARED_DIR.toString(), "ssl", "letsencrypt", "logs").toString());
|
||||
|
||||
cmds.add("--work-dir");
|
||||
cmds.add(Paths.get(SHARED_DIR.toString(), "ssl", "letsencrypt").toString());
|
||||
|
||||
// append dns names
|
||||
for(String hostName : hostNames) {
|
||||
cmds.add("-d");
|
||||
cmds.add(hostName);
|
||||
}
|
||||
|
||||
if (ShellUtilities.execute(cmds.toArray(new String[cmds.size()]))) {
|
||||
// Assume the cert is stored in a folder called "letsencrypt/config/live/<domain>"
|
||||
Path keyPath = Paths.get(config, "live", hostNames[0], "privkey.pem");
|
||||
Path certPath = Paths.get(config, "live", hostNames[0], "fullchain.pem"); // fullchain required
|
||||
certificateManager.createTrustedKeystore(keyPath.toFile(), certPath.toFile());
|
||||
log.info("Files imported, converted and saved. Reloading SslContextFactory...");
|
||||
certificateManager.reloadSslContextFactory();
|
||||
log.info("Reloaded SSL successfully.");
|
||||
return true;
|
||||
} else {
|
||||
log.warn("Something went wrong renewing the LetsEncrypt certificate. Please run the certbot command manually to learn more.");
|
||||
}
|
||||
} catch(Exception e) {
|
||||
log.error("Error renewing/reloading LetsEncrypt cert", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
130
tray/src/qz/installer/certificate/KeyPairWrapper.java
Normal file
130
tray/src/qz/installer/certificate/KeyPairWrapper.java
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.installer.certificate;
|
||||
|
||||
import qz.common.Constants;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyStore;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Enumeration;
|
||||
|
||||
/**
|
||||
* Wrap handling of X509Certificate, PrivateKey and KeyStore conversion
|
||||
*/
|
||||
public class KeyPairWrapper {
|
||||
public enum Type {CA, SSL}
|
||||
|
||||
private Type type;
|
||||
private PrivateKey key;
|
||||
private char[] password;
|
||||
private X509Certificate cert;
|
||||
private KeyStore keyStore; // for SSL
|
||||
|
||||
public KeyPairWrapper(Type type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public KeyPairWrapper(Type type, KeyPair keyPair, X509Certificate cert) {
|
||||
this.type = type;
|
||||
this.key = keyPair.getPrivate();
|
||||
this.cert = cert;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load from disk
|
||||
*/
|
||||
public void init(File keyFile, char[] password) throws IOException, GeneralSecurityException {
|
||||
KeyStore keyStore = KeyStore.getInstance(keyFile.getName().endsWith(".jks") ? "JKS" : "PKCS12");
|
||||
keyStore.load(new FileInputStream(keyFile), password);
|
||||
init(keyStore, password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load from memory
|
||||
*/
|
||||
public void init(KeyStore keyStore, char[] password) throws GeneralSecurityException {
|
||||
this.keyStore = keyStore;
|
||||
KeyStore.ProtectionParameter param = new KeyStore.PasswordProtection(password);
|
||||
KeyStore.PrivateKeyEntry entry = (KeyStore.PrivateKeyEntry)keyStore.getEntry(getAlias(), param);
|
||||
// the entry we assume is always wrong for pkcs12 imports, search for it instead
|
||||
if(entry == null) {
|
||||
Enumeration<String> enumerator = keyStore.aliases();
|
||||
while(enumerator.hasMoreElements()) {
|
||||
String alias = enumerator.nextElement();
|
||||
if(keyStore.isKeyEntry(alias)) {
|
||||
this.password = password;
|
||||
this.key = ((KeyStore.PrivateKeyEntry)keyStore.getEntry(alias, param)).getPrivateKey();
|
||||
this.cert = (X509Certificate)keyStore.getCertificate(alias);
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new GeneralSecurityException("Could not initialize the KeyStore for internal use");
|
||||
}
|
||||
|
||||
this.password = password;
|
||||
this.key = entry.getPrivateKey();
|
||||
this.cert = (X509Certificate)keyStore.getCertificate(getAlias());
|
||||
}
|
||||
|
||||
public X509Certificate getCert() {
|
||||
return cert;
|
||||
}
|
||||
|
||||
public PrivateKey getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
public String getPasswordString() {
|
||||
return new String(password);
|
||||
}
|
||||
|
||||
public char[] getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public static String getAlias(Type type) {
|
||||
switch(type) {
|
||||
case SSL:
|
||||
return Constants.PROPS_FILE; // "qz-tray"
|
||||
case CA:
|
||||
default:
|
||||
return "root-ca";
|
||||
}
|
||||
}
|
||||
|
||||
public String getAlias() {
|
||||
return getAlias(getType());
|
||||
}
|
||||
|
||||
public String propsPrefix() {
|
||||
switch(type) {
|
||||
case SSL:
|
||||
return "wss";
|
||||
case CA:
|
||||
default:
|
||||
return "ca";
|
||||
}
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public KeyStore getKeyStore() {
|
||||
return keyStore;
|
||||
}
|
||||
}
|
||||
365
tray/src/qz/installer/certificate/LinuxCertificateInstaller.java
Normal file
365
tray/src/qz/installer/certificate/LinuxCertificateInstaller.java
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.installer.certificate;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.bouncycastle.asn1.DEROctetString;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.SubjectKeyIdentifier;
|
||||
import org.bouncycastle.util.encoders.Base64;
|
||||
import qz.auth.X509Constants;
|
||||
import qz.common.Constants;
|
||||
import qz.installer.Installer;
|
||||
import qz.utils.ByteUtilities;
|
||||
import qz.utils.ShellUtilities;
|
||||
import qz.utils.SystemUtilities;
|
||||
import qz.utils.UnixUtilities;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.io.*;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static qz.installer.Installer.PrivilegeLevel.*;
|
||||
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*/
|
||||
public class LinuxCertificateInstaller extends NativeCertificateInstaller {
|
||||
private static final Logger log = LogManager.getLogger(LinuxCertificateInstaller.class);
|
||||
private static final String CA_CERTIFICATES = "/usr/local/share/ca-certificates/";
|
||||
private static final String CA_CERTIFICATE_NAME = Constants.PROPS_FILE + "-root.crt"; // e.g. qz-tray-root.crt
|
||||
private static final String PK11_KIT_ID = "pkcs11:id=";
|
||||
|
||||
private static String[] NSSDB_URLS = {
|
||||
// Conventional cert store
|
||||
"sql:" + System.getenv("HOME") + "/.pki/nssdb/",
|
||||
|
||||
// Snap-specific cert stores
|
||||
"sql:" + System.getenv("HOME") + "/snap/chromium/current/.pki/nssdb/",
|
||||
"sql:" + System.getenv("HOME") + "/snap/brave/current/.pki/nssdb/",
|
||||
"sql:" + System.getenv("HOME") + "/snap/opera/current/.pki/nssdb/",
|
||||
"sql:" + System.getenv("HOME") + "/snap/opera-beta/current/.pki/nssdb/"
|
||||
};
|
||||
|
||||
private Installer.PrivilegeLevel certType;
|
||||
|
||||
public LinuxCertificateInstaller(Installer.PrivilegeLevel certType) {
|
||||
setInstallType(certType);
|
||||
findCertutil();
|
||||
}
|
||||
|
||||
public Installer.PrivilegeLevel getInstallType() {
|
||||
return certType;
|
||||
}
|
||||
|
||||
public void setInstallType(Installer.PrivilegeLevel certType) {
|
||||
this.certType = certType;
|
||||
if (this.certType == SYSTEM) {
|
||||
log.warn("Command \"certutil\" (required for certain browsers) needs to run as USER. We'll try again on launch.");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean remove(List<String> idList) {
|
||||
boolean success = true;
|
||||
if(certType == SYSTEM) {
|
||||
boolean first = distrustUsingUpdateCaCertificates(idList);
|
||||
boolean second = distrustUsingTrustAnchor(idList);
|
||||
success = first || second;
|
||||
} else {
|
||||
for(String nickname : idList) {
|
||||
for(String nssdb : NSSDB_URLS) {
|
||||
success = success && ShellUtilities.execute("certutil", "-d", nssdb, "-D", "-n", nickname);
|
||||
}
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
public List<String> find() {
|
||||
ArrayList<String> nicknames = new ArrayList<>();
|
||||
if(certType == SYSTEM) {
|
||||
nicknames = findUsingTrustAnchor();
|
||||
nicknames.addAll(findUsingUsingUpdateCaCert());
|
||||
} else {
|
||||
try {
|
||||
for(String nssdb : NSSDB_URLS) {
|
||||
Process p = Runtime.getRuntime().exec(new String[] {"certutil", "-d", nssdb, "-L"});
|
||||
BufferedReader in = new BufferedReader(new InputStreamReader(p.getInputStream()));
|
||||
String line;
|
||||
while((line = in.readLine()) != null) {
|
||||
if (line.startsWith(Constants.ABOUT_COMPANY + " ")) {
|
||||
nicknames.add(Constants.ABOUT_COMPANY);
|
||||
break; // Stop reading input; nicknames can't appear more than once
|
||||
}
|
||||
}
|
||||
in.close();
|
||||
}
|
||||
}
|
||||
catch(IOException e) {
|
||||
log.warn("Could not get certificate nicknames", e);
|
||||
}
|
||||
}
|
||||
return nicknames;
|
||||
}
|
||||
|
||||
public boolean verify(File ignore) { return true; } // no easy way to validate a cert, assume it's installed
|
||||
|
||||
public boolean add(File certFile) {
|
||||
boolean success = true;
|
||||
|
||||
if(certType == SYSTEM) {
|
||||
// Attempt two common methods for installing the SSL certificate
|
||||
File systemCertFile;
|
||||
boolean first = (systemCertFile = trustUsingUpdateCaCertificates(certFile)) != null;
|
||||
boolean second = trustUsingTrustAnchor(systemCertFile, certFile);
|
||||
success = first || second;
|
||||
} else if(certType == USER) {
|
||||
// Install certificate to local profile using "certutil"
|
||||
for(String nssdb : NSSDB_URLS) {
|
||||
String[] parts = nssdb.split(":", 2);
|
||||
if (parts.length > 1) {
|
||||
File folder = new File(parts[1]);
|
||||
// If .pki/nssdb doesn't exist yet, don't create it! Per https://github.com/qzind/tray/issues/1003
|
||||
if(folder.exists() && folder.isDirectory()) {
|
||||
if (!ShellUtilities.execute("certutil", "-d", nssdb, "-A", "-t", "TC", "-n", Constants.ABOUT_COMPANY, "-i", certFile.getPath())) {
|
||||
log.warn("Something went wrong creating {}. HTTPS will fail on certain browsers which depend on it.", nssdb);
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private boolean findCertutil() {
|
||||
boolean installed = ShellUtilities.execute("which", "certutil");
|
||||
if (!installed) {
|
||||
if (certType == SYSTEM && promptCertutil()) {
|
||||
if(UnixUtilities.isUbuntu() || UnixUtilities.isDebian()) {
|
||||
installed = ShellUtilities.execute("apt-get", "install", "-y", "libnss3-tools");
|
||||
} else if(UnixUtilities.isFedora()) {
|
||||
installed = ShellUtilities.execute("dnf", "install", "-y", "nss-tools");
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!installed) {
|
||||
log.warn("A critical component, \"certutil\" wasn't found and cannot be installed automatically. HTTPS will fail on certain browsers which depend on it.");
|
||||
}
|
||||
return installed;
|
||||
}
|
||||
|
||||
private boolean promptCertutil() {
|
||||
// Assume silent or headless installs want certutil
|
||||
if(Installer.IS_SILENT || GraphicsEnvironment.isHeadless()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
SystemUtilities.setSystemLookAndFeel(true);
|
||||
return JOptionPane.YES_OPTION == JOptionPane.showConfirmDialog(null, "A critical component, \"certutil\" wasn't found. Attempt to fetch it now?");
|
||||
} catch(Throwable ignore) {}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common technique for installing system-wide certificates on Debian-based systems (Ubuntu, etc.)
|
||||
*
|
||||
* This technique is only known to work for select browsers, such as Epiphany. Browsers such as
|
||||
* Firefox and Chromium require different techniques.
|
||||
*
|
||||
* @return Full path to the destination file if successful, otherwise <code>null</code>
|
||||
*/
|
||||
private File trustUsingUpdateCaCertificates(File certFile) {
|
||||
if(hasUpdateCaCertificatesCommand()) {
|
||||
File destFile = new File(CA_CERTIFICATES, CA_CERTIFICATE_NAME);
|
||||
log.debug("Copying SYSTEM SSL certificate {} to {}", certFile.getPath(), destFile.getPath());
|
||||
try {
|
||||
if (new File(CA_CERTIFICATES).isDirectory()) {
|
||||
// Note: preserveFileDate=false per https://github.com/qzind/tray/issues/1011
|
||||
FileUtils.copyFile(certFile, destFile, false);
|
||||
if (destFile.isFile()) {
|
||||
// Attempt "update-ca-certificates" (Debian)
|
||||
if (!ShellUtilities.execute("update-ca-certificates")) {
|
||||
log.warn("Something went wrong calling \"update-ca-certificates\" for the SYSTEM SSL certificate.");
|
||||
} else {
|
||||
return destFile;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.warn("{} is not a valid directory, skipping", CA_CERTIFICATES);
|
||||
}
|
||||
}
|
||||
catch(IOException e) {
|
||||
log.warn("Error copying SYSTEM SSL certificate file", e);
|
||||
}
|
||||
} else {
|
||||
log.warn("Skipping SYSTEM SSL certificate install using \"update-ca-certificates\", command missing or invalid");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common technique for installing system-wide certificates on Fedora-based systems
|
||||
*
|
||||
* Uses first existing non-null file provided
|
||||
*/
|
||||
private boolean trustUsingTrustAnchor(File ... certFiles) {
|
||||
if (hasTrustAnchorCommand()) {
|
||||
for(File certFile : certFiles) {
|
||||
if (certFile == null || !certFile.exists()) {
|
||||
continue;
|
||||
}
|
||||
// Install certificate to system using "trust anchor" (Fedora)
|
||||
if (ShellUtilities.execute("trust", "anchor", "--store", certFile.getPath())) {
|
||||
return true;
|
||||
} else {
|
||||
log.warn("Something went wrong calling \"trust anchor\" for the SYSTEM SSL certificate.");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.warn("Skipping SYSTEM SSL certificate install using \"trust anchor\", command missing or invalid");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean distrustUsingUpdateCaCertificates(List<String> paths) {
|
||||
if(hasUpdateCaCertificatesCommand()) {
|
||||
boolean deleted = false;
|
||||
for(String path : paths) {
|
||||
// Process files only; not "trust anchor" URIs
|
||||
if(!path.startsWith(PK11_KIT_ID)) {
|
||||
File certFile = new File(path);
|
||||
if (certFile.isFile() && certFile.delete()) {
|
||||
deleted = true;
|
||||
} else {
|
||||
log.warn("SYSTEM SSL certificate {} does not exist, skipping", certFile.getPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Attempt "update-ca-certificates" (Debian)
|
||||
if(deleted) {
|
||||
if (ShellUtilities.execute("update-ca-certificates")) {
|
||||
return true;
|
||||
} else {
|
||||
log.warn("Something went wrong calling \"update-ca-certificates\" for the SYSTEM SSL certificate.");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.warn("Skipping SYSTEM SSL certificate removal using \"update-ca-certificates\", command missing or invalid");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean distrustUsingTrustAnchor(List<String> idList) {
|
||||
if(hasTrustAnchorCommand()) {
|
||||
for(String id : idList) {
|
||||
// only remove by id
|
||||
if (id.startsWith(PK11_KIT_ID) && !ShellUtilities.execute("trust", "anchor", "--remove", id)) {
|
||||
log.warn("Something went wrong calling \"trust anchor\" for the SYSTEM SSL certificate.");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.warn("Skipping SYSTEM SSL certificate removal using \"trust anchor\", command missing or invalid");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for the presence of a QZ certificate in known locations (e.g. /usr/local/share/ca-certificates/
|
||||
* and return the path if found
|
||||
*/
|
||||
private ArrayList<String> findUsingUsingUpdateCaCert() {
|
||||
ArrayList<String> found = new ArrayList<>();
|
||||
File[] systemCertFiles = { new File(CA_CERTIFICATES, CA_CERTIFICATE_NAME) };
|
||||
for(File file : systemCertFiles) {
|
||||
if(file.isFile()) {
|
||||
found.add(file.getPath());
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find QZ installed certificates in the "trust anchor" by searching by email.
|
||||
*
|
||||
* The "trust" utility identifies certificates as URIs:
|
||||
* Example:
|
||||
* pkcs11:id=%7C%5D%02%84%13%D4%CC%8A%9B%81%CE%17%1C%2E%29%1E%9C%48%63%42;type=cert
|
||||
* ... which is an encoded version of the cert's SubjectKeyIdentifier field
|
||||
* To identify a match:
|
||||
* 1. Extract all trusted certificates and look for a familiar email address
|
||||
* 2. If found, construct and store a "trust" compatible URI as the nickname
|
||||
*/
|
||||
private ArrayList<String> findUsingTrustAnchor() {
|
||||
ArrayList<String> uris = new ArrayList<>();
|
||||
File tempFile = null;
|
||||
try {
|
||||
// Temporary location for system certificates
|
||||
tempFile = File.createTempFile("trust-extract-for-qz-", ".pem");
|
||||
// Delete before use: "trust extract" requires an empty file
|
||||
tempFile.delete();
|
||||
if(ShellUtilities.execute("trust", "extract", "--format", "pem-bundle", tempFile.getPath())) {
|
||||
BufferedReader reader = new BufferedReader(new FileReader(tempFile));
|
||||
String line;
|
||||
StringBuilder base64 = new StringBuilder();
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if(line.startsWith(X509Constants.BEGIN_CERT)) {
|
||||
// Beginning of a new certificate
|
||||
base64.setLength(0);
|
||||
} else if(line.startsWith(X509Constants.END_CERT)) {
|
||||
// End of the existing certificate
|
||||
byte[] certBytes = Base64.decode(base64.toString());
|
||||
CertificateFactory factory = CertificateFactory.getInstance("X.509");
|
||||
X509Certificate cert = (X509Certificate)factory.generateCertificate(new ByteArrayInputStream(certBytes));
|
||||
if(CertificateManager.emailMatches(cert, true)) {
|
||||
byte[] extensionValue = cert.getExtensionValue(Extension.subjectKeyIdentifier.getId());
|
||||
byte[] octets = DEROctetString.getInstance(extensionValue).getOctets();
|
||||
SubjectKeyIdentifier subjectKeyIdentifier = SubjectKeyIdentifier.getInstance(octets);
|
||||
byte[] keyIdentifier = subjectKeyIdentifier.getKeyIdentifier();
|
||||
String hex = ByteUtilities.bytesToHex(keyIdentifier, true);
|
||||
String uri = PK11_KIT_ID + hex.replaceAll("(.{2})", "%$1") + ";type=cert";
|
||||
log.info("Found matching cert: {}", uri);
|
||||
|
||||
uris.add(uri);
|
||||
}
|
||||
} else {
|
||||
base64.append(line);
|
||||
}
|
||||
}
|
||||
|
||||
reader.close();
|
||||
}
|
||||
} catch(IOException | CertificateException e) {
|
||||
log.warn("An error occurred finding preexisting \"trust anchor\" certificates", e);
|
||||
} finally {
|
||||
if(tempFile != null && !tempFile.delete()) {
|
||||
tempFile.deleteOnExit();
|
||||
}
|
||||
}
|
||||
return uris;
|
||||
}
|
||||
|
||||
private boolean hasUpdateCaCertificatesCommand() {
|
||||
return ShellUtilities.execute("which", "update-ca-certificates");
|
||||
}
|
||||
|
||||
private boolean hasTrustAnchorCommand() {
|
||||
return ShellUtilities.execute("trust", "anchor", "--help");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.installer.certificate;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.common.Constants;
|
||||
import qz.installer.Installer;
|
||||
import qz.utils.ShellUtilities;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class MacCertificateInstaller extends NativeCertificateInstaller {
|
||||
private static final Logger log = LogManager.getLogger(MacCertificateInstaller.class);
|
||||
|
||||
public static final String USER_STORE = System.getProperty("user.home") + "/Library/Keychains/login.keychain"; // aka login.keychain-db
|
||||
public static final String SYSTEM_STORE = "/Library/Keychains/System.keychain";
|
||||
private String certStore;
|
||||
|
||||
public MacCertificateInstaller(Installer.PrivilegeLevel certType) {
|
||||
setInstallType(certType);
|
||||
}
|
||||
|
||||
public boolean add(File certFile) {
|
||||
if (certStore.equals(USER_STORE)) {
|
||||
// This will prompt the user
|
||||
return ShellUtilities.execute("security", "add-trusted-cert", "-r", "trustRoot", "-k", certStore, certFile.getPath());
|
||||
} else {
|
||||
return ShellUtilities.execute("security", "add-trusted-cert", "-d", "-r", "trustRoot", "-k", certStore, certFile.getPath());
|
||||
}
|
||||
}
|
||||
|
||||
public boolean remove(List<String> idList) {
|
||||
boolean success = true;
|
||||
for (String certId : idList) {
|
||||
success = success && ShellUtilities.execute("security", "delete-certificate", "-Z", certId, certStore);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
public List<String> find() {
|
||||
ArrayList<String> hashList = new ArrayList<>();
|
||||
try {
|
||||
Process p = Runtime.getRuntime().exec(new String[] {"security", "find-certificate", "-a", "-e", Constants.ABOUT_EMAIL, "-Z", certStore});
|
||||
BufferedReader in = new BufferedReader(new InputStreamReader(p.getInputStream()));
|
||||
String line;
|
||||
while ((line = in.readLine()) != null) {
|
||||
if (line.contains("SHA-1") && line.contains(":")) {
|
||||
hashList.add(line.split(":", 2)[1].trim());
|
||||
}
|
||||
}
|
||||
in.close();
|
||||
} catch(IOException e) {
|
||||
log.warn("Could not get certificate list", e);
|
||||
}
|
||||
return hashList;
|
||||
}
|
||||
|
||||
public boolean verify(File certFile) {
|
||||
return ShellUtilities.execute( "security", "verify-cert", "-c", certFile.getPath());
|
||||
}
|
||||
|
||||
public void setInstallType(Installer.PrivilegeLevel type) {
|
||||
if (type == Installer.PrivilegeLevel.USER) {
|
||||
certStore = USER_STORE;
|
||||
} else {
|
||||
certStore = SYSTEM_STORE;
|
||||
}
|
||||
}
|
||||
|
||||
public Installer.PrivilegeLevel getInstallType() {
|
||||
if (certStore == USER_STORE) {
|
||||
return Installer.PrivilegeLevel.USER;
|
||||
} else {
|
||||
return Installer.PrivilegeLevel.SYSTEM;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.installer.certificate;
|
||||
|
||||
import org.bouncycastle.openssl.jcajce.JcaMiscPEMGenerator;
|
||||
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.installer.Installer;
|
||||
import qz.utils.SystemUtilities;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.List;
|
||||
|
||||
public abstract class NativeCertificateInstaller {
|
||||
private static final Logger log = LogManager.getLogger(NativeCertificateInstaller.class);
|
||||
protected static NativeCertificateInstaller instance;
|
||||
|
||||
public static NativeCertificateInstaller getInstance() {
|
||||
return getInstance(SystemUtilities.isAdmin() ? Installer.PrivilegeLevel.SYSTEM : Installer.PrivilegeLevel.USER);
|
||||
}
|
||||
public static NativeCertificateInstaller getInstance(Installer.PrivilegeLevel type) {
|
||||
if (instance == null) {
|
||||
switch(SystemUtilities.getOs()) {
|
||||
case WINDOWS:
|
||||
instance = new WindowsCertificateInstaller(type);
|
||||
break;
|
||||
case MAC:
|
||||
instance = new MacCertificateInstaller(type);
|
||||
break;
|
||||
case LINUX:
|
||||
default:
|
||||
instance = new LinuxCertificateInstaller(type);
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a certificate from memory
|
||||
*/
|
||||
public boolean install(X509Certificate cert) {
|
||||
File certFile = null;
|
||||
try {
|
||||
certFile = File.createTempFile(KeyPairWrapper.getAlias(KeyPairWrapper.Type.CA) + "-", CertificateManager.DEFAULT_CERTIFICATE_EXTENSION);
|
||||
JcaMiscPEMGenerator generator = new JcaMiscPEMGenerator(cert);
|
||||
JcaPEMWriter writer = new JcaPEMWriter(new OutputStreamWriter(Files.newOutputStream(certFile.toPath(), StandardOpenOption.CREATE)));
|
||||
writer.writeObject(generator.generate());
|
||||
writer.close();
|
||||
|
||||
return install(certFile);
|
||||
} catch(IOException e) {
|
||||
log.warn("Could not install cert from temp file", e);
|
||||
} finally {
|
||||
if(certFile != null && !certFile.delete()) {
|
||||
certFile.deleteOnExit();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a certificate from disk
|
||||
*/
|
||||
public boolean install(File certFile) {
|
||||
String helper = instance.getClass().getSimpleName();
|
||||
String store = instance.getInstallType().name();
|
||||
if(SystemUtilities.isJar()) {
|
||||
if (remove(find())) {
|
||||
log.info("Certificate removed from {} store using {}", store, helper);
|
||||
} else {
|
||||
log.warn("Could not remove certificate from {} store using {}", store, helper);
|
||||
}
|
||||
} else {
|
||||
log.info("Skipping {} store certificate removal, IDE detected.", store, helper);
|
||||
}
|
||||
if (add(certFile)) {
|
||||
log.info("Certificate added to {} store using {}", store, helper);
|
||||
return true;
|
||||
} else {
|
||||
log.warn("Could not install certificate to {} store using {}", store, helper);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public abstract boolean add(File certFile);
|
||||
public abstract boolean remove(List<String> idList);
|
||||
public abstract List<String> find();
|
||||
public abstract boolean verify(File certFile);
|
||||
public abstract void setInstallType(Installer.PrivilegeLevel certType);
|
||||
public abstract Installer.PrivilegeLevel getInstallType();
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.installer.certificate;
|
||||
|
||||
import com.sun.jna.Memory;
|
||||
import com.sun.jna.Native;
|
||||
import com.sun.jna.Pointer;
|
||||
import com.sun.jna.Structure;
|
||||
import com.sun.jna.platform.win32.Kernel32Util;
|
||||
import com.sun.jna.platform.win32.WinNT;
|
||||
import com.sun.jna.win32.StdCallLibrary;
|
||||
import com.sun.jna.win32.W32APIOptions;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.openssl.PEMParser;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.common.Constants;
|
||||
import qz.installer.Installer;
|
||||
|
||||
import java.io.*;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.List;
|
||||
|
||||
public class WindowsCertificateInstaller extends NativeCertificateInstaller {
|
||||
private static final Logger log = LogManager.getLogger(WindowsCertificateInstaller.class);
|
||||
private WinCrypt.HCERTSTORE store;
|
||||
private byte[] certBytes;
|
||||
private Installer.PrivilegeLevel certType;
|
||||
|
||||
public WindowsCertificateInstaller(Installer.PrivilegeLevel certType) {
|
||||
setInstallType(certType);
|
||||
}
|
||||
|
||||
public boolean add(File certFile) {
|
||||
log.info("Writing certificate {} to {} store using Crypt32...", certFile, certType);
|
||||
try {
|
||||
|
||||
byte[] bytes = getCertBytes(certFile);
|
||||
Pointer pointer = new Memory(bytes.length);
|
||||
pointer.write(0, bytes, 0, bytes.length);
|
||||
|
||||
boolean success = Crypt32.INSTANCE.CertAddEncodedCertificateToStore(
|
||||
openStore(),
|
||||
WinCrypt.X509_ASN_ENCODING,
|
||||
pointer,
|
||||
bytes.length,
|
||||
Crypt32.CERT_STORE_ADD_REPLACE_EXISTING,
|
||||
null
|
||||
);
|
||||
if(!success) {
|
||||
log.warn(Kernel32Util.formatMessage(Native.getLastError()));
|
||||
}
|
||||
|
||||
closeStore();
|
||||
|
||||
return success;
|
||||
} catch(IOException e) {
|
||||
log.warn("An error occurred installing the certificate", e);
|
||||
} finally {
|
||||
certBytes = null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private byte[] getCertBytes(File certFile) throws IOException {
|
||||
if(certBytes == null) {
|
||||
PEMParser pem = new PEMParser(new FileReader(certFile));
|
||||
X509CertificateHolder certHolder = (X509CertificateHolder)pem.readObject();
|
||||
certBytes = certHolder.getEncoded();
|
||||
}
|
||||
return certBytes;
|
||||
}
|
||||
|
||||
private WinCrypt.HCERTSTORE openStore() {
|
||||
if(store == null) {
|
||||
store = openStore(certType);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
private void closeStore() {
|
||||
if(store != null && closeStore(store)) {
|
||||
store = null;
|
||||
} else {
|
||||
log.warn("Unable to close {} cert store", certType);
|
||||
}
|
||||
}
|
||||
|
||||
private static WinCrypt.HCERTSTORE openStore(Installer.PrivilegeLevel certType) {
|
||||
log.info("Opening {} store using Crypt32...", certType);
|
||||
|
||||
WinCrypt.HCERTSTORE store = Crypt32.INSTANCE.CertOpenStore(
|
||||
Crypt32.CERT_STORE_PROV_SYSTEM,
|
||||
0,
|
||||
null,
|
||||
certType == Installer.PrivilegeLevel.USER ? Crypt32.CERT_SYSTEM_STORE_CURRENT_USER : Crypt32.CERT_SYSTEM_STORE_LOCAL_MACHINE,
|
||||
"ROOT"
|
||||
);
|
||||
if(store == null) {
|
||||
log.warn(Kernel32Util.formatMessage(Native.getLastError()));
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
private static boolean closeStore(WinCrypt.HCERTSTORE certStore) {
|
||||
boolean isClosed = Crypt32.INSTANCE.CertCloseStore(
|
||||
certStore, 0
|
||||
);
|
||||
if(!isClosed) {
|
||||
log.warn(Kernel32Util.formatMessage(Native.getLastError()));
|
||||
}
|
||||
return isClosed;
|
||||
}
|
||||
|
||||
public boolean remove(List<String> ignore) {
|
||||
boolean success = true;
|
||||
|
||||
WinCrypt.CERT_CONTEXT hCertContext;
|
||||
WinCrypt.CERT_CONTEXT pPrevCertContext = null;
|
||||
while(true) {
|
||||
hCertContext = Crypt32.INSTANCE.CertFindCertificateInStore(
|
||||
openStore(),
|
||||
WinCrypt.X509_ASN_ENCODING,
|
||||
0,
|
||||
Crypt32.CERT_FIND_SUBJECT_STR,
|
||||
Constants.ABOUT_EMAIL,
|
||||
pPrevCertContext);
|
||||
|
||||
if(hCertContext == null) {
|
||||
break;
|
||||
}
|
||||
|
||||
pPrevCertContext = Crypt32.INSTANCE.CertDuplicateCertificateContext(hCertContext);
|
||||
|
||||
if(success = (success && Crypt32.INSTANCE.CertDeleteCertificateFromStore(hCertContext))) {
|
||||
log.info("Successfully deleted certificate matching {}", Constants.ABOUT_EMAIL);
|
||||
} else {
|
||||
log.info("Could not delete certificate: {}", Kernel32Util.formatMessage(Native.getLastError()));
|
||||
}
|
||||
}
|
||||
|
||||
closeStore();
|
||||
return success;
|
||||
}
|
||||
|
||||
public List<String> find() {
|
||||
return null;
|
||||
}
|
||||
|
||||
public void setInstallType(Installer.PrivilegeLevel type) {
|
||||
this.certType = type;
|
||||
}
|
||||
|
||||
public Installer.PrivilegeLevel getInstallType() {
|
||||
return certType;
|
||||
}
|
||||
|
||||
public boolean verify(File certFile) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-1");
|
||||
md.update(getCertBytes(certFile));
|
||||
WinCrypt.DATA_BLOB thumbPrint = new WinCrypt.DATA_BLOB(md.digest());
|
||||
WinNT.HANDLE cert = Crypt32.INSTANCE.CertFindCertificateInStore(
|
||||
openStore(),
|
||||
WinCrypt.X509_ASN_ENCODING,
|
||||
0,
|
||||
Crypt32.CERT_FIND_SHA1_HASH,
|
||||
thumbPrint,
|
||||
null);
|
||||
|
||||
return cert != null;
|
||||
} catch(IOException | NoSuchAlgorithmException e) {
|
||||
log.warn("An error occurred verifying the cert is installed: {}", certFile, e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The JNA's Crypt32 instance oversimplifies store handling, preventing user stores from being used
|
||||
*/
|
||||
interface Crypt32 extends StdCallLibrary {
|
||||
int CERT_SYSTEM_STORE_CURRENT_USER = 65536;
|
||||
int CERT_SYSTEM_STORE_LOCAL_MACHINE = 131072;
|
||||
int CERT_STORE_PROV_SYSTEM = 10;
|
||||
int CERT_STORE_ADD_REPLACE_EXISTING = 3;
|
||||
int CERT_FIND_SUBJECT_STR = 524295;
|
||||
int CERT_FIND_SHA1_HASH = 65536;
|
||||
|
||||
Crypt32 INSTANCE = Native.load("Crypt32", Crypt32.class, W32APIOptions.DEFAULT_OPTIONS);
|
||||
|
||||
WinCrypt.HCERTSTORE CertOpenStore(int lpszStoreProvider, int dwMsgAndCertEncodingType, Pointer hCryptProv, int dwFlags, String pvPara);
|
||||
boolean CertCloseStore(WinCrypt.HCERTSTORE hCertStore, int dwFlags);
|
||||
boolean CertAddEncodedCertificateToStore(WinCrypt.HCERTSTORE hCertStore, int dwCertEncodingType, Pointer pbCertEncoded, int cbCertEncoded, int dwAddDisposition, Pointer ppCertContext);
|
||||
WinCrypt.CERT_CONTEXT CertFindCertificateInStore (WinCrypt.HCERTSTORE hCertStore, int dwCertEncodingType, int dwFindFlags, int dwFindType, String pvFindPara, WinCrypt.CERT_CONTEXT pPrevCertContext);
|
||||
WinCrypt.CERT_CONTEXT CertFindCertificateInStore (WinCrypt.HCERTSTORE hCertStore, int dwCertEncodingType, int dwFindFlags, int dwFindType, Structure pvFindPara, WinCrypt.CERT_CONTEXT pPrevCertContext);
|
||||
boolean CertDeleteCertificateFromStore(WinCrypt.CERT_CONTEXT pCertContext);
|
||||
boolean CertFreeCertificateContext(WinCrypt.CERT_CONTEXT pCertContext);
|
||||
WinCrypt.CERT_CONTEXT CertDuplicateCertificateContext(WinCrypt.CERT_CONTEXT pCertContext);
|
||||
}
|
||||
|
||||
// Polyfill from JNA5+
|
||||
@SuppressWarnings("UnusedDeclaration") //Library class
|
||||
public static class WinCrypt {
|
||||
public static int X509_ASN_ENCODING = 0x00000001;
|
||||
public static class HCERTSTORE extends WinNT.HANDLE {
|
||||
public HCERTSTORE() {}
|
||||
public HCERTSTORE(Pointer p) {
|
||||
super(p);
|
||||
}
|
||||
}
|
||||
public static class CERT_CONTEXT extends WinNT.HANDLE {
|
||||
public CERT_CONTEXT() {}
|
||||
public CERT_CONTEXT(Pointer p) {
|
||||
super(p);
|
||||
}
|
||||
}
|
||||
public static class DATA_BLOB extends com.sun.jna.platform.win32.WinCrypt.DATA_BLOB {
|
||||
// Wrap the constructor for code readability
|
||||
public DATA_BLOB() {
|
||||
super();
|
||||
}
|
||||
public DATA_BLOB(byte[] data) {
|
||||
super(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.installer.certificate;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.common.Constants;
|
||||
import qz.installer.Installer;
|
||||
import qz.utils.ShellUtilities;
|
||||
import qz.utils.WindowsUtilities;
|
||||
|
||||
import java.io.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Command Line technique for installing certificates on Windows
|
||||
* Fallback class for when JNA is not available (e.g. Windows on ARM)
|
||||
*/
|
||||
@SuppressWarnings("UnusedDeclaration") //Library class
|
||||
public class WindowsCertificateInstallerCli extends NativeCertificateInstaller {
|
||||
private static final Logger log = LogManager.getLogger(WindowsCertificateInstallerCli.class);
|
||||
private Installer.PrivilegeLevel certType;
|
||||
|
||||
public WindowsCertificateInstallerCli(Installer.PrivilegeLevel certType) {
|
||||
setInstallType(certType);
|
||||
}
|
||||
|
||||
public boolean add(File certFile) {
|
||||
if (WindowsUtilities.isWindowsXP()) return false;
|
||||
if (certType == Installer.PrivilegeLevel.USER) {
|
||||
// This will prompt the user
|
||||
return ShellUtilities.execute("certutil.exe", "-addstore", "-f", "-user", "Root", certFile.getPath());
|
||||
} else {
|
||||
return ShellUtilities.execute("certutil.exe", "-addstore", "-f", "Root", certFile.getPath());
|
||||
}
|
||||
}
|
||||
|
||||
public boolean remove(List<String> idList) {
|
||||
if (WindowsUtilities.isWindowsXP()) return false;
|
||||
boolean success = true;
|
||||
for (String certId : idList) {
|
||||
if (certType == Installer.PrivilegeLevel.USER) {
|
||||
success = success && ShellUtilities.execute("certutil.exe", "-delstore", "-user", "Root", certId);
|
||||
} else {
|
||||
success = success && ShellUtilities.execute("certutil.exe", "-delstore", "Root", certId);
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of serials, if found
|
||||
*/
|
||||
public List<String> find() {
|
||||
ArrayList<String> serialList = new ArrayList<>();
|
||||
try {
|
||||
Process p;
|
||||
if (certType == Installer.PrivilegeLevel.USER) {
|
||||
p = Runtime.getRuntime().exec(new String[] {"certutil.exe", "-store", "-user", "Root"});
|
||||
} else {
|
||||
p = Runtime.getRuntime().exec(new String[] {"certutil.exe", "-store", "Root"});
|
||||
}
|
||||
BufferedReader in = new BufferedReader(new InputStreamReader(p.getInputStream()));
|
||||
String line;
|
||||
while ((line = in.readLine()) != null) {
|
||||
if (line.contains("================")) {
|
||||
// First line is serial
|
||||
String serial = parseNextLine(in);
|
||||
if (serial != null) {
|
||||
// Second line is issuer
|
||||
String issuer = parseNextLine(in);
|
||||
if (issuer.contains("OU=" + Constants.ABOUT_COMPANY)) {
|
||||
serialList.add(serial);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
in.close();
|
||||
} catch(Exception e) {
|
||||
log.info("Unable to find a Trusted Root Certificate matching \"OU={}\"", Constants.ABOUT_COMPANY);
|
||||
}
|
||||
return serialList;
|
||||
}
|
||||
|
||||
public boolean verify(File certFile) {
|
||||
return verifyCert(certFile);
|
||||
}
|
||||
|
||||
public static boolean verifyCert(File certFile) {
|
||||
// -user also will check the root store
|
||||
String dwErrorStatus = ShellUtilities.execute( new String[] {"certutil", "-user", "-verify", certFile.getPath() }, new String[] { "dwErrorStatus=" }, false, false);
|
||||
if(!dwErrorStatus.isEmpty()) {
|
||||
String[] parts = dwErrorStatus.split("[\r\n\\s]+");
|
||||
for(String part : parts) {
|
||||
if(part.startsWith("dwErrorStatus=")) {
|
||||
log.info("Certificate validity says {}", part);
|
||||
String[] status = part.split("=", 2);
|
||||
if (status.length == 2) {
|
||||
return status[1].trim().equals("0");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log.warn("Unable to determine certificate validity, you'll be prompted on startup");
|
||||
return false;
|
||||
}
|
||||
|
||||
public void setInstallType(Installer.PrivilegeLevel type) {
|
||||
this.certType = type;
|
||||
}
|
||||
|
||||
public Installer.PrivilegeLevel getInstallType() {
|
||||
return certType;
|
||||
}
|
||||
|
||||
private static String parseNextLine(BufferedReader reader) throws IOException {
|
||||
String data = reader.readLine();
|
||||
if (data != null) {
|
||||
String[] split = data.split(":", 2);
|
||||
if (split.length == 2) {
|
||||
return split[1].trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package qz.installer.certificate.firefox;
|
||||
|
||||
class ConflictingPolicyException extends Exception {
|
||||
ConflictingPolicyException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.installer.certificate.firefox;
|
||||
|
||||
import com.github.zafarkhaja.semver.Version;
|
||||
import com.sun.jna.platform.win32.WinReg;
|
||||
import org.codehaus.jettison.json.JSONException;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.common.Constants;
|
||||
import qz.installer.Installer;
|
||||
import qz.installer.certificate.CertificateManager;
|
||||
import qz.installer.certificate.firefox.locator.AppAlias;
|
||||
import qz.installer.certificate.firefox.locator.AppInfo;
|
||||
import qz.installer.certificate.firefox.locator.AppLocator;
|
||||
import qz.utils.JsonWriter;
|
||||
import qz.utils.ShellUtilities;
|
||||
import qz.utils.SystemUtilities;
|
||||
import qz.utils.WindowsUtilities;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Installs the Firefox Policy file via Enterprise Policy, Distribution Policy file or AutoConfig, depending on OS & version
|
||||
*/
|
||||
public class FirefoxCertificateInstaller {
|
||||
protected static final Logger log = LogManager.getLogger(FirefoxCertificateInstaller.class);
|
||||
|
||||
/**
|
||||
* Versions are for Mozilla's official Firefox release.
|
||||
* 3rd-party/clones may adopt Enterprise Policy support under
|
||||
* different version numbers, adapt as needed.
|
||||
*/
|
||||
private static final Version WINDOWS_POLICY_VERSION = Version.valueOf("62.0.0");
|
||||
private static final Version MAC_POLICY_VERSION = Version.valueOf("63.0.0");
|
||||
private static final Version LINUX_POLICY_VERSION = Version.valueOf("65.0.0");
|
||||
public static final Version FIREFOX_RESTART_VERSION = Version.valueOf("60.0.0");
|
||||
|
||||
public static final String LINUX_GLOBAL_POLICY_LOCATION = "/etc/firefox/policies/policies.json";
|
||||
public static final String LINUX_SNAP_CERT_LOCATION = "/etc/firefox/policies/" + Constants.PROPS_FILE + CertificateManager.DEFAULT_CERTIFICATE_EXTENSION; // See https://github.com/mozilla/policy-templates/issues/936
|
||||
public static final String LINUX_GLOBAL_CERT_LOCATION = "/usr/lib/mozilla/certificates/" + Constants.PROPS_FILE + CertificateManager.DEFAULT_CERTIFICATE_EXTENSION;
|
||||
private static String DISTRIBUTION_ENTERPRISE_ROOT_POLICY = "{ \"policies\": { \"Certificates\": { \"ImportEnterpriseRoots\": true } } }";
|
||||
private static String DISTRIBUTION_INSTALL_CERT_POLICY = "{ \"policies\": { \"Certificates\": { \"Install\": [ \"" + Constants.PROPS_FILE + CertificateManager.DEFAULT_CERTIFICATE_EXTENSION + "\", \"" + LINUX_SNAP_CERT_LOCATION + "\" ] } } }";
|
||||
private static String DISTRIBUTION_REMOVE_CERT_POLICY = "{ \"policies\": { \"Certificates\": { \"Install\": [ \"/opt/" + Constants.PROPS_FILE + "/auth/root-ca.crt\"] } } }";
|
||||
|
||||
public static final String DISTRIBUTION_POLICY_LOCATION = "distribution/policies.json";
|
||||
public static final String DISTRIBUTION_MAC_POLICY_LOCATION = "Contents/Resources/" + DISTRIBUTION_POLICY_LOCATION;
|
||||
|
||||
public static final String POLICY_AUDIT_MESSAGE = "Enterprise policy installed by " + Constants.ABOUT_TITLE + " on " + SystemUtilities.timeStamp();
|
||||
|
||||
public static void install(X509Certificate cert, String ... hostNames) {
|
||||
// Blindly install Firefox enterprise policies to the system (macOS, Windows)
|
||||
ArrayList<AppAlias.Alias> enterpriseFailed = new ArrayList<>();
|
||||
for(AppAlias.Alias alias : AppAlias.FIREFOX.getAliases()) {
|
||||
boolean success = false;
|
||||
try {
|
||||
if(alias.isEnterpriseReady() && !hasEnterprisePolicy(alias, false)) {
|
||||
log.info("Installing Firefox enterprise certificate policy for {}", alias);
|
||||
success = installEnterprisePolicy(alias, false);
|
||||
}
|
||||
} catch(ConflictingPolicyException e) {
|
||||
log.warn("Conflict found installing {} enterprise cert support. We'll fallback on the distribution policy instead", alias.getName(), e);
|
||||
}
|
||||
if(!success) {
|
||||
enterpriseFailed.add(alias);
|
||||
}
|
||||
}
|
||||
|
||||
// Search for installed instances
|
||||
ArrayList<AppInfo> foundApps = AppLocator.getInstance().locate(AppAlias.FIREFOX);
|
||||
ArrayList<Path> processPaths = null;
|
||||
|
||||
for(AppInfo appInfo : foundApps) {
|
||||
boolean success = false;
|
||||
if (honorsPolicy(appInfo)) {
|
||||
if((SystemUtilities.isWindows()|| SystemUtilities.isMac()) && !enterpriseFailed.contains(appInfo.getAlias())) {
|
||||
// Enterprise policy was already installed
|
||||
success = true;
|
||||
} else {
|
||||
log.info("Installing Firefox distribution policy for {}", appInfo);
|
||||
success = installDistributionPolicy(appInfo, cert);
|
||||
}
|
||||
} else {
|
||||
log.info("Installing Firefox auto-config script for {}", appInfo);
|
||||
try {
|
||||
String certData = Base64.getEncoder().encodeToString(cert.getEncoded());
|
||||
success = LegacyFirefoxCertificateInstaller.installAutoConfigScript(appInfo, certData, hostNames);
|
||||
}
|
||||
catch(CertificateEncodingException e) {
|
||||
log.warn("Unable to install auto-config script for {}", appInfo, e);
|
||||
}
|
||||
}
|
||||
if(success) {
|
||||
issueRestartWarning(processPaths = AppLocator.getRunningPaths(foundApps, processPaths), appInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void uninstall() {
|
||||
ArrayList<AppInfo> appList = AppLocator.getInstance().locate(AppAlias.FIREFOX);
|
||||
for(AppInfo appInfo : appList) {
|
||||
if(honorsPolicy(appInfo)) {
|
||||
if(SystemUtilities.isWindows() || SystemUtilities.isMac()) {
|
||||
log.info("Skipping uninstall of Firefox enterprise root certificate policy for {}", appInfo);
|
||||
} else {
|
||||
try {
|
||||
File policy = appInfo.getPath().resolve(DISTRIBUTION_POLICY_LOCATION).toFile();
|
||||
if(policy.exists()) {
|
||||
JsonWriter.write(appInfo.getPath().resolve(DISTRIBUTION_POLICY_LOCATION).toString(), DISTRIBUTION_INSTALL_CERT_POLICY, false, true);
|
||||
}
|
||||
} catch(IOException | JSONException e) {
|
||||
log.warn("Unable to remove Firefox policy for {}", appInfo, e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.info("Uninstalling Firefox auto-config script for {}", appInfo);
|
||||
LegacyFirefoxCertificateInstaller.uninstallAutoConfigScript(appInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean honorsPolicy(AppInfo appInfo) {
|
||||
if (appInfo.getVersion() == null) {
|
||||
log.warn("Firefox-compatible browser found {}, but no version information is available", appInfo);
|
||||
return false;
|
||||
}
|
||||
if(SystemUtilities.isWindows()) {
|
||||
return appInfo.getVersion().greaterThanOrEqualTo(WINDOWS_POLICY_VERSION);
|
||||
} else if (SystemUtilities.isMac()) {
|
||||
return appInfo.getVersion().greaterThanOrEqualTo(MAC_POLICY_VERSION);
|
||||
} else {
|
||||
return appInfo.getVersion().greaterThanOrEqualTo(LINUX_POLICY_VERSION);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if an alternative Firefox policy (e.g. registry, plist user or system) is installed
|
||||
*/
|
||||
private static boolean hasEnterprisePolicy(AppAlias.Alias alias, boolean userOnly) throws ConflictingPolicyException {
|
||||
if(SystemUtilities.isWindows()) {
|
||||
String key = String.format("Software\\Policies\\%s\\%s\\Certificates", alias.getVendor(), alias.getName(true));
|
||||
Integer foundPolicy = WindowsUtilities.getRegInt(userOnly ? WinReg.HKEY_CURRENT_USER : WinReg.HKEY_LOCAL_MACHINE, key, "ImportEnterpriseRoots");
|
||||
if(foundPolicy != null) {
|
||||
return foundPolicy == 1;
|
||||
}
|
||||
} else if(SystemUtilities.isMac()) {
|
||||
String policyLocation = "/Library/Preferences/";
|
||||
if(userOnly) {
|
||||
policyLocation = System.getProperty("user.home") + policyLocation;
|
||||
}
|
||||
String policesEnabled = ShellUtilities.executeRaw(new String[] { "defaults", "read", policyLocation + alias.getBundleId(), "EnterprisePoliciesEnabled"}, true);
|
||||
String foundPolicy = ShellUtilities.executeRaw(new String[] {"defaults", "read", policyLocation + alias.getBundleId(), "Certificates"}, true);
|
||||
if(!policesEnabled.isEmpty() && !foundPolicy.isEmpty()) {
|
||||
// Policies exist, decide how to proceed
|
||||
if(policesEnabled.trim().equals("1") && foundPolicy.contains("ImportEnterpriseRoots = 1;")) {
|
||||
return true;
|
||||
}
|
||||
throw new ConflictingPolicyException(String.format("%s enterprise policy conflict at %s: %s", alias.getName(), policyLocation + alias.getBundleId(), foundPolicy));
|
||||
}
|
||||
} else {
|
||||
// Linux alternate policy not yet supported
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install policy to distribution/policies.json
|
||||
*/
|
||||
public static boolean installDistributionPolicy(AppInfo app, X509Certificate cert) {
|
||||
Path jsonPath = app.getPath().resolve(SystemUtilities.isMac() ? DISTRIBUTION_MAC_POLICY_LOCATION:DISTRIBUTION_POLICY_LOCATION);
|
||||
String jsonPolicy = SystemUtilities.isWindows() || SystemUtilities.isMac() ? DISTRIBUTION_ENTERPRISE_ROOT_POLICY:DISTRIBUTION_INSTALL_CERT_POLICY;
|
||||
|
||||
// Special handling for snaps
|
||||
if(app.getPath().toString().startsWith("/snap")) {
|
||||
log.info("Snap detected, installing policy file to global location instead: {}", LINUX_GLOBAL_POLICY_LOCATION);
|
||||
jsonPath = Paths.get(LINUX_GLOBAL_POLICY_LOCATION);
|
||||
}
|
||||
|
||||
try {
|
||||
if(jsonPolicy.equals(DISTRIBUTION_INSTALL_CERT_POLICY)) {
|
||||
// Linux lacks the concept of "enterprise roots", we'll write it to a known location instead
|
||||
writeCertFile(cert, LINUX_SNAP_CERT_LOCATION); // so that the snap can read from it
|
||||
writeCertFile(cert, LINUX_GLOBAL_CERT_LOCATION); // default location for non-snaps
|
||||
}
|
||||
|
||||
File jsonFile = jsonPath.toFile();
|
||||
|
||||
// Make sure we can traverse and read
|
||||
File distribution = jsonFile.getParentFile();
|
||||
distribution.mkdirs();
|
||||
distribution.setReadable(true, false);
|
||||
distribution.setExecutable(true, false);
|
||||
|
||||
if(jsonPolicy.equals(DISTRIBUTION_INSTALL_CERT_POLICY)) {
|
||||
// Delete previous policy
|
||||
JsonWriter.write(jsonPath.toString(), DISTRIBUTION_REMOVE_CERT_POLICY, false, true);
|
||||
}
|
||||
|
||||
JsonWriter.write(jsonPath.toString(), jsonPolicy, false, false);
|
||||
|
||||
// Make sure ew can read
|
||||
jsonFile.setReadable(true, false);
|
||||
return true;
|
||||
} catch(JSONException | IOException e) {
|
||||
log.warn("Could not install distribution policy {} to {}", jsonPolicy, jsonPath.toString(), e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean installEnterprisePolicy(AppAlias.Alias alias, boolean userOnly) {
|
||||
if(SystemUtilities.isWindows()) {
|
||||
String key = String.format("Software\\Policies\\%s\\%s\\Certificates", alias.getVendor(), alias.getName(true));;
|
||||
WindowsUtilities.addRegValue(userOnly ? WinReg.HKEY_CURRENT_USER : WinReg.HKEY_LOCAL_MACHINE, key, "Comment", POLICY_AUDIT_MESSAGE);
|
||||
return WindowsUtilities.addRegValue(userOnly ? WinReg.HKEY_CURRENT_USER : WinReg.HKEY_LOCAL_MACHINE, key, "ImportEnterpriseRoots", 1);
|
||||
} else if(SystemUtilities.isMac()) {
|
||||
String policyLocation = "/Library/Preferences/";
|
||||
if(userOnly) {
|
||||
policyLocation = System.getProperty("user.home") + policyLocation;
|
||||
}
|
||||
return ShellUtilities.execute(new String[] {"defaults", "write", policyLocation + alias.getBundleId(), "EnterprisePoliciesEnabled", "-bool", "TRUE"}, true) &&
|
||||
ShellUtilities.execute(new String[] {"defaults", "write", policyLocation + alias.getBundleId(), "Certificates", "-dict", "ImportEnterpriseRoots", "-bool", "TRUE",
|
||||
"Comment", "-string", POLICY_AUDIT_MESSAGE}, true);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean issueRestartWarning(ArrayList<Path> runningPaths, AppInfo appInfo) {
|
||||
boolean firefoxIsRunning = runningPaths.contains(appInfo.getExePath());
|
||||
|
||||
// Edge case for detecting if snap is running, since we can't compare the exact path easily
|
||||
for(Path runningPath : runningPaths) {
|
||||
if(runningPath.startsWith("/snap/")) {
|
||||
firefoxIsRunning = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (firefoxIsRunning) {
|
||||
if (appInfo.getVersion().greaterThanOrEqualTo(FirefoxCertificateInstaller.FIREFOX_RESTART_VERSION)) {
|
||||
try {
|
||||
Installer.getInstance().spawn(appInfo.getExePath().toString(), "-private", "about:restartrequired");
|
||||
return true;
|
||||
}
|
||||
catch(Exception ignore) {}
|
||||
} else {
|
||||
log.warn("{} must be restarted manually for changes to take effect", appInfo);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void writeCertFile(X509Certificate cert, String location) throws IOException {
|
||||
File certFile = new File(location);
|
||||
|
||||
// Make sure we can traverse and read
|
||||
File certs = new File(location).getParentFile();
|
||||
certs.mkdirs();
|
||||
certs.setReadable(true, false);
|
||||
certs.setExecutable(true, false);
|
||||
File mozilla = certs.getParentFile();
|
||||
mozilla.setReadable(true, false);
|
||||
mozilla.setExecutable(true, false);
|
||||
|
||||
// Make sure we can read
|
||||
CertificateManager.writeCert(cert, certFile);
|
||||
certFile.setReadable(true, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.installer.certificate.firefox;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.common.Constants;
|
||||
import qz.installer.certificate.CertificateChainBuilder;
|
||||
import qz.installer.certificate.firefox.locator.AppInfo;
|
||||
import qz.utils.FileUtilities;
|
||||
import qz.utils.SystemUtilities;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Path;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Legacy Firefox Certificate installer
|
||||
*
|
||||
* For old Firefox-compatible browsers still in the wild such as Firefox 52 ESR, SeaMonkey, WaterFox, etc.
|
||||
*/
|
||||
public class LegacyFirefoxCertificateInstaller {
|
||||
private static final Logger log = LogManager.getLogger(CertificateChainBuilder.class);
|
||||
|
||||
private static final String CFG_TEMPLATE = "assets/firefox-autoconfig.js.in";
|
||||
private static final String CFG_FILE = Constants.PROPS_FILE + ".cfg";
|
||||
private static final String PREFS_FILE = Constants.PROPS_FILE + ".js";
|
||||
private static final String PREFS_DIR = "defaults/pref";
|
||||
private static final String MAC_PREFIX = "Contents/Resources";
|
||||
|
||||
public static boolean installAutoConfigScript(AppInfo appInfo, String certData, String ... hostNames) {
|
||||
try {
|
||||
if(appInfo.getPath().toString().equals("/usr/bin")) {
|
||||
throw new Exception("Preventing install to root location");
|
||||
}
|
||||
writePrefsFile(appInfo);
|
||||
writeParsedConfig(appInfo, certData, false, hostNames);
|
||||
return true;
|
||||
} catch(Exception e) {
|
||||
log.warn("Error installing auto-config support for {}", appInfo, e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean uninstallAutoConfigScript(AppInfo appInfo) {
|
||||
try {
|
||||
writeParsedConfig(appInfo, "", true);
|
||||
return true;
|
||||
} catch(Exception e) {
|
||||
log.warn("Error uninstalling auto-config support for {}", appInfo, e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static File tryWrite(AppInfo appInfo, boolean mkdirs, String ... paths) throws IOException {
|
||||
Path dir = appInfo.getPath();
|
||||
if (SystemUtilities.isMac()) {
|
||||
dir = dir.resolve(MAC_PREFIX);
|
||||
}
|
||||
for (String path : paths) {
|
||||
dir = dir.resolve(path);
|
||||
}
|
||||
File file = dir.toFile();
|
||||
|
||||
if(mkdirs) file.mkdirs();
|
||||
if(file.exists() && file.isDirectory() && file.canWrite()) {
|
||||
return file;
|
||||
}
|
||||
|
||||
throw new IOException(String.format("Directory does not exist or is not writable: %s", file));
|
||||
}
|
||||
|
||||
public static void deleteFile(File parent, String ... paths) {
|
||||
if(parent != null) {
|
||||
String toDelete = parent.getPath();
|
||||
for (String path : paths) {
|
||||
toDelete += File.separator + path;
|
||||
}
|
||||
File deleteFile = new File(toDelete);
|
||||
if (!deleteFile.exists()) {
|
||||
} else if (new File(toDelete).delete()) {
|
||||
log.info("Deleted old file: {}", toDelete);
|
||||
} else {
|
||||
log.warn("Could not delete old file: {}", toDelete);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void writePrefsFile(AppInfo app) throws Exception {
|
||||
File prefsDir = tryWrite(app, true, PREFS_DIR);
|
||||
deleteFile(prefsDir, "firefox-prefs.js"); // cleanup old version
|
||||
|
||||
// first check that there aren't other prefs files
|
||||
String pref = "general.config.filename";
|
||||
for (File file : prefsDir.listFiles()) {
|
||||
try {
|
||||
BufferedReader reader = new BufferedReader(new FileReader(file));
|
||||
String line;
|
||||
while((line = reader.readLine()) != null) {
|
||||
if(line.contains(pref) && !line.contains(CFG_FILE)) {
|
||||
throw new Exception(String.format("Browser already has %s defined in %s:\n %s", pref, file, line));
|
||||
}
|
||||
}
|
||||
} catch(IOException ignore) {}
|
||||
}
|
||||
|
||||
// write out the new prefs file
|
||||
File prefsFile = new File(prefsDir, PREFS_FILE);
|
||||
BufferedWriter writer = new BufferedWriter(new FileWriter(prefsFile));
|
||||
String[] data = {
|
||||
String.format("pref('%s', '%s');", pref, CFG_FILE),
|
||||
"pref('general.config.obscure_value', 0);"
|
||||
};
|
||||
for (String line : data) {
|
||||
writer.write(line + "\n");
|
||||
}
|
||||
writer.close();
|
||||
prefsFile.setReadable(true, false);
|
||||
}
|
||||
|
||||
private static void writeParsedConfig(AppInfo appInfo, String certData, boolean uninstall, String ... hostNames) throws IOException, CertificateEncodingException{
|
||||
if (hostNames.length == 0) hostNames = CertificateChainBuilder.DEFAULT_HOSTNAMES;
|
||||
|
||||
File cfgDir = tryWrite(appInfo, false);
|
||||
deleteFile(cfgDir, "firefox-config.cfg"); // cleanup old version
|
||||
File dest = new File(cfgDir.getPath(), CFG_FILE);
|
||||
|
||||
HashMap<String, String> fieldMap = new HashMap<>();
|
||||
// Dynamic fields
|
||||
fieldMap.put("%CERT_DATA%", certData);
|
||||
fieldMap.put("%COMMON_NAME%", hostNames[0]);
|
||||
fieldMap.put("%TIMESTAMP%", uninstall ? "-1" : "" + new Date().getTime());
|
||||
fieldMap.put("%APP_PATH%", SystemUtilities.isMac() ? SystemUtilities.getAppPath() != null ? SystemUtilities.getAppPath().toString() : "" : "");
|
||||
fieldMap.put("%UNINSTALL%", "" + uninstall);
|
||||
|
||||
FileUtilities.configureAssetFile(CFG_TEMPLATE, dest, fieldMap, LegacyFirefoxCertificateInstaller.class);
|
||||
dest.setReadable(true, false);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// Firefox AutoConfig Certificate Installer for Legacy Firefox versions
|
||||
// This is part of the QZ Tray application
|
||||
//
|
||||
var serviceObserver = {
|
||||
observe: function observe(aSubject, aTopic, aData) {
|
||||
// Get NSS certdb object
|
||||
var certdb = getCertDB();
|
||||
|
||||
if (needsUninstall()) {
|
||||
deleteCertificate();
|
||||
unregisterProtocol();
|
||||
} else if (needsCert()) {
|
||||
deleteCertificate();
|
||||
installCertificate();
|
||||
registerProtocol();
|
||||
}
|
||||
|
||||
// Compares the timestamp embedded in this script against that stored in the browser's about:config
|
||||
function needsCert() {
|
||||
try {
|
||||
return getPref("%PROPS_FILE%.installer.timestamp") != "%TIMESTAMP%";
|
||||
} catch(notfound) {}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Installs the embedded base64 certificate into the browser
|
||||
function installCertificate() {
|
||||
certdb.addCertFromBase64(getCertData(), "C,C,C", "%COMMON_NAME% - %ABOUT_COMPANY%");
|
||||
pref("%PROPS_FILE%.installer.timestamp", "%TIMESTAMP%");
|
||||
}
|
||||
|
||||
// Deletes the certificate, if it exists
|
||||
function deleteCertificate() {
|
||||
var certs = certdb.getCerts();
|
||||
var enumerator = certs.getEnumerator();
|
||||
while (enumerator.hasMoreElements()) {
|
||||
var cert = enumerator.getNext().QueryInterface(Components.interfaces.nsIX509Cert);
|
||||
if (cert.containsEmailAddress("%ABOUT_EMAIL%")) {
|
||||
try {
|
||||
certdb.deleteCertificate(cert);
|
||||
} catch (ignore) {}
|
||||
}
|
||||
}
|
||||
pref("%PROPS_FILE%.installer.timestamp", "-1");
|
||||
}
|
||||
|
||||
// Register the specified protocol to open with the specified application
|
||||
function registerProtocol() {
|
||||
// Only register if platform needs it (e.g. macOS)
|
||||
var trayApp = "%APP_PATH%";
|
||||
if (!trayApp) { return; }
|
||||
try {
|
||||
var hservice = Components.classes["@mozilla.org/uriloader/handler-service;1"].getService(Components.interfaces.nsIHandlerService);
|
||||
var pservice = Components.classes["@mozilla.org/uriloader/external-protocol-service;1"].getService(Components.interfaces.nsIExternalProtocolService);
|
||||
|
||||
var file = Components.classes["@mozilla.org/file/local;1"].createInstance(Components.interfaces.nsIFile);
|
||||
file.initWithPath(trayApp);
|
||||
|
||||
var lhandler = Components.classes["@mozilla.org/uriloader/local-handler-app;1"].createInstance(Components.interfaces.nsILocalHandlerApp);
|
||||
lhandler.executable = file;
|
||||
lhandler.name = "%PROPS_FILE%";
|
||||
|
||||
var protocol = pservice.getProtocolHandlerInfo("%DATA_DIR%");
|
||||
protocol.preferredApplicationHandler = lhandler;
|
||||
protocol.preferredAction = 2; // useHelperApp
|
||||
protocol.alwaysAskBeforeHandling = false;
|
||||
hservice.store(protocol);
|
||||
} catch(ignore) {}
|
||||
}
|
||||
|
||||
// De-register the specified protocol from opening with the specified application
|
||||
function unregisterProtocol() {
|
||||
// Only register if platform needs it (e.g. macOS)
|
||||
var trayApp = "%APP_PATH%";
|
||||
if (!trayApp) { return; }
|
||||
try {
|
||||
var hservice = Components.classes["@mozilla.org/uriloader/handler-service;1"].getService(Components.interfaces.nsIHandlerService);
|
||||
var pservice = Components.classes["@mozilla.org/uriloader/external-protocol-service;1"].getService(Components.interfaces.nsIExternalProtocolService);
|
||||
hservice.remove(pservice.getProtocolHandlerInfo("%DATA_DIR%"));
|
||||
} catch(ignore) {}
|
||||
}
|
||||
|
||||
// Get certdb object
|
||||
function getCertDB() {
|
||||
// Import certificate using NSS certdb API (http://tinyurl.com/x509certdb)
|
||||
var id = "@mozilla.org/security/x509certdb;1";
|
||||
var db1 = Components.classes[id].getService(Components.interfaces.nsIX509CertDB);
|
||||
var db2 = db1;
|
||||
try {
|
||||
db2 = Components.classes[id].getService(Components.interfaces.nsIX509CertDB2);
|
||||
} catch(ignore) {}
|
||||
return db2;
|
||||
}
|
||||
|
||||
// The certificate to import (automatically generated by desktop installer)
|
||||
function getCertData() {
|
||||
return "%CERT_DATA%";
|
||||
}
|
||||
|
||||
// Whether or not an uninstall should occur, flagged by the installer/uninstaller
|
||||
function needsUninstall() {
|
||||
try {
|
||||
if (getPref("%PROPS_FILE%.installer.timestamp") == "-1") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch(notfound) {
|
||||
return false;
|
||||
}
|
||||
return %UNINSTALL%;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Components.utils.import("resource://gre/modules/Services.jsm");
|
||||
Services.obs.addObserver(serviceObserver, "profile-after-change", false);
|
||||
@@ -0,0 +1,91 @@
|
||||
package qz.installer.certificate.firefox.locator;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public enum AppAlias {
|
||||
// Tor Browser intentionally excluded; Tor's proxy blocks localhost connections
|
||||
FIREFOX(
|
||||
new Alias("Mozilla", "Mozilla Firefox", "org.mozilla.firefox", true),
|
||||
new Alias("Mozilla", "Firefox Developer Edition", "org.mozilla.firefoxdeveloperedition", true),
|
||||
new Alias("Mozilla", "Firefox Nightly", "org.mozilla.nightly", true),
|
||||
new Alias("Mozilla", "SeaMonkey", "org.mozilla.seamonkey", false),
|
||||
new Alias("Waterfox", "Waterfox", "net.waterfox.waterfoxcurrent", true),
|
||||
new Alias("Waterfox", "Waterfox Classic", "org.waterfoxproject.waterfox classic", false),
|
||||
new Alias("Mozilla", "Pale Moon", "org.mozilla.palemoon", false),
|
||||
// IceCat is technically enterprise ready, but not officially distributed for macOS, Windows
|
||||
new Alias("Mozilla", "IceCat", "org.gnu.icecat", false)
|
||||
);
|
||||
Alias[] aliases;
|
||||
AppAlias(Alias... aliases) {
|
||||
this.aliases = aliases;
|
||||
}
|
||||
|
||||
public Alias[] getAliases() {
|
||||
return aliases;
|
||||
}
|
||||
|
||||
public static Alias findAlias(AppAlias appAlias, String appName, boolean stripVendor) {
|
||||
if (appName != null) {
|
||||
for (Alias alias : appAlias.aliases) {
|
||||
if (appName.toLowerCase(Locale.ENGLISH).matches(alias.getName(stripVendor).toLowerCase(Locale.ENGLISH))) {
|
||||
return alias;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static class Alias {
|
||||
private String vendor;
|
||||
private String name;
|
||||
private String bundleId;
|
||||
private boolean enterpriseReady;
|
||||
private String posix;
|
||||
|
||||
public Alias(String vendor, String name, String bundleId, boolean enterpriseReady) {
|
||||
this.name = name;
|
||||
this.vendor = vendor;
|
||||
this.bundleId = bundleId;
|
||||
this.enterpriseReady = enterpriseReady;
|
||||
this.posix = getName(true).replaceAll(" ", "").toLowerCase(Locale.ENGLISH);
|
||||
}
|
||||
|
||||
public String getVendor() {
|
||||
return vendor;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove vendor prefix if exists
|
||||
*/
|
||||
public String getName(boolean stripVendor) {
|
||||
if(stripVendor && "Mozilla".equals(vendor) && name.startsWith(vendor)) {
|
||||
return name.substring(vendor.length()).trim();
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getBundleId() {
|
||||
return bundleId;
|
||||
}
|
||||
|
||||
public String getPosix() {
|
||||
return posix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the app is known to recognizes enterprise policies, such as GPO
|
||||
*/
|
||||
public boolean isEnterpriseReady() {
|
||||
return enterpriseReady;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
100
tray/src/qz/installer/certificate/firefox/locator/AppInfo.java
Normal file
100
tray/src/qz/installer/certificate/firefox/locator/AppInfo.java
Normal file
@@ -0,0 +1,100 @@
|
||||
package qz.installer.certificate.firefox.locator;
|
||||
|
||||
import com.github.zafarkhaja.semver.Version;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import qz.installer.certificate.firefox.locator.AppAlias.Alias;
|
||||
|
||||
/**
|
||||
* Container class for installed app information
|
||||
*/
|
||||
public class AppInfo {
|
||||
private AppAlias.Alias alias;
|
||||
private Path path;
|
||||
private Path exePath;
|
||||
private Version version;
|
||||
|
||||
public AppInfo(Alias alias, Path exePath, String version) {
|
||||
this.alias = alias;
|
||||
this.path = exePath.getParent();
|
||||
this.exePath = exePath;
|
||||
this.version = parseVersion(version);
|
||||
}
|
||||
|
||||
public AppInfo(Alias alias, Path path, Path exePath, String version) {
|
||||
this.alias = alias;
|
||||
this.path = path;
|
||||
this.exePath = exePath;
|
||||
this.version = parseVersion(version);
|
||||
}
|
||||
|
||||
public AppInfo(Alias alias, Path exePath) {
|
||||
this.alias = alias;
|
||||
this.path = exePath.getParent();
|
||||
this.exePath = exePath;
|
||||
}
|
||||
|
||||
public Alias getAlias() {
|
||||
return alias;
|
||||
}
|
||||
|
||||
public String getName(boolean stripVendor) {
|
||||
return alias.getName(stripVendor);
|
||||
}
|
||||
|
||||
public Path getExePath() {
|
||||
return exePath;
|
||||
}
|
||||
|
||||
public Path getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public void setPath(Path path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
public Version getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public void setVersion(String version) {
|
||||
this.version = parseVersion(version);
|
||||
}
|
||||
|
||||
public void setVersion(Version version) {
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
private static Version parseVersion(String version) {
|
||||
try {
|
||||
// Ensure < 3 octets (e.g. "56.0") doesn't failing
|
||||
while(version.split("\\.").length < 3) {
|
||||
version = version + ".0";
|
||||
}
|
||||
return Version.valueOf(version);
|
||||
} catch(Exception ignore1) {
|
||||
// Catch poor formatting (e.g. "97.0a1"), try to use major version only
|
||||
if(version.split("\\.").length > 0) {
|
||||
try {
|
||||
String[] tryFix = version.split("\\.");
|
||||
return Version.valueOf(tryFix[0] + ".0.0-unknown");
|
||||
} catch(Exception ignore2) {}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if(o instanceof AppInfo && o != null && path != null) {
|
||||
return path.equals(((AppInfo)o).getPath());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return alias + " " + path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package qz.installer.certificate.firefox.locator;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.utils.ShellUtilities;
|
||||
import qz.utils.SystemUtilities;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
|
||||
public abstract class AppLocator {
|
||||
protected static final Logger log = LogManager.getLogger(AppLocator.class);
|
||||
|
||||
private static AppLocator INSTANCE = getPlatformSpecificAppLocator();
|
||||
|
||||
public abstract ArrayList<AppInfo> locate(AppAlias appAlias);
|
||||
public abstract ArrayList<Path> getPidPaths(ArrayList<String> pids);
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public ArrayList<String> getPids(String ... processNames) {
|
||||
return getPids(new ArrayList<>(Arrays.asList(processNames)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Linux, Mac
|
||||
*/
|
||||
public ArrayList<String> getPids(ArrayList<String> processNames) {
|
||||
String[] response;
|
||||
ArrayList<String> pidList = new ArrayList<>();
|
||||
|
||||
if(processNames.contains("firefox") && !(SystemUtilities.isWindows() || SystemUtilities.isMac())) {
|
||||
processNames.add("MainThread"); // Workaround Firefox 79 https://github.com/qzind/tray/issues/701
|
||||
processNames.add("GeckoMain"); // Workaround Firefox 94 https://bugzilla.mozilla.org/show_bug.cgi?id=1742606
|
||||
}
|
||||
|
||||
if (processNames.size() == 0) return pidList;
|
||||
|
||||
// Quoting handled by the command processor (e.g. pgrep -x "myapp|my app" is perfectly valid)
|
||||
String data = ShellUtilities.executeRaw("pgrep", "-x", String.join("|", processNames));
|
||||
|
||||
//Splitting an empty string results in a 1 element array, this is not what we want
|
||||
if (!data.isEmpty()) {
|
||||
response = data.split("\\s*\\r?\\n");
|
||||
Collections.addAll(pidList, response);
|
||||
}
|
||||
|
||||
return pidList;
|
||||
}
|
||||
|
||||
public static ArrayList<Path> getRunningPaths(ArrayList<AppInfo> appList) {
|
||||
return getRunningPaths(appList, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the running executables matching on <code>AppInfo.getExePath</code>
|
||||
* This is resource intensive; if a non-null <code>cache</code> is provided, it will return that instead
|
||||
*/
|
||||
public static ArrayList<Path> getRunningPaths(ArrayList<AppInfo> appList, ArrayList<Path> cache) {
|
||||
if(cache == null) {
|
||||
ArrayList<String> appNames = new ArrayList<>();
|
||||
for(AppInfo app : appList) {
|
||||
String exeName = app.getExePath().getFileName().toString();
|
||||
if (!appNames.contains(exeName)) appNames.add(exeName);
|
||||
}
|
||||
cache = INSTANCE.getPidPaths(INSTANCE.getPids(appNames));
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
public static AppLocator getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private static AppLocator getPlatformSpecificAppLocator() {
|
||||
switch(SystemUtilities.getOs()) {
|
||||
case WINDOWS:
|
||||
return new WindowsAppLocator();
|
||||
case MAC:
|
||||
return new MacAppLocator();
|
||||
default:
|
||||
return new LinuxAppLocator();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package qz.installer.certificate.firefox.locator;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.utils.ShellUtilities;
|
||||
import qz.utils.SystemUtilities;
|
||||
import qz.utils.UnixUtilities;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
|
||||
public class LinuxAppLocator extends AppLocator {
|
||||
private static final Logger log = LogManager.getLogger(LinuxAppLocator.class);
|
||||
|
||||
public ArrayList<AppInfo> locate(AppAlias appAlias) {
|
||||
ArrayList<AppInfo> appList = new ArrayList<>();
|
||||
|
||||
// Workaround for calling "firefox --version" as sudo
|
||||
String[] env = appendPaths("HOME=/tmp");
|
||||
|
||||
// Search for matching executable in all path values
|
||||
aliasLoop:
|
||||
for(AppAlias.Alias alias : appAlias.aliases) {
|
||||
// Add non-standard app search locations (e.g. Fedora)
|
||||
for (String dirname : appendPaths(alias.getPosix(), "/usr/lib/$/bin", "/usr/lib64/$/bin", "/usr/lib/$", "/usr/lib64/$")) {
|
||||
Path path = Paths.get(dirname, alias.getPosix());
|
||||
if (Files.isRegularFile(path) && Files.isExecutable(path)) {
|
||||
log.info("Found {} {}: {}, investigating...", alias.getVendor(), alias.getName(true), path);
|
||||
try {
|
||||
File file = path.toFile().getCanonicalFile(); // fix symlinks
|
||||
if(file.getPath().endsWith("/snap")) {
|
||||
// Ubuntu 22.04+ ships Firefox as a snap
|
||||
// Snaps are read-only and are symlinks back to /usr/bin/snap
|
||||
// Reset the executable back to /snap/bin/firefox to get proper version information
|
||||
file = path.toFile();
|
||||
}
|
||||
if(file.getPath().endsWith(".sh")) {
|
||||
// Legacy Ubuntu likes to use .../firefox/firefox.sh, return .../firefox/firefox instead
|
||||
log.info("Found an '.sh' file: {}, removing file extension: {}", file, file = new File(FilenameUtils.removeExtension(file.getPath())));
|
||||
}
|
||||
String contentType = Files.probeContentType(file.toPath());
|
||||
if(contentType == null) {
|
||||
// Fallback to commandline per https://bugs.openjdk.org/browse/JDK-8188228
|
||||
contentType = ShellUtilities.executeRaw("file", "--mime-type", "--brief", file.getPath()).trim();
|
||||
}
|
||||
if(contentType != null && contentType.endsWith("/x-shellscript")) {
|
||||
if(UnixUtilities.isFedora()) {
|
||||
// Firefox's script is full of variables and not parsable, fallback to /usr/lib64/$, etc
|
||||
log.info("Found shell script at {}, but we're on Fedora, so we'll look in some known locations instead.", file.getPath());
|
||||
continue;
|
||||
}
|
||||
// Debian and Arch like to place a stub script directly in /usr/bin/
|
||||
// TODO: Split into a function; possibly recurse on search paths
|
||||
log.info("{} bin was expected but script found... Reading...", appAlias.name());
|
||||
BufferedReader reader = new BufferedReader(new FileReader(file));
|
||||
String line;
|
||||
while((line = reader.readLine()) != null) {
|
||||
if(line.startsWith("exec") && line.contains(alias.getPosix())) {
|
||||
String[] parts = line.split(" ");
|
||||
// Get the app name after "exec"
|
||||
if (parts.length > 1) {
|
||||
log.info("Found a familiar line '{}', using '{}'", line, parts[1]);
|
||||
Path p = Paths.get(parts[1]);
|
||||
String exec = parts[1];
|
||||
// Handle edge-case for esr release
|
||||
if(!p.isAbsolute()) {
|
||||
// Script doesn't contain the full path, go deeper
|
||||
exec = Paths.get(dirname, exec).toFile().getCanonicalPath();
|
||||
log.info("Calculated full bin path {}", exec);
|
||||
}
|
||||
// Make sure it actually exists
|
||||
if(!(file = new File(exec)).exists()) {
|
||||
log.warn("Sorry, we couldn't detect the real path of {}. Skipping...", appAlias.name());
|
||||
continue aliasLoop;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
reader.close();
|
||||
} else {
|
||||
log.info("Assuming {} {} is installed: {}", alias.getVendor(), alias.getName(true), file);
|
||||
}
|
||||
AppInfo appInfo = new AppInfo(alias, file.toPath());
|
||||
if(file.getPath().startsWith("/snap/")) {
|
||||
// Ubuntu 22.04+ uses snaps, fallback to a sane "path" value
|
||||
String snapPath = file.getPath(); // e.g. /snap/bin/firefox
|
||||
snapPath = snapPath.replaceFirst("/bin/", "/");
|
||||
snapPath += "/current";
|
||||
appInfo.setPath(Paths.get(snapPath));
|
||||
}
|
||||
|
||||
appList.add(appInfo);
|
||||
|
||||
// Call "--version" on executable to obtain version information
|
||||
Process p = Runtime.getRuntime().exec(new String[] {file.getPath(), "--version" }, env);
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()));
|
||||
String version = reader.readLine();
|
||||
reader.close();
|
||||
if (version != null) {
|
||||
log.info("We obtained version info: {}, but we'll need to parse it", version);
|
||||
if(version.contains(" ")) {
|
||||
String[] split = version.split(" ");
|
||||
String parsed = split[split.length - 1];
|
||||
String stripped = parsed.replaceAll("[^\\d.]", "");
|
||||
appInfo.setVersion(stripped);
|
||||
if(!parsed.equals(stripped)) {
|
||||
// Add the meta data back (e.g. "esr")
|
||||
appInfo.getVersion().setBuildMetadata(parsed.replaceAll("[\\d.]", ""));
|
||||
}
|
||||
} else {
|
||||
appInfo.setVersion(version.trim());
|
||||
}
|
||||
}
|
||||
break;
|
||||
} catch(Exception e) {
|
||||
log.warn("Something went wrong getting app info for {} {}", alias.getVendor(), alias.getName(true), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return appList;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ArrayList<Path> getPidPaths(ArrayList<String> pids) {
|
||||
ArrayList<Path> pathList = new ArrayList<>();
|
||||
|
||||
for(String pid : pids) {
|
||||
try {
|
||||
pathList.add(Paths.get("/proc/", pid, !SystemUtilities.isSolaris() ? "/exe" : "/path/a.out").toRealPath());
|
||||
} catch(IOException e) {
|
||||
log.warn("Process {} vanished", pid);
|
||||
}
|
||||
}
|
||||
|
||||
return pathList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a PATH value with provided paths appended, replacing "$" with POSIX app name
|
||||
* Useful for strange Firefox install locations (e.g. Fedora)
|
||||
*
|
||||
* Usage: appendPaths("firefox", "/usr/lib64");
|
||||
*
|
||||
*/
|
||||
private static String[] appendPaths(String posix, String ... prefixes) {
|
||||
String newPath = System.getenv("PATH");
|
||||
for (String prefix : prefixes) {
|
||||
newPath = newPath + File.pathSeparator + prefix.replaceAll("\\$", posix);
|
||||
}
|
||||
return newPath.split(File.pathSeparator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package qz.installer.certificate.firefox.locator;
|
||||
|
||||
import com.sun.jna.Library;
|
||||
import com.sun.jna.Memory;
|
||||
import com.sun.jna.Native;
|
||||
import com.sun.jna.Pointer;
|
||||
import com.sun.jna.ptr.IntByReference;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Node;
|
||||
import org.w3c.dom.NodeList;
|
||||
import org.xml.sax.SAXException;
|
||||
import qz.utils.ShellUtilities;
|
||||
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
|
||||
public class MacAppLocator extends AppLocator{
|
||||
protected static final Logger log = LogManager.getLogger(MacAppLocator.class);
|
||||
|
||||
private static String[] BLACKLISTED_PATHS = new String[]{"/Volumes/", "/.Trash/", "/Applications (Parallels)/" };
|
||||
|
||||
/**
|
||||
* Helper class for finding key/value siblings from the DDM
|
||||
*/
|
||||
private enum SiblingNode {
|
||||
NAME("_name"),
|
||||
PATH("path"),
|
||||
VERSION("version");
|
||||
|
||||
private String key;
|
||||
private boolean wants;
|
||||
|
||||
SiblingNode(String key) {
|
||||
this.key = key;
|
||||
this.wants = false;
|
||||
}
|
||||
|
||||
private boolean isKey(Node node) {
|
||||
if (node.getNodeName().equals("key") && node.getTextContent().equals(key)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ArrayList<AppInfo> locate(AppAlias appAlias) {
|
||||
ArrayList<AppInfo> appList = new ArrayList<>();
|
||||
Document doc;
|
||||
|
||||
try {
|
||||
// system_profile benchmarks about 30% better than lsregister
|
||||
Process p = Runtime.getRuntime().exec(new String[] {"system_profiler", "SPApplicationsDataType", "-xml"}, ShellUtilities.envp);
|
||||
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
|
||||
// don't let the <!DOCTYPE> fail parsing per https://github.com/qzind/tray/issues/809
|
||||
dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
|
||||
doc = dbf.newDocumentBuilder().parse(p.getInputStream());
|
||||
} catch(IOException | ParserConfigurationException | SAXException e) {
|
||||
log.warn("Could not retrieve app listing for {}", appAlias.name(), e);
|
||||
return appList;
|
||||
}
|
||||
doc.normalizeDocument();
|
||||
|
||||
NodeList nodeList = doc.getElementsByTagName("dict");
|
||||
for (int i = 0; i < nodeList.getLength(); i++) {
|
||||
NodeList dict = nodeList.item(i).getChildNodes();
|
||||
HashMap<SiblingNode, String> foundApp = new HashMap<>();
|
||||
for (int j = 0; j < dict.getLength(); j++) {
|
||||
Node node = dict.item(j);
|
||||
if (node.getNodeType() == Node.ELEMENT_NODE) {
|
||||
for (SiblingNode sibling : SiblingNode.values()) {
|
||||
if (sibling.wants) {
|
||||
foundApp.put(sibling, node.getTextContent());
|
||||
sibling.wants = false;
|
||||
break;
|
||||
} else if(sibling.isKey(node)) {
|
||||
sibling.wants = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AppAlias.Alias alias;
|
||||
if((alias = AppAlias.findAlias(appAlias, foundApp.get(SiblingNode.NAME), true)) != null) {
|
||||
appList.add(new AppInfo(alias, Paths.get(foundApp.get(SiblingNode.PATH)),
|
||||
getExePath(foundApp.get(SiblingNode.PATH)), foundApp.get(SiblingNode.VERSION)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove blacklisted paths
|
||||
Iterator<AppInfo> appInfoIterator = appList.iterator();
|
||||
while(appInfoIterator.hasNext()) {
|
||||
AppInfo appInfo = appInfoIterator.next();
|
||||
for(String listEntry : BLACKLISTED_PATHS) {
|
||||
if (appInfo.getPath() != null && appInfo.getPath().toString().contains(listEntry)) {
|
||||
appInfoIterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
return appList;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ArrayList<Path> getPidPaths(ArrayList<String> pids) {
|
||||
ArrayList<Path> processPaths = new ArrayList();
|
||||
for (String pid : pids) {
|
||||
Pointer buf = new Memory(SystemB.PROC_PIDPATHINFO_MAXSIZE);
|
||||
SystemB.INSTANCE.proc_pidpath(Integer.parseInt(pid), buf, SystemB.PROC_PIDPATHINFO_MAXSIZE);
|
||||
processPaths.add(Paths.get(buf.getString(0).trim()));
|
||||
}
|
||||
return processPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate executable path by parsing Contents/Info.plist
|
||||
*/
|
||||
private static Path getExePath(String appPath) {
|
||||
Path path = Paths.get(appPath).toAbsolutePath().normalize();
|
||||
Path plist = path.resolve("Contents/Info.plist");
|
||||
Document doc;
|
||||
try {
|
||||
if(!plist.toFile().exists()) {
|
||||
log.warn("Could not locate plist file for {}: {}", appPath, plist);
|
||||
return null;
|
||||
}
|
||||
// Convert potentially binary plist files to XML
|
||||
Process p = Runtime.getRuntime().exec(new String[] {"plutil", "-convert", "xml1", plist.toString(), "-o", "-"}, ShellUtilities.envp);
|
||||
doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(p.getInputStream());
|
||||
} catch(IOException | ParserConfigurationException | SAXException e) {
|
||||
log.warn("Could not parse plist file for {}: {}", appPath, appPath, e);
|
||||
return null;
|
||||
}
|
||||
doc.normalizeDocument();
|
||||
|
||||
boolean upNext = false;
|
||||
NodeList nodeList = doc.getElementsByTagName("dict");
|
||||
for (int i = 0; i < nodeList.getLength(); i++) {
|
||||
NodeList dict = nodeList.item(i).getChildNodes();
|
||||
for(int j = 0; j < dict.getLength(); j++) {
|
||||
Node node = dict.item(j);
|
||||
if ("key".equals(node.getNodeName()) && node.getTextContent().equals("CFBundleExecutable")) {
|
||||
upNext = true;
|
||||
} else if (upNext && "string".equals(node.getNodeName())) {
|
||||
return path.resolve("Contents/MacOS/" + node.getTextContent());
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private interface SystemB extends Library {
|
||||
SystemB INSTANCE = Native.load("System", SystemB.class);
|
||||
int PROC_ALL_PIDS = 1;
|
||||
int PROC_PIDPATHINFO_MAXSIZE = 1024 * 4;
|
||||
int sysctlbyname(String name, Pointer oldp, IntByReference oldlenp, Pointer newp, int newlen);
|
||||
int proc_listpids(int type, int typeinfo, int[] buffer, int buffersize);
|
||||
int proc_pidpath(int pid, Pointer buffer, int buffersize);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.installer.certificate.firefox.locator;
|
||||
|
||||
import com.sun.jna.Memory;
|
||||
import com.sun.jna.Native;
|
||||
import com.sun.jna.Pointer;
|
||||
import com.sun.jna.platform.win32.Kernel32;
|
||||
import com.sun.jna.platform.win32.Psapi;
|
||||
import com.sun.jna.platform.win32.Tlhelp32;
|
||||
import com.sun.jna.platform.win32.WinNT;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.installer.certificate.firefox.locator.AppAlias.Alias;
|
||||
import qz.utils.WindowsUtilities;
|
||||
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Locale;
|
||||
|
||||
import static com.sun.jna.platform.win32.WinReg.HKEY_LOCAL_MACHINE;
|
||||
|
||||
public class WindowsAppLocator extends AppLocator{
|
||||
protected static final Logger log = LogManager.getLogger(MacAppLocator.class);
|
||||
|
||||
private static String REG_TEMPLATE = "Software\\%s%s\\%s%s";
|
||||
|
||||
@Override
|
||||
public ArrayList<AppInfo> locate(AppAlias appAlias) {
|
||||
ArrayList<AppInfo> appList = new ArrayList<>();
|
||||
for (Alias alias : appAlias.aliases) {
|
||||
if (alias.getVendor() != null) {
|
||||
String[] suffixes = new String[]{ "", " ESR"};
|
||||
String[] prefixes = new String[]{ "", "WOW6432Node\\"};
|
||||
for (String suffix : suffixes) {
|
||||
for (String prefix : prefixes) {
|
||||
String key = String.format(REG_TEMPLATE, prefix, alias.getVendor(), alias.getName(), suffix);
|
||||
AppInfo appInfo = getAppInfo(alias, key, suffix);
|
||||
if (appInfo != null && !appList.contains(appInfo)) {
|
||||
appList.add(appInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return appList;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ArrayList<String> getPids(ArrayList<String> processNames) {
|
||||
ArrayList<String> pidList = new ArrayList<>();
|
||||
|
||||
if (processNames.isEmpty()) return pidList;
|
||||
|
||||
Tlhelp32.PROCESSENTRY32 pe32 = new Tlhelp32.PROCESSENTRY32();
|
||||
pe32.dwSize = new WinNT.DWORD(pe32.size());
|
||||
|
||||
// Fetch a snapshot of all processes
|
||||
WinNT.HANDLE hSnapshot = Kernel32.INSTANCE.CreateToolhelp32Snapshot(Tlhelp32.TH32CS_SNAPPROCESS, new WinNT.DWORD(0));
|
||||
if (hSnapshot.equals(WinNT.INVALID_HANDLE_VALUE)) {
|
||||
log.warn("Process snapshot has invalid handle");
|
||||
return pidList;
|
||||
}
|
||||
|
||||
if (Kernel32.INSTANCE.Process32First(hSnapshot, pe32)) {
|
||||
do {
|
||||
String processName = Native.toString(pe32.szExeFile);
|
||||
if(processNames.contains(processName.toLowerCase(Locale.ENGLISH))) {
|
||||
pidList.add(pe32.th32ProcessID.toString());
|
||||
}
|
||||
} while (Kernel32.INSTANCE.Process32Next(hSnapshot, pe32));
|
||||
}
|
||||
|
||||
Kernel32.INSTANCE.CloseHandle(hSnapshot);
|
||||
return pidList;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public ArrayList<Path> getPidPaths(ArrayList<String> pids) {
|
||||
ArrayList<Path> pathList = new ArrayList<>();
|
||||
|
||||
for(String pid : pids) {
|
||||
WinNT.HANDLE hProcess = Kernel32.INSTANCE.OpenProcess(WinNT.PROCESS_QUERY_INFORMATION | WinNT.PROCESS_VM_READ, false, Integer.parseInt(pid));
|
||||
if (hProcess == null) {
|
||||
log.warn("Handle for PID {} is missing, skipping.", pid);
|
||||
continue;
|
||||
}
|
||||
|
||||
int bufferSize = WinNT.MAX_PATH;
|
||||
Pointer buffer = new Memory(bufferSize * Native.WCHAR_SIZE);
|
||||
|
||||
if (Psapi.INSTANCE.GetModuleFileNameEx(hProcess, null, buffer, bufferSize) == 0) {
|
||||
log.warn("Full path to PID {} is empty, skipping.", pid);
|
||||
Kernel32.INSTANCE.CloseHandle(hProcess);
|
||||
continue;
|
||||
}
|
||||
|
||||
Kernel32.INSTANCE.CloseHandle(hProcess);
|
||||
pathList.add(Paths.get(Native.WCHAR_SIZE == 1 ?
|
||||
buffer.getString(0) :
|
||||
buffer.getWideString(0)));
|
||||
}
|
||||
return pathList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use a proprietary Firefox-only technique for getting "PathToExe" registry value
|
||||
*/
|
||||
private static AppInfo getAppInfo(Alias alias, String key, String suffix) {
|
||||
String version = WindowsUtilities.getRegString(HKEY_LOCAL_MACHINE, key, "CurrentVersion");
|
||||
if (version != null) {
|
||||
version = version.split(" ")[0]; // chop off (x86 ...)
|
||||
if (!suffix.isEmpty()) {
|
||||
if (key.endsWith(suffix)) {
|
||||
key = key.substring(0, key.length() - suffix.length());
|
||||
}
|
||||
version = version + suffix;
|
||||
}
|
||||
String exePath = WindowsUtilities.getRegString(HKEY_LOCAL_MACHINE, key + " " + version + "\\bin", "PathToExe");
|
||||
|
||||
if (exePath != null) {
|
||||
// SemVer: Replace spaces in suffixes with dashes
|
||||
version = version.replaceAll(" ", "-");
|
||||
return new AppInfo(alias, Paths.get(exePath), version);
|
||||
} else {
|
||||
log.warn("Couldn't locate \"PathToExe\" for \"{}\" in \"{}\", skipping", alias.getName(), key);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
161
tray/src/qz/installer/provision/ProvisionInstaller.java
Normal file
161
tray/src/qz/installer/provision/ProvisionInstaller.java
Normal file
@@ -0,0 +1,161 @@
|
||||
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<Step> 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<Step> 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 CA:
|
||||
invoker = new CaInvoker(step, PropertyInvoker.getProperties(step));
|
||||
break;
|
||||
case CERT:
|
||||
invoker = new CertInvoker(step);
|
||||
break;
|
||||
case CONF:
|
||||
invoker = new ConfInvoker(step);
|
||||
break;
|
||||
case SCRIPT:
|
||||
invoker = new ScriptInvoker(step);
|
||||
break;
|
||||
case SOFTWARE:
|
||||
invoker = new SoftwareInvoker(step);
|
||||
break;
|
||||
case REMOVER:
|
||||
invoker = new RemoverInvoker(step);
|
||||
break;
|
||||
case RESOURCE:
|
||||
invoker = new ResourceInvoker(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();
|
||||
}
|
||||
|
||||
public ArrayList<Step> getSteps() {
|
||||
return steps;
|
||||
}
|
||||
|
||||
private static ArrayList<Step> parse(JSONArray jsonArray, Object relativeObject) throws JSONException {
|
||||
ArrayList<Step> 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;
|
||||
}
|
||||
}
|
||||
49
tray/src/qz/installer/provision/invoker/CaInvoker.java
Normal file
49
tray/src/qz/installer/provision/invoker/CaInvoker.java
Normal file
@@ -0,0 +1,49 @@
|
||||
package qz.installer.provision.invoker;
|
||||
|
||||
import qz.build.provision.Step;
|
||||
import qz.common.PropertyHelper;
|
||||
import qz.utils.ArgValue;
|
||||
import qz.utils.FileUtilities;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Combines ResourceInvoker and PropertyInvoker to deploy a file and set a property to its deployed path
|
||||
*/
|
||||
public class CaInvoker extends InvokableResource {
|
||||
Step step;
|
||||
PropertyHelper properties;
|
||||
|
||||
public CaInvoker(Step step, PropertyHelper properties) {
|
||||
this.step = step;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean invoke() throws IOException {
|
||||
// First, write our cert file
|
||||
File caCert = dataToFile(step);
|
||||
if(caCert == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Next, handle our property step
|
||||
Step propsStep = step.clone();
|
||||
|
||||
// If the property already exists, snag it
|
||||
String key = ArgValue.AUTHCERT_OVERRIDE.getMatch();
|
||||
String value = caCert.getPath();
|
||||
if (properties.containsKey(key)) {
|
||||
value = properties.getProperty(key) + FileUtilities.FILE_SEPARATOR + value;
|
||||
}
|
||||
|
||||
propsStep.setData(String.format("%s=%s", key, value));
|
||||
|
||||
if (new PropertyInvoker(propsStep, properties).invoke()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
26
tray/src/qz/installer/provision/invoker/CertInvoker.java
Normal file
26
tray/src/qz/installer/provision/invoker/CertInvoker.java
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
46
tray/src/qz/installer/provision/invoker/ConfInvoker.java
Normal file
46
tray/src/qz/installer/provision/invoker/ConfInvoker.java
Normal file
@@ -0,0 +1,46 @@
|
||||
package qz.installer.provision.invoker;
|
||||
|
||||
import qz.build.provision.Step;
|
||||
import qz.common.PropertyHelper;
|
||||
import qz.utils.SystemUtilities;
|
||||
|
||||
import java.util.AbstractMap;
|
||||
|
||||
public class ConfInvoker extends PropertyInvoker {
|
||||
public ConfInvoker(Step step) {
|
||||
super(step, new PropertyHelper(calculateConfPath(step)));
|
||||
}
|
||||
|
||||
public static String calculateConfPath(Step step) {
|
||||
String relativePath = step.getArgs().get(0);
|
||||
if(SystemUtilities.isMac()) {
|
||||
return SystemUtilities.getJarParentPath().
|
||||
resolve("../PlugIns/Java.runtime/Contents/Home/conf").
|
||||
resolve(relativePath).
|
||||
normalize()
|
||||
.toString();
|
||||
} else {
|
||||
return SystemUtilities.getJarParentPath()
|
||||
.resolve("runtime/conf")
|
||||
.resolve(relativePath)
|
||||
.normalize()
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean invoke() {
|
||||
Step step = getStep();
|
||||
// Java uses the same "|" delimiter as we do, only parse one property at a time
|
||||
AbstractMap.SimpleEntry<String, String> pair = parsePropertyPair(step, step.getData());
|
||||
if (!pair.getValue().isEmpty()) {
|
||||
properties.setProperty(pair);
|
||||
if (properties.save()) {
|
||||
log.info("Successfully provisioned '1' '{}'", step.getType());
|
||||
return true;
|
||||
}
|
||||
log.error("An error occurred saving properties '{}' to file", step.getData());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
10
tray/src/qz/installer/provision/invoker/Invokable.java
Normal file
10
tray/src/qz/installer/provision/invoker/Invokable.java
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
99
tray/src/qz/installer/provision/invoker/PropertyInvoker.java
Normal file
99
tray/src/qz/installer/provision/invoker/PropertyInvoker.java
Normal file
@@ -0,0 +1,99 @@
|
||||
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.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
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() {
|
||||
HashMap<String, String> pairs = parsePropertyPairs(step);
|
||||
if (!pairs.isEmpty()) {
|
||||
for(Map.Entry<String, String> pair : pairs.entrySet()) {
|
||||
properties.setProperty(pair);
|
||||
}
|
||||
if (properties.save()) {
|
||||
log.info("Successfully provisioned '{}' '{}'", pairs.size(), step.getType());
|
||||
return true;
|
||||
}
|
||||
log.error("An error occurred saving properties '{}' to file", step.getData());
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
public static HashMap<String, String> parsePropertyPairs(Step step) {
|
||||
HashMap<String, String> pairs = new HashMap<>();
|
||||
if(step.getData() != null && !step.getData().trim().isEmpty()) {
|
||||
String[] props = step.getData().split("\\|");
|
||||
for(String prop : props) {
|
||||
AbstractMap.SimpleEntry<String,String> pair = parsePropertyPair(step, prop);
|
||||
if (pair != null) {
|
||||
if(pairs.get(pair.getKey()) != null) {
|
||||
log.warn("Property {} already exists, replacing [before: {}, after: {}] ",
|
||||
pair.getKey(), pairs.get(pair.getKey()), pair.getValue());
|
||||
}
|
||||
pairs.put(pair.getKey(), pair.getValue());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.error("Skipping Step '{}', Data is null or empty", step.getType());
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
|
||||
public static AbstractMap.SimpleEntry<String, String> 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;
|
||||
}
|
||||
|
||||
public Step getStep() {
|
||||
return step;
|
||||
}
|
||||
}
|
||||
100
tray/src/qz/installer/provision/invoker/RemoverInvoker.java
Normal file
100
tray/src/qz/installer/provision/invoker/RemoverInvoker.java
Normal file
@@ -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<String> 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<String> getRemoveCommand() {
|
||||
ArrayList<String> 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;
|
||||
}
|
||||
}
|
||||
19
tray/src/qz/installer/provision/invoker/ResourceInvoker.java
Normal file
19
tray/src/qz/installer/provision/invoker/ResourceInvoker.java
Normal file
@@ -0,0 +1,19 @@
|
||||
package qz.installer.provision.invoker;
|
||||
|
||||
import qz.build.provision.Step;
|
||||
|
||||
/**
|
||||
* Stub class for deploying an otherwise "action-less" resource, only to be used by other tasks
|
||||
*/
|
||||
public class ResourceInvoker extends InvokableResource {
|
||||
private Step step;
|
||||
|
||||
public ResourceInvoker(Step step) {
|
||||
this.step = step;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean invoke() throws Exception {
|
||||
return dataToFile(step) != null;
|
||||
}
|
||||
}
|
||||
77
tray/src/qz/installer/provision/invoker/ScriptInvoker.java
Normal file
77
tray/src/qz/installer/provision/invoker/ScriptInvoker.java
Normal file
@@ -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<String> 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<String> getInterpreter(Script engine) {
|
||||
ArrayList<String> 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;
|
||||
}
|
||||
}
|
||||
87
tray/src/qz/installer/provision/invoker/SoftwareInvoker.java
Normal file
87
tray/src/qz/installer/provision/invoker/SoftwareInvoker.java
Normal file
@@ -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<String> command = getInstallCommand(installer, step.getArgs(), payload);
|
||||
boolean success = ShellUtilities.execute(command.toArray(new String[command.size()]), payload.getParentFile());
|
||||
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<String> getInstallCommand(Software installer, List<String> args, File payload) {
|
||||
ArrayList<String> 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;
|
||||
}
|
||||
|
||||
}
|
||||
44
tray/src/qz/installer/shortcut/LinuxShortcutCreator.java
Normal file
44
tray/src/qz/installer/shortcut/LinuxShortcutCreator.java
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2016 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.installer.shortcut;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.installer.LinuxInstaller;
|
||||
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*/
|
||||
class LinuxShortcutCreator extends ShortcutCreator {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(LinuxShortcutCreator.class);
|
||||
private static String DESKTOP = System.getProperty("user.home") + "/Desktop/";
|
||||
|
||||
public boolean canAutoStart() {
|
||||
return Files.exists(Paths.get(LinuxInstaller.STARTUP_DIR, LinuxInstaller.SHORTCUT_NAME));
|
||||
}
|
||||
public void createDesktopShortcut() {
|
||||
copyShortcut(LinuxInstaller.APP_LAUNCHER, DESKTOP);
|
||||
}
|
||||
|
||||
private static void copyShortcut(String source, String target) {
|
||||
try {
|
||||
Files.copy(Paths.get(source), Paths.get(target));
|
||||
} catch(IOException e) {
|
||||
log.warn("Error creating shortcut {}", target, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
100
tray/src/qz/installer/shortcut/MacShortcutCreator.java
Normal file
100
tray/src/qz/installer/shortcut/MacShortcutCreator.java
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2016 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
package qz.installer.shortcut;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Node;
|
||||
import org.w3c.dom.NodeList;
|
||||
import org.xml.sax.SAXException;
|
||||
import qz.common.Constants;
|
||||
import qz.utils.MacUtilities;
|
||||
import qz.utils.SystemUtilities;
|
||||
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*/
|
||||
class MacShortcutCreator extends ShortcutCreator {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(MacShortcutCreator.class);
|
||||
private static String SHORTCUT_PATH = System.getProperty("user.home") + "/Desktop/" + Constants.ABOUT_TITLE;
|
||||
|
||||
/**
|
||||
* Verify LaunchAgents plist file exists and parse it to verify it's enabled
|
||||
*/
|
||||
@Override
|
||||
public boolean canAutoStart() {
|
||||
// plist is stored as io.qz.plist
|
||||
Path plistPath = Paths.get("/Library/LaunchAgents", MacUtilities.getBundleId() + ".plist");
|
||||
|
||||
if (Files.exists(plistPath)) {
|
||||
try {
|
||||
DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance();
|
||||
DocumentBuilder dBuilder = dbFactory.newDocumentBuilder();
|
||||
Document doc = dBuilder.parse(plistPath.toFile());
|
||||
doc.getDocumentElement().normalize();
|
||||
|
||||
NodeList dictList = doc.getElementsByTagName("dict");
|
||||
|
||||
// Loop to find "RunAtLoad" key, then the adjacent key
|
||||
boolean foundItem = false;
|
||||
if (dictList.getLength() > 0) {
|
||||
NodeList children = dictList.item(0).getChildNodes();
|
||||
for(int n = 0; n < children.getLength(); n++) {
|
||||
Node item = children.item(n);
|
||||
// Apple stores booleans as adjacent tags to their owner
|
||||
if (foundItem) {
|
||||
String nodeName = children.item(n).getNodeName();
|
||||
log.debug("Found RunAtLoad value {}", nodeName);
|
||||
return "true".equals(nodeName);
|
||||
}
|
||||
if (item.getNodeName().equals("key") && item.getTextContent().equals("RunAtLoad")) {
|
||||
log.debug("Found RunAtLoad key in {}", plistPath);
|
||||
foundItem = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
log.warn("RunAtLoad was not in plist {}, autostart will not work.", plistPath);
|
||||
}
|
||||
catch(SAXException | IOException | ParserConfigurationException e) {
|
||||
log.warn("Error reading plist {}, autostart will not work.", plistPath, e);
|
||||
}
|
||||
} else {
|
||||
log.warn("No plist {} found, autostart will not work", plistPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void createDesktopShortcut() {
|
||||
try {
|
||||
new File(SHORTCUT_PATH).delete();
|
||||
if(SystemUtilities.getJarParentPath().endsWith("Contents")) {
|
||||
// We're probably running from an .app bundle
|
||||
Files.createSymbolicLink(Paths.get(SHORTCUT_PATH), SystemUtilities.getAppPath());
|
||||
} else {
|
||||
// We're running from a mystery location, use the jar instead
|
||||
Files.createSymbolicLink(Paths.get(SHORTCUT_PATH), SystemUtilities.getJarPath());
|
||||
}
|
||||
|
||||
} catch(IOException e) {
|
||||
log.warn("Could not create desktop shortcut {}", SHORTCUT_PATH, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
41
tray/src/qz/installer/shortcut/ShortcutCreator.java
Normal file
41
tray/src/qz/installer/shortcut/ShortcutCreator.java
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2016 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.installer.shortcut;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.utils.SystemUtilities;
|
||||
|
||||
/**
|
||||
* Utility class for creating, querying and removing startup shortcuts and
|
||||
* desktop shortcuts.
|
||||
*
|
||||
* @author Tres Finocchiaro
|
||||
*/
|
||||
public abstract class ShortcutCreator {
|
||||
private static ShortcutCreator instance;
|
||||
protected static final Logger log = LogManager.getLogger(ShortcutCreator.class);
|
||||
public abstract boolean canAutoStart();
|
||||
public abstract void createDesktopShortcut();
|
||||
|
||||
public static ShortcutCreator getInstance() {
|
||||
if (instance == null) {
|
||||
if (SystemUtilities.isWindows()) {
|
||||
instance = new WindowsShortcutCreator();
|
||||
} else if (SystemUtilities.isMac()) {
|
||||
instance = new MacShortcutCreator();
|
||||
} else {
|
||||
instance = new LinuxShortcutCreator();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
60
tray/src/qz/installer/shortcut/WindowsShortcutCreator.java
Normal file
60
tray/src/qz/installer/shortcut/WindowsShortcutCreator.java
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2016 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*
|
||||
*/
|
||||
|
||||
package qz.installer.shortcut;
|
||||
|
||||
import com.sun.jna.platform.win32.Win32Exception;
|
||||
import mslinks.ShellLinkException;
|
||||
import mslinks.ShellLinkHelper;
|
||||
import qz.common.Constants;
|
||||
import qz.installer.WindowsSpecialFolders;
|
||||
import qz.utils.SystemUtilities;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*/
|
||||
public class WindowsShortcutCreator extends ShortcutCreator {
|
||||
private static String SHORTCUT_NAME = Constants.ABOUT_TITLE + ".lnk";
|
||||
|
||||
public void createDesktopShortcut() {
|
||||
createShortcut(WindowsSpecialFolders.DESKTOP.toString());
|
||||
}
|
||||
|
||||
public boolean canAutoStart() {
|
||||
try {
|
||||
return Files.exists(Paths.get(WindowsSpecialFolders.COMMON_STARTUP.toString(), SHORTCUT_NAME));
|
||||
} catch(Win32Exception e) {
|
||||
log.warn("An exception occurred locating the startup folder; autostart cannot be determined.", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void createShortcut(String folderPath) {
|
||||
try {
|
||||
ShellLinkHelper.createLink(getAppPath(), folderPath + File.separator + SHORTCUT_NAME);
|
||||
}
|
||||
catch(ShellLinkException | IOException ex) {
|
||||
log.warn("Error creating desktop shortcut", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates .exe path from .jar
|
||||
* fixme: overlaps SystemUtilities.getAppPath
|
||||
*/
|
||||
private static String getAppPath() {
|
||||
return SystemUtilities.getJarPath().toString().replaceAll(".jar$", ".exe");
|
||||
}
|
||||
}
|
||||
752
tray/src/qz/printer/PrintOptions.java
Normal file
752
tray/src/qz/printer/PrintOptions.java
Normal file
@@ -0,0 +1,752 @@
|
||||
package qz.printer;
|
||||
|
||||
import org.codehaus.jettison.json.JSONArray;
|
||||
import org.codehaus.jettison.json.JSONException;
|
||||
import org.codehaus.jettison.json.JSONObject;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.utils.LoggerUtilities;
|
||||
import qz.utils.PrintingUtilities;
|
||||
import qz.utils.SystemUtilities;
|
||||
|
||||
import javax.print.attribute.ResolutionSyntax;
|
||||
import javax.print.attribute.Size2DSyntax;
|
||||
import javax.print.attribute.standard.Chromaticity;
|
||||
import javax.print.attribute.standard.OrientationRequested;
|
||||
import javax.print.attribute.standard.PrinterResolution;
|
||||
import javax.print.attribute.standard.Sides;
|
||||
import java.awt.*;
|
||||
import java.awt.print.PageFormat;
|
||||
import java.awt.print.PrinterException;
|
||||
import java.awt.print.PrinterJob;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public class PrintOptions {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(PrintOptions.class);
|
||||
|
||||
private Pixel psOptions = new Pixel();
|
||||
private Raw rawOptions = new Raw();
|
||||
private Default defOptions = new Default();
|
||||
|
||||
|
||||
/**
|
||||
* Parses the provided JSON Object into relevant Pixel and Raw options
|
||||
*/
|
||||
public PrintOptions(JSONObject configOpts, PrintOutput output, PrintingUtilities.Format format) {
|
||||
if (configOpts == null) { return; }
|
||||
|
||||
//check for raw options
|
||||
if (!configOpts.isNull("forceRaw")) {
|
||||
rawOptions.forceRaw = configOpts.optBoolean("forceRaw", false);
|
||||
} else if (!configOpts.isNull("altPrinting")) {
|
||||
log.warn("Raw option \"altPrinting\" is deprecated. Please use \"forceRaw\" instead.");
|
||||
rawOptions.forceRaw = configOpts.optBoolean("altPrinting", false);
|
||||
}
|
||||
if (rawOptions.forceRaw && SystemUtilities.isWindows()) {
|
||||
log.warn("Forced raw printing is not supported on Windows");
|
||||
rawOptions.forceRaw = false;
|
||||
}
|
||||
|
||||
if (!configOpts.isNull("encoding")) {
|
||||
JSONObject encodings = configOpts.optJSONObject("encoding");
|
||||
if (encodings != null) {
|
||||
rawOptions.srcEncoding = encodings.optString("from", null);
|
||||
rawOptions.destEncoding = encodings.optString("to", null);
|
||||
} else {
|
||||
rawOptions.destEncoding = configOpts.optString("encoding", null);
|
||||
}
|
||||
}
|
||||
if (!configOpts.isNull("spool")) {
|
||||
JSONObject spool = configOpts.optJSONObject("spool");
|
||||
if (spool != null) {
|
||||
if (!spool.isNull("size")) {
|
||||
try { rawOptions.spoolSize = spool.getInt("size"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "integer", "spool.size", spool.opt("size")); }
|
||||
}
|
||||
// TODO: Implement spool.start
|
||||
if (!spool.isNull("end")) {
|
||||
rawOptions.spoolEnd = spool.optString("end");
|
||||
}
|
||||
|
||||
} else {
|
||||
LoggerUtilities.optionWarn(log, "JSONObject", "spool", configOpts.opt("spool"));
|
||||
}
|
||||
} else {
|
||||
// Deprecated
|
||||
if (!configOpts.isNull("perSpool")) {
|
||||
try { rawOptions.spoolSize = configOpts.getInt("perSpool"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "integer", "perSpool", configOpts.opt("perSpool")); }
|
||||
}
|
||||
if (!configOpts.isNull("endOfDoc")) {
|
||||
rawOptions.spoolEnd = configOpts.optString("endOfDoc", null);
|
||||
}
|
||||
}
|
||||
if (!configOpts.isNull("copies")) {
|
||||
try { rawOptions.copies = configOpts.getInt("copies"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "integer", "copies", configOpts.opt("copies")); }
|
||||
}
|
||||
if (!configOpts.isNull("jobName")) {
|
||||
rawOptions.jobName = configOpts.optString("jobName", null);
|
||||
}
|
||||
if (!configOpts.isNull("retainTemp")) {
|
||||
rawOptions.retainTemp = configOpts.optBoolean("retainTemp", false);
|
||||
}
|
||||
|
||||
|
||||
//check for pixel options
|
||||
if (!configOpts.isNull("units")) {
|
||||
switch(configOpts.optString("units")) {
|
||||
case "mm":
|
||||
psOptions.units = Unit.MM; break;
|
||||
case "cm":
|
||||
psOptions.units = Unit.CM; break;
|
||||
case "in":
|
||||
psOptions.units = Unit.INCH; break;
|
||||
default:
|
||||
LoggerUtilities.optionWarn(log, "valid value", "units", configOpts.opt("units")); break;
|
||||
}
|
||||
}
|
||||
if (!configOpts.isNull("bounds")) {
|
||||
try {
|
||||
JSONObject bounds = configOpts.getJSONObject("bounds");
|
||||
psOptions.bounds = new Bounds(bounds.optDouble("x", 0), bounds.optDouble("y", 0), bounds.optDouble("width", 0), bounds.optDouble("height", 0));
|
||||
}
|
||||
catch(JSONException e) {
|
||||
LoggerUtilities.optionWarn(log, "JSONObject", "bounds", configOpts.opt("bounds"));
|
||||
}
|
||||
}
|
||||
if (!configOpts.isNull("colorType")) {
|
||||
try {
|
||||
psOptions.colorType = ColorType.valueOf(configOpts.optString("colorType").toUpperCase(Locale.ENGLISH));
|
||||
}
|
||||
catch(IllegalArgumentException e) {
|
||||
LoggerUtilities.optionWarn(log, "valid value", "colorType", configOpts.opt("colorType"));
|
||||
}
|
||||
}
|
||||
if (!configOpts.isNull("copies")) {
|
||||
try { psOptions.copies = configOpts.getInt("copies"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "integer", "copies", configOpts.opt("copies")); }
|
||||
if (psOptions.copies < 1) {
|
||||
log.warn("Cannot have less than one copy");
|
||||
psOptions.copies = 1;
|
||||
}
|
||||
}
|
||||
if (!configOpts.isNull("density")) {
|
||||
JSONObject asymmDPI = configOpts.optJSONObject("density");
|
||||
if (asymmDPI != null) {
|
||||
psOptions.density = asymmDPI.optInt("feed");
|
||||
psOptions.crossDensity = asymmDPI.optInt("cross");
|
||||
} else {
|
||||
List<PrinterResolution> rSupport = output.isSetService()?
|
||||
output.getNativePrinter().getResolutions():new ArrayList<>();
|
||||
|
||||
JSONArray possibleDPIs = configOpts.optJSONArray("density");
|
||||
if (possibleDPIs != null && possibleDPIs.length() > 0) {
|
||||
PrinterResolution usableRes = null;
|
||||
|
||||
if (!rSupport.isEmpty()) {
|
||||
for(int i = 0; i < possibleDPIs.length(); i++) {
|
||||
PrinterResolution compareRes;
|
||||
asymmDPI = possibleDPIs.optJSONObject(i);
|
||||
if (asymmDPI != null) {
|
||||
compareRes = new PrinterResolution(asymmDPI.optInt("cross"), asymmDPI.optInt("feed"), psOptions.units.resSyntax);
|
||||
} else {
|
||||
compareRes = new PrinterResolution(possibleDPIs.optInt(i), possibleDPIs.optInt(i), psOptions.units.resSyntax);
|
||||
}
|
||||
|
||||
if (rSupport.contains(compareRes)) {
|
||||
usableRes = compareRes;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (usableRes == null) {
|
||||
log.warn("Supported printer densities not found, using first value provided");
|
||||
asymmDPI = possibleDPIs.optJSONObject(0);
|
||||
if (asymmDPI != null) {
|
||||
psOptions.density = asymmDPI.optInt("feed");
|
||||
psOptions.crossDensity = asymmDPI.optInt("cross");
|
||||
} else {
|
||||
psOptions.density = possibleDPIs.optInt(0);
|
||||
}
|
||||
} else {
|
||||
psOptions.density = usableRes.getFeedResolution(psOptions.units.resSyntax);
|
||||
psOptions.crossDensity = usableRes.getCrossFeedResolution(psOptions.units.resSyntax);
|
||||
}
|
||||
} else {
|
||||
String relDPI = configOpts.optString("density", "").toLowerCase(Locale.ENGLISH);
|
||||
if ("best".equals(relDPI)) {
|
||||
PrinterResolution bestRes = null;
|
||||
for(PrinterResolution pr : rSupport) {
|
||||
if (bestRes == null || !pr.lessThanOrEquals(bestRes)) {
|
||||
bestRes = pr;
|
||||
}
|
||||
}
|
||||
if (bestRes != null) {
|
||||
psOptions.density = bestRes.getFeedResolution(psOptions.units.resSyntax);
|
||||
psOptions.crossDensity = bestRes.getCrossFeedResolution(psOptions.units.resSyntax);
|
||||
} else {
|
||||
log.warn("No print densities were found; density: \"{}\" is being ignored", relDPI);
|
||||
}
|
||||
} else if ("draft".equals(relDPI)) {
|
||||
PrinterResolution lowestRes = null;
|
||||
for(PrinterResolution pr : rSupport) {
|
||||
if (lowestRes == null || pr.lessThanOrEquals(lowestRes)) {
|
||||
lowestRes = pr;
|
||||
}
|
||||
}
|
||||
if (lowestRes != null) {
|
||||
psOptions.density = lowestRes.getFeedResolution(psOptions.units.resSyntax);
|
||||
psOptions.crossDensity = lowestRes.getCrossFeedResolution(psOptions.units.resSyntax);
|
||||
} else {
|
||||
log.warn("No print densities were found; density: \"{}\" is being ignored", relDPI);
|
||||
}
|
||||
} else {
|
||||
try { psOptions.density = configOpts.getDouble("density"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "density", configOpts.opt("density")); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!configOpts.isNull("dithering")) {
|
||||
try {
|
||||
if (configOpts.getBoolean("dithering")) {
|
||||
psOptions.dithering = RenderingHints.VALUE_DITHER_ENABLE;
|
||||
} else {
|
||||
psOptions.dithering = RenderingHints.VALUE_DITHER_DISABLE;
|
||||
}
|
||||
}
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "boolean", "dithering", configOpts.opt("dithering")); }
|
||||
}
|
||||
if (!configOpts.isNull("duplex")) {
|
||||
try {
|
||||
if (configOpts.getBoolean("duplex")) {
|
||||
psOptions.duplex = Sides.DUPLEX;
|
||||
}
|
||||
}
|
||||
catch(JSONException e) {
|
||||
//not a boolean, try as a string
|
||||
try {
|
||||
String duplex = configOpts.getString("duplex").toLowerCase(Locale.ENGLISH);
|
||||
if (duplex.matches("^(duplex|(two.sided.)?long(.edge)?)$")) {
|
||||
psOptions.duplex = Sides.DUPLEX;
|
||||
} else if (duplex.matches("^(tumble|(two.sided.)?short(.edge)?)$")) {
|
||||
psOptions.duplex = Sides.TUMBLE;
|
||||
}
|
||||
//else - one sided (default)
|
||||
}
|
||||
catch(JSONException e2) { LoggerUtilities.optionWarn(log, "valid value", "duplex", configOpts.opt("duplex")); }
|
||||
}
|
||||
}
|
||||
if (!configOpts.isNull("interpolation")) {
|
||||
switch(configOpts.optString("interpolation")) {
|
||||
case "bicubic":
|
||||
psOptions.interpolation = RenderingHints.VALUE_INTERPOLATION_BICUBIC; break;
|
||||
case "bilinear":
|
||||
psOptions.interpolation = RenderingHints.VALUE_INTERPOLATION_BILINEAR; break;
|
||||
case "nearest-neighbor":
|
||||
case "nearest":
|
||||
psOptions.interpolation = RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR; break;
|
||||
default:
|
||||
LoggerUtilities.optionWarn(log, "valid value", "interpolation", configOpts.opt("interpolation")); break;
|
||||
}
|
||||
}
|
||||
if (!configOpts.isNull("jobName")) {
|
||||
psOptions.jobName = configOpts.optString("jobName", null);
|
||||
}
|
||||
if (!configOpts.isNull("legacy")) {
|
||||
psOptions.legacy = configOpts.optBoolean("legacy", false);
|
||||
}
|
||||
if (!configOpts.isNull("margins")) {
|
||||
Margins m = new Margins();
|
||||
JSONObject subMargins = configOpts.optJSONObject("margins");
|
||||
if (subMargins != null) {
|
||||
//each individually
|
||||
if (!subMargins.isNull("top")) {
|
||||
try { m.top = subMargins.getDouble("top"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "margins.top", subMargins.opt("top")); }
|
||||
}
|
||||
if (!subMargins.isNull("right")) {
|
||||
try { m.right = subMargins.getDouble("right"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "margins.right", subMargins.opt("right")); }
|
||||
}
|
||||
if (!subMargins.isNull("bottom")) {
|
||||
try { m.bottom = subMargins.getDouble("bottom"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "margins.bottom", subMargins.opt("bottom")); }
|
||||
}
|
||||
if (!subMargins.isNull("left")) {
|
||||
try { m.left = subMargins.getDouble("left"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "margins.left", subMargins.opt("left")); }
|
||||
}
|
||||
} else {
|
||||
try { m.setAll(configOpts.getDouble("margins")); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "margins", configOpts.opt("margins")); }
|
||||
}
|
||||
|
||||
psOptions.margins = m;
|
||||
}
|
||||
if (!configOpts.isNull("orientation")) {
|
||||
try {
|
||||
psOptions.orientation = Orientation.valueOf(configOpts.optString("orientation").replaceAll("-", "_").toUpperCase(Locale.ENGLISH));
|
||||
}
|
||||
catch(IllegalArgumentException e) {
|
||||
LoggerUtilities.optionWarn(log, "valid value", "orientation", configOpts.opt("orientation"));
|
||||
}
|
||||
}
|
||||
if (!configOpts.isNull("paperThickness")) {
|
||||
try { psOptions.paperThickness = configOpts.getDouble("paperThickness"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "paperThickness", configOpts.opt("paperThickness")); }
|
||||
}
|
||||
if (!configOpts.isNull("spool")) {
|
||||
JSONObject spool = configOpts.optJSONObject("spool");
|
||||
if (spool != null) {
|
||||
if (!spool.isNull("size")) {
|
||||
try { psOptions.spoolSize = spool.getInt("size"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "integer", "spool.size", spool.opt("size")); }
|
||||
}
|
||||
} else {
|
||||
LoggerUtilities.optionWarn(log, "JSONObject", "spool", configOpts.opt("spool"));
|
||||
}
|
||||
}
|
||||
if (!configOpts.isNull("printerTray")) {
|
||||
psOptions.printerTray = configOpts.optString("printerTray", null);
|
||||
// Guard empty string value; will break pattern matching
|
||||
if(psOptions.printerTray != null && psOptions.printerTray.trim().equals("")) {
|
||||
psOptions.printerTray = null;
|
||||
}
|
||||
}
|
||||
if (!configOpts.isNull("rasterize")) {
|
||||
try { psOptions.rasterize = configOpts.getBoolean("rasterize"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "boolean", "rasterize", configOpts.opt("rasterize")); }
|
||||
}
|
||||
if (!configOpts.isNull("rotation")) {
|
||||
try { psOptions.rotation = configOpts.getDouble("rotation"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "rotation", configOpts.opt("rotation")); }
|
||||
}
|
||||
if (!configOpts.isNull("scaleContent")) {
|
||||
try { psOptions.scaleContent = configOpts.getBoolean("scaleContent"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "boolean", "scaleContent", configOpts.opt("scaleContent")); }
|
||||
}
|
||||
if (!configOpts.isNull("size")) {
|
||||
Size s = new Size();
|
||||
JSONObject subSize = configOpts.optJSONObject("size");
|
||||
if (subSize != null) {
|
||||
if (!subSize.isNull("width")) {
|
||||
try { s.width = subSize.getDouble("width"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "size.width", subSize.opt("width")); }
|
||||
}
|
||||
if (!subSize.isNull("height")) {
|
||||
try { s.height = subSize.getDouble("height"); }
|
||||
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "size.height", subSize.opt("height")); }
|
||||
}
|
||||
|
||||
if (s.height <= 0 && s.width <= 0) {
|
||||
log.warn("Page size has been set without dimensions, using default");
|
||||
} else {
|
||||
psOptions.size = s;
|
||||
}
|
||||
} else {
|
||||
LoggerUtilities.optionWarn(log, "JSONObject", "size", configOpts.opt("size"));
|
||||
}
|
||||
}
|
||||
|
||||
//grab any useful service defaults
|
||||
PrinterResolution defaultRes = null;
|
||||
if (output.isSetService()) {
|
||||
defaultRes = output.getNativePrinter().getResolution().value();
|
||||
|
||||
if (defaultRes == null) {
|
||||
//printer has no default resolution set, see if it is possible to pull anything
|
||||
List<PrinterResolution> rSupport = output.getNativePrinter().getResolutions();
|
||||
if (rSupport.size() > 0) {
|
||||
defaultRes = rSupport.get(0);
|
||||
log.warn("Default resolution for {} is missing, using fallback: {}", output.getNativePrinter().getName(), defaultRes);
|
||||
} else {
|
||||
log.warn("Default resolution for {} is missing, no fallback available.", output.getNativePrinter().getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (defaultRes != null) {
|
||||
//convert dphi to unit-dependant density ourselves (to keep as double type)
|
||||
defOptions.density = (double)defaultRes.getFeedResolution(1) / psOptions.getUnits().getDPIUnits();
|
||||
} else {
|
||||
try { defOptions.density = configOpts.getDouble("fallbackDensity"); }
|
||||
catch(JSONException e) {
|
||||
LoggerUtilities.optionWarn(log, "double", "fallbackDensity", configOpts.opt("fallbackDensity"));
|
||||
//manually convert default dphi to a density value based on units
|
||||
defOptions.density = 60000d / psOptions.getUnits().getDPIUnits();
|
||||
}
|
||||
}
|
||||
if ((psOptions.isRasterize() || format == PrintingUtilities.Format.IMAGE) && psOptions.getDensity() <= 1) {
|
||||
psOptions.density = defOptions.density;
|
||||
psOptions.crossDensity = defOptions.density;
|
||||
}
|
||||
|
||||
if (output.isSetService()) {
|
||||
try {
|
||||
PrinterJob job = PrinterJob.getPrinterJob();
|
||||
job.setPrintService(output.getPrintService());
|
||||
PageFormat page = job.getPageFormat(null);
|
||||
defOptions.pageSize = new Size(page.getWidth(), page.getHeight());
|
||||
}
|
||||
catch(PrinterException e) {
|
||||
log.warn("Unable to find the default paper size");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public Raw getRawOptions() {
|
||||
return rawOptions;
|
||||
}
|
||||
|
||||
public Pixel getPixelOptions() {
|
||||
return psOptions;
|
||||
}
|
||||
|
||||
public Default getDefaultOptions() { return defOptions; }
|
||||
|
||||
|
||||
// Option groups //
|
||||
|
||||
/** Raw printing options */
|
||||
public class Raw {
|
||||
private boolean forceRaw = false; //Alternate printing for linux systems
|
||||
private String destEncoding = null; //Text encoding / charset
|
||||
private String srcEncoding = null; //Conversion text encoding
|
||||
private String spoolEnd = null; //End of document character(s)
|
||||
private int spoolSize = 1; //Pages per spool
|
||||
private int copies = 1; //Job copies
|
||||
private String jobName = null; //Job name
|
||||
private boolean retainTemp = false; //Retain any temporary files
|
||||
|
||||
|
||||
public boolean isForceRaw() {
|
||||
return forceRaw;
|
||||
}
|
||||
|
||||
public String getDestEncoding() {
|
||||
return destEncoding;
|
||||
}
|
||||
|
||||
public String getSrcEncoding() {
|
||||
return srcEncoding;
|
||||
}
|
||||
|
||||
public String getSpoolEnd() {
|
||||
return spoolEnd;
|
||||
}
|
||||
|
||||
public int getSpoolSize() {
|
||||
return spoolSize;
|
||||
}
|
||||
|
||||
public int getCopies() {
|
||||
return copies;
|
||||
}
|
||||
|
||||
public boolean isRetainTemp() { return retainTemp; }
|
||||
|
||||
public String getJobName(String defaultVal) {
|
||||
return jobName == null || jobName.isEmpty()? defaultVal:jobName;
|
||||
}
|
||||
}
|
||||
|
||||
/** Pixel printing options */
|
||||
public class Pixel {
|
||||
private Bounds bounds = null; //Bounding box rectangle
|
||||
private ColorType colorType = ColorType.COLOR; //Color / black&white
|
||||
private int copies = 1; //Job copies
|
||||
private double crossDensity = 0; //Cross feed density
|
||||
private double density = 0; //Pixel density (DPI or DPMM), feed density if crossDensity is defined
|
||||
private Object dithering = RenderingHints.VALUE_DITHER_DEFAULT; //Image dithering
|
||||
private Sides duplex = Sides.ONE_SIDED; //Multi-siding
|
||||
private Object interpolation = RenderingHints.VALUE_INTERPOLATION_BICUBIC; //Image interpolation
|
||||
private String jobName = null; //Job name
|
||||
private boolean legacy = false; //Legacy printing
|
||||
private Margins margins = new Margins(); //Page margins
|
||||
private Orientation orientation = null; //Page orientation
|
||||
private double paperThickness = -1; //Paper thickness
|
||||
private int spoolSize = 0; //Pages before sending to printer
|
||||
private String printerTray = null; //Printer tray to use
|
||||
private boolean rasterize = true; //Whether documents are rasterized before printing
|
||||
private double rotation = 0; //Image rotation
|
||||
private boolean scaleContent = true; //Adjust paper size for best image fit
|
||||
private Size size = null; //Paper size
|
||||
private Unit units = Unit.INCH; //Units for density, margins, size
|
||||
|
||||
|
||||
public Bounds getBounds() {
|
||||
return bounds;
|
||||
}
|
||||
|
||||
public ColorType getColorType() {
|
||||
return colorType;
|
||||
}
|
||||
|
||||
public int getCopies() {
|
||||
return copies;
|
||||
}
|
||||
|
||||
public double getCrossDensity() {
|
||||
return crossDensity;
|
||||
}
|
||||
|
||||
public double getDensity() {
|
||||
return density;
|
||||
}
|
||||
|
||||
public Object getDithering() {
|
||||
return dithering;
|
||||
}
|
||||
|
||||
public Sides getDuplex() {
|
||||
return duplex;
|
||||
}
|
||||
|
||||
public Object getInterpolation() {
|
||||
return interpolation;
|
||||
}
|
||||
|
||||
public String getJobName(String defaultVal) {
|
||||
return jobName == null || jobName.isEmpty()? defaultVal:jobName;
|
||||
}
|
||||
|
||||
public boolean isLegacy() {
|
||||
return legacy;
|
||||
}
|
||||
|
||||
public Margins getMargins() {
|
||||
return margins;
|
||||
}
|
||||
|
||||
public Orientation getOrientation() {
|
||||
return orientation;
|
||||
}
|
||||
|
||||
public double getPaperThickness() {
|
||||
return paperThickness;
|
||||
}
|
||||
|
||||
public int getSpoolSize() {
|
||||
return spoolSize;
|
||||
}
|
||||
|
||||
public String getPrinterTray() {
|
||||
return printerTray;
|
||||
}
|
||||
|
||||
public boolean isRasterize() {
|
||||
return rasterize;
|
||||
}
|
||||
|
||||
public double getRotation() {
|
||||
return rotation;
|
||||
}
|
||||
|
||||
public boolean isScaleContent() {
|
||||
return scaleContent;
|
||||
}
|
||||
|
||||
public Size getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public Unit getUnits() {
|
||||
return units;
|
||||
}
|
||||
}
|
||||
|
||||
/** PrintService Defaults **/
|
||||
public class Default {
|
||||
private double density;
|
||||
private Size pageSize;
|
||||
|
||||
|
||||
public double getDensity() {
|
||||
return density;
|
||||
}
|
||||
|
||||
public Size getPageSize() {
|
||||
return pageSize;
|
||||
}
|
||||
}
|
||||
|
||||
// Sub options //
|
||||
|
||||
/** Pixel page size options */
|
||||
public class Size {
|
||||
private double width = -1; //Page width
|
||||
private double height = -1; //Page height
|
||||
|
||||
|
||||
public Size() {}
|
||||
|
||||
public Size(double width, double height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
public double getWidth() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public double getHeight() {
|
||||
return height;
|
||||
}
|
||||
}
|
||||
|
||||
/** Pixel page margins options */
|
||||
public class Margins {
|
||||
private double top = 0; //Top page margin
|
||||
private double right = 0; //Right page margin
|
||||
private double bottom = 0; //Bottom page margin
|
||||
private double left = 0; //Left page margin
|
||||
|
||||
private void setAll(double margin) {
|
||||
top = margin;
|
||||
right = margin;
|
||||
bottom = margin;
|
||||
left = margin;
|
||||
}
|
||||
|
||||
|
||||
public double top() {
|
||||
return top;
|
||||
}
|
||||
|
||||
public double right() {
|
||||
return right;
|
||||
}
|
||||
|
||||
public double bottom() {
|
||||
return bottom;
|
||||
}
|
||||
|
||||
public double left() {
|
||||
return left;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bounding box generic rectangle */
|
||||
public class Bounds {
|
||||
private double x;
|
||||
private double y;
|
||||
private double width;
|
||||
private double height;
|
||||
|
||||
public Bounds(double x, double y, double width, double height) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
public double getX() {
|
||||
return x;
|
||||
}
|
||||
|
||||
public double getY() {
|
||||
return y;
|
||||
}
|
||||
|
||||
public double getWidth() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public double getHeight() {
|
||||
return height;
|
||||
}
|
||||
}
|
||||
|
||||
/** Pixel dimension values */
|
||||
public enum Unit {
|
||||
INCH(ResolutionSyntax.DPI, 1.0f, 1.0f, Size2DSyntax.INCH), //1in = 1in
|
||||
CM(ResolutionSyntax.DPCM, .3937f, 2.54f, 10000), //1cm = .3937in ; 1in = 2.54cm
|
||||
MM(ResolutionSyntax.DPCM * 10, .03937f, 25.4f, Size2DSyntax.MM); //1mm = .03937in ; 1in = 25.4mm
|
||||
|
||||
private final float fromInch;
|
||||
private final float toInch; //multiplicand to convert to inches
|
||||
private final int resSyntax;
|
||||
private final int µm;
|
||||
|
||||
Unit(int resSyntax, float toIN, float fromIN, int µm) {
|
||||
toInch = toIN;
|
||||
fromInch = fromIN;
|
||||
this.resSyntax = resSyntax;
|
||||
this.µm = µm;
|
||||
}
|
||||
|
||||
public float toInches() {
|
||||
return toInch;
|
||||
}
|
||||
|
||||
public float as1Inch() {
|
||||
return fromInch;
|
||||
}
|
||||
|
||||
public int getDPIUnits() {
|
||||
return resSyntax;
|
||||
}
|
||||
|
||||
public int getMediaSizeUnits() {
|
||||
return µm;
|
||||
}
|
||||
}
|
||||
|
||||
/** Pixel page orientation option */
|
||||
public enum Orientation {
|
||||
PORTRAIT(OrientationRequested.PORTRAIT, PageFormat.PORTRAIT, 0),
|
||||
REVERSE_PORTRAIT(OrientationRequested.PORTRAIT, PageFormat.PORTRAIT, 180),
|
||||
LANDSCAPE(OrientationRequested.LANDSCAPE, PageFormat.LANDSCAPE, 270),
|
||||
REVERSE_LANDSCAPE(OrientationRequested.REVERSE_LANDSCAPE, PageFormat.REVERSE_LANDSCAPE, 90);
|
||||
|
||||
private final OrientationRequested orientationRequested;
|
||||
private final int orientationFormat;
|
||||
private final int degreesRot;
|
||||
|
||||
Orientation(OrientationRequested orientationRequested, int orientationFormat, int degreesRot) {
|
||||
this.orientationRequested = orientationRequested;
|
||||
this.orientationFormat = orientationFormat;
|
||||
this.degreesRot = degreesRot;
|
||||
}
|
||||
|
||||
|
||||
public OrientationRequested getAsOrientRequested() {
|
||||
return orientationRequested;
|
||||
}
|
||||
|
||||
public int getAsOrientFormat() {
|
||||
return orientationFormat;
|
||||
}
|
||||
|
||||
public int getDegreesRot() {
|
||||
return degreesRot;
|
||||
}
|
||||
}
|
||||
|
||||
/** Pixel page color option */
|
||||
public enum ColorType {
|
||||
COLOR(Chromaticity.COLOR),
|
||||
GREYSCALE(Chromaticity.MONOCHROME),
|
||||
GRAYSCALE(Chromaticity.MONOCHROME),
|
||||
BLACKWHITE(Chromaticity.MONOCHROME),
|
||||
DEFAULT(null);
|
||||
|
||||
private final Chromaticity chromatic;
|
||||
|
||||
ColorType(Chromaticity chromatic) {
|
||||
this.chromatic = chromatic;
|
||||
}
|
||||
|
||||
|
||||
public Chromaticity getAsChromaticity() {
|
||||
return chromatic;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
92
tray/src/qz/printer/PrintOutput.java
Normal file
92
tray/src/qz/printer/PrintOutput.java
Normal file
@@ -0,0 +1,92 @@
|
||||
package qz.printer;
|
||||
|
||||
import org.codehaus.jettison.json.JSONException;
|
||||
import org.codehaus.jettison.json.JSONObject;
|
||||
import qz.printer.info.NativePrinter;
|
||||
import qz.utils.FileUtilities;
|
||||
|
||||
import javax.print.PrintService;
|
||||
import javax.print.attribute.standard.Media;
|
||||
import java.io.File;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
public class PrintOutput {
|
||||
|
||||
private NativePrinter printer = null;
|
||||
|
||||
private File file = null;
|
||||
|
||||
private String host = null;
|
||||
private int port = -1;
|
||||
|
||||
|
||||
public PrintOutput(JSONObject configPrinter) throws JSONException, IllegalArgumentException {
|
||||
if (configPrinter == null) { return; }
|
||||
|
||||
if (configPrinter.has("name")) {
|
||||
printer = PrintServiceMatcher.matchPrinter(configPrinter.getString("name"));
|
||||
if (printer == null) {
|
||||
throw new IllegalArgumentException("Cannot find printer with name \"" + configPrinter.getString("name") + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
if (configPrinter.has("file")) {
|
||||
String filename = configPrinter.getString("file");
|
||||
if (!FileUtilities.isGoodExtension(Paths.get(filename))) {
|
||||
throw new IllegalArgumentException("Writing to file \"" + filename + "\" is denied for security reasons. (Prohibited file extension)");
|
||||
} else if (FileUtilities.isBadPath(filename)) {
|
||||
throw new IllegalArgumentException("Writing to file \"" + filename + "\" is denied for security reasons. (Prohibited directory name)");
|
||||
} else {
|
||||
file = new File(filename);
|
||||
}
|
||||
}
|
||||
|
||||
if (configPrinter.has("host")) {
|
||||
host = configPrinter.getString("host");
|
||||
port = configPrinter.optInt("port", 9100); // default to port 9100 (HP/JetDirect standard) if not provided
|
||||
}
|
||||
|
||||
//at least one method must be set for printing
|
||||
if (!isSetService() && !isSetFile() && !isSetHost()) {
|
||||
throw new IllegalArgumentException("No printer output has been specified");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public boolean isSetService() {
|
||||
return printer != null && printer.getPrintService() != null && !printer.getPrintService().isNull();
|
||||
}
|
||||
|
||||
public PrintService getPrintService() {
|
||||
return printer.getPrintService().value();
|
||||
}
|
||||
|
||||
public NativePrinter getNativePrinter() {
|
||||
return printer;
|
||||
}
|
||||
|
||||
public boolean isSetFile() {
|
||||
return file != null;
|
||||
}
|
||||
|
||||
public File getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
public boolean isSetHost() {
|
||||
return host != null;
|
||||
}
|
||||
|
||||
public String getHost() {
|
||||
return host;
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
return port;
|
||||
}
|
||||
|
||||
public Media[] getSupportedMedia() {
|
||||
return (Media[])getPrintService().getSupportedAttributeValues(Media.class, null, null);
|
||||
}
|
||||
|
||||
}
|
||||
242
tray/src/qz/printer/PrintServiceMatcher.java
Normal file
242
tray/src/qz/printer/PrintServiceMatcher.java
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2016 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.printer;
|
||||
|
||||
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.printer.info.CachedPrintServiceLookup;
|
||||
import qz.printer.info.NativePrinter;
|
||||
import qz.printer.info.NativePrinterMap;
|
||||
import qz.utils.SystemUtilities;
|
||||
|
||||
import javax.print.PrintService;
|
||||
import javax.print.PrintServiceLookup;
|
||||
import javax.print.attribute.ResolutionSyntax;
|
||||
import javax.print.attribute.standard.*;
|
||||
import java.util.*;
|
||||
|
||||
public class PrintServiceMatcher {
|
||||
private static final Logger log = LogManager.getLogger(PrintServiceMatcher.class);
|
||||
|
||||
// PrintService is slow in CUPS, use a cache instead per JDK-7001133
|
||||
// TODO: Include JDK version test for caching when JDK-7001133 is fixed upstream
|
||||
private static final boolean useCache = SystemUtilities.isUnix();
|
||||
|
||||
public static NativePrinterMap getNativePrinterList(boolean silent, boolean withAttributes) {
|
||||
NativePrinterMap printers = NativePrinterMap.getInstance();
|
||||
printers.putAll(true, lookupPrintServices());
|
||||
if (withAttributes) { printers.values().forEach(NativePrinter::getDriverAttributes); }
|
||||
if (!silent) { log.debug("Found {} printers", printers.size()); }
|
||||
return printers;
|
||||
}
|
||||
|
||||
private static PrintService[] lookupPrintServices() {
|
||||
return useCache ? CachedPrintServiceLookup.lookupPrintServices() :
|
||||
PrintServiceLookup.lookupPrintServices(null, null);
|
||||
}
|
||||
|
||||
private static PrintService lookupDefaultPrintService() {
|
||||
return useCache ? CachedPrintServiceLookup.lookupDefaultPrintService() :
|
||||
PrintServiceLookup.lookupDefaultPrintService();
|
||||
}
|
||||
|
||||
public static NativePrinterMap getNativePrinterList(boolean silent) {
|
||||
return getNativePrinterList(silent, false);
|
||||
}
|
||||
|
||||
public static NativePrinterMap getNativePrinterList() {
|
||||
return getNativePrinterList(false);
|
||||
}
|
||||
|
||||
public static NativePrinter getDefaultPrinter() {
|
||||
PrintService defaultService = lookupDefaultPrintService();
|
||||
|
||||
if(defaultService == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
NativePrinterMap printers = NativePrinterMap.getInstance();
|
||||
if (!printers.contains(defaultService)) {
|
||||
printers.putAll(false, defaultService);
|
||||
}
|
||||
|
||||
return printers.get(defaultService);
|
||||
}
|
||||
|
||||
public static String findPrinterName(String query) throws JSONException {
|
||||
NativePrinter printer = PrintServiceMatcher.matchPrinter(query);
|
||||
|
||||
if (printer != null) {
|
||||
return printer.getPrintService().value().getName();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds {@code PrintService} by looking at any matches to {@code printerSearch}.
|
||||
*
|
||||
* @param printerSearch Search query to compare against service names.
|
||||
*/
|
||||
public static NativePrinter matchPrinter(String printerSearch, boolean silent) {
|
||||
NativePrinter exact = null;
|
||||
NativePrinter begins = null;
|
||||
NativePrinter partial = null;
|
||||
|
||||
if (!silent) { log.debug("Searching for PrintService matching {}", printerSearch); }
|
||||
|
||||
// Fix for https://github.com/qzind/tray/issues/931
|
||||
// This is more than an optimization, removal will lead to a regression
|
||||
NativePrinter defaultPrinter = getDefaultPrinter();
|
||||
if (defaultPrinter != null && printerSearch.equals(defaultPrinter.getName())) {
|
||||
if (!silent) { log.debug("Matched default printer, skipping further search"); }
|
||||
return defaultPrinter;
|
||||
}
|
||||
|
||||
printerSearch = printerSearch.toLowerCase(Locale.ENGLISH);
|
||||
|
||||
// Search services for matches
|
||||
for(NativePrinter printer : getNativePrinterList(silent).values()) {
|
||||
if (printer.getName() == null) {
|
||||
continue;
|
||||
}
|
||||
String printerName = printer.getName().toLowerCase(Locale.ENGLISH);
|
||||
if (printerName.equals(printerSearch)) {
|
||||
exact = printer;
|
||||
break;
|
||||
}
|
||||
if (printerName.startsWith(printerSearch)) {
|
||||
begins = printer;
|
||||
continue;
|
||||
}
|
||||
if (printerName.contains(printerSearch)) {
|
||||
partial = printer;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (SystemUtilities.isMac()) {
|
||||
// 1.9 compat: fallback for old style names
|
||||
PrinterName name = printer.getLegacyName();
|
||||
if (name == null || name.getValue() == null) { continue; }
|
||||
printerName = name.getValue().toLowerCase(Locale.ENGLISH);
|
||||
if (printerName.equals(printerSearch)) {
|
||||
exact = printer;
|
||||
continue;
|
||||
}
|
||||
if (printerName.startsWith(printerSearch)) {
|
||||
begins = printer;
|
||||
continue;
|
||||
}
|
||||
if (printerName.contains(printerSearch)) {
|
||||
partial = printer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return closest match
|
||||
NativePrinter use = null;
|
||||
if (exact != null) {
|
||||
use = exact;
|
||||
} else if (begins != null) {
|
||||
use = begins;
|
||||
} else if (partial != null) {
|
||||
use = partial;
|
||||
}
|
||||
|
||||
if (use != null) {
|
||||
if(!silent) log.debug("Found match: {}", use.getPrintService().value().getName());
|
||||
} else {
|
||||
log.warn("Printer not found: {}", printerSearch);
|
||||
}
|
||||
|
||||
return use;
|
||||
}
|
||||
|
||||
public static NativePrinter matchPrinter(String printerSearch) {
|
||||
return matchPrinter(printerSearch, false);
|
||||
}
|
||||
|
||||
public static JSONArray getPrintersJSON(boolean includeDetails) throws JSONException {
|
||||
JSONArray list = new JSONArray();
|
||||
|
||||
PrintService defaultService = lookupDefaultPrintService();
|
||||
|
||||
boolean mediaTrayCrawled = false;
|
||||
|
||||
for(NativePrinter printer : getNativePrinterList().values()) {
|
||||
PrintService ps = printer.getPrintService().value();
|
||||
JSONObject jsonService = new JSONObject();
|
||||
jsonService.put("name", ps.getName());
|
||||
|
||||
if (includeDetails) {
|
||||
jsonService.put("driver", printer.getDriver().value());
|
||||
jsonService.put("connection", printer.getConnection());
|
||||
jsonService.put("default", ps == defaultService);
|
||||
|
||||
if (!mediaTrayCrawled) {
|
||||
log.info("Gathering printer MediaTray information...");
|
||||
mediaTrayCrawled = true;
|
||||
}
|
||||
|
||||
HashSet<String> uniqueSizes = new HashSet<>(); // prevents duplicates
|
||||
JSONArray trays = new JSONArray();
|
||||
JSONArray sizes = new JSONArray();
|
||||
|
||||
for(Media m : (Media[])ps.getSupportedAttributeValues(Media.class, null, null)) {
|
||||
if (m instanceof MediaTray) { trays.put(m.toString()); }
|
||||
if (m instanceof MediaSizeName) {
|
||||
if(uniqueSizes.add(m.toString())) {
|
||||
MediaSize mediaSize = MediaSize.getMediaSizeForName((MediaSizeName)m);
|
||||
if(mediaSize == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
JSONObject size = new JSONObject();
|
||||
size.put("name", m.toString());
|
||||
|
||||
JSONObject in = new JSONObject();
|
||||
in.put("width", mediaSize.getX(MediaPrintableArea.INCH));
|
||||
in.put("height", mediaSize.getY(MediaPrintableArea.INCH));
|
||||
size.put("in", in);
|
||||
|
||||
JSONObject mm = new JSONObject();
|
||||
mm.put("width", mediaSize.getX(MediaPrintableArea.MM));
|
||||
mm.put("height", mediaSize.getY(MediaPrintableArea.MM));
|
||||
size.put("mm", mm);
|
||||
|
||||
sizes.put(size);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if(trays.length() > 0) {
|
||||
jsonService.put("trays", trays);
|
||||
}
|
||||
if(sizes.length() > 0) {
|
||||
jsonService.put("sizes", sizes);
|
||||
}
|
||||
|
||||
PrinterResolution res = printer.getResolution().value();
|
||||
int density = -1; if (res != null) { density = res.getFeedResolution(ResolutionSyntax.DPI); }
|
||||
jsonService.put("density", density);
|
||||
}
|
||||
|
||||
list.put(jsonService);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
}
|
||||
97
tray/src/qz/printer/action/PrintDirect.java
Normal file
97
tray/src/qz/printer/action/PrintDirect.java
Normal file
@@ -0,0 +1,97 @@
|
||||
package qz.printer.action;
|
||||
|
||||
import org.apache.commons.codec.binary.Base64InputStream;
|
||||
import org.codehaus.jettison.json.JSONArray;
|
||||
import org.codehaus.jettison.json.JSONException;
|
||||
import org.codehaus.jettison.json.JSONObject;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.common.Constants;
|
||||
import qz.printer.PrintOptions;
|
||||
import qz.printer.PrintOutput;
|
||||
import qz.utils.PrintingUtilities;
|
||||
|
||||
import javax.print.DocFlavor;
|
||||
import javax.print.DocPrintJob;
|
||||
import javax.print.PrintException;
|
||||
import javax.print.SimpleDoc;
|
||||
import javax.print.attribute.HashPrintRequestAttributeSet;
|
||||
import javax.print.attribute.PrintRequestAttributeSet;
|
||||
import javax.print.attribute.standard.JobName;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Locale;
|
||||
|
||||
public class PrintDirect extends PrintRaw {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(PrintDirect.class);
|
||||
|
||||
private ArrayList<String> prints = new ArrayList<>();
|
||||
private ArrayList<PrintingUtilities.Flavor> flavors = new ArrayList<>();
|
||||
|
||||
|
||||
@Override
|
||||
public PrintingUtilities.Format getFormat() {
|
||||
return PrintingUtilities.Format.DIRECT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void parseData(JSONArray printData, PrintOptions options) throws JSONException, UnsupportedOperationException {
|
||||
for(int i = 0; i < printData.length(); i++) {
|
||||
JSONObject data = printData.optJSONObject(i);
|
||||
if (data == null) { continue; }
|
||||
|
||||
prints.add(data.getString("data"));
|
||||
flavors.add(PrintingUtilities.Flavor.parse(data, PrintingUtilities.Flavor.PLAIN));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void print(PrintOutput output, PrintOptions options) throws PrintException {
|
||||
PrintRequestAttributeSet attributes = new HashPrintRequestAttributeSet();
|
||||
attributes.add(new JobName(options.getRawOptions().getJobName(Constants.RAW_PRINT), Locale.getDefault()));
|
||||
|
||||
for(int i = 0; i < prints.size(); i++) {
|
||||
DocPrintJob printJob = output.getPrintService().createPrintJob();
|
||||
InputStream stream = null;
|
||||
|
||||
try {
|
||||
switch(flavors.get(i)) {
|
||||
case BASE64:
|
||||
stream = new Base64InputStream(new ByteArrayInputStream(prints.get(i).getBytes("UTF-8")));
|
||||
break;
|
||||
case FILE:
|
||||
stream = new DataInputStream(new URL(prints.get(i)).openStream());
|
||||
break;
|
||||
case PLAIN:
|
||||
default:
|
||||
stream = new ByteArrayInputStream(prints.get(i).getBytes("UTF-8"));
|
||||
break;
|
||||
}
|
||||
|
||||
SimpleDoc doc = new SimpleDoc(stream, DocFlavor.INPUT_STREAM.AUTOSENSE, null);
|
||||
|
||||
waitForPrint(printJob, doc, attributes);
|
||||
}
|
||||
catch(IOException e) {
|
||||
throw new PrintException(e);
|
||||
}
|
||||
finally {
|
||||
if (stream != null) {
|
||||
try { stream.close(); } catch(Exception ignore) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup() {
|
||||
prints.clear();
|
||||
flavors.clear();
|
||||
}
|
||||
|
||||
}
|
||||
414
tray/src/qz/printer/action/PrintHTML.java
Normal file
414
tray/src/qz/printer/action/PrintHTML.java
Normal file
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* @author Tres Finocchiaro
|
||||
*
|
||||
* Copyright (C) 2016 Tres Finocchiaro, QZ Industries, LLC
|
||||
*
|
||||
* LGPL 2.1 This is free software. This software and source code are released under
|
||||
* the "LGPL 2.1 License". A copy of this license should be distributed with
|
||||
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
|
||||
*/
|
||||
|
||||
package qz.printer.action;
|
||||
|
||||
import com.sun.javafx.print.PrintHelper;
|
||||
import com.sun.javafx.print.Units;
|
||||
import javafx.print.*;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.codehaus.jettison.json.JSONArray;
|
||||
import org.codehaus.jettison.json.JSONException;
|
||||
import org.codehaus.jettison.json.JSONObject;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import qz.common.Constants;
|
||||
import qz.printer.PrintOptions;
|
||||
import qz.printer.PrintOutput;
|
||||
import qz.printer.action.html.WebApp;
|
||||
import qz.printer.action.html.WebAppModel;
|
||||
import qz.utils.PrintingUtilities;
|
||||
|
||||
import javax.print.attribute.PrintRequestAttributeSet;
|
||||
import javax.print.attribute.standard.Copies;
|
||||
import javax.print.attribute.standard.CopiesSupported;
|
||||
import javax.print.attribute.standard.Sides;
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.print.PageFormat;
|
||||
import java.awt.print.PrinterException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
public class PrintHTML extends PrintImage implements PrintProcessor {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(PrintHTML.class);
|
||||
|
||||
private List<WebAppModel> models;
|
||||
|
||||
private JLabel legacyLabel = null;
|
||||
|
||||
|
||||
public PrintHTML() {
|
||||
super();
|
||||
models = new ArrayList<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PrintingUtilities.Format getFormat() {
|
||||
return PrintingUtilities.Format.HTML;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void parseData(JSONArray printData, PrintOptions options) throws JSONException, UnsupportedOperationException {
|
||||
try {
|
||||
PrintOptions.Pixel pxlOpts = options.getPixelOptions();
|
||||
if (!pxlOpts.isLegacy()) {
|
||||
WebApp.initialize();
|
||||
}
|
||||
|
||||
for(int i = 0; i < printData.length(); i++) {
|
||||
JSONObject data = printData.getJSONObject(i);
|
||||
|
||||
PrintingUtilities.Flavor flavor = PrintingUtilities.Flavor.parse(data, PrintingUtilities.Flavor.FILE);
|
||||
|
||||
String source;
|
||||
switch(flavor) {
|
||||
case FILE:
|
||||
case PLAIN:
|
||||
// We'll toggle between 'plain' and 'file' when we construct WebAppModel
|
||||
source = data.getString("data");
|
||||
break;
|
||||
default:
|
||||
source = new String(flavor.read(data.getString("data")), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
double pageZoom = (pxlOpts.getDensity() * pxlOpts.getUnits().as1Inch()) / 72.0;
|
||||
if (pageZoom <= 1) { pageZoom = 1; }
|
||||
|
||||
double pageWidth = 0;
|
||||
double pageHeight = 0;
|
||||
double convertFactor = (72.0 / pxlOpts.getUnits().as1Inch());
|
||||
|
||||
boolean renderFromHeight = Arrays.asList(PrintOptions.Orientation.LANDSCAPE,
|
||||
PrintOptions.Orientation.REVERSE_LANDSCAPE).contains(pxlOpts.getOrientation());
|
||||
|
||||
if (pxlOpts.getSize() != null) {
|
||||
if (!renderFromHeight) {
|
||||
pageWidth = pxlOpts.getSize().getWidth() * convertFactor;
|
||||
} else {
|
||||
pageWidth = pxlOpts.getSize().getHeight() * convertFactor;
|
||||
}
|
||||
} else if (options.getDefaultOptions().getPageSize() != null) {
|
||||
if (!renderFromHeight) {
|
||||
pageWidth = options.getDefaultOptions().getPageSize().getWidth();
|
||||
} else {
|
||||
pageWidth = options.getDefaultOptions().getPageSize().getHeight();
|
||||
}
|
||||
}
|
||||
|
||||
if (pxlOpts.getMargins() != null) {
|
||||
PrintOptions.Margins margins = pxlOpts.getMargins();
|
||||
if (!renderFromHeight || pxlOpts.isRasterize()) {
|
||||
pageWidth -= (margins.left() + margins.right()) * convertFactor;
|
||||
} else {
|
||||
pageWidth -= (margins.top() + margins.bottom()) * convertFactor; //due to vector margin matching
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.isNull("options")) {
|
||||
JSONObject dataOpt = data.getJSONObject("options");
|
||||
|
||||
if (!dataOpt.isNull("pageWidth") && dataOpt.optDouble("pageWidth") > 0) {
|
||||
pageWidth = dataOpt.optDouble("pageWidth") * convertFactor;
|
||||
}
|
||||
if (!dataOpt.isNull("pageHeight") && dataOpt.optDouble("pageHeight") > 0) {
|
||||
pageHeight = dataOpt.optDouble("pageHeight") * convertFactor;
|
||||
}
|
||||
}
|
||||
|
||||
models.add(new WebAppModel(source, (flavor != PrintingUtilities.Flavor.FILE), pageWidth, pageHeight, pxlOpts.isScaleContent(), pageZoom));
|
||||
}
|
||||
|
||||
log.debug("Parsed {} html records", models.size());
|
||||
}
|
||||
catch(IOException e) {
|
||||
throw new UnsupportedOperationException("Unable to start JavaFX service", e);
|
||||
}
|
||||
catch(NoClassDefFoundError e) {
|
||||
throw new UnsupportedOperationException("JavaFX libraries not found", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void print(PrintOutput output, PrintOptions options) throws PrinterException {
|
||||
if (options.getPixelOptions().isLegacy()) {
|
||||
printLegacy(output, options);
|
||||
} else if (options.getPixelOptions().isRasterize()) {
|
||||
//grab a snapshot of the pages for PrintImage instead of printing directly
|
||||
for(WebAppModel model : models) {
|
||||
try { images.add(WebApp.raster(model)); }
|
||||
catch(Throwable t) {
|
||||
if (model.getZoom() > 1 && t instanceof IllegalArgumentException) {
|
||||
//probably a unrecognized image loader error, try at default zoom
|
||||
try {
|
||||
log.warn("Capture failed with increased zoom, attempting with default value");
|
||||
model.setZoom(1);
|
||||
images.add(WebApp.raster(model));
|
||||
}
|
||||
catch(Throwable tt) {
|
||||
throw new PrinterException(tt.getMessage());
|
||||
}
|
||||
} else {
|
||||
throw new PrinterException(t.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
super.print(output, options);
|
||||
} else {
|
||||
Printer fxPrinter = null;
|
||||
for(Printer p : Printer.getAllPrinters()) {
|
||||
if (p.getName().equals(output.getPrintService().getName())) {
|
||||
fxPrinter = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (fxPrinter == null) {
|
||||
throw new PrinterException("Cannot find printer under the JavaFX libraries");
|
||||
}
|
||||
|
||||
PrinterJob job = PrinterJob.createPrinterJob(fxPrinter);
|
||||
|
||||
|
||||
// apply option settings
|
||||
PrintOptions.Pixel pxlOpts = options.getPixelOptions();
|
||||
JobSettings settings = job.getJobSettings();
|
||||
settings.setJobName(pxlOpts.getJobName(Constants.HTML_PRINT));
|
||||
settings.setPrintQuality(PrintQuality.HIGH);
|
||||
|
||||
// If colortype is default, leave printColor blank. The system's printer settings will be used instead.
|
||||
if (pxlOpts.getColorType() != PrintOptions.ColorType.DEFAULT) {
|
||||
settings.setPrintColor(getColor(pxlOpts));
|
||||
}
|
||||
if (pxlOpts.getDuplex() == Sides.DUPLEX || pxlOpts.getDuplex() == Sides.TWO_SIDED_LONG_EDGE) {
|
||||
settings.setPrintSides(PrintSides.DUPLEX);
|
||||
}
|
||||
if (pxlOpts.getDuplex() == Sides.TUMBLE || pxlOpts.getDuplex() == Sides.TWO_SIDED_SHORT_EDGE) {
|
||||
settings.setPrintSides(PrintSides.TUMBLE);
|
||||
}
|
||||
if (pxlOpts.getPrinterTray() != null) {
|
||||
PaperSource tray = findFXTray(fxPrinter.getPrinterAttributes().getSupportedPaperSources(), pxlOpts.getPrinterTray());
|
||||
if (tray != null) {
|
||||
settings.setPaperSource(tray);
|
||||
}
|
||||
}
|
||||
|
||||
if (pxlOpts.getDensity() > 0) {
|
||||
settings.setPrintResolution(PrintHelper.createPrintResolution((int)pxlOpts.getDensity(), (int)pxlOpts.getDensity()));
|
||||
}
|
||||
|
||||
Paper paper;
|
||||
if (pxlOpts.getSize() != null && pxlOpts.getSize().getWidth() > 0 && pxlOpts.getSize().getHeight() > 0) {
|
||||
double convert = 1;
|
||||
Units units = getUnits(pxlOpts);
|
||||
if (units == null) {
|
||||
convert = 10; //need to adjust from cm to mm only for DPCM sizes
|
||||
units = Units.MM;
|
||||
}
|
||||
paper = PrintHelper.createPaper("Custom", pxlOpts.getSize().getWidth() * convert, pxlOpts.getSize().getHeight() * convert, units);
|
||||
} else {
|
||||
PrintOptions.Size paperSize = options.getDefaultOptions().getPageSize();
|
||||
paper = PrintHelper.createPaper("Default", paperSize.getWidth(), paperSize.getHeight(), Units.POINT);
|
||||
}
|
||||
|
||||
PageOrientation orient = fxPrinter.getPrinterAttributes().getDefaultPageOrientation();
|
||||
if (pxlOpts.getOrientation() != null) {
|
||||
orient = getOrientation(pxlOpts);
|
||||
}
|
||||
|
||||
try {
|
||||
PageLayout layout;
|
||||
PrintOptions.Margins m = pxlOpts.getMargins();
|
||||
if (m != null) {
|
||||
//force access to the page layout constructor as the adjusted margins on small sizes are wildly inaccurate
|
||||
Constructor<PageLayout> plCon = PageLayout.class.getDeclaredConstructor(Paper.class, PageOrientation.class, double.class, double.class, double.class, double.class);
|
||||
plCon.setAccessible(true);
|
||||
|
||||
//margins defined as pnt (1/72nds)
|
||||
double asPnt = pxlOpts.getUnits().toInches() * 72;
|
||||
if (orient == PageOrientation.PORTRAIT || orient == PageOrientation.REVERSE_PORTRAIT) {
|
||||
layout = plCon.newInstance(paper, orient, m.left() * asPnt, m.right() * asPnt, m.top() * asPnt, m.bottom() * asPnt);
|
||||
} else {
|
||||
//rotate margins to match raster prints
|
||||
layout = plCon.newInstance(paper, orient, m.top() * asPnt, m.bottom() * asPnt, m.right() * asPnt, m.left() * asPnt);
|
||||
}
|
||||
} else {
|
||||
//if margins are not provided, use default paper margins
|
||||
PageLayout valid = fxPrinter.getDefaultPageLayout();
|
||||
layout = fxPrinter.createPageLayout(paper, orient, valid.getLeftMargin(), valid.getRightMargin(), valid.getTopMargin(), valid.getBottomMargin());
|
||||
}
|
||||
|
||||
//force our layout as the default to avoid default-margin exceptions on small paper sizes
|
||||
Field field = fxPrinter.getClass().getDeclaredField("defPageLayout");
|
||||
field.setAccessible(true);
|
||||
field.set(fxPrinter, layout);
|
||||
|
||||
settings.setPageLayout(layout);
|
||||
}
|
||||
catch(Exception e) {
|
||||
log.error("Failed to set custom layout", e);
|
||||
}
|
||||
|
||||
settings.setCopies(pxlOpts.getCopies());
|
||||
log.trace("{}", settings.toString());
|
||||
|
||||
//javaFX lies about this value, so pull from original print service
|
||||
CopiesSupported cSupport = (CopiesSupported)output.getPrintService()
|
||||
.getSupportedAttributeValues(Copies.class, output.getPrintService().getSupportedDocFlavors()[0], null);
|
||||
|
||||
try {
|
||||
if (cSupport != null && cSupport.contains(pxlOpts.getCopies())) {
|
||||
for(WebAppModel model : models) {
|
||||
WebApp.print(job, model);
|
||||
}
|
||||
} else {
|
||||
settings.setCopies(1); //manually handle copies if they are not supported
|
||||
for(int i = 0; i < pxlOpts.getCopies(); i++) {
|
||||
for(WebAppModel model : models) {
|
||||
WebApp.print(job, model);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Throwable t) {
|
||||
job.cancelJob();
|
||||
throw new PrinterException(t.getMessage());
|
||||
}
|
||||
|
||||
//send pending prints
|
||||
job.endJob();
|
||||
}
|
||||
}
|
||||
|
||||
private void printLegacy(PrintOutput output, PrintOptions options) throws PrinterException {
|
||||
PrintOptions.Pixel pxlOpts = options.getPixelOptions();
|
||||
|
||||
java.awt.print.PrinterJob job = java.awt.print.PrinterJob.getPrinterJob();
|
||||
job.setPrintService(output.getPrintService());
|
||||
PageFormat page = job.getPageFormat(null);
|
||||
|
||||
PrintRequestAttributeSet attributes = applyDefaultSettings(pxlOpts, page, output.getSupportedMedia());
|
||||
|
||||
//setup swing ui
|
||||
JFrame legacyFrame = new JFrame(pxlOpts.getJobName(Constants.HTML_PRINT));
|
||||
legacyFrame.setUndecorated(true);
|
||||
legacyFrame.setLayout(new FlowLayout());
|
||||
legacyFrame.setExtendedState(Frame.ICONIFIED);
|
||||
|
||||
legacyLabel = new JLabel();
|
||||
legacyLabel.setOpaque(true);
|
||||
legacyLabel.setBackground(Color.WHITE);
|
||||
legacyLabel.setBorder(null);
|
||||
legacyLabel.setDoubleBuffered(false);
|
||||
|
||||
legacyFrame.add(legacyLabel);
|
||||
|
||||
try {
|
||||
for(WebAppModel model : models) {
|
||||
if (model.isPlainText()) {
|
||||
legacyLabel.setText(cleanHtmlContent(model.getSource()));
|
||||
} else {
|
||||
try(InputStream fis = new URL(model.getSource()).openStream()) {
|
||||
String webPage = cleanHtmlContent(IOUtils.toString(fis, "UTF-8"));
|
||||
legacyLabel.setText(webPage);
|
||||
}
|
||||
}
|
||||
|
||||
legacyFrame.pack();
|
||||
legacyFrame.setVisible(true);
|
||||
|
||||
job.setPrintable(this);
|
||||
printCopies(output, pxlOpts, job, attributes);
|
||||
}
|
||||
}
|
||||
catch(Exception e) {
|
||||
throw new PrinterException(e.getMessage());
|
||||
}
|
||||
finally {
|
||||
legacyFrame.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private String cleanHtmlContent(String html) {
|
||||
return html.replaceAll("^[\\s\\S]*<(HTML|html)\\b.*?>", "<html>");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int print(Graphics graphics, PageFormat pageFormat, int pageIndex) throws PrinterException {
|
||||
if (legacyLabel == null) {
|
||||
return super.print(graphics, pageFormat, pageIndex);
|
||||
} else {
|
||||
if (graphics == null) { throw new PrinterException("No graphics specified"); }
|
||||
if (pageFormat == null) { throw new PrinterException("No page format specified"); }
|
||||
|
||||
if (pageIndex + 1 > models.size()) {
|
||||
return NO_SUCH_PAGE;
|
||||
}
|
||||
log.trace("Requested page {} for printing", pageIndex);
|
||||
|
||||
Graphics2D graphics2D = (Graphics2D)graphics;
|
||||
graphics2D.setRenderingHints(buildRenderingHints(dithering, interpolation));
|
||||
graphics2D.translate(pageFormat.getImageableX(), pageFormat.getImageableY());
|
||||
graphics2D.scale(pageFormat.getImageableWidth() / pageFormat.getWidth(), pageFormat.getImageableHeight() / pageFormat.getHeight());
|
||||
legacyLabel.paint(graphics2D);
|
||||
|
||||
return PAGE_EXISTS;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup() {
|
||||
super.cleanup();
|
||||
|
||||
models.clear();
|
||||
legacyLabel = null;
|
||||
}
|
||||
|
||||
public static Units getUnits(PrintOptions.Pixel opts) {
|
||||
switch(opts.getUnits()) {
|
||||
case INCH:
|
||||
return Units.INCH;
|
||||
case MM:
|
||||
return Units.MM;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static PageOrientation getOrientation(PrintOptions.Pixel opts) {
|
||||
switch(opts.getOrientation()) {
|
||||
case LANDSCAPE:
|
||||
return PageOrientation.LANDSCAPE;
|
||||
case REVERSE_LANDSCAPE:
|
||||
return PageOrientation.REVERSE_LANDSCAPE;
|
||||
case REVERSE_PORTRAIT:
|
||||
return PageOrientation.REVERSE_PORTRAIT;
|
||||
default:
|
||||
return PageOrientation.PORTRAIT;
|
||||
}
|
||||
}
|
||||
|
||||
public static PrintColor getColor(PrintOptions.Pixel opts) {
|
||||
switch(opts.getColorType()) {
|
||||
case COLOR:
|
||||
return PrintColor.COLOR;
|
||||
default:
|
||||
return PrintColor.MONOCHROME;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user