perf(ui): weave @FxThread at build time, keep the agent as a safety net
The load-time Byte Buddy agent cost ~420 ms on every launch before the splash screen could show. FxThreadPlugin now weaves @FxThread at build time (byte-buddy-maven-plugin, process-classes) with the same inlined advice and method matcher as the agent, and marks each woven class @FxThreadWoven. FxThreadAgent.install() only attaches when the application's classes run from a directory (an IDE run), where an incremental compiler may have overwritten woven classes, and then weaves only the classes without the marker, so nothing is woven twice; -Dpholio.fxthread.agent=always|never overrides the choice. A Maven-built jar starts with no agent: the splash's first paint moves from ~0.83 s to ~0.47 s. The measurements behind this are in .claude/plans/startup-time-analysis.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
# Startup time analysis — desktop launch (`--ui`)
|
||||
|
||||
Measured on 2026-09-30, macOS arm64, Zulu 26.0.1 FX, `java -jar target/pholio.jar --ui` on a real library.
|
||||
Class-load milestones from `-Xlog:class+load:file=cl.log:uptime` (JVM uptime), application milestones from
|
||||
the log.
|
||||
|
||||
## Timeline
|
||||
|
||||
| JVM uptime | Step | Classes loaded |
|
||||
|---:|---|---:|
|
||||
| 0 → 0.077 s | JVM start, Spring Boot `JarLauncher` (nested jars) | 1 368 |
|
||||
| **0.077 → 0.498 s** | **`FxThreadAgent.install()`: Byte Buddy self-attach + agent setup** | **1 696** |
|
||||
| 0.498 → 0.563 s | picocli: `LaunchOptions` resolves UI vs headless | 321 |
|
||||
| 0.563 → ~0.85 s | JavaFX toolkit (Glass, Prism/Metal, fonts at 0.69 s), splash built and first painted (`ProgressBarSkin` 0.83 s) | ~2 180 |
|
||||
| ~0.85 s | **splash on screen** | |
|
||||
| 1.098 s | `init()`: Spring `SpringApplication` starts | |
|
||||
| ~1.7 → 2.2 s | settings/geo databases, then the library database opened + Flyway | |
|
||||
| 2.5 → 3.8 s | components: media formats, recognition (ONNX models), UI views | |
|
||||
| 3.8 s | Spring context ready | |
|
||||
| 3.9 → 4.2 s | `start()`: main window built and shown, splash fades out | |
|
||||
|
||||
Total ~4.2 s to the main window; ~11 950 classes load after the splash appears.
|
||||
|
||||
## Before the splash (~0.85 s)
|
||||
|
||||
1. **Byte Buddy agent, ~420 ms — half of it.** `main()` installs it first so `@FxThread` classes are woven
|
||||
as they load. Options:
|
||||
- **Install it in parallel**: start it on a thread first thing in `main()`, join it just before Spring
|
||||
loads application classes (start of `PholioFxApplication.init()`, and before the headless commands run).
|
||||
picocli, `GuiBootstrap`, JavaFX and the splash never use `@FxThread`. Splash would show at ~0.45 s.
|
||||
To check: `PholioFxApplication` is loaded by JavaFX at 0.571 s, before the agent would be ready — it
|
||||
must carry no `@FxThread` method.
|
||||
- **Weave at build time** instead of at load time: removes the cost entirely (see the build-time weaving
|
||||
discussion — the reason it was dropped was IDE incremental compilation overwriting woven classes).
|
||||
2. **picocli, ~65 ms**: a fast path when the arguments are exactly `--ui` could skip the parse. Minor.
|
||||
3. **JavaFX toolkit, ~290 ms**: mostly fixed. Only the splash image (1376×768 PNG) could be lighter
|
||||
(JPEG): tens of milliseconds at most.
|
||||
|
||||
Nothing Spring-related runs before the splash, so there is no bean to defer at that stage.
|
||||
|
||||
### Done: build-time weaving, agent as a safety net (2026-09-30)
|
||||
|
||||
`FxThreadPlugin` (byte-buddy-maven-plugin, `process-classes`) weaves `@FxThread` at build time and marks each
|
||||
woven class `@FxThreadWoven`; `FxThreadAgent.install()` now only attaches when the application's classes run
|
||||
from a directory (an IDE run), and then weaves only the unmarked classes. Measured on the Maven-built jar:
|
||||
Byte Buddy is no longer loaded, `GuiBootstrap` is reached at 0.197 s (was 0.563 s) and the splash's first
|
||||
paint at ~0.47 s (was ~0.83 s). A run from `target/classes` still installs the agent (logged at INFO).
|
||||
|
||||
## AOT
|
||||
|
||||
- **JDK AOT cache** (Leyden, `-XX:AOTCache`, JDK 24+): pre-loads and pre-links classes, which is exactly the
|
||||
pre-splash cost (JavaFX, picocli, Byte Buddy). Caveats, to verify in practice:
|
||||
- needs a plain-jar class path, not the fat jar's nested jars — the extracted layout
|
||||
(`java -Djarmode=tools -jar … extract`) the installers already use fits;
|
||||
- a load-time transforming agent like `FxThreadAgent` can prevent cached classes from being used for the
|
||||
classes it transforms;
|
||||
- classes Byte Buddy generates at runtime are not cached.
|
||||
So removing or reducing the agent cost stays worthwhile even with the AOT cache.
|
||||
- **Spring AOT** (`spring-boot:process-aot`): speeds up the context build, which happens *after* the splash —
|
||||
shortens how long the splash stays, not when it appears.
|
||||
|
||||
## After the splash (~3 s of Spring) — candidates to defer
|
||||
|
||||
- ~1 s with no log between the media formats (2.5 s) and context ready (3.8 s): the ONNX recognition models
|
||||
are loaded eagerly at startup; loading them on first use would move that off the critical path.
|
||||
- `jSystemThemeDetector` / OSHI logs a warning during startup (`MacOperatingSystem: Unable …`).
|
||||
- Confirm with Spring's `BufferingApplicationStartup`, which times every bean.
|
||||
|
||||
## How to re-measure
|
||||
|
||||
```bash
|
||||
java -Xlog:class+load:file=cl.log:uptime --enable-native-access=ALL-UNNAMED -jar target/pholio.jar --ui
|
||||
grep -m1 "org.icroco.pholio.ui.GuiBootstrap " cl.log # and other milestones
|
||||
```
|
||||
@@ -26,11 +26,10 @@
|
||||
|
||||
<atlantafx.version>2.1.0</atlantafx.version>
|
||||
<!--
|
||||
Runtime bytecode instrumentation for @FxThread (org.icroco.pholio.ui.common):
|
||||
FxThreadAgent self-attaches a Byte Buddy agent at JVM startup rather than weaving at
|
||||
build time — see FxThread's own javadoc for why (a build-time Maven plugin's weave gets
|
||||
silently dropped whenever an IDE's own incremental compiler rebuilds target/classes,
|
||||
which happens on every IDE-triggered run).
|
||||
Bytecode instrumentation for @FxThread (org.icroco.pholio.ui.common): woven at build time
|
||||
by FxThreadPlugin (byte-buddy-maven-plugin below), with FxThreadAgent as a load-time safety
|
||||
net for classes run from a directory, which an IDE may have recompiled unwoven — see
|
||||
FxThread's own javadoc.
|
||||
-->
|
||||
<byte-buddy.version>1.18.12</byte-buddy.version>
|
||||
<commons-codec.version>1.22.1</commons-codec.version>
|
||||
@@ -668,6 +667,32 @@
|
||||
<!-- </configuration>-->
|
||||
<!-- </plugin>-->
|
||||
|
||||
<plugin>
|
||||
<!--
|
||||
Weaves @FxThread at build time (org.icroco.pholio.ui.common.FxThreadPlugin) into target/classes,
|
||||
at process-classes: after maven-compiler-plugin (Lombok, ErrorProne and NullAway are done), before
|
||||
tests and packaging, so the tests and the repackaged jar both run woven classes. A jar-launched
|
||||
application then needs no load-time agent (~420 ms off the time to splash screen); FxThreadAgent
|
||||
only steps in for classes run from a directory, which an IDE may have recompiled unwoven.
|
||||
-->
|
||||
<groupId>net.bytebuddy</groupId>
|
||||
<artifactId>byte-buddy-maven-plugin</artifactId>
|
||||
<version>${byte-buddy.version}</version>
|
||||
<configuration>
|
||||
<transformations>
|
||||
<transformation>
|
||||
<plugin>org.icroco.pholio.ui.common.FxThreadPlugin</plugin>
|
||||
</transformation>
|
||||
</transformations>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>transform</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
|
||||
@@ -14,7 +14,7 @@ import org.springframework.context.ApplicationContextInitializer;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.Taskbar;
|
||||
import java.awt.*;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Objects;
|
||||
@@ -39,7 +39,9 @@ public class PholioFxApplication extends Application {
|
||||
|
||||
private @Nullable ConfigurableApplicationContext context;
|
||||
|
||||
/** Feeds {@code SplashPreloader} while the context builds — see {@link StartupProgressReporter}. */
|
||||
/**
|
||||
* Feeds {@code SplashPreloader} while the context builds — see {@link StartupProgressReporter}.
|
||||
*/
|
||||
private final StartupProgressReporter startupProgress = new StartupProgressReporter(this::notifyPreloader);
|
||||
|
||||
@Override
|
||||
@@ -112,7 +114,8 @@ public class PholioFxApplication extends Application {
|
||||
try (InputStream in = PholioFxApplication.class.getResourceAsStream("/images/spo-macos-1024x1024.png")) {
|
||||
taskbar.setIconImage(ImageIO.read(Objects.requireNonNull(in, "spo-macos-1024x1024.png missing")));
|
||||
log.debug("Dock icon set");
|
||||
} catch (IOException | RuntimeException e) {
|
||||
}
|
||||
catch (IOException | RuntimeException e) {
|
||||
log.warn("Could not set dock icon", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ import java.lang.annotation.Target;
|
||||
* Marks a method — or, on a class, every method it declares — that must always run on the JavaFX
|
||||
* Application Thread, replacing a hand-written {@link FxUtils#onFxThread} wrapper around the body.
|
||||
*
|
||||
* <p>Enforced by {@link FxThreadAgent}, a Byte Buddy agent that self-attaches at JVM startup and weaves
|
||||
* every matching method as it loads — not a runtime check, not a Spring proxy, and not a build-time Maven
|
||||
* plugin. None of those were arbitrary rejections:
|
||||
* <p>Enforced by Byte Buddy bytecode weaving — at build time by {@link FxThreadPlugin}
|
||||
* ({@code byte-buddy-maven-plugin}), and at load time by {@link FxThreadAgent} only for classes an IDE may
|
||||
* have recompiled since — not a runtime check and not a Spring proxy. How it got there was no arbitrary
|
||||
* choice:
|
||||
*
|
||||
* <ul>
|
||||
* <li>AspectJ compile-time weaving was tried first and ruled out: its compiler cannot yet target this
|
||||
@@ -22,17 +23,18 @@ import java.lang.annotation.Target;
|
||||
* intercepts calls arriving from outside the bean. This codebase's actual candidates overwhelmingly
|
||||
* self-invoke the method that needs wrapping from an {@code @EventListener} or a constructor, which a
|
||||
* proxy silently cannot see — it would report success while doing nothing.
|
||||
* <li>Build-time weaving via {@code byte-buddy-maven-plugin} was tried third, worked, and was reverted
|
||||
* after it shipped: an IDE's own incremental compiler recompiles into {@code target/classes} on every
|
||||
* run by default, silently overwriting the woven classes with plain ones the Maven plugin never
|
||||
* touched. Nothing failed at build time — a scenegraph mutation just ran on the wrong thread at
|
||||
* runtime, the first time the application was actually launched from an IDE.
|
||||
* <li>Build-time weaving alone ({@code byte-buddy-maven-plugin}) was tried third, worked, and was
|
||||
* reverted after it shipped: an IDE's own incremental compiler recompiles into {@code target/classes} on
|
||||
* every run by default, silently overwriting the woven classes with plain ones. Nothing failed at build
|
||||
* time — a scenegraph mutation just ran on the wrong thread at runtime.
|
||||
* <li>A load-time agent alone came next: correct whichever compiler produced a class, but ~420 ms spent
|
||||
* on every launch before the splash screen could show.
|
||||
* </ul>
|
||||
*
|
||||
* <p>A JVM agent has none of these limitations: it rewrites the method itself as the class loads, so
|
||||
* self-invocation, constructor-time calls, and classes that are not Spring beans are all covered the same
|
||||
* way — and unlike a build-time weave, it applies identically no matter which compiler produced the
|
||||
* {@code .class} file being loaded.
|
||||
* <p>Both weaves rewrite the method itself, so self-invocation, constructor-time calls, and classes that are
|
||||
* not Spring beans are covered the same way. Combined — build-time weaving marking each class
|
||||
* {@link FxThreadWoven}, the agent installed only for classes run from a directory and weaving only the
|
||||
* unmarked ones — a Maven-built jar starts with no agent, and an IDE run is still always correct.
|
||||
*
|
||||
* <p><strong>{@code void}-only.</strong> Off the FX thread, the wrapped call is dispatched via
|
||||
* {@code Platform.runLater} — fire-and-forget, with no return value a synchronous caller could receive.
|
||||
|
||||
@@ -9,6 +9,10 @@ import net.bytebuddy.matcher.ElementMatcher;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static net.bytebuddy.matcher.ElementMatchers.isAbstract;
|
||||
import static net.bytebuddy.matcher.ElementMatchers.isAnnotatedWith;
|
||||
import static net.bytebuddy.matcher.ElementMatchers.isConstructor;
|
||||
@@ -23,16 +27,21 @@ import static net.bytebuddy.matcher.ElementMatchers.returns;
|
||||
* any class of this application's own is touched — since a class can only be woven as it loads, not
|
||||
* afterwards.
|
||||
*
|
||||
* <p><strong>Why a runtime agent, not a build-time Maven plugin.</strong> An earlier version of this
|
||||
* weaved at build time via {@code byte-buddy-maven-plugin}, bound to Maven's {@code process-classes}
|
||||
* phase. That worked for every build and test run driven through Maven — and broke silently the moment
|
||||
* the application was actually launched from an IDE, because an IDE's own incremental compiler recompiles
|
||||
* straight into {@code target/classes} on every run by default, overwriting the woven {@code .class}
|
||||
* files with plain ones from a compiler that has never heard of this Maven plugin. The failure mode is not
|
||||
* a build error anywhere — it is a scenegraph mutation running on the wrong thread at runtime, minutes
|
||||
* later, with nothing in the stack trace to suggest {@code @FxThread} was ever involved. A JVM agent
|
||||
* instruments classes as the JVM loads them, which happens identically no matter which compiler produced
|
||||
* the {@code .class} file — IDE, {@code mvn spring-boot:run}, or the packaged jar.
|
||||
* <p><strong>Build-time weaving first, this agent only as the safety net.</strong> {@link FxThreadPlugin}
|
||||
* weaves at build time ({@code byte-buddy-maven-plugin}, {@code process-classes}) and marks every class it
|
||||
* weaves {@link FxThreadWoven}. Build-time weaving alone was tried first and dropped: an IDE's incremental
|
||||
* compiler recompiles straight into {@code target/classes}, overwriting woven {@code .class} files with
|
||||
* plain ones — no build error anywhere, just a scenegraph mutation on the wrong thread minutes later. And an
|
||||
* agent alone costs ~420 ms before the splash screen can show (measured: Byte Buddy's self-attach and its
|
||||
* ~1,700 classes), on every launch, packaged ones included. So {@link #install()} now decides:
|
||||
* <ul>
|
||||
* <li>classes loaded from a <strong>jar</strong> — the Maven-built jar, the installers — are all woven
|
||||
* already: no agent;
|
||||
* <li>classes loaded from a <strong>directory</strong> (an IDE run, {@code target/classes}) may have been
|
||||
* recompiled by the IDE: the agent is installed, and weaves only the classes <em>not</em> marked
|
||||
* {@link FxThreadWoven} — exactly the recompiled ones, never a class twice;
|
||||
* <li>{@code -Dpholio.fxthread.agent=always|never} overrides that choice.
|
||||
* </ul>
|
||||
*/
|
||||
public final class FxThreadAgent {
|
||||
|
||||
@@ -49,6 +58,13 @@ public final class FxThreadAgent {
|
||||
}
|
||||
installed = true;
|
||||
|
||||
String mode = System.getProperty("pholio.fxthread.agent", "auto");
|
||||
boolean fromDirectory = loadedFromDirectory();
|
||||
if ("never".equalsIgnoreCase(mode) || (!"always".equalsIgnoreCase(mode) && !fromDirectory)) {
|
||||
log.debug("@FxThread woven at build time; no agent");
|
||||
return;
|
||||
}
|
||||
|
||||
ByteBuddyAgent.install();
|
||||
new AgentBuilder.Default()
|
||||
// AgentBuilder's default InitializationStrategy (SelfInjection) registers every visited
|
||||
@@ -67,11 +83,31 @@ public final class FxThreadAgent {
|
||||
// Scoped to this application's own classes: matching every loaded type (JDK, Spring,
|
||||
// JavaFX, every library) would run matcherFor's reflection over each one for no benefit,
|
||||
// since nothing outside this codebase ever carries @FxThread.
|
||||
.type(nameStartsWith("org.icroco.pholio."))
|
||||
// Classes FxThreadPlugin already wove at build time are skipped: only those an IDE has
|
||||
// recompiled since (so without the marker) are woven here.
|
||||
.type(nameStartsWith("org.icroco.pholio.").and(not(isAnnotatedWith(FxThreadWoven.class))))
|
||||
.transform((builder, typeDescription, classLoader, module, protectionDomain) ->
|
||||
builder.visit(Advice.to(FxThreadInterceptor.class).on(matcherFor(typeDescription))))
|
||||
.installOnByteBuddyAgent();
|
||||
log.debug("@FxThread instrumentation installed");
|
||||
log.info("@FxThread load-time weaving installed ({}): classes run from a directory may have been "
|
||||
+ "recompiled without build-time weaving", fromDirectory ? "class directory" : "forced");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this application's classes come from a directory ({@code target/classes}, an IDE run) rather
|
||||
* than a jar — a nested {@code jar:} URL in the repackaged jar, a plain {@code .jar} file once extracted.
|
||||
*/
|
||||
private static boolean loadedFromDirectory() {
|
||||
try {
|
||||
var source = FxThreadAgent.class.getProtectionDomain().getCodeSource();
|
||||
if (source == null || source.getLocation() == null || !"file".equals(source.getLocation().getProtocol())) {
|
||||
return false;
|
||||
}
|
||||
return Files.isDirectory(Path.of(source.getLocation().toURI()));
|
||||
}
|
||||
catch (URISyntaxException | RuntimeException e) {
|
||||
return true; // cannot tell: weave, the safe choice
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,7 +115,7 @@ public final class FxThreadAgent {
|
||||
* declared by a class that carries {@link FxThread} itself — in which case every non-abstract,
|
||||
* non-static, non-constructor method of that class is woven, not just some of them.
|
||||
*/
|
||||
private static ElementMatcher.Junction<MethodDescription> matcherFor(TypeDescription typeDescription) {
|
||||
static ElementMatcher.Junction<MethodDescription> matcherFor(TypeDescription typeDescription) {
|
||||
boolean classAnnotated = typeDescription.getDeclaredAnnotations().isAnnotationPresent(FxThread.class);
|
||||
return classAnnotated
|
||||
? not(isConstructor()).and(not(isStatic())).and(not(isAbstract())).and(returns(void.class))
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.icroco.pholio.ui.common;
|
||||
|
||||
import net.bytebuddy.asm.Advice;
|
||||
import net.bytebuddy.build.Plugin;
|
||||
import net.bytebuddy.description.annotation.AnnotationDescription;
|
||||
import net.bytebuddy.description.method.MethodDescription;
|
||||
import net.bytebuddy.description.type.TypeDescription;
|
||||
import net.bytebuddy.dynamic.ClassFileLocator;
|
||||
import net.bytebuddy.dynamic.DynamicType;
|
||||
|
||||
/**
|
||||
* Build-time weaving of {@link FxThread}: run by {@code byte-buddy-maven-plugin} over {@code target/classes}
|
||||
* at {@code process-classes} (see {@code pom.xml}), after javac, Lombok and ErrorProne are done.
|
||||
*
|
||||
* <p>Applies exactly what {@link FxThreadAgent} applies at load time — the same inlined
|
||||
* {@link FxThreadInterceptor} {@link Advice}, on the same methods ({@link FxThreadAgent#matcherFor}) — and
|
||||
* marks each woven class {@link FxThreadWoven}. A Maven-built jar is therefore fully woven and starts with
|
||||
* no agent at all; the agent only steps in for classes a compiler other than Maven's has produced (an IDE's
|
||||
* incremental build), which is what made build-time weaving alone unsafe before — see {@link FxThreadAgent}.
|
||||
*
|
||||
* <p>Only classes that actually use {@link FxThread}, and are not already woven, are touched: every other
|
||||
* class file is left byte-for-byte as javac wrote it.
|
||||
*/
|
||||
public class FxThreadPlugin implements Plugin {
|
||||
|
||||
@Override
|
||||
public boolean matches(TypeDescription target) {
|
||||
return target.getName().startsWith("org.icroco.pholio.")
|
||||
&& !target.getDeclaredAnnotations().isAnnotationPresent(FxThreadWoven.class)
|
||||
&& usesFxThread(target);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DynamicType.Builder<?> apply(DynamicType.Builder<?> builder, TypeDescription typeDescription,
|
||||
ClassFileLocator classFileLocator) {
|
||||
return builder.visit(Advice.to(FxThreadInterceptor.class).on(FxThreadAgent.matcherFor(typeDescription)))
|
||||
.annotateType(AnnotationDescription.Builder.ofType(FxThreadWoven.class).build());
|
||||
}
|
||||
|
||||
private static boolean usesFxThread(TypeDescription type) {
|
||||
return type.getDeclaredAnnotations().isAnnotationPresent(FxThread.class)
|
||||
|| type.getDeclaredMethods().stream()
|
||||
.anyMatch(method -> method.getDeclaredAnnotations().isAnnotationPresent(FxThread.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.icroco.pholio.ui.common;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Added by {@link FxThreadPlugin} to every class it weaves at build time — never written by hand.
|
||||
*
|
||||
* <p>Tells {@link FxThreadAgent} a class already carries its {@link FxThread} advice, so the agent, when it
|
||||
* runs at all (a run from class directories, typically the IDE), only weaves the classes an incremental
|
||||
* compiler has recompiled since — which lack the marker — and never weaves a class twice. The Maven plugin
|
||||
* skips marked classes the same way, so re-running {@code process-classes} over already-woven output is a
|
||||
* no-op.
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface FxThreadWoven {
|
||||
}
|
||||
Reference in New Issue
Block a user