Updated to better support gnome-shell desktop environments.

This commit is contained in:
nathan 2015-10-28 16:34:27 +01:00
parent ac74d8bdd7
commit c8fac55967
4 changed files with 560 additions and 15 deletions

View File

@ -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.
```

View File

@ -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);

View File

@ -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.");
}
}
}
}

View File

@ -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);
}