2006-02-12

Finishing GNOME startup in a Java Swing application

I've been using Ubuntu more than Mac OS recently. My girlfriend doesn't like Ubuntu as much as Mac OS, mainly because of Ubuntu's much-vaunted "human" color scheme. It looks like "shit", apparently, with all that brown.

I have to agree. Though I don't see why that means I have to use it.

Where Mac OS's default desktop is a nice airy blue, Ubuntu has some kind of ugly menacing dust-storm. Or maybe it's a sunset over a sea of excrement. (The filename describes it as a "lagoon".) Whatever, it's best covered by windows.

Anyway. There are much better web sites for those of you interested in number twos. All I have to offer is a tip for making your Java applications better integrated with the GNOME desktop.

GNOME uses .desktop files to represent applications. These files contain things like all the localized variants of the application's name, the executable to run when double-clicked on, and various other settings. One of the settings is a boolean called StartupNotify. If that's set to true, when your application is launched GNOME gives some feedback to the user that the application is still launching. At the time of writing, that's a little "Starting " entry in the task bar and a special rotating "busy" cursor.

You know you've been started like this because you'll have the environment variable DESKTOP_STARTUP_ID set. The trouble is, it's not particularly easy to tell GNOME when you've finished launching. Native GNOME programs can use gdk_notify_startup_complete, which is actually called by default for them if they don't explicitly ask for manual control. There doesn't appear to be a GNOME command-line tool to finish a startup session, which is a shame.

If you poke about, this turns out to be the minimal program to finish launching. It's in C++ rather than Java because Sun's XlibWrapper class isn't publicly accessible, and you can imagine just how horrid it would be to do all this using reflection.

#include <sstream>
#include <string>
#include <X11/Xlib.h>

// Finishes the GNOME startup sessions whose ids are given on the command line.
// Based on the function gdk_notify_startup_complete from:
// http://cvs.gnome.org/viewcvs/gtk%2B/gdk/x11/gdkdisplay-x11.c?view=markup

class XDisplay {
public:
XDisplay() {
display = XOpenDisplay(0);
}

~XDisplay() {
XFlush(display);
XCloseDisplay(display);
}

Atom getAtomByName(const char* atom_name) {
return XInternAtom(display, atom_name, False);
}

Display* display;
};

static std::string escape_for_xmessage(const std::string& s) {
std::ostringstream oss;
for (std::string::const_iterator it = s.begin(); it != s.end(); ++it) {
if (*it == ' ' || *it == '"' || *it == '\\') {
oss << '\\';
}
oss << *it;
}
return oss.str();
}

static void broadcast_xmessage(const std::string& message) {
XDisplay xdisplay;
Window xroot_window = DefaultRootWindow(xdisplay.display);

XSetWindowAttributes attrs;
attrs.override_redirect = True;
attrs.event_mask = PropertyChangeMask | StructureNotifyMask;
Window xwindow = XCreateWindow(xdisplay.display, xroot_window, -100, -100, 1, 1, 0, CopyFromParent, CopyFromParent, CopyFromParent, CWOverrideRedirect | CWEventMask, &attrs);

Atom type_atom = xdisplay.getAtomByName("_NET_STARTUP_INFO");
Atom type_atom_begin = xdisplay.getAtomByName("_NET_STARTUP_INFO_BEGIN");

XEvent xevent;
xevent.xclient.type = ClientMessage;
xevent.xclient.message_type = type_atom_begin;
xevent.xclient.display = xdisplay.display;
xevent.xclient.window = xwindow;
xevent.xclient.format = 8;

const char* src = message.c_str();
const char* src_end = src + message.length() + 1; // Include trailing NUL.

while (src != src_end) {
char* dest = &xevent.xclient.data.b[0];
char* dest_end = dest + 20;
while (dest != dest_end && src != src_end) {
*dest++ = *src++;
}
while (dest != dest_end) {
*dest++ = 0;
}
XSendEvent(xdisplay.display, xroot_window, False, PropertyChangeMask, &xevent);
xevent.xclient.message_type = type_atom;
}

XDestroyWindow(xdisplay.display, xwindow);
}

void finish_startup(const std::string& startup_id) {
broadcast_xmessage("remove: ID=" + escape_for_xmessage(startup_id));
}

int main(int, char* args[]) {
++args;
while (*args != 0) {
finish_startup(*args++);
}
exit(EXIT_SUCCESS);
}

You'll notice that I expect to be passed the id (or ids, since it was no extra work) on the command line rather than through the environment variable. The reason for this is that I'm expecting to be called from Java, and you can't unset environment variables in Java. So I have my applications' Ruby start-up script set a system property (which you can clear with System.clearProperty) and unset the environment variable so that it doesn't get passed to the JVM or its children:

# Pass any GNOME startup notification id through as a system property.
# That way it isn't accidentally inherited by the JVM's children.
desktop_startup_id = ENV['DESKTOP_STARTUP_ID']
if desktop_startup_id != nil
ENV['DESKTOP_STARTUP_ID'] = nil
args << "-Dgnome.DESKTOP_STARTUP_ID=#{desktop_startup_id}"
end

I then have this bit of Java to check for the system property and do the right thing:

public static void finishGnomeStartup() {
String DESKTOP_STARTUP_ID = System.getProperty("gnome.DESKTOP_STARTUP_ID");
if (DESKTOP_STARTUP_ID != null) {
System.clearProperty("gnome.DESKTOP_STARTUP_ID");
ProcessUtilities.spawn(null, new String[] { "finish-gnome-startup", DESKTOP_STARTUP_ID });
}
}

The application I most wanted this for was the Terminator terminal emulator. There's an added complication there, that I hope Sun will take into account if/when they fix this in the JVM.

Terminator works round the fact that the JVM still takes an age to start by noticing in the startup script that an instance is already running, and asking it (via a socket) to open a new window rather than starting a new instance in a new JVM. This means that we have a new DESKTOP_SESSION_ID to finish. There are two obvious choices here. We could pass the information through to the running instance and have it call finishGnomeStartup after opening the new window. Or we could have the script assume that opening the new window in the existing instance will take no time and finish the startup from the script.

I chose the latter route because I didn't want to have to go to the trouble of coping with a user who's running our script as fast as he can click. The system property doesn't really scale well, though we could set system properties with the ids (or script pids) encoded in their names and search the system properties for matches, or we could pass the startup id through to the point where the corresponding window is opened and then call finish-gnome-startup. But finishing the startup from the script works well enough. If I were at Sun, though, I'd probably have to expose some way for user code to finish an arbitrary startup id.

Or make the JVM start a hundred times faster than it does.

As usual the full source is in salma-hayek. Packages for Terminator itself are available for both Debian- and RedHat-based distributions, as well as a Mac ".dmg" and Cygwin ".msi".