From 254498748e58968fab280217533111d936803cd6 Mon Sep 17 00:00:00 2001 From: Tres Finocchiaro Date: Sun, 16 Jul 2023 01:35:28 -0400 Subject: [PATCH] Initial support for Windows Service --- ant/windows/installer.xml | 37 +++++++++++- ant/windows/windows.properties | 1 + src/qz/common/Constants.java | 1 + src/qz/installer/Installer.java | 8 ++- src/qz/installer/LinuxInstaller.java | 10 ++++ src/qz/installer/MacInstaller.java | 10 ++++ src/qz/installer/WindowsInstaller.java | 78 +++++++++++++++++++++++++- src/qz/utils/ArgParser.java | 6 ++ src/qz/utils/ArgValue.java | 12 +++- src/qz/utils/FileUtilities.java | 14 +++++ src/qz/utils/WindowsUtilities.java | 44 +++++++++++++++ 11 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 ant/windows/windows.properties 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); + } }