Add reusable JavaFX tray lifecycle with safe fallback
This commit is contained in:
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user