Add reusable JavaFX tray lifecycle with safe fallback

This commit is contained in:
2026-11-07 18:20:35 +01:00
parent 2620a17c64
commit c76f5b2a28
3 changed files with 216 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Tray lifecycle
`TraySupport.attach(stage, awtImage, tooltip, mayQuit, quit)` attaches one primary
JavaFX window to the native AWT system tray. Call attach and lifecycle methods on
the FX thread. The asynchronous backend keeps AWT initialization off that thread.
`availableProperty()` becomes true only after the icon is installed. While
available, window close hides the stage and implicit JavaFX exit is disabled.
Ordinary minimization is unchanged. Open shows, deiconifies and focuses the stage.
The tray menu offers Open and Quit. `requestQuit()` restores the window and calls
`mayQuit` on the FX thread; false cancels without removing the icon. On acceptance,
the registration is removed and `quit` runs (typically Platform::exit). Applications
own Save/Discard/Cancel prompts. `close()` removes handlers/icon and restores the
previous implicit-exit setting; call it during application shutdown as well.
Unsupported, failed or still-pending tray installation never hides the only window.
Window close restores it instead. Applications must retain an accessible explicit
Quit action for that fallback. Native callbacks are marshalled onto FX. An install
that completes after disposal is immediately removed, including during shutdown.
An overload accepts a Backend for alternative tray implementations. Its future
resolves to a Registration, or null if unsupported; Registration.close must be
safe from any thread. This is also the public seam for lifecycle tests. The tests
exercise real JavaFX Stage close/open, cancelled/accepted quit, and unsupported
fallback with deterministic native registrations. Native platform behavior must
also be checked in the consuming application.
@@ -0,0 +1,134 @@
package de.neitzel.fx.tray;
import javafx.application.Platform;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyBooleanWrapper;
import javafx.event.EventHandler;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
import java.awt.EventQueue;
import java.awt.Image;
import java.awt.MenuItem;
import java.awt.PopupMenu;
import java.awt.SystemTray;
import java.awt.TrayIcon;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.function.BooleanSupplier;
/** Close-to-tray lifecycle for one primary JavaFX window, with an accessible fallback. */
public final class TraySupport implements AutoCloseable {
/** Installed native icon. Removal must be safe from any thread. */
@FunctionalInterface public interface Registration extends AutoCloseable {
@Override void close();
}
/** Native integration port. Return null when unsupported; callbacks may run on any thread. */
@FunctionalInterface public interface Backend {
CompletableFuture<Registration> install(Image icon, String label, Runnable open, Runnable quit);
}
private final Stage stage;
private final BooleanSupplier mayQuit;
private final Runnable quit;
private final boolean previousImplicitExit;
private final ReadOnlyBooleanWrapper available = new ReadOnlyBooleanWrapper(false);
private final EventHandler<WindowEvent> closeHandler;
private volatile boolean closed;
private boolean quitting;
private Registration registration;
private final Object lifecycleLock = new Object();
/** Attaches asynchronously to the platform tray. Call on the JavaFX application thread. */
public static TraySupport attach(Stage stage, Image icon, String label, BooleanSupplier mayQuit, Runnable quit) {
return attach(stage, icon, label, mayQuit, quit, TraySupport::installAwt);
}
/** Attaches using an alternate native backend; useful where AWT tray integration is unavailable. */
public static TraySupport attach(Stage stage, Image icon, String label, BooleanSupplier mayQuit, Runnable quit, Backend backend) {
if (!Platform.isFxApplicationThread()) throw new IllegalStateException("Attach tray support on the JavaFX thread");
return new TraySupport(stage, icon, label, mayQuit, quit, backend);
}
private TraySupport(Stage stage, Image icon, String label, BooleanSupplier mayQuit, Runnable quit, Backend backend) {
this.stage = Objects.requireNonNull(stage);
this.mayQuit = Objects.requireNonNull(mayQuit);
this.quit = Objects.requireNonNull(quit);
previousImplicitExit = Platform.isImplicitExit();
closeHandler = event -> {
event.consume();
if (available.get()) stage.hide(); else open();
};
stage.addEventFilter(WindowEvent.WINDOW_CLOSE_REQUEST, closeHandler);
CompletableFuture<Registration> installing;
try { installing = backend.install(Objects.requireNonNull(icon), Objects.requireNonNull(label), () -> onFx(this::open), () -> onFx(this::requestQuit)); }
catch (RuntimeException unsupported) { installing = CompletableFuture.completedFuture(null); }
installing.whenComplete((installed, failure) -> {
synchronized (lifecycleLock) {
if (closed) { if (installed != null) installed.close(); return; }
registration = installed;
}
onFx(() -> {
if (closed) return;
available.set(installed != null && failure == null);
if (available.get()) Platform.setImplicitExit(false);
});
});
}
/** Whether a native icon has actually been installed; false while initialization is pending. */
public boolean isAvailable() { return available.get(); }
public ReadOnlyBooleanProperty availableProperty() { return available.getReadOnlyProperty(); }
/** Restores and focuses the window. Call on the JavaFX thread. */
public void open() {
if (closed) return;
stage.show(); stage.setIconified(false); stage.toFront(); stage.requestFocus();
}
/** Requests an explicit quit. A false callback leaves the window and icon available. */
public void requestQuit() {
if (closed || quitting) return;
quitting = true;
open();
boolean accepted = false;
try {
if (mayQuit.getAsBoolean()) { accepted = true; close(); quit.run(); }
} finally { if (!accepted) quitting = false; }
}
/** Removes integration and restores the prior implicit-exit setting. Call on the FX thread. */
@Override public void close() {
synchronized (lifecycleLock) {
if (closed) return;
closed = true;
if (registration != null) registration.close();
}
stage.removeEventFilter(WindowEvent.WINDOW_CLOSE_REQUEST, closeHandler);
available.set(false);
Platform.setImplicitExit(previousImplicitExit);
}
private static void onFx(Runnable task) {
if (Platform.isFxApplicationThread()) task.run(); else Platform.runLater(task);
}
private static CompletableFuture<Registration> installAwt(Image image, String label, Runnable open, Runnable quit) {
CompletableFuture<Registration> result = new CompletableFuture<>();
EventQueue.invokeLater(() -> {
try {
if (!SystemTray.isSupported()) { result.complete(null); return; }
PopupMenu menu = new PopupMenu();
MenuItem restore = new MenuItem("Open"), exit = new MenuItem("Quit");
restore.addActionListener(event -> open.run()); exit.addActionListener(event -> quit.run());
menu.add(restore); menu.addSeparator(); menu.add(exit);
TrayIcon icon = new TrayIcon(image, label, menu); icon.setImageAutoSize(true);
icon.addActionListener(event -> open.run());
SystemTray tray = SystemTray.getSystemTray(); tray.add(icon);
result.complete(() -> EventQueue.invokeLater(() -> tray.remove(icon)));
} catch (Exception unsupported) { result.completeExceptionally(unsupported); }
});
return result;
}
}
@@ -0,0 +1,56 @@
package de.neitzel.fx.tray;
import javafx.application.Platform;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
import org.junit.jupiter.api.Test;
import java.awt.image.BufferedImage;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
class TraySupportTest {
@Test void closeOpenQuitAndUnavailableTrayKeepTheWindowRecoverable() throws Exception {
CountDownLatch started=new CountDownLatch(1);
try { Platform.startup(started::countDown); } catch(IllegalStateException alreadyStarted) { started.countDown(); }
assertTrue(started.await(10,TimeUnit.SECONDS));
CountDownLatch done=new CountDownLatch(1); AtomicReference<Throwable> failure=new AtomicReference<>();
Platform.runLater(() -> {
try {
Platform.setImplicitExit(false);
Stage stage=new Stage(); stage.show();
AtomicReference<Runnable> open=new AtomicReference<>(), quit=new AtomicReference<>();
AtomicBoolean allowed=new AtomicBoolean(), exited=new AtomicBoolean(), removed=new AtomicBoolean();
TraySupport.Backend backend=(icon,label,onOpen,onQuit) -> {
open.set(onOpen); quit.set(onQuit);
return CompletableFuture.completedFuture(() -> removed.set(true));
};
var tray=TraySupport.attach(stage,new BufferedImage(16,16,BufferedImage.TYPE_INT_ARGB),"test",allowed::get,() -> exited.set(true),backend);
assertTrue(tray.isAvailable());
stage.fireEvent(new WindowEvent(stage,WindowEvent.WINDOW_CLOSE_REQUEST));
assertFalse(stage.isShowing()); assertFalse(exited.get());
open.get().run(); assertTrue(stage.isShowing());
quit.get().run(); assertFalse(exited.get());
allowed.set(true); quit.get().run(); assertTrue(exited.get());
tray.close(); assertTrue(removed.get());
var unavailable=TraySupport.attach(stage,new BufferedImage(16,16,BufferedImage.TYPE_INT_ARGB),"test",() -> true,() -> {},
(icon,label,onOpen,onQuit) -> CompletableFuture.completedFuture(null));
stage.fireEvent(new WindowEvent(stage,WindowEvent.WINDOW_CLOSE_REQUEST));
assertTrue(stage.isShowing()); assertFalse(unavailable.isAvailable());
unavailable.close();
CompletableFuture<TraySupport.Registration> pending=new CompletableFuture<>();
AtomicBoolean lateRemoved=new AtomicBoolean();
var late=TraySupport.attach(stage,new BufferedImage(16,16,BufferedImage.TYPE_INT_ARGB),"test",() -> true,() -> {},
(icon,label,onOpen,onQuit) -> pending);
late.close(); pending.complete(() -> lateRemoved.set(true));
assertTrue(lateRemoved.get()); assertFalse(late.isAvailable());
stage.hide();
} catch(Throwable error) { failure.set(error); } finally { done.countDown(); }
});
assertTrue(done.await(10,TimeUnit.SECONDS));
if(failure.get()!=null) throw new AssertionError(failure.get());
}
}