diff --git a/fx/docs/tray-support.md b/fx/docs/tray-support.md new file mode 100644 index 0000000..dd4ce58 --- /dev/null +++ b/fx/docs/tray-support.md @@ -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. diff --git a/fx/src/main/java/de/neitzel/fx/tray/TraySupport.java b/fx/src/main/java/de/neitzel/fx/tray/TraySupport.java new file mode 100644 index 0000000..e209bd2 --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/tray/TraySupport.java @@ -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 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 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 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 installAwt(Image image, String label, Runnable open, Runnable quit) { + CompletableFuture 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; + } +} diff --git a/fx/src/test/java/de/neitzel/fx/tray/TraySupportTest.java b/fx/src/test/java/de/neitzel/fx/tray/TraySupportTest.java new file mode 100644 index 0000000..bf2d18c --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/tray/TraySupportTest.java @@ -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 failure=new AtomicReference<>(); + Platform.runLater(() -> { + try { + Platform.setImplicitExit(false); + Stage stage=new Stage(); stage.show(); + AtomicReference 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 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()); + } +}