Add reusable JavaFX tray lifecycle with safe fallback
This commit is contained in:
@@ -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