diff --git a/ant/windows/installer.xml b/ant/windows/installer.xml
index e51bf0beb..fa4c72285 100644
--- a/ant/windows/installer.xml
+++ b/ant/windows/installer.xml
@@ -1,10 +1,11 @@
+
-
+
@@ -175,4 +176,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Downloading nssm ${nssm.url}
+ Temporarily saving nssm to ${nssm.zip}
+
+
+
+
+
+
+
+
+ Copying ${nssm.subdir}/nssm.exe
+
+
+
+
+
+
+
+
diff --git a/ant/windows/windows.properties b/ant/windows/windows.properties
new file mode 100644
index 000000000..587be4ce1
--- /dev/null
+++ b/ant/windows/windows.properties
@@ -0,0 +1 @@
+nssm.url=https://nssm.cc/ci/nssm-2.24-101-g897c7ad.zip
\ No newline at end of file
diff --git a/src/qz/common/Constants.java b/src/qz/common/Constants.java
index 3284cbc0a..e5d51f2e8 100644
--- a/src/qz/common/Constants.java
+++ b/src/qz/common/Constants.java
@@ -34,6 +34,7 @@ public class Constants {
public static final int BORDER_PADDING = 10;
public static final String ABOUT_TITLE = "QZ Tray";
+ public static final String ABOUT_DESCRIPTION = "Print and communicate with devices from a web browser";
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";
diff --git a/src/qz/installer/Installer.java b/src/qz/installer/Installer.java
index e6a9bacf9..f18a438d1 100644
--- a/src/qz/installer/Installer.java
+++ b/src/qz/installer/Installer.java
@@ -49,7 +49,9 @@ public enum PrivilegeLevel {
public abstract Installer addAppLauncher();
public abstract Installer addStartupEntry();
public abstract Installer addSystemSettings();
+ public abstract Installer addServiceRegistration(String user);
public abstract Installer removeSystemSettings();
+ public abstract Installer removeServiceRegistration();
public abstract void spawn(List args) throws Exception;
public abstract void setDestination(String destination);
@@ -93,7 +95,8 @@ public static boolean preinstall() {
public static void install() throws Exception {
getInstance();
log.info("Installing to {}", instance.getDestination());
- instance.removeLibs()
+ instance.removeServiceRegistration()
+ .removeLibs()
.deployApp()
.removeLegacyStartup()
.removeLegacyFiles()
@@ -110,6 +113,7 @@ public static void uninstall() {
log.info("Uninstalling from {}", instance.getDestination());
instance.removeSharedDirectory()
.removeSystemSettings()
+ .removeServiceRegistration()
.removeCerts();
}
@@ -177,8 +181,8 @@ public Installer removeLegacyFiles() {
// QZ Tray 2.0 files
dirs.add("demo/js/3rdparty");
- dirs.add("utils");
dirs.add("auth");
+ files.add("utils/windows-cleanup.js");
files.add("demo/js/qz-websocket.js");
files.add("windows-icon.ico");
diff --git a/src/qz/installer/LinuxInstaller.java b/src/qz/installer/LinuxInstaller.java
index c529b3c13..d2b898157 100644
--- a/src/qz/installer/LinuxInstaller.java
+++ b/src/qz/installer/LinuxInstaller.java
@@ -368,4 +368,14 @@ private static HashMap getUserEnv(String matchingUser) {
return env;
}
+ @Override
+ public Installer addServiceRegistration(String user) {
+ throw new UnsupportedOperationException("This feature is not yet supported on Linux");
+ }
+
+ @Override
+ public Installer removeServiceRegistration() {
+ return this; // no-op
+ }
+
}
diff --git a/src/qz/installer/MacInstaller.java b/src/qz/installer/MacInstaller.java
index 73b029d2a..496b7992a 100644
--- a/src/qz/installer/MacInstaller.java
+++ b/src/qz/installer/MacInstaller.java
@@ -124,4 +124,14 @@ public void spawn(List args) throws Exception {
Runtime.getRuntime().exec(args.toArray(new String[args.size()]));
}
}
+
+ @Override
+ public Installer addServiceRegistration(String user) {
+ throw new UnsupportedOperationException("This feature is not yet supported on macOS");
+ }
+
+ @Override
+ public Installer removeServiceRegistration() {
+ return this; // no-op
+ }
}
diff --git a/src/qz/installer/WindowsInstaller.java b/src/qz/installer/WindowsInstaller.java
index 87645882a..3c72bc59b 100644
--- a/src/qz/installer/WindowsInstaller.java
+++ b/src/qz/installer/WindowsInstaller.java
@@ -18,9 +18,7 @@
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.utils.*;
import qz.ws.PrintSocketServer;
import javax.swing.*;
@@ -204,4 +202,78 @@ public void spawn(List args) throws Exception {
}
ShellUtilities.execute(args.toArray(new String[args.size()]));
}
+
+ @Override
+ public Installer addServiceRegistration(String user) {
+ log.warn("Registering system service: {}", PROPS_FILE);
+ if(!SystemUtilities.isAdmin()) {
+ throw new UnsupportedOperationException("Installing a service requires elevation");
+ }
+
+ if(WindowsUtilities.serviceExists(PROPS_FILE)) {
+ log.warn("System service is already registered, removing.");
+ removeServiceRegistration();
+ }
+
+ Path nssm = SystemUtilities.getJarParentPath().resolve("utils/nssm.exe");
+ Path qz = SystemUtilities.getJarParentPath().resolve(PROPS_FILE + ".exe");
+ String servicePath = String.format("\"" + qz.toString() + "\" %s %s %s",
+ ArgValue.WAIT.getMatches()[0],
+ ArgValue.STEAL.getMatches()[0],
+ ArgValue.HEADLESS.getMatches()[0]);
+
+ // Install the service
+ if(ShellUtilities.execute(nssm.toString(), "install", PROPS_FILE,
+ qz.toString(),
+ ArgValue.WAIT.getMatches()[0],
+ ArgValue.STEAL.getMatches()[0],
+ ArgValue.HEADLESS.getMatches()[0])) {
+ ShellUtilities.execute(nssm.toString(), "set", PROPS_FILE, "DisplayName", ABOUT_TITLE);
+ ShellUtilities.execute(nssm.toString(), "set", PROPS_FILE, "Description", ABOUT_DESCRIPTION);
+ ShellUtilities.execute(nssm.toString(), "set", PROPS_FILE, "DependOnService", "Spooler");
+ log.info("Successfully registered system service: {}", PROPS_FILE);
+ if(user != null && !user.trim().isEmpty()) {
+ log.info("Setting service to run as {}", user);
+ if(!ShellUtilities.execute(nssm.toString(), "set", "ObjectName", user)) {
+ log.warn("Could not set service to run as {}, please configure manually.", user);
+ }
+ }
+ // Kill all running instances
+ TaskKiller.killAll();
+ // Instruct autostart to be ignored
+ FileUtilities.disableGlobalAutoStart();
+ log.info("Starting system service: {}", PROPS_FILE);
+ if(WindowsUtilities.startService(PROPS_FILE)) {
+ log.info("System system service started successfully.", PROPS_FILE);
+ return this;
+ }
+ }
+ throw new UnsupportedOperationException("An error occurred installing the service");
+ }
+
+ @Override
+ public Installer removeServiceRegistration() {
+ log.info("Removing system service: {}", PROPS_FILE);
+ if(!SystemUtilities.isAdmin()) {
+ throw new UnsupportedOperationException("Removing a service requires elevation");
+ }
+
+ if(WindowsUtilities.serviceExists(PROPS_FILE)) {
+ WindowsUtilities.stopService(PROPS_FILE);
+ Path nssm = SystemUtilities.getJarParentPath().resolve("utils/nssm.exe");
+ if(ShellUtilities.execute(nssm.toString(), "remove", PROPS_FILE, "confirm")) {
+ // Old tutorials used "QZ Tray" as the service name
+ ShellUtilities.execute(nssm.toString(), "remove", ABOUT_TITLE, "confirm");
+ // Restore default autostart settings by deleting the preference file
+ FileUtils.deleteQuietly(FileUtilities.SHARED_DIR.resolve(AUTOSTART_FILE).toFile());
+ log.info("System service successfully removed: {}", PROPS_FILE);
+ } else {
+ log.error("An error occurred removing system service: {}", PROPS_FILE);
+ }
+ } else {
+ log.info("System service was not found, skipping.");
+ }
+
+ return this;
+ }
}
diff --git a/src/qz/utils/ArgParser.java b/src/qz/utils/ArgParser.java
index b0b58790f..34e137c43 100644
--- a/src/qz/utils/ArgParser.java
+++ b/src/qz/utils/ArgParser.java
@@ -195,6 +195,12 @@ public ExitStatus processInstallerArgs(ArgValue argValue, List args) {
case UNINSTALL:
Installer.uninstall();
return SUCCESS;
+ case SERVICE:
+ if(hasFlag(REMOVE)) {
+ Installer.getInstance().removeServiceRegistration();
+ } else {
+ Installer.getInstance().addServiceRegistration(valueOf(RUNAS));
+ }
case SPAWN:
args.remove(0); // first argument is "spawn", remove it
Installer.getInstance().spawn(args);
diff --git a/src/qz/utils/ArgValue.java b/src/qz/utils/ArgValue.java
index a1560f3ae..48bccdc03 100644
--- a/src/qz/utils/ArgValue.java
+++ b/src/qz/utils/ArgValue.java
@@ -33,6 +33,8 @@ public enum ArgValue {
"--honorautostart", "-A"),
STEAL(OPTION, "Ask other running instance to stop so that this instance can take precedence.", null,
"--steal", Constants.DATA_DIR + ":steal"),
+ WAIT(OPTION, "Wait for launcher to terminate (Windows only)", null,
+ "--wait"),
HEADLESS(OPTION, "Force startup \"headless\" without graphical interface or interactive components.", null,
"--headless"),
@@ -45,6 +47,8 @@ public enum ArgValue {
"certgen"),
UNINSTALL(INSTALLER, "Perform all uninstall tasks: Stop instances, delete files, unregister settings.", null,
"uninstall"),
+ SERVICE(INSTALLER, "Installs as system service (Windows only).", "service [--user jdoe] [--remove]",
+ "service"),
SPAWN(INSTALLER, "Spawn an instance of the specified program as the logged-in user, avoiding starting as the root user if possible.", "spawn [program params ...]",
"spawn"),
@@ -122,7 +126,13 @@ public enum ArgValueOption {
PFX(ArgValue.CERTGEN, "Path to a paired HTTPS private key and certificate in PKCS#12 format.",
"--pfx", "--pkcs12"),
PASS(ArgValue.CERTGEN, "Password for decoding private key.",
- "--pass", "-p");
+ "--pass", "-p"),
+
+ // service
+ RUNAS(ArgValue.SERVICE, "Username to run the system service as (Windows only)",
+ "--runas", "--user", "-u"),
+ REMOVE(ArgValue.SERVICE, "Remove the system service as (Windows only)",
+ "--remove", "-r");
ArgValue parent;
String description;
diff --git a/src/qz/utils/FileUtilities.java b/src/qz/utils/FileUtilities.java
index 788fca336..fa305c4ec 100644
--- a/src/qz/utils/FileUtilities.java
+++ b/src/qz/utils/FileUtilities.java
@@ -39,6 +39,7 @@
import java.io.*;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
+import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.*;
import java.text.SimpleDateFormat;
@@ -48,6 +49,7 @@
import java.util.zip.ZipOutputStream;
import static qz.common.Constants.ALLOW_FILE;
+import static qz.common.Constants.AUTOSTART_FILE;
/**
* Common static file i/o utilities
@@ -798,6 +800,18 @@ public static boolean isAutostart() {
}
}
+ public static boolean disableGlobalAutoStart() {
+ Path autostart = SHARED_DIR.resolve(AUTOSTART_FILE);
+ try {
+ Files.write(autostart, "0".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE_NEW, StandardOpenOption.TRUNCATE_EXISTING);
+ return true;
+ }
+ catch(IOException e) {
+ log.warn("Unable to write a zero to the global autostart file: {}", autostart);
+ }
+ return false;
+ }
+
/**
* Configures the given embedded resource file using qz.common.Constants combined with the provided
* HashMap and writes to the specified location
diff --git a/src/qz/utils/WindowsUtilities.java b/src/qz/utils/WindowsUtilities.java
index a2176802c..a169c4fda 100644
--- a/src/qz/utils/WindowsUtilities.java
+++ b/src/qz/utils/WindowsUtilities.java
@@ -451,4 +451,48 @@ public static boolean isWow64() {
}
return isWow64;
}
+
+ public static boolean stopService(String serviceName) {
+ try {
+ W32ServiceManager serviceManager = new W32ServiceManager();
+ serviceManager.open(Winsvc.SC_MANAGER_ALL_ACCESS);
+ W32Service service = serviceManager.openService(serviceName, Winsvc.SC_MANAGER_ALL_ACCESS);
+ service.stopService();
+ service.close();
+ return true;
+ } catch(Throwable t) {
+ log.warn("Could not stop service {} using JNA, will fallback to command line.", serviceName);
+ }
+
+ // Start the newly registered service
+ return ShellUtilities.execute("net", "stop", Constants.PROPS_FILE);
+ }
+
+ public static boolean startService(String serviceName) {
+ try {
+ W32ServiceManager serviceManager = new W32ServiceManager();
+ serviceManager.open(Winsvc.SC_MANAGER_ALL_ACCESS);
+ W32Service service = serviceManager.openService(serviceName, Winsvc.SC_MANAGER_ALL_ACCESS);
+ service.startService();
+ service.close();
+ return true;
+ } catch(Throwable t) {
+ log.warn("Could not start service {} using JNA, will fallback to command line.", serviceName);
+ }
+
+ // Start the newly registered service
+ return ShellUtilities.execute("net", "start", Constants.PROPS_FILE);
+ }
+
+ public static boolean serviceExists(String serviceName) {
+ try {
+ W32ServiceManager serviceManager = new W32ServiceManager();
+ serviceManager.open(Winsvc.SC_MANAGER_ALL_ACCESS);
+ W32Service service = serviceManager.openService(serviceName, Winsvc.SC_MANAGER_ALL_ACCESS);
+ return true;
+ } catch(Win32Exception e) {
+ return false;
+ } catch(Throwable t) {}
+ return ShellUtilities.execute("sc", "query", serviceName);
+ }
}