feat(ui): show a splash screen while the desktop interface starts

SplashPreloader is a JavaFX Preloader, registered by GuiBootstrap through
javafx.preloader, so it only exists on the --ui path and shows as soon as
the toolkit is up, before the Spring context is built. It displays the
splash image with the application name, the build version read from
META-INF/build-info.properties, a status line and a progress bar, and fades
out once the main window is actually on screen.

StartupProgressReporter, a BeanPostProcessor added to the context before it
refreshes, turns the context's construction into real progress (beans
initialised over bean definitions) and status lines following the measured
phases: opening the library, loading components, preparing the interface.

The template's placeholder custom.property is dropped from build-info.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
This commit is contained in:
2026-09-30 15:49:28 -04:00
co-authored by Claude Opus 5.5
parent d3d942eeaf
commit bfa8a523f4
10 changed files with 452 additions and 6 deletions
+1 -5
View File
@@ -488,15 +488,11 @@
</requiresUnpack>
</configuration>
<executions>
<!-- META-INF/build-info.properties: build.version is shown on the splash screen (SplashPreloader). -->
<execution>
<goals>
<goal>build-info</goal>
</goals>
<configuration>
<additionalProperties>
<custom.property>your-value</custom.property>
</additionalProperties>
</configuration>
</execution>
</executions>
</plugin>
@@ -1,6 +1,7 @@
package org.icroco.pholio.ui;
import javafx.application.Application;
import org.icroco.pholio.ui.splash.SplashPreloader;
/**
* Entry point for the desktop launch path.
@@ -46,6 +47,13 @@ public final class GuiBootstrap {
*/
private static final String DISABLE_PRISM_DIRTY_OPTS_PROPERTY = "prism.dirtyopts";
/**
* The splash screen: {@link Application#launch} reads this property to start a JavaFX preloader before
* the application's own {@code init()}. Set here, on the desktop path only, so a headless run never shows
* it; an explicit value (an empty one to disable it) is left alone.
*/
private static final String PRELOADER_PROPERTY = "javafx.preloader";
private GuiBootstrap() {
}
@@ -72,5 +80,8 @@ public final class GuiBootstrap {
if (System.getProperty(DISABLE_PRISM_DIRTY_OPTS_PROPERTY) == null) {
System.setProperty(DISABLE_PRISM_DIRTY_OPTS_PROPERTY, "false");
}
if (System.getProperty(PRELOADER_PROPERTY) == null) {
System.setProperty(PRELOADER_PROPERTY, SplashPreloader.class.getName());
}
}
}
@@ -5,6 +5,8 @@ import javafx.application.HostServices;
import javafx.stage.Stage;
import org.icroco.pholio.LaunchMode;
import org.icroco.pholio.PholioBootstrap;
import org.icroco.pholio.ui.splash.SplashNotification;
import org.icroco.pholio.ui.splash.StartupProgressReporter;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -37,15 +39,29 @@ public class PholioFxApplication extends Application {
private @Nullable ConfigurableApplicationContext context;
/** Feeds {@code SplashPreloader} while the context builds — see {@link StartupProgressReporter}. */
private final StartupProgressReporter startupProgress = new StartupProgressReporter(this::notifyPreloader);
@Override
public void init() {
String[] args = getParameters().getRaw().toArray(String[]::new);
context = PholioBootstrap.builder(LaunchMode.UI)
.initializers(hostServicesBean())
.initializers(hostServicesBean(), splashProgress())
.run(args);
log.info("Spring context ready");
}
/**
* Hooks {@link #startupProgress} into the context before it refreshes: as a bean post-processor, to see
* every bean being built, and as a factory post-processor, to learn how many there are once scanned.
*/
private ApplicationContextInitializer<ConfigurableApplicationContext> splashProgress() {
return ctx -> {
ctx.addBeanFactoryPostProcessor(factory -> startupProgress.expect(factory.getBeanDefinitionCount()));
ctx.getBeanFactory().addBeanPostProcessor(startupProgress);
};
}
/**
* Publishes {@link HostServices} as a bean.
*
@@ -59,10 +75,13 @@ public class PholioFxApplication extends Application {
@Override
public void start(Stage primaryStage) {
startupProgress.interfaceStarted();
MacOsProcessName.set("Pholio");
setDockIcon();
// JavaFX always calls init() before start(), so context is set by the time this runs.
Objects.requireNonNull(context).getBean(StageManager.class).showMainWindow(primaryStage);
// Only now that the main window is actually on screen: the splash fades out, never leaving a gap.
notifyPreloader(SplashNotification.finished());
}
/**
@@ -0,0 +1,22 @@
package org.icroco.pholio.ui.splash;
import javafx.application.Preloader.PreloaderNotification;
import org.jspecify.annotations.Nullable;
/**
* What the application tells {@link SplashPreloader} while it starts.
*
* @param progress 0 to 1, or a negative value to leave the progress bar where it is
* @param message already-translated status line, or {@code null} to keep the current one
* @param done the main window is showing: fade the splash out
*/
public record SplashNotification(double progress, @Nullable String message, boolean done) implements PreloaderNotification {
public static SplashNotification status(double progress, @Nullable String message) {
return new SplashNotification(progress, message, false);
}
public static SplashNotification finished() {
return new SplashNotification(1, null, true);
}
}
@@ -0,0 +1,189 @@
package org.icroco.pholio.ui.splash;
import javafx.animation.FadeTransition;
import javafx.application.Preloader;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.util.Duration;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.Properties;
import java.util.ResourceBundle;
/**
* The splash screen of a {@code --ui} launch — a JavaFX {@link Preloader}, so it exists only on the desktop
* path ({@code GuiBootstrap} names it through the {@code javafx.preloader} property) and shows as soon as the
* JavaFX toolkit is up: before {@code PholioFxApplication.init()}, which is where the Spring context — the
* bulk of the startup time — gets built.
*
* <p>It stays until the application says the main window is showing ({@link SplashNotification#done()},
* sent from {@code PholioFxApplication.start}), then fades out. JavaFX's own lifecycle notifications are
* deliberately ignored: {@code BEFORE_START} arrives before the main window is built, which would leave a
* gap with nothing on screen. Progress and status come from {@link StartupProgressReporter}.
*
* <p>Built with no Spring and none of the application's stylesheets — neither exists yet — so everything
* here is plain JavaFX, styled inline plus a small embedded stylesheet for the progress bar, and the first
* status line comes straight from the {@code messages} bundle in the JVM's default locale (later lines are
* translated by the application itself).
*/
public class SplashPreloader extends Preloader {
private static final Logger log = LoggerFactory.getLogger(SplashPreloader.class);
/** Half the image's own 1376×768: crisp on HiDPI screens, a reasonable size on the others. */
private static final double WIDTH = 688;
private static final double HEIGHT = 384;
private static final String PROGRESS_CSS = """
.splash-progress { -fx-background-color: transparent; -fx-padding: 0; -fx-pref-height: 4; }
.splash-progress > .track { -fx-background-color: rgba(255,255,255,0.18); -fx-background-insets: 0;
-fx-background-radius: 2; }
.splash-progress > .bar { -fx-background-color: #7fd4c9; -fx-background-insets: 0;
-fx-background-radius: 2; -fx-padding: 2; }
""";
private final Label status = new Label();
private final ProgressBar progress = new ProgressBar(ProgressBar.INDETERMINATE_PROGRESS);
private @Nullable Stage stage;
@Override
public void start(Stage stage) {
this.stage = stage;
ImageView image = new ImageView(new Image(SplashPreloader.class.getResourceAsStream("/images/splashscreen.png")));
image.setFitWidth(WIDTH);
image.setFitHeight(HEIGHT);
image.setSmooth(true);
Label title = new Label("Pholio");
title.setStyle("-fx-text-fill: white; -fx-font-size: 26px; -fx-font-weight: bold;"
+ " -fx-effect: dropshadow(gaussian, rgba(0,0,0,0.7), 8, 0.2, 0, 1);");
Label version = new Label(buildVersion());
version.setStyle("-fx-text-fill: rgba(255,255,255,0.75); -fx-font-size: 13px;"
+ " -fx-effect: dropshadow(gaussian, rgba(0,0,0,0.7), 6, 0.2, 0, 1);");
version.setVisible(!version.getText().isEmpty());
HBox heading = new HBox(10, title, version);
heading.setAlignment(Pos.BASELINE_LEFT);
status.setText(bundleText("splash.starting"));
status.setStyle("-fx-text-fill: rgba(255,255,255,0.85); -fx-font-size: 12px;"
+ " -fx-effect: dropshadow(gaussian, rgba(0,0,0,0.8), 6, 0.3, 0, 1);");
progress.setPrefWidth(WIDTH - 48);
progress.getStyleClass().add("splash-progress");
VBox overlay = new VBox(6, heading, status, progress);
overlay.setAlignment(Pos.BOTTOM_LEFT);
overlay.setPadding(new Insets(0, 24, 20, 24));
overlay.setStyle("-fx-background-color: linear-gradient(to bottom, transparent 55%, rgba(0,0,0,0.65) 100%);");
StackPane root = new StackPane(image, overlay);
Rectangle clip = new Rectangle(WIDTH, HEIGHT);
clip.setArcWidth(24);
clip.setArcHeight(24);
root.setClip(clip);
Scene scene = new Scene(root, WIDTH, HEIGHT);
scene.setFill(Color.TRANSPARENT);
// The track and the bar are the ProgressBar's own sub-nodes, out of reach of an inline style.
scene.getStylesheets().add("data:text/css," + URLEncoder.encode(PROGRESS_CSS, StandardCharsets.UTF_8).replace("+", "%20"));
stage.initStyle(StageStyle.TRANSPARENT);
stage.setScene(scene);
stage.setTitle("Pholio");
Rectangle2D screen = Screen.getPrimary().getVisualBounds();
stage.setX(screen.getMinX() + (screen.getWidth() - WIDTH) / 2);
stage.setY(screen.getMinY() + (screen.getHeight() - HEIGHT) / 2);
stage.show();
log.debug("Splash screen shown");
}
@Override
public void handleApplicationNotification(PreloaderNotification notification) {
if (!(notification instanceof SplashNotification splash)) {
return;
}
if (splash.message() != null) {
status.setText(splash.message());
}
if (splash.progress() >= 0) {
progress.setProgress(splash.progress());
}
if (splash.done()) {
close();
}
}
/** See the class javadoc: the application decides when the splash goes, not JavaFX's lifecycle. */
@Override
public void handleStateChangeNotification(StateChangeNotification notification) {
}
/** A failed startup must not leave the splash covering JavaFX's own error reporting. */
@Override
public boolean handleErrorNotification(ErrorNotification notification) {
if (stage != null) {
stage.hide();
}
return false;
}
private void close() {
Stage shown = stage;
if (shown == null || !shown.isShowing()) {
return;
}
FadeTransition fade = new FadeTransition(Duration.millis(250), shown.getScene().getRoot());
fade.setToValue(0);
fade.setOnFinished(event -> shown.hide());
fade.play();
}
/**
* {@code build.version} from the {@code META-INF/build-info.properties} spring-boot-maven-plugin
* generates at build time — empty when the classes were compiled without it (an IDE-only build).
*/
private static String buildVersion() {
try (InputStream in = SplashPreloader.class.getResourceAsStream("/META-INF/build-info.properties")) {
if (in == null) {
return "";
}
Properties info = new Properties();
info.load(in);
return info.getProperty("build.version", "");
}
catch (IOException e) {
log.debug("Could not read build-info.properties", e);
return "";
}
}
private static String bundleText(String key) {
try {
return ResourceBundle.getBundle("messages", Locale.getDefault()).getString(key);
}
catch (MissingResourceException e) {
return "";
}
}
}
@@ -0,0 +1,118 @@
package org.icroco.pholio.ui.splash;
import org.icroco.pholio.infra.i18n.I18nService;
import org.icroco.pholio.infra.library.LibraryService;
import org.icroco.pholio.infra.preferences.AppPreferences;
import org.jspecify.annotations.Nullable;
import org.springframework.beans.factory.config.BeanPostProcessor;
import java.util.function.Consumer;
/**
* Turns the Spring context's construction — most of a desktop launch — into splash-screen progress.
*
* <p>Progress is real, not a timer: the share of the context's bean definitions initialised so far, mapped
* onto {@link #CONTEXT_START}..{@link #CONTEXT_END} of the bar (the rest belongs to JavaFX starting before,
* and to the main window being built after). Not every definition is instantiated at startup, so the bar
* rarely reaches {@link #CONTEXT_END} on its own — {@link #interfaceStarted()} and the final notification
* complete it.
*
* <p>The status line follows the phases measured on a real launch, in order: opening the library (its
* database, and Flyway migrations if any — spotted as {@link LibraryService} being built, which a real launch
* logs just before the library's datasource opens; not the {@code DataSource} itself, which the {@code ui}
* layer must not depend on), then loading the application's components, then building the interface (the
* first bean from the {@code ui} package). Lines are translated through {@link I18nService} once it exists — early
* enough for every line after the first — and each phase is announced once.
*
* <p>Registered by {@code PholioFxApplication.init()} on the context before it refreshes; it never becomes
* a bean itself.
*/
public final class StartupProgressReporter implements BeanPostProcessor {
static final double CONTEXT_START = 0.10;
static final double CONTEXT_END = 0.90;
/** Below this change, a progress notification is not worth a round trip to the FX thread. */
private static final double STEP = 0.01;
private enum EPhase {STARTING, OPENING_LIBRARY, LOADING, INTERFACE}
private final Consumer<SplashNotification> sink;
private int expected = 1;
private int initialised;
private double lastSent = -1;
private EPhase phase = EPhase.STARTING;
private @Nullable I18nService i18n;
private @Nullable AppPreferences preferences;
public StartupProgressReporter(Consumer<SplashNotification> sink) {
this.sink = sink;
}
/** How many bean definitions the context holds once scanned — the denominator of the progress. */
public void expect(int beanDefinitionCount) {
expected = Math.max(1, beanDefinitionCount);
}
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) {
if (phase == EPhase.STARTING && bean instanceof LibraryService) {
enter(EPhase.OPENING_LIBRARY);
} else if (phase != EPhase.INTERFACE && bean.getClass().getPackageName().startsWith("org.icroco.pholio.ui")) {
enter(EPhase.INTERFACE);
}
return bean;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof I18nService service) {
i18n = service;
} else if (bean instanceof AppPreferences prefs) {
preferences = prefs;
} else if (bean instanceof LibraryService && phase == EPhase.OPENING_LIBRARY) {
enter(EPhase.LOADING);
}
initialised++;
double progress = CONTEXT_START + (CONTEXT_END - CONTEXT_START) * Math.min(1.0, (double) initialised / expected);
if (progress - lastSent >= STEP) {
send(progress, null);
}
return bean;
}
/** {@code PholioFxApplication.start()}: the context is ready, the main window is being built. */
public void interfaceStarted() {
phase = EPhase.INTERFACE;
send(CONTEXT_END, text("splash.preparingInterface"));
}
private void enter(EPhase next) {
phase = next;
String message = switch (next) {
case OPENING_LIBRARY -> libraryMessage();
case LOADING -> text("splash.loadingComponents");
case INTERFACE -> text("splash.preparingInterface");
case STARTING -> null;
};
send(-1, message);
}
private @Nullable String libraryMessage() {
String library = preferences == null ? null : preferences.getValue("library", "last-opened", String.class);
return library == null || library.isBlank()
? text("splash.openingLibrary")
: text("splash.openingLibraryNamed", library);
}
private @Nullable String text(String key, Object... args) {
return i18n == null ? null : i18n.get(key, args);
}
private void send(double progress, @Nullable String message) {
if (progress >= 0) {
lastSent = progress;
}
sink.accept(SplashNotification.status(progress, message));
}
}
+7
View File
@@ -307,3 +307,10 @@ task.startup.pruning.anonymous.persons=Cleaning up unnamed people
task.startup.library.sync=Synchronizing library
task.startup.geocoding.reference.data=Installing reverse geocoding data
task.startup.checking.media.files=Checking media files
# SplashPreloader / StartupProgressReporter
splash.starting=Starting…
splash.openingLibrary=Opening your library…
splash.openingLibraryNamed=Opening the library “{0}”…
splash.loadingComponents=Loading components…
splash.preparingInterface=Preparing the interface…
@@ -310,3 +310,10 @@ task.startup.pruning.anonymous.persons=Nettoyage des personnes anonymes
task.startup.library.sync=Synchronisation de la bibliothèque
task.startup.geocoding.reference.data=Installation des données de géocodage inverse
task.startup.checking.media.files=Vérification des fichiers
# SplashPreloader / StartupProgressReporter
splash.starting=Démarrage…
splash.openingLibrary=Ouverture de votre photothèque…
splash.openingLibraryNamed=Ouverture de la photothèque « {0} »…
splash.loadingComponents=Chargement des composants…
splash.preparingInterface=Préparation de l’interface…
@@ -27,6 +27,7 @@ class GuiBootstrapTest {
private static final String ENABLE_PREVIEW_PROPERTY = "javafx.enablePreview";
private static final String DISABLE_PRISM_DIRTY_OPTS_PROPERTY = "prism.dirtyopts";
private static final String PRELOADER_PROPERTY = "javafx.preloader";
// Saved, not just cleared: the pom sets javafx.enablePreview=true as a real JVM system property for the
// whole Surefire fork (other test classes, e.g. AppHeaderBarTest, need it for real HeaderBar
@@ -34,11 +35,14 @@ class GuiBootstrapTest {
// this class's own tests run would silently break every later test class in the same fork.
private @Nullable String originalEnablePreview;
private @Nullable String originalDirtyOpts;
private @Nullable String originalPreloader;
@BeforeEach
void captureAndClearProperties() {
originalEnablePreview = System.getProperty(ENABLE_PREVIEW_PROPERTY);
originalDirtyOpts = System.getProperty(DISABLE_PRISM_DIRTY_OPTS_PROPERTY);
originalPreloader = System.getProperty(PRELOADER_PROPERTY);
System.clearProperty(PRELOADER_PROPERTY);
System.clearProperty(ENABLE_PREVIEW_PROPERTY);
System.clearProperty(DISABLE_PRISM_DIRTY_OPTS_PROPERTY);
}
@@ -47,6 +51,7 @@ class GuiBootstrapTest {
void restoreProperties() {
restore(ENABLE_PREVIEW_PROPERTY, originalEnablePreview);
restore(DISABLE_PRISM_DIRTY_OPTS_PROPERTY, originalDirtyOpts);
restore(PRELOADER_PROPERTY, originalPreloader);
}
private static void restore(String property, @Nullable String value) {
@@ -91,6 +96,17 @@ class GuiBootstrapTest {
assertThat(System.getProperty(DISABLE_PRISM_DIRTY_OPTS_PROPERTY)).isEqualTo("true");
}
@Test
void registersTheSplashScreenAsTheJavaFxPreloaderUnlessOneIsGiven(SoftAssertions softly) {
GuiBootstrap.configureSystemProperties();
softly.assertThat(System.getProperty(PRELOADER_PROPERTY))
.isEqualTo("org.icroco.pholio.ui.splash.SplashPreloader");
System.setProperty(PRELOADER_PROPERTY, "");
GuiBootstrap.configureSystemProperties();
softly.assertThat(System.getProperty(PRELOADER_PROPERTY)).as("an explicit empty value disables it").isEmpty();
}
@Test
void doesNotOverrideAnExplicitEnablePreviewChoice() {
System.setProperty(ENABLE_PREVIEW_PROPERTY, "false");
@@ -0,0 +1,61 @@
package org.icroco.pholio.ui.splash;
import org.assertj.core.api.SoftAssertions;
import org.icroco.pholio.infra.i18n.I18nService;
import org.icroco.pholio.infra.library.LibraryService;
import org.icroco.pholio.infra.preferences.AppPreferences;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class StartupProgressReporterTest {
@Test
void announcesEachPhaseOnceInOrderAndProgressesWithTheBeans() {
List<SplashNotification> sent = new ArrayList<>();
StartupProgressReporter reporter = new StartupProgressReporter(sent::add);
reporter.expect(10);
I18nService i18n = mock(I18nService.class);
when(i18n.get(anyString(), any(Object[].class))).thenAnswer(call -> {
Object[] args = call.getArguments();
return args.length > 1 && args[1] != null ? args[0] + ":" + args[1] : String.valueOf(args[0]);
});
AppPreferences preferences = mock(AppPreferences.class);
when(preferences.getValue("library", "last-opened", String.class)).thenReturn("Photos");
initialise(reporter, i18n, "i18n");
initialise(reporter, preferences, "preferences");
initialise(reporter, new Object(), "someInfraBean"); // still starting: no message
initialise(reporter, mock(LibraryService.class), "libraryService"); // opening the library, then loading
initialise(reporter, SplashNotification.finished(), "uiBean"); // a ui-package bean: interface
reporter.interfaceStarted();
List<String> messages = sent.stream().map(SplashNotification::message).filter(Objects::nonNull).toList();
double lastProgress = sent.stream().mapToDouble(SplashNotification::progress).filter(p -> p >= 0).max().orElse(0);
SoftAssertions softly = new SoftAssertions();
softly.assertThat(messages).containsExactly(
"splash.openingLibraryNamed:Photos",
"splash.loadingComponents",
"splash.preparingInterface",
"splash.preparingInterface");
softly.assertThat(sent.stream().mapToDouble(SplashNotification::progress).filter(p -> p >= 0))
.as("never goes backwards").isSorted();
softly.assertThat(lastProgress).isEqualTo(StartupProgressReporter.CONTEXT_END);
softly.assertThat(sent).noneMatch(SplashNotification::done);
softly.assertAll();
}
private static void initialise(StartupProgressReporter reporter, Object bean, String name) {
reporter.postProcessBeforeInitialization(bean, name);
reporter.postProcessAfterInitialization(bean, name);
}
}