diff --git a/README.md b/README.md index 1ca0309..ae43056 100644 --- a/README.md +++ b/README.md @@ -8,24 +8,44 @@ This libraries only purpose is to show *reasonably* decent system-tray icons and There are a number of problems on Linux with the Swing (and SWT) system-tray icons, namely that: 1. Swing system-tray icons on linux **do not** support transparent backgrounds (they have a white background) -2. Swing/SWT **do not** support app-indicators, which are necessary on more recent versions of linux +2. Swing/SWT **do not** support app-indicators, which are necessary on more recent versions of gnu/linux distros. 3. Swing popup menus look like crap - swing-based system-tray uses a JMenuPopup, which looks nicer than the java 'regular' one. - - app-indicators use native popups (a system limitation). + - app-indicators use native popups. + - gtk-indicators use native popups. This is for cross-platform use, specifically - linux 32/64, mac 32/64, and windows 32/64. Java 6+ ``` -To customize the delay (for hiding the popup) when the cursor is "moused out" of the - popup menu, change the value of 'SystemTrayMenuPopup.hidePopupDelay' +Customization parameters: + +SystemTrayMenuPopup.hidePopupDelay (type long, default value '1000L') + - Allows you to customize the delay (for hiding the popup) when the cursor is "moused out" of the popup menu + +GnomeShellExtension.ENABLE_SHELL_RESTART (type boolean, default value 'true') + - Permit the gnome-shell to be restarted when the extension is installed. + + +GnomeShellExtension.SHELL_RESTART_TIMEOUT_MILLIS (type long, default value '5000L') + - Default timeout to wait for the gnome-shell to completely restart. This is a best-guess estimate. + + +GnomeShellExtension.SHELL_RESTART_COMMAND (type String, default value 'gnome-shell --replace &') + - Command to restart the gnome-shell. It is recommended to start it in the background (hence '&') + + +SystemTray.TRAY_SIZE (type int, default value '24') + - Size of the tray, so that the icon can properly scale based on OS. (if it's not exact) + - NOTE: Must be set after any other customization options, as a static call to SystemTray will cause initialization of the library. + + +SystemTray.ICON_PATH (type String, default value '') + - Location of the icon (to make it easier when specifying icons) + - NOTE: Must be set after any other customization options, as a static call to SystemTray will cause initialization of the library. -Not all system tray icons are the same size (default is 22px), so to properly scale the icon - to fit, change the value of 'SystemTray.TRAY_SIZE' -You might want to specify the root location of the icons used (to make it easier when - specifying icons), change the value of 'SystemTray.ICON_PATH' A *simple* example is as follows: @@ -63,4 +83,14 @@ Note: This project was heavily influence by the excellent Lantern project, *Many* thanks to them for figuring out AppIndicators via JNA. https://github.com/getlantern/lantern ``` +``` +Note: Gnome-shell users will experience an extension install to also support this + functionality. Additionally, a shell restart is necessary for the extension + to be noticed by the shell. You can disable the restart behavior if you like, + and the 'system tray' functinality will be picked up on log out/in, or a + manual restart. + + Also, screw you gnome-project leads, for making it such a pain-in-the-ass + to do something so incredibly simple and basic. +``` diff --git a/src/dorkbox/util/tray/SystemTray.java b/src/dorkbox/util/tray/SystemTray.java index 7d29631..5646d5f 100644 --- a/src/dorkbox/util/tray/SystemTray.java +++ b/src/dorkbox/util/tray/SystemTray.java @@ -19,13 +19,23 @@ import dorkbox.util.NamedThreadFactory; import dorkbox.util.OS; import dorkbox.util.jna.linux.AppIndicator; import dorkbox.util.jna.linux.GtkSupport; +import dorkbox.util.process.ShellProcessBuilder; import dorkbox.util.tray.linux.AppIndicatorTray; +import dorkbox.util.tray.linux.GnomeShellExtension; import dorkbox.util.tray.linux.GtkSystemTray; import dorkbox.util.tray.swing.SwingSystemTray; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.*; +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintStream; import java.math.BigInteger; import java.net.URL; import java.nio.ByteBuffer; @@ -54,7 +64,7 @@ class SystemTray { public static int TRAY_SIZE = 22; /** - * Location of the icon + * Location of the icon (to make it easier when specifying icons) */ public static String ICON_PATH = ""; @@ -72,13 +82,38 @@ class SystemTray { if (GtkSupport.isSupported) { // quick check, because we know that unity uses app-indicator. Maybe REALLY old versions do not. We support 14.04 LTE at least String getenv = System.getenv("XDG_CURRENT_DESKTOP"); - if (getenv != null && getenv.equals("Unity")) { + if ("Unity".equalsIgnoreCase(getenv)) { try { trayType = AppIndicatorTray.class; } catch (Throwable ignored) { } + } else if ("GNOME".equalsIgnoreCase(getenv)) { + // if the "topicons" extension is installed, don't install us (because it will override what we do, where ours + // is more specialized - so it only modified our tray icon (instead of ALL tray icons) + + try { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(8196); + PrintStream outputStream = new PrintStream(byteArrayOutputStream); + + // gnome-shell --version + final ShellProcessBuilder shellVersion = new ShellProcessBuilder(outputStream); + shellVersion.setExecutable("gnome-shell"); + shellVersion.addArgument("--version"); + shellVersion.start(); + + String output = ShellProcessBuilder.getOutput(byteArrayOutputStream); + + if (!output.isEmpty()) { + GnomeShellExtension.install(logger, output); + trayType = GtkSystemTray.class; + } + } catch (Throwable ignored) { + trayType = null; + } } + + // Try to autodetect if we can use app indicators (or if we need to fallback to GTK indicators) if (trayType == null) { BufferedReader bin = null; try { @@ -89,6 +124,7 @@ class SystemTray { if (listFiles != null) { for (File procs : listFiles) { String name = procs.getName(); + if (!Character.isDigit(name.charAt(0))) { continue; } @@ -101,6 +137,7 @@ class SystemTray { try { bin = new BufferedReader(new FileReader(status)); String readLine = bin.readLine(); + if (readLine != null && readLine.contains("indicator-app")) { // make sure we can also load the library (it might be the wrong version) try { @@ -119,6 +156,16 @@ class SystemTray { } } } + + // make one last ditch effort + if (trayType == null) { + try { + final AppIndicator instance = AppIndicator.INSTANCE; + trayType = AppIndicatorTray.class; + } catch (Throwable ignored) { + logger.error("AppIndicator support detected, but unable to load the library. Falling back to GTK"); + } + } } catch (Throwable ignored) { } finally { if (bin != null) { @@ -246,11 +293,15 @@ class SystemTray { digest.reset(); digest.update(bytes); + // For KDE4, it must also be unique across runs - byte[] longBytes = new byte[8]; - ByteBuffer wrap = ByteBuffer.wrap(longBytes); - wrap.putLong(runtimeRandom); - digest.update(longBytes); + String getenv = System.getenv("XDG_CURRENT_DESKTOP"); + if (getenv != null && getenv.contains("kde")) { + byte[] longBytes = new byte[8]; + ByteBuffer wrap = ByteBuffer.wrap(longBytes); + wrap.putLong(runtimeRandom); + digest.update(longBytes); + } byte[] hashBytes = digest.digest(); String hash = new BigInteger(1, hashBytes).toString(32); diff --git a/src/dorkbox/util/tray/linux/GnomeShellExtension.java b/src/dorkbox/util/tray/linux/GnomeShellExtension.java new file mode 100644 index 0000000..3d2be87 --- /dev/null +++ b/src/dorkbox/util/tray/linux/GnomeShellExtension.java @@ -0,0 +1,193 @@ +/* + * Copyright 2015 dorkbox, llc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dorkbox.util.tray.linux; + +import dorkbox.util.process.ShellProcessBuilder; +import org.slf4j.Logger; + +import java.io.BufferedWriter; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintStream; + +public +class GnomeShellExtension { + private static final String UID = "SystemTray@dorkbox"; + + /** + * Permit the gnome-shell to be restarted when the extension is installed. + */ + public static boolean ENABLE_SHELL_RESTART = true; + + /** + * Default timeout to wait for the gnome-shell to completely restart. This is a best-guess estimate. + */ + public static long SHELL_RESTART_TIMEOUT_MILLIS = 5000L; + + /** + * Command to restart the gnome-shell. It is recommended to start it in the background (hence '&') + */ + public static String SHELL_RESTART_COMMAND = "gnome-shell --replace &"; + + public static void install(final Logger logger, final String shellVersionString) throws IOException { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(8196); + PrintStream outputStream = new PrintStream(byteArrayOutputStream); + + // gsettings get org.gnome.shell enabled-extensions + final ShellProcessBuilder gsettings = new ShellProcessBuilder(outputStream); + gsettings.setExecutable("gsettings"); + gsettings.addArgument("get"); + gsettings.addArgument("org.gnome.shell"); + gsettings.addArgument("enabled-extensions"); + gsettings.start(); + + String output = ShellProcessBuilder.getOutput(byteArrayOutputStream); + + boolean hasTopIcons = output.contains("topIcons@adel.gadllah@gmail.com"); + boolean hasSystemTray = output.contains(UID); + + // topIcons will convert ALL icons to be at the top of the screen, so there is no reason to have both installed + if (!hasTopIcons && !hasSystemTray) { + // have to copy the extension over and enable it. + String userHome = System.getProperty("user.home"); + + final File file = new File(userHome + "/.local/share/gnome-shell/extensions/" + UID); + if (!file.isDirectory()) { + final boolean mkdirs = file.mkdirs(); + if (!mkdirs) { + final String msg = "Unable to create extension location: " + file; + logger.error(msg); + throw new RuntimeException(msg); + } + } + + InputStream reader = null; + FileOutputStream fileOutputStream = null; + try { + fileOutputStream = new FileOutputStream(new File(file, "extension.js")); + reader = GnomeShellExtension.class.getResourceAsStream("extension.js"); + + byte[] buffer = new byte[4096]; + int read; + while ((read = reader.read(buffer)) > 0) { + fileOutputStream.write(buffer, 0, read); + } + } finally { + if (reader != null) { + try { + reader.close(); + } catch (Exception ignored) { + } + } + if (fileOutputStream != null) { + try { + fileOutputStream.close(); + } catch (Exception ignored) { + } + } + } + + // have to create the metadata.json file (and make it so that it's **always** current). + // we do this via getting the shell version + + + // GNOME Shell 3.14.1 + String versionOutput = shellVersionString.replaceAll("[^\\d.]", ""); // should just be 3.14.1 + + // now change to major version only (only if applicable) + final int indexOf = versionOutput.indexOf('.'); + final int lastIndexOf = versionOutput.lastIndexOf('.'); + if (indexOf < lastIndexOf) { + versionOutput = versionOutput.substring(0, lastIndexOf); + } + + String metadata = "{\n" + + " \"description\": \"Shows a java tray icon on the top notification tray\",\n" + + " \"name\": \"Dorkbox SystemTray\",\n" + + " \"shell-version\": [\n" + + " \"" + versionOutput + "\"\n" + + " ],\n" + + " \"url\": \"https://github.com/dorkbox/SystemTray\",\n" + + " \"uuid\": \"SystemTray@dorkbox\",\n" + + " \"version\": 1\n" + + "}"; + + + BufferedWriter outputWriter = null; + try { + outputWriter = new BufferedWriter(new FileWriter(new File(file, "metadata.json"), false)); + // FileWriter always assumes default encoding is OK + outputWriter.write(metadata); + outputWriter.flush(); + outputWriter.close(); + } catch (Exception e) { + if (outputWriter != null) { + try { + outputWriter.close(); + } catch (Exception ignored) { + } + } + } + + // now we have to enable us + final StringBuilder stringBuilder = new StringBuilder(output); + stringBuilder.delete(0, 4); + stringBuilder.delete(stringBuilder.length() - 2, stringBuilder.length()); + if (stringBuilder.length() > 2) { + stringBuilder.append(", "); + } + stringBuilder.append("'") + .append(UID) + .append("'"); + + stringBuilder.append("]"); + + // gsettings set org.gnome.shell enabled-extensions "['SystemTray@dorkbox']" + // gsettings set org.gnome.shell enabled-extensions "['xyz', 'SystemTray@dorkbox']" + final ShellProcessBuilder setGsettings = new ShellProcessBuilder(outputStream); + setGsettings.setExecutable("gsettings"); + setGsettings.addArgument("set"); + setGsettings.addArgument("org.gnome.shell"); + setGsettings.addArgument("enabled-extensions"); + setGsettings.addArgument(stringBuilder.toString()); + setGsettings.start(); + + + if (ENABLE_SHELL_RESTART) { + logger.info("Restarting gnome-shell, so tray notification changes can be applied."); + + // now we have to restart the gnome shell via bash + final ShellProcessBuilder restartShell = new ShellProcessBuilder(); + // restart shell in background process + restartShell.addArgument(SHELL_RESTART_COMMAND); + restartShell.start(); + + // have to give the shell time to restart + try { + Thread.sleep(SHELL_RESTART_TIMEOUT_MILLIS); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + logger.info("Shell restarted."); + } + } + } +} diff --git a/src/dorkbox/util/tray/linux/extension.js b/src/dorkbox/util/tray/linux/extension.js new file mode 100644 index 0000000..c0c58b7 --- /dev/null +++ b/src/dorkbox/util/tray/linux/extension.js @@ -0,0 +1,271 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* + * Copyright 2015 dorkbox, llc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + * This is heavily modified from an online email from Vladimir Khrustalev. + * + * The source material is NOT GPLx/MIT/BSD/Apache/etc, because those licenses + * were not specified in accordance with those license requirements (there + * was no license specified or implied). As such, this is to be considered as + * released by the original sources as public domain. + */ + +const Clutter = imports.gi.Clutter; +const Shell = imports.gi.Shell; +const St = imports.gi.St; +const Main = imports.ui.main; +const GLib = imports.gi.GLib; +const Lang = imports.lang; +const Panel = imports.ui.panel; +const PanelMenu = imports.ui.panelMenu; +const Meta = imports.gi.Meta; +const Mainloop = imports.mainloop; +const NotificationDaemon = imports.ui.notificationDaemon; + +let trayAddedId = 0; +let orig_onTrayIconAdded; + +let trayRemovedId = 0; +let orig_onTrayIconRemoved; + +let orig_getSource = null; +let icons = []; + +let notificationDaemon; + + +// this value is hardcoded into the display manager +const PANEL_ICON_SIZE = 24; + +function init() { + if (Main.legacyTray) { + notificationDaemon = Main.legacyTray; + NotificationDaemon.STANDARD_TRAY_ICON_IMPLEMENTATIONS = imports.ui.legacyTray.STANDARD_TRAY_ICON_IMPLEMENTATIONS; + } + else if (Main.notificationDaemon._fdoNotificationDaemon) { + notificationDaemon = Main.notificationDaemon._fdoNotificationDaemon; + orig_getSource = Lang.bind(notificationDaemon, NotificationDaemon.FdoNotificationDaemon.prototype._getSource); + } + else { + notificationDaemon = Main.notificationDaemon; + orig_getSource = Lang.bind(notificationDaemon, NotificationDaemon.NotificationDaemon.prototype._getSource); + } +} + +function enable() { + GLib.idle_add(GLib.PRIORITY_LOW, installHook); +} + +function disable() { + if (trayAddedId != 0) { + notificationDaemon._trayManager.disconnect(trayAddedId); + trayAddedId = 0; + } + + if (trayRemovedId != 0) { + notificationDaemon._trayManager.disconnect(trayRemovedId); + trayRemovedId = 0; + } + + notificationDaemon._trayIconAddedId = notificationDaemon._trayManager.connect('tray-icon-added', orig_onTrayIconAdded); + notificationDaemon._trayIconRemovedId = notificationDaemon._trayManager.connect('tray-icon-removed', orig_onTrayIconRemoved); + + notificationDaemon._getSource = orig_getSource; + + for (let i = 0; i < icons.length; i++) { + let icon = icons[i]; + let parent = icon.get_parent(); + if (icon._clicked) { + icon.disconnect(icon._clicked); + } + icon._clicked = undefined; + + if (icon._proxyAlloc) { + Main.panel._rightBox.disconnect(icon._proxyAlloc); + } + + icon._clickProxy.destroy(); + + parent.remove_actor(icon); + parent.destroy(); + notificationDaemon._onTrayIconAdded(notificationDaemon, icon); + } + + icons = []; +} + + + +function installHook() { + //global.log("Installing hook") + + // disable the "normal" method of adding icons + notificationDaemon._trayManager.disconnect(notificationDaemon._trayIconAddedId); + notificationDaemon._trayManager.disconnect(notificationDaemon._trayIconRemovedId); + + // save the original method + orig_onTrayIconAdded = Lang.bind(notificationDaemon, notificationDaemon._onTrayIconAdded); + orig_onTrayIconRemoved = Lang.bind(notificationDaemon, notificationDaemon._onTrayIconRemoved) + + // add our hook. If our icon doesn't have our specific title, it calls the original method + trayAddedId = notificationDaemon._trayManager.connect('tray-icon-added', onTrayIconAdded); + trayRemovedId = notificationDaemon._trayManager.connect('tray-icon-removed', onTrayIconRemoved); + + notificationDaemon._getSource = getSourceHook; + + // move icons to top + let toDestroy = []; + if (notificationDaemon._sources) { + for (let i = 0; i < notificationDaemon._sources.length; i++) { + let source = notificationDaemon._sources[i]; + + if (!source.trayIcon) { + continue; + } + + let icon = source.trayIcon; + + // we could set the title in java, HOWEVER because of race conditions, it's not consistent. So we check for 'java' + if (icon.title !== "java") { + continue; + } + + let parent = icon.get_parent(); + parent.remove_actor(icon); + + onTrayIconAdded(this, icon); + toDestroy.push(source); + } + } + else { + for (let i = 0; i < notificationDaemon._iconBox.get_n_children(); i++) { + let button = notificationDaemon._iconBox.get_child_at_index(i); + let icon = button.child; + + // we could set the title in java, HOWEVER because of race conditions, it's not consistent. So we check for 'java' + if (icon.title !== "java") { + continue; + } + + button.remove_actor(icon); + onTrayIconAdded(this, icon); + + toDestroy.push(button); + } + } + + for (let i = 0; i < toDestroy.length; i++) { + toDestroy[i].destroy(); + } +} + +function getSourceHook (title, pid, ndata, sender, trayIcon) { + // we could set the title in java, HOWEVER because of race conditions, it's not consistent. So we check for 'java' + if (trayIcon && title === "java") { + //global.log("create source"); + onTrayIconAdded(this, trayIcon); + return null; + } + + return getSource(title, pid, ndata, sender, trayIcon); +} + +// this is the hook that lets us only add ourselves. +function onTrayIconAdded(o, icon) { + //global.log("adding tray icon 1 " + icon.title); + + let wmClass = icon.wm_class ? icon.wm_class.toLowerCase() : ''; + if (NotificationDaemon.STANDARD_TRAY_ICON_IMPLEMENTATIONS[wmClass] !== undefined) { + return; + } + + // we could set the title in java, HOWEVER because of race conditions, it's not consistent. So we check for 'java' + if (icon.title !== "java") { + orig_onTrayIconAdded(o, icon); + return; + } + + //global.log("adding tray icon 2 " + icon.title); + + let buttonBox = new PanelMenu.ButtonBox(); + let box = buttonBox.actor; + let parent = box.get_parent(); + + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + let iconSize = PANEL_ICON_SIZE * scaleFactor; + + icon.set_size(iconSize, iconSize); + box.add_actor(icon); + + // Reactive actors will receive events. + icon.set_reactive(true); + + if (parent) { + // remove from the (if present) "collapsy tab icon thing" + parent.remove_actor(box); + } + + // setup proxy for handling click notifications, make it a little larger than the icon + let clickProxy = new St.Bin({ width: iconSize + 4, height: iconSize + 4}); + clickProxy.set_reactive(true); + + icon._proxyAlloc = Main.panel._rightBox.connect('allocation-changed', function() { + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, function() { + let [x, y] = icon.get_transformed_position(); + // need to offset the proxy, so the icon is centered inside the click handler + clickProxy.set_position(x - 2, y); + }); + }); + + icon.connect("destroy", function() { + Main.panel._rightBox.disconnect(icon._proxyAlloc); + clickProxy.destroy(); + }); + + clickProxy.connect('button-release-event', function(actor, event) { + icon.click(event); + }); + + icon._clickProxy = clickProxy; + + + Main.uiGroup.add_actor(clickProxy); + + // add the box to the right panel, always at position 0 + Main.panel._rightBox.insert_child_at_index(box, 0); + + icons.push(icon); +} + +function onTrayIconRemoved(o, icon) { + //global.log("removing tray icon " + icon.title); + + // we could set the title in java, HOWEVER because of race conditions, it's not consistent. So we check for 'java' + if (icon.title !== "java") { + orig_onTrayIconRemoved(o, icon); + return; + } + + let parent = icon.get_parent(); + + if (parent) { + parent.destroy(); + } + + icon.destroy(); + icons.splice(icons.indexOf(icon), 1); +} +