diff --git a/src/qz/common/AboutInfo.java b/src/qz/common/AboutInfo.java index 38187c2d0..a5dd48b67 100644 --- a/src/qz/common/AboutInfo.java +++ b/src/qz/common/AboutInfo.java @@ -143,6 +143,7 @@ public static String getPreferredHostname() { } 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())); @@ -158,14 +159,14 @@ public static Version findLatestVersion() { JSONObject versionData = versions.getJSONObject(i); if(versionData.getString("target_commitish").equals("master")) { Version latestVersion = Version.valueOf(versionData.getString("name")); - log.trace("Found latest version: {}", latestVersion); + log.trace("Found latest version of {} online: {}", Constants.ABOUT_TITLE, latestVersion); return latestVersion; } } - log.error("Failed to get latest version info"); + throw new Exception("Could not find valid json version information online."); } catch(Exception e) { - log.error("Failed to get latest version info", e); + log.error("Failed to get latest version of {} online", Constants.ABOUT_TITLE, e); } return Constants.VERSION; diff --git a/src/qz/common/Constants.java b/src/qz/common/Constants.java index 036906ab7..d83a1bd43 100644 --- a/src/qz/common/Constants.java +++ b/src/qz/common/Constants.java @@ -5,6 +5,8 @@ import java.awt.*; +import static qz.ws.SingleInstanceChecker.STEAL_WEBSOCKET_PROPERTY; + /** * Created by robert on 7/9/2014. */ @@ -23,7 +25,7 @@ public class Constants { 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" }; + 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 LOG_SIZE = 524288; diff --git a/src/qz/installer/MacInstaller.java b/src/qz/installer/MacInstaller.java index f09892e0a..070523039 100644 --- a/src/qz/installer/MacInstaller.java +++ b/src/qz/installer/MacInstaller.java @@ -66,8 +66,8 @@ public String getDestination() { 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[] { "qz://*" }).isEmpty()) { - ShellUtilities.execute("/usr/bin/defaults", "write", plist, "URLWhitelist", "-array-add", "qz://*"); + if(ShellUtilities.execute(new String[] { "/usr/bin/defaults", "write", plist }, new String[] {DATA_DIR + "://*" }).isEmpty()) { + ShellUtilities.execute("/usr/bin/defaults", "write", plist, "URLWhitelist", "-array-add", DATA_DIR +"://*"); } return this; } diff --git a/src/qz/installer/TaskKiller.java b/src/qz/installer/TaskKiller.java index 7694cdcaf..80fab2973 100644 --- a/src/qz/installer/TaskKiller.java +++ b/src/qz/installer/TaskKiller.java @@ -33,7 +33,7 @@ public static boolean killAll() { ArrayList javaProcs; String[] trayProcs; - int selfProc; + int selfProc = SystemUtilities.getProcessId(); String[] killCmd; // Disable service until reboot if(SystemUtilities.isMac()) { @@ -43,12 +43,10 @@ public static boolean killAll() { // Windows may be running under javaw.exe (normal) or java.exe (terminal) javaProcs = AppLocator.getInstance().getPids("java.exe", "javaw.exe"); trayProcs = ShellUtilities.executeRaw(TRAY_PID_QUERY_WIN32).split("\\s*\\r?\\n"); - selfProc = Kernel32.INSTANCE.GetCurrentProcessId(); killCmd = KILL_PID_CMD_WIN32; } else { javaProcs = AppLocator.getInstance().getPids( "java"); trayProcs = ShellUtilities.executeRaw(TRAY_PID_QUERY_POSIX).split("\\s*\\r?\\n"); - selfProc = MacUtilities.getProcessID(); // Works for Linux too killCmd = KILL_PID_CMD_POSIX; } if (!javaProcs.isEmpty()) { diff --git a/src/qz/installer/WindowsInstaller.java b/src/qz/installer/WindowsInstaller.java index 6b558ee02..330c343c4 100644 --- a/src/qz/installer/WindowsInstaller.java +++ b/src/qz/installer/WindowsInstaller.java @@ -62,6 +62,8 @@ public Installer removeLegacyStartup() { 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"; diff --git a/src/qz/installer/certificate/WindowsCertificateInstaller.java b/src/qz/installer/certificate/WindowsCertificateInstaller.java index 3a03be3de..3764053fa 100644 --- a/src/qz/installer/certificate/WindowsCertificateInstaller.java +++ b/src/qz/installer/certificate/WindowsCertificateInstaller.java @@ -195,7 +195,7 @@ interface Crypt32 extends StdCallLibrary { int CERT_FIND_SUBJECT_STR = 524295; int CERT_FIND_SHA1_HASH = 65536; - Crypt32 INSTANCE = Native.loadLibrary("Crypt32", Crypt32.class, W32APIOptions.DEFAULT_OPTIONS); + 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); diff --git a/src/qz/installer/certificate/firefox/locator/MacAppLocator.java b/src/qz/installer/certificate/firefox/locator/MacAppLocator.java index acd24e70f..641b8cfbd 100644 --- a/src/qz/installer/certificate/firefox/locator/MacAppLocator.java +++ b/src/qz/installer/certificate/firefox/locator/MacAppLocator.java @@ -158,7 +158,7 @@ private static Path getExePath(String appPath) { } private interface SystemB extends Library { - SystemB INSTANCE = Native.loadLibrary("System", SystemB.class); + 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); diff --git a/src/qz/printer/status/Cups.java b/src/qz/printer/status/Cups.java index dfa9e2d56..de9dd6e0c 100644 --- a/src/qz/printer/status/Cups.java +++ b/src/qz/printer/status/Cups.java @@ -7,7 +7,7 @@ */ public interface Cups extends Library { - Cups INSTANCE = Native.loadLibrary("cups", Cups.class); + Cups INSTANCE = Native.load("cups", Cups.class); /** * Static class to facilitate readability of values diff --git a/src/qz/ui/BasicDialog.java b/src/qz/ui/BasicDialog.java index 21ae238a9..bb2df0fc0 100644 --- a/src/qz/ui/BasicDialog.java +++ b/src/qz/ui/BasicDialog.java @@ -176,7 +176,7 @@ public void setVisible(boolean b) { // fix window focus on macOS if (SystemUtilities.isMac() && !GraphicsEnvironment.isHeadless()) { ShellUtilities.executeAppleScript("tell application \"System Events\" \n" + - "set frontmost of every process whose unix id is " + MacUtilities.getProcessID() + " to true \n" + + "set frontmost of every process whose unix id is " + SystemUtilities.getProcessId() + " to true \n" + "end tell"); } super.setVisible(b); diff --git a/src/qz/utils/ArgValue.java b/src/qz/utils/ArgValue.java index 193589d7f..ef815c7f3 100644 --- a/src/qz/utils/ArgValue.java +++ b/src/qz/utils/ArgValue.java @@ -31,6 +31,8 @@ public enum ArgValue { // Options AUTOSTART(OPTION,"Read and honor any autostart preferences before launching.", null, "--honorautostart", "-A"), + STEAL(OPTION, "Ask other running instance to stop so that this instance can take precedence.", null, + "--steal", Constants.DATA_DIR + ":steal"), HEADLESS(OPTION, "Force startup \"headless\" without graphical interface or interactive components.", null, "--headless"), diff --git a/src/qz/utils/GtkUtilities.java b/src/qz/utils/GtkUtilities.java index 44bd6bc3d..191ec4be2 100644 --- a/src/qz/utils/GtkUtilities.java +++ b/src/qz/utils/GtkUtilities.java @@ -98,7 +98,7 @@ private interface GTK extends Library { } private interface GTK3 extends GTK { - GTK3 INSTANCE = Native.loadLibrary("gtk-3", GTK3.class); + GTK3 INSTANCE = Native.load("gtk-3", GTK3.class); // Gtk 3.0+ int gtk_get_minor_version (); @@ -112,7 +112,7 @@ private interface GTK3 extends GTK { } private interface GTK2 extends GTK { - GTK2 INSTANCE = Native.loadLibrary("gtk-x11-2.0", GTK2.class); + GTK2 INSTANCE = Native.load("gtk-x11-2.0", GTK2.class); // Gtk 2.1-3.0 double gdk_screen_get_resolution(Pointer screen); diff --git a/src/qz/utils/MacUtilities.java b/src/qz/utils/MacUtilities.java index 4866686d7..e506bb890 100644 --- a/src/qz/utils/MacUtilities.java +++ b/src/qz/utils/MacUtilities.java @@ -149,7 +149,7 @@ public static int getScaleFactor() { return 1; } - public static int getProcessID() { + static int getProcessId() { if(pid == null) { try { pid = CLibrary.INSTANCE.getpid(); @@ -163,7 +163,7 @@ public static int getProcessID() { } private interface CLibrary extends Library { - CLibrary INSTANCE = (CLibrary) Native.loadLibrary("c", CLibrary.class); + CLibrary INSTANCE = Native.load("c", CLibrary.class); int getpid (); } diff --git a/src/qz/utils/SystemUtilities.java b/src/qz/utils/SystemUtilities.java index ecf04b0c1..61fd3f35d 100644 --- a/src/qz/utils/SystemUtilities.java +++ b/src/qz/utils/SystemUtilities.java @@ -12,6 +12,7 @@ import com.github.zafarkhaja.semver.Version; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.ssl.Base64; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import qz.common.Constants; @@ -22,12 +23,15 @@ import java.io.File; import java.io.IOException; import java.net.URLDecoder; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.nio.file.Paths; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; +import java.util.Random; import java.util.TimeZone; import static com.sun.jna.platform.win32.WinReg.*; @@ -111,6 +115,13 @@ public static boolean isAdmin() { } } + public static int getProcessId() { + if(isWindows()) { + return WindowsUtilities.getProcessId(); + } + return MacUtilities.getProcessId(); // works for Linux too + } + /** * Handle Java versioning nuances * To eventually be replaced with java.lang.Runtime.Version (JDK9+) @@ -359,7 +370,7 @@ public static boolean prefersMaskTrayIcon() { if (Constants.MASK_TRAY_SUPPORTED) { if (SystemUtilities.isMac()) { // Assume a pid of -1 is a broken JNA - return MacUtilities.getProcessID() != -1; + return MacUtilities.getProcessId() != -1; } else if (SystemUtilities.isWindows() && SystemUtilities.getOSVersion().getMajorVersion() >= 10) { return true; } @@ -584,4 +595,55 @@ public static void clearAlgorithms() { log.warn("Unable to apply JDK-8266929 patch. Some algorithms may fail.", e); } } + + /** + * A challenge which can only be calculated by an app installed and running on this machine + * Calculates two bytes: + * - First byte is a salted version of the timestamp of qz-tray.jar + * - Second byte is a throw-away byte + * - Bytes are converted to Base64 and returned as a String + */ + public static String calculateSaltedChallenge() { + int salt = new Random().nextInt(9); + long salted = (calculateChallenge() * 10) + salt; + long obfuscated = salted * new Random().nextInt(9); + ByteBuffer buffer = ByteBuffer.allocate(Long.BYTES * 2); + buffer.putLong(obfuscated); + buffer.putLong(0, salted); + return new String(Base64.encodeBase64(buffer.array(), false), StandardCharsets.UTF_8); + } + + private static long calculateChallenge() { + if(getJarPath() != null) { + File jarFile = new File(getJarPath()); + if (jarFile.exists()) { + return jarFile.lastModified(); + } + } + return -1L; // Fallback when running from IDE + } + + /** + * Decodes challenge string to see if it originated from this application + * - Base64 string is decoded into two bytes + * - First byte is unsalted + * - Second byte is ignored + * - If unsalted value of first byte matches the timestamp of qz-tray.jar, return true + * - If unsalted value doesn't match or if any exceptions occurred, we assume the message is invalid + */ + public static boolean validateSaltedChallenge(String message) { + try { + log.info("Attempting to validating challenge: {}", message); + byte[] decoded = Base64.decodeBase64(message); + ByteBuffer buffer = ByteBuffer.allocate(Long.BYTES * 2); + buffer.put(decoded); + buffer.flip();//need flip + long salted = buffer.getLong(0); // only first byte matters + long challenge = salted / 10L; + return challenge == calculateChallenge(); + } catch(Exception ignore) { + log.warn("An exception occurred validating challenge: {}", message, ignore); + } + return false; + } } diff --git a/src/qz/utils/WindowsUtilities.java b/src/qz/utils/WindowsUtilities.java index bbae13322..6d6992e6b 100644 --- a/src/qz/utils/WindowsUtilities.java +++ b/src/qz/utils/WindowsUtilities.java @@ -1,7 +1,9 @@ package qz.utils; import com.github.zafarkhaja.semver.Version; +import com.sun.jna.Native; import com.sun.jna.platform.win32.*; +import com.sun.jna.win32.W32APIOptions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import qz.common.Constants; @@ -285,4 +287,13 @@ public static boolean elevatedFileCopy(Path source, Path destination) { String[] command = {"Start-Process", "powershell.exe", "-ArgumentList", args, "-Wait", "-Verb", "RunAs"}; return ShellUtilities.execute("powershell.exe", "-command", String.join(" ", command)); } + + static int getProcessId() { + try { + return Kernel32.INSTANCE.GetCurrentProcessId(); + } catch(UnsatisfiedLinkError | NoClassDefFoundError e) { + log.warn("Could not obtain process ID. This usually means JNA isn't working. Returning -1."); + } + return -1; + } } diff --git a/src/qz/ws/PrintSocketClient.java b/src/qz/ws/PrintSocketClient.java index 15f67ea2a..25a731e45 100644 --- a/src/qz/ws/PrintSocketClient.java +++ b/src/qz/ws/PrintSocketClient.java @@ -5,6 +5,7 @@ import org.codehaus.jettison.json.JSONArray; import org.codehaus.jettison.json.JSONException; import org.codehaus.jettison.json.JSONObject; +import org.eclipse.jetty.server.Server; import org.eclipse.jetty.websocket.api.Session; import org.eclipse.jetty.websocket.api.WebSocketException; import org.eclipse.jetty.websocket.api.annotations.*; @@ -46,6 +47,11 @@ public class PrintSocketClient { //websocket port -> Connection private static final HashMap openConnections = new HashMap<>(); + private Server server; + + public PrintSocketClient(Server server) { + this.server = server; + } @OnWebSocketConnect public void onConnect(Session session) { @@ -588,7 +594,21 @@ private void processMessage(Session session, JSONObject json, SocketConnection c case GET_VERSION: sendResult(session, UID, Constants.VERSION); break; - + case WEBSOCKET_STOP: + log.info("Another instance of {} is asking this to close", Constants.ABOUT_TITLE); + String challenge = json.optString("challenge", ""); + if(SystemUtilities.validateSaltedChallenge(challenge)) { + log.info("Challenge validated: {}, honoring shutdown request", challenge); + + session.close(SingleInstanceChecker.REQUEST_INSTANCE_TAKEOVER); + try { + server.stop(); + } catch(Exception ignore) {} + trayManager.exit(0); + } else { + log.warn("A valid challenge was not provided: {}, ignoring request to close", challenge); + } + break; case INVALID: default: sendError(session, UID, "Invalid function call: " + json.optString("call", "NONE")); diff --git a/src/qz/ws/PrintSocketServer.java b/src/qz/ws/PrintSocketServer.java index dca3c9a95..90b35ece9 100644 --- a/src/qz/ws/PrintSocketServer.java +++ b/src/qz/ws/PrintSocketServer.java @@ -43,6 +43,9 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import static qz.utils.ArgParser.ExitStatus.SUCCESS; +import static qz.utils.ArgValue.STEAL; + /** * Created by robert on 9/9/2014. */ @@ -63,13 +66,14 @@ public class PrintSocketServer { private static boolean forceHeadless; - public static void main(String[] args) { ArgParser parser = new ArgParser(args); if (parser.intercept()) { System.exit(parser.getExitCode()); } forceHeadless = parser.hasFlag(ArgValue.HEADLESS); + SingleInstanceChecker.stealWebsocket = parser.hasFlag(STEAL); + 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()); @@ -89,6 +93,7 @@ public static void main(String[] args) { // Load overridable preferences set in qz-tray.properties file NetworkUtilities.setPreferences(certificateManager.getProperties()); + SingleInstanceChecker.setPreferences(certificateManager.getProperties()); // Linux needs the cert installed in user-space on every launch for Chrome SSL to work if (!SystemUtilities.isWindows() && !SystemUtilities.isMac()) { @@ -151,7 +156,7 @@ public static void runServer() { // Handle WebSocket connections WebSocketUpgradeFilter filter = WebSocketUpgradeFilter.configure(context); - filter.addMapping(new ServletPathSpec("/"), (req, resp) -> new PrintSocketClient()); + filter.addMapping(new ServletPathSpec("/"), (req, resp) -> new PrintSocketClient(server)); filter.getFactory().getPolicy().setMaxTextMessageSize(MAX_MESSAGE_SIZE); // Handle HTTP landing page diff --git a/src/qz/ws/SingleInstanceChecker.java b/src/qz/ws/SingleInstanceChecker.java index e741ed70d..e6f266b68 100644 --- a/src/qz/ws/SingleInstanceChecker.java +++ b/src/qz/ws/SingleInstanceChecker.java @@ -10,6 +10,9 @@ package qz.ws; +import org.codehaus.jettison.json.JSONException; +import org.codehaus.jettison.json.JSONObject; +import org.eclipse.jetty.websocket.api.CloseStatus; import org.eclipse.jetty.websocket.api.Session; import org.eclipse.jetty.websocket.api.annotations.*; import org.eclipse.jetty.websocket.client.ClientUpgradeRequest; @@ -18,9 +21,12 @@ import org.slf4j.LoggerFactory; import qz.common.Constants; import qz.common.TrayManager; +import qz.utils.ArgValue; +import qz.utils.SystemUtilities; import java.io.IOException; import java.net.URI; +import java.util.Properties; /** * Created by Kyle on 12/1/2015. @@ -31,16 +37,23 @@ public class SingleInstanceChecker { private static final Logger log = LoggerFactory.getLogger(SingleInstanceChecker.class); + public static CloseStatus INSTANCE_ALREADY_RUNNING = new CloseStatus(4441, "Already running"); + public static CloseStatus REQUEST_INSTANCE_TAKEOVER = new CloseStatus(4442, "WebSocket stolen"); + + public static final String STEAL_WEBSOCKET_FLAG = "stealWebsocket"; + public static final String STEAL_WEBSOCKET_PROPERTY = "websocket.steal"; + private static final int AUTO_CLOSE = 6 * 1000; private static final int TIMEOUT = 3 * 1000; + public static boolean stealWebsocket; + private TrayManager trayManager; private WebSocketClient client; public SingleInstanceChecker(TrayManager trayManager, int port) { this.trayManager = trayManager; - log.debug("Checking for a running instance of {} on port {}", Constants.ABOUT_TITLE, port); autoCloseClient(AUTO_CLOSE); connectTo("ws://localhost:" + port); @@ -68,7 +81,7 @@ private void connectTo(String uri) { @OnWebSocketClose public void onClose(int statusCode, String reason) { - log.warn("Connection closed, {}", reason); + log.warn("Remote connection closed - {}", reason); } @OnWebSocketError @@ -91,11 +104,35 @@ public void onConnect(Session session) { @OnWebSocketMessage public void onMessage(Session session, String message) { - session.close(); - if (message.equals(Constants.PROBE_RESPONSE)) { log.warn("{} is already running on {}", Constants.ABOUT_TITLE, session.getRemoteAddress().toString()); - trayManager.exit(0); + if(stealWebsocket) { + stealInstance(session); + } else { + shutDown(session); + } + } + } + + private void shutDown(Session session) { + session.close(INSTANCE_ALREADY_RUNNING); + log.info("{} is shutting down now.", Constants.ABOUT_TITLE); + trayManager.exit(0); + } + + private void stealInstance(Session session) { + log.info("Asking other instance of {} to shut down.", Constants.ABOUT_TITLE); + try { + JSONObject reply = new JSONObject(); + reply.put("call", SocketMethod.WEBSOCKET_STOP.getCallName()); + // Send something unique, only an app running on this PC would know + reply.put("challenge", SystemUtilities.calculateSaltedChallenge()); + session.getRemote().sendString(reply.toString()); + log.info("Remote shutdown message delivered."); + } + catch(IOException | JSONException e) { + log.warn("Unable to send message, giving up.", e); + shutDown(session); } } @@ -114,4 +151,23 @@ private void autoCloseClient(final int millis) { } }).start(); } + + public static void setPreferences(Properties props) { + // Don't override if already set via command line + if(stealWebsocket) { + log.info("Picked up command line flag: {}", ArgValue.STEAL.getMatches()[0]); + } else { + // Don't override if set by System property + stealWebsocket = Boolean.parseBoolean(System.getProperty(STEAL_WEBSOCKET_FLAG, "false")); + if (stealWebsocket) { + log.info("Picked up flag from system property: {}", STEAL_WEBSOCKET_FLAG); + } else { + stealWebsocket = Boolean.parseBoolean(props.getProperty(STEAL_WEBSOCKET_PROPERTY, "false")); + if (stealWebsocket) { + log.info("Picked up flag from properties file: {}", STEAL_WEBSOCKET_PROPERTY); + } + } + } + log.info("If other instances of {} are found, {} INSTANCE will shut down", Constants.ABOUT_TITLE, stealWebsocket ? "the OTHER" : "THIS"); + } } diff --git a/src/qz/ws/SocketMethod.java b/src/qz/ws/SocketMethod.java index 1887fc47f..3290f076d 100644 --- a/src/qz/ws/SocketMethod.java +++ b/src/qz/ws/SocketMethod.java @@ -54,6 +54,8 @@ public enum SocketMethod { NETWORKING_DEVICE_LEGACY("websocket.getNetworkInfo", true), GET_VERSION("getVersion", false), + WEBSOCKET_STOP("websocket.stop", false), + INVALID("", false);