December 20, 2021

Starting Java GUIs on macOS over SSH

On macOS, launching Java GUIs or running the OpenJDK Regression Harness jtreg over SSH is a bit of a head-scratcher. If I start a simple test program via SSH, I get the following error message even though a running GUI is accessible:

admin@test Desktop % java WindowTest.java
Exception in thread "main" java.awt.HeadlessException: 
The application is not running in a desktop session,
but this program performed an operation which requires it.
	at java.desktop/java.awt.GraphicsEnvironment.checkHeadless(GraphicsEnvironment.java:166)
	at java.desktop/java.awt.Window.<init>(Window.java:553)
	at java.desktop/java.awt.Frame.<init>(Frame.java:428)
	at java.desktop/javax.swing.JFrame.<init>(JFrame.java:224)
	at WindowTest.main(WindowTest.java:5)

But do not despair! Running Java GUIs over SSH is possible on macOS.

Please read Accessing the macOS GUI in Automation Contexts to learn how to configure your CI server properly.

First, let us figure out what Java complains about. When digging into the OpenJDK source, the relevant code block for macOS is a JNI call into native code:

// originally from java.base/macosx/native/libjava/java_props_macosx.c
// environment variable to bypass the aqua session check
char *ev = getenv("AWT_FORCE_HEADFUL");
if (ev && (strncasecmp(ev, "true", 4) == 0)) {
    // if "true" then tell the caller we're in an Aqua session without
    // actually checking
    return JNI_TRUE;
}
// Is the WindowServer available?
SecuritySessionId session_id;
SessionAttributeBits session_info;
OSStatus status = SessionGetInfo(callerSecuritySession, &session_id, &session_info);
if (status == noErr) {
    if (session_info & sessionHasGraphicAccess) {
        return JNI_TRUE;
    }
}
return JNI_FALSE;

The interesting part is the second half: sessionHasGraphicAccess from the Security framework is queried to check whether the user has access to a GUI. This means Java requires a full Aqua session. As this message from 2013 to the OpenJDK mailing list by Apple’s Mike Swingler reveals, it was a conscious decision to require a full Aqua session:

The crux of the problem is that the session you get when you SSH in isn’t an ‘Aqua’ session - so while apps you launch may be able to connect to the window server and show a window, lots of subtle stuff is going to be broken that relies on the session:

  • The connection to the pasteboard server -> DnD and some copy/paste operations won’t function properly
  • App foreground activation doesn’t occur correctly (LaunchServices)
  • The “robot” operations may also be busted, but I haven’t checked recently.
  • Any AppleScript events to or from the process

Since these are design issues that are unlikely to ever be addressed by Apple (they have been present since OS X 10.0) we decided for Java 7+ that we will not further the misconception that you can fully interact with the logged in user from an SSH session.

The Solution

If you connect to the machine with ssh [email protected], andreas must be logged into the GUI either directly or via Screen Sharing (VNC). Otherwise, the GUI is inaccessible.

As we have learnt in Accessing the macOS GUI in Automation Contexts, we can change into an Aqua session with the help of Launch Services and open:

  1. Write a shell script that invokes Java and save it on disk (I saved it to /Users/andreas/test.sh). I will again run my simple test program (I use a single file source-code program, requires OpenJDK 11 or higher):

    #! /usr/bin/env bash
    
    /usr/bin/java /Users/andreas/WindowTest.java
    
    # Quit Terminal.app that is started to run *this* script. Otherwise,
    # the desktop becomes cluttered with Terminal windows.
    kill -9 $(ps -p $(ps -p $PPID -o ppid=) -o ppid=) 
    
  2. Make the script executable.

  3. Start this script with the help of open and Terminal.app:

    open --wait-apps --new --fresh -a Terminal.app /Users/andreas/test.sh
    

Thanks to Launch Services, the Bash script will be run within an Aqua session, and Java displays the JFrame on the screen. It works with all current versions of Java (8, 11, 17).

Please see this section from “Accessing the macOS GUI in Automation Contexts” for a complete explanation of the technique.

Possible Workarounds

None of these workarounds gives you reliable results with jtreg.

Now, you might think that the solution offered above is overly complex (I agree!), and maybe you have already spotted the workaround in OpenJDK code fragment above: You can override the check for the Aqua session by setting the environment variable AWT_FORCE_HEADFUL to true.

andreas@test ~ % AWT_FORCE_HEADFUL=true java WindowTest.java 

And indeed, not only is the exception gone, but Java draws the JFrame on my desktop, even if the screen is locked.

But, be warned: As with all workarounds, this one might engulf your computers in flames or cause other unexpected side effects. Especially, you will not get an error if no GUI is available.

On the web, various other workarounds are recommended that do not work (anymore):

  • Setting the system property java.awt.headless=false (source) because LWCToolkit performs the isInAquaSession() check once more. You only end up with a different exception.
  • Setting the environment variable AWT_TOOLKIT=CToolkit. That might have worked in a distant past (think Java 6 or 7).

Unblocking UI Automation

If you plan to run automated tests that interact with the UI by using macOS’ accessibility APIs, you have to explicitly allow screen automation starting with macOS 10.14. Go to System PreferencesSecurity & PrivacyAccessibility.

  • If you start programs with the help of open, you only need to add Terminal to the allowed programs.
  • If you go through SSH directly, you need to add /usr/libexec/sshd-keygen-wrapper (if you click on +, press ⌘ ⇧ G to go to /usr/libexec so that you can select sshd-keygen-wrapper).

While you are at it, you might want to grant either tool Full Disk Access in the same pane if you have not already.

Allowing Terminal and sshd-keygen-wrapper to automate the UI

The Test Program

import javax.swing.JFrame;

public class WindowTest {
	public static void main(String[] args) {
		var window = new JFrame("Test");
		window.setSize(640, 480);
		window.setVisible(true);
	}
}