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