diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7dcfc1ec..8c160526 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,7 @@ jobs: uses: actions/setup-java@v5 with: distribution: 'temurin' - java-version: 21 + java-version: 25 - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: @@ -41,7 +41,7 @@ jobs: uses: actions/setup-java@v5 with: distribution: 'temurin' - java-version: 21 + java-version: 25 - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: diff --git a/.github/workflows/maven-publish.yml b/.github/workflows/maven-publish.yml index f9201640..d7b3603e 100644 --- a/.github/workflows/maven-publish.yml +++ b/.github/workflows/maven-publish.yml @@ -12,7 +12,7 @@ jobs: uses: actions/setup-java@v5 with: distribution: 'temurin' - java-version: 21 + java-version: 25 - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: diff --git a/config/src/main/java/dev/faststats/config/SimpleConfig.java b/config/src/main/java/dev/faststats/config/SimpleConfig.java index 23f5074a..491e6398 100644 --- a/config/src/main/java/dev/faststats/config/SimpleConfig.java +++ b/config/src/main/java/dev/faststats/config/SimpleConfig.java @@ -9,7 +9,6 @@ import org.jspecify.annotations.Nullable; import java.io.IOException; -import java.io.OutputStreamWriter; import java.nio.file.Files; import java.nio.file.Path; import java.util.Properties; @@ -21,15 +20,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; @ApiStatus.Internal -public record SimpleConfig( - UUID serverId, - boolean enabled, - boolean additionalMetrics, - boolean debug, - boolean submitMetrics, - boolean errorTracking, - boolean firstRun -) implements Config { +public final class SimpleConfig implements Config { private static final int CONFIG_VERSION = 2; private static final String COMMENT = """ @@ -58,7 +49,86 @@ public record SimpleConfig( Learn more at: https://faststats.dev/info Since this is your first start with FastStats, submission will not start - until you restart the server to allow you to opt out if you prefer."""; + until after a restart, to allow you to opt out if you prefer."""; + + private final Path file; + private final UUID serverId; + private final boolean debug; + private final boolean firstRun; + private volatile boolean additionalMetrics; + private volatile boolean enabled; + private volatile boolean errorTracking; + private volatile boolean submitMetrics; + + public SimpleConfig( + final Path file, + final UUID serverId, + final boolean enabled, + final boolean additionalMetrics, + final boolean debug, + final boolean submitMetrics, + final boolean errorTracking, + final boolean firstRun + ) { + this.file = file; + this.serverId = serverId; + this.enabled = enabled; + this.additionalMetrics = additionalMetrics; + this.debug = debug; + this.submitMetrics = submitMetrics; + this.errorTracking = errorTracking; + this.firstRun = firstRun; + } + + @Override + public UUID serverId() { + return serverId; + } + + @Override + public boolean enabled() { + return enabled; + } + + public void enabled(final boolean enabled) { + this.enabled = enabled; + } + + @Override + public boolean additionalMetrics() { + return additionalMetrics; + } + + public void additionalMetrics(final boolean additionalMetrics) { + this.additionalMetrics = additionalMetrics; + } + + @Override + public boolean debug() { + return debug; + } + + @Override + public boolean submitMetrics() { + return submitMetrics; + } + + public void submitMetrics(final boolean submitMetrics) { + this.submitMetrics = submitMetrics; + } + + @Override + public boolean errorTracking() { + return errorTracking; + } + + public void errorTracking(final boolean errorTracking) { + this.errorTracking = errorTracking; + } + + public boolean firstRun() { + return firstRun; + } @Contract(mutates = "io") public static SimpleConfig read(final Path file, final LoggerFactory factory) throws RuntimeException { @@ -91,8 +161,7 @@ public static SimpleConfig read(final Path file, final LoggerFactory factory) th if (configVersion != null && configVersion < CONFIG_VERSION) logger.info("Updating config version from %s to %s", configVersion, CONFIG_VERSION); Files.createDirectories(file.getParent()); - try (final var out = Files.newOutputStream(file); - final var writer = new OutputStreamWriter(out, UTF_8)) { + try (final var writer = Files.newBufferedWriter(file, UTF_8)) { final var store = new Properties(); store.setProperty("enabled", Boolean.toString(enabled)); @@ -112,6 +181,7 @@ public static SimpleConfig read(final Path file, final LoggerFactory factory) th } return new SimpleConfig( + file, serverId, enabled && enabledFlag, enabled && enabledFlag && additionalMetrics, @@ -122,6 +192,23 @@ public static SimpleConfig read(final Path file, final LoggerFactory factory) th ); } + @Contract(mutates = "io") + public void persist() throws RuntimeException { + final var properties = readOrEmpty(file); + if (properties == null) throw new IllegalStateException("Metrics config has not been initialized"); + + properties.setProperty("enabled", Boolean.toString(enabled())); + properties.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics())); + properties.setProperty("submitErrors", Boolean.toString(errorTracking())); + properties.setProperty("submitMetrics", Boolean.toString(submitMetrics())); + + try (final var writer = Files.newBufferedWriter(file, UTF_8)) { + properties.store(writer, COMMENT); + } catch (final IOException e) { + throw new RuntimeException("Failed to save metrics config", e); + } + } + // fixme: this code sucks ass @Contract(value = "_, _, _, !null, _, _-> !null") private static @Nullable T parse( @@ -180,4 +267,17 @@ public boolean preSubmissionStart(final SimpleContext context) { } return true; } + + @Override + public String toString() { + return "SimpleConfig{" + + "serverId=" + serverId + + ", debug=" + debug + + ", firstRun=" + firstRun + + ", additionalMetrics=" + additionalMetrics + + ", enabled=" + enabled + + ", errorTracking=" + errorTracking + + ", submitMetrics=" + submitMetrics + + '}'; + } } diff --git a/core/src/main/java/dev/faststats/Metrics.java b/core/src/main/java/dev/faststats/Metrics.java index 6fe7c503..f50f2ad3 100644 --- a/core/src/main/java/dev/faststats/Metrics.java +++ b/core/src/main/java/dev/faststats/Metrics.java @@ -3,12 +3,18 @@ import dev.faststats.data.Metric; import org.jetbrains.annotations.Contract; +import java.util.stream.Stream; + /** * Metrics interface. * * @since 0.24.0 */ public interface Metrics { + // todo: document + @Contract(pure = true) + Stream> stream(); + /** * A metrics factory. * diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 6d5a0de3..b3f4cccd 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -12,9 +12,11 @@ import java.util.HashSet; import java.util.Optional; import java.util.Properties; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.function.Function; +// todo: revise public non-sealed abstract class SimpleContext implements FastStatsContext { private final @Token String token; private final Config config; @@ -24,6 +26,7 @@ public non-sealed abstract class SimpleContext implements FastStatsContext { private final Logger logger; protected volatile boolean ready = false; + public volatile boolean submissionActive; private @Nullable Metrics metrics; private @Nullable FeatureFlagService featureFlagService; @@ -56,14 +59,33 @@ protected SimpleContext(final Factory factory, final LoggerFactory loggerF @MustBeInvokedByOverriders protected final void initializeServices(final Factory factory) throws IllegalStateException { + initializeServices(factory, false); + } + + /** + * Initializes service descriptions before consent so this context can participate in a live lifecycle registry. + */ + @MustBeInvokedByOverriders + protected final void initializeManagedServices(final Factory factory) throws IllegalStateException { + initializeServices(factory, true); + } + + private void initializeServices(final Factory factory, final boolean lifecycleManaged) { if (factory.metrics == null && factory.errorTracker == null && factory.featureFlagService == null) throw new IllegalStateException("Context created without any service attached, was this intentional?"); - if (!preSubmissionStart()) return; + final var start = preSubmissionStart(); + if (!lifecycleManaged && !start) return; - this.metrics = config.submitMetrics() && factory.metrics != null ? factory.metrics.apply(metricsFactory()) : null; - this.errorTrackerService = config.errorTracking() && factory.errorTracker != null ? new SimpleErrorTrackerService(this, factory.errorTracker) : null; + if (factory.metrics != null && (lifecycleManaged || config.submitMetrics())) { + final var metricsFactory = metricsFactory(); + this.metrics = factory.metrics.apply(metricsFactory); + } + this.errorTrackerService = factory.errorTracker != null && (lifecycleManaged || config.errorTracking()) + ? new SimpleErrorTrackerService(this, factory.errorTracker) + : null; this.featureFlagService = factory.featureFlagService != null ? factory.featureFlagService.apply(new SimpleFeatureFlagService.Factory(this)) : null; + this.submissionActive = start; final var features = new HashSet(3); features.add("metrics=" + (metrics != null ? "yes" : "no")); @@ -161,6 +183,7 @@ public void shutdown() { if (errorTrackerService != null) errorTrackerService.shutdown(); if (featureFlagService instanceof final SimpleFeatureFlagService service) service.shutdown(); if (metrics instanceof final SimpleMetrics simpleMetrics) simpleMetrics.shutdown(); + this.submissionActive = false; ready = false; } diff --git a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java index 250ecfce..b86431f3 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java @@ -73,6 +73,7 @@ private static void handleUncaughtException(final Thread thread, final Throwable } private void submit() { + if (!context.submissionActive || !context.getConfig().errorTracking()) return; try { final var data = createData(); if (data == null) return; @@ -111,7 +112,7 @@ private void submit() { return data; } - private void clear() { + public void clear() { globalErrorTracker.clear(); errorTrackers.forEach(SimpleErrorTracker::clear); } diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index 3209d333..e680c967 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -11,8 +11,10 @@ import java.util.HashSet; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; @ApiStatus.Internal +// todo: revise public abstract class SimpleMetrics extends SubmissionService implements Metrics { private static final String COLLECT_PATH = "/v1/collect"; @@ -26,7 +28,7 @@ public abstract class SimpleMetrics extends SubmissionService implements Metrics @Contract(mutates = "io") protected SimpleMetrics(final Factory factory) { super(factory.context); - this.metrics = context.getConfig().additionalMetrics() ? Set.copyOf(factory.metrics) : Set.of(); + this.metrics = Set.copyOf(factory.metrics); this.flush = factory.flush; } @@ -43,6 +45,7 @@ public boolean isClientApplication() { } private boolean submit() { + if (!context.submissionActive || !context.getConfig().submitMetrics()) return false; try { if (submit(url, createData(), "metrics")) { if (flush != null) flush.run(); @@ -78,6 +81,7 @@ private void appendInternalData(final JsonObject metrics) { } private void appendCustomData(final JsonObject metrics) { + if (!context.getConfig().additionalMetrics()) return; this.metrics.forEach(metric -> { try { if (metrics.has(metric.getId())) { @@ -124,6 +128,11 @@ protected void shutdown() { } } + @Override + public Stream> stream() { + return metrics.stream(); + } + public abstract static class Factory implements Metrics.Factory { private @Nullable Runnable flush; private final Set> metrics = new HashSet<>(0); diff --git a/core/src/main/java/dev/faststats/internal/Logger.java b/core/src/main/java/dev/faststats/internal/Logger.java index be4bcf74..4b79dd89 100644 --- a/core/src/main/java/dev/faststats/internal/Logger.java +++ b/core/src/main/java/dev/faststats/internal/Logger.java @@ -23,7 +23,7 @@ default void debug(final LogLevel level, @PrintFormat final String message, @Nul } String caller(); - + LoggerFactory factory(); void print(LogLevel level, @Nullable Throwable t, String message); diff --git a/fabric/build.gradle.kts b/fabric/build.gradle.kts index b0badbab..57f10221 100644 --- a/fabric/build.gradle.kts +++ b/fabric/build.gradle.kts @@ -33,8 +33,25 @@ allprojects { subprojects { if (project.name == "example-mod") return@subprojects + // todo: move to respective sub-module + val onboardingBand = when (project.name) { + "1.16.1-1.17.1" -> ":onboarding:versions:1.16.1-1.17.1" + "1.18-1.18.2" -> ":onboarding:versions:1.18-1.18.2" + "1.19-1.19.3" -> ":onboarding:versions:1.19-1.19.3" + "1.18-1.21.8" -> ":onboarding:versions:1.19.4-1.21.8" + "1.21.9-1.21.11" -> ":onboarding:versions:1.21.9-1.21.11" + "26.1-26.3" -> ":onboarding:versions:26.1-26.3" + else -> null + } + evaluationDependsOn(":onboarding") + onboardingBand?.let { evaluationDependsOn(it) } + dependencies { compileOnlyApi(project(":fabric")) + compileOnlyApi(project(":onboarding")) + onboardingBand?.let { + compileOnlyApi(project(it)) + } } tasks.jar { @@ -42,12 +59,15 @@ subprojects { from(project(":fabric").sourceSets["main"].output) from(project(":config").sourceSets["main"].output) from(project(":core").sourceSets["main"].output) + from(project(":onboarding").sourceSets["main"].output) + onboardingBand?.let { from(project(it).sourceSets["main"].output) } } } dependencies { compileOnlyApi(project(":core")) compileOnly(project(":config")) + compileOnly(project(":onboarding")) minecraft("com.mojang:minecraft:26.1.2") compileOnly("net.fabricmc.fabric-api:fabric-api:0.150.0+26.1.2") compileOnly("net.fabricmc:fabric-loader:0.19.3") diff --git a/fabric/example-mod/build.gradle.kts b/fabric/example-mod/build.gradle.kts index a998da26..2c83fe77 100644 --- a/fabric/example-mod/build.gradle.kts +++ b/fabric/example-mod/build.gradle.kts @@ -1,6 +1,6 @@ plugins { id("net.fabricmc.fabric-loom") version "1.15-SNAPSHOT" - kotlin("jvm") + //kotlin("jvm") } java.toolchain.languageVersion = JavaLanguageVersion.of(25) @@ -10,6 +10,9 @@ tasks.compileJava { } dependencies { + compileOnly(project(":fabric")) + compileOnly(project(":config")) + compileOnly(project(":onboarding")) compileOnly("net.fabricmc:fabric-loader:0.19.3") compileOnly(project(":fabric:versions:26.1-26.3")) include(project(":fabric:versions:26.1-26.3")) diff --git a/fabric/example-mod/src/main/java/com/example/OnboardingScreen.java b/fabric/example-mod/src/main/java/com/example/OnboardingScreen.java new file mode 100644 index 00000000..edeb2880 --- /dev/null +++ b/fabric/example-mod/src/main/java/com/example/OnboardingScreen.java @@ -0,0 +1,527 @@ +package com.example; + +import com.mojang.blaze3d.platform.cursor.CursorTypes; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Checkbox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.Style; +import net.minecraft.network.chat.TextColor; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.net.URI; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +@NullMarked +public abstract class OnboardingScreen { + + // todo: add a button to open the onboarding screen somewhere in the settings + + private static final class Onboarding extends Screen { + private static final String MODS_URL = "https://faststats.dev/mods"; + private static final String ABUSE_URL = "https://faststats.dev/abuse"; + private static final String INFO_URL = "https://faststats.dev/info"; + private static final Component title = Component.literal("FastStats Metrics"); + private static final Component description = Component.literal("") + .append(link("FastStats", MODS_URL)) + .append(" collects anonymous usage statistics and errors.\n") + .append("Keeping Metrics and Error tracking enabled helps developers to improve their mods.\n\n") + .append("If you suspect a developer is collecting personal data or bypassing any opt-out option,\n") + .append("please report it at: ") + .append(link(ABUSE_URL, ABUSE_URL)); + + private static final Component submitMetrics = Component.literal("Submit Metrics"); + private static final Component submitAdditionalMetrics = Component.literal("Submit Additional Metrics (provided by the developer)"); + private static final Component errorTracking = Component.literal("Submit Errors"); + + private static final int REFERENCE_WIDTH = 1280; + private static final int REFERENCE_HEIGHT = 720; + private static final int MAX_PANEL_WIDTH = 1186; + private static final int PANEL_MARGIN = 27; + private static final int PANEL_SIDE_MARGIN = 16; + private static final int TITLE_OFFSET = 45; + private static final int SCROLL_TOP_OFFSET = 88; + private static final int CONTENT_TOP_OFFSET = 122; + private static final int BOX_PADDING_X = 24; + private static final int BOX_PADDING_Y = 16; + private static final int BOX_GAP = 12; + private static final int CHECKBOX_GAP = 30; + private static final int CHECKBOX_TOP_GAP = 22; + private static final int BUTTON_TOP_GAP = 16; + private static final int BUTTON_GAP = 2; + private static final int BUTTON_HEIGHT = 20; + + private static final String[] alwaysCollectedData = new String[]{ + "Mod version", + "Fabric version", + "..." + }; + + private static final Set expandedDetails = new HashSet<>(); + + private static final Set mods = Set.of( + new Mod("TreeHugger69", new String[]{"Default Metrics", "Error Tracking"}, new String[]{"client_age", "language"}), + new Mod("Sucker123", new String[]{"Default Metrics"}, new String[]{}) + ); + + private record Mod(String name, String[] metrics, String[] additionalMetrics) { + + } + + private boolean collectedDataExpanded; + private boolean installedModsExpanded; + private boolean submitMetricsSelected = true; + private int scrollOffset; + + private Checkbox submitMetricsWidget; + private Checkbox submitAdditionalMetricsWidget; + private Checkbox errorTrackingWidget; + private Button declineButton; + private Button acceptButton; + + private Onboarding() { + super(title); + } + + @Override + protected void init() { + final var submitAdditionalMetricsSelected = new AtomicBoolean(true); + final var errorTrackingSelected = new AtomicBoolean(true); + + this.scrollOffset = Math.min(this.scrollOffset, this.maxScrollOffset()); + + this.submitMetricsWidget = this.addRenderableWidget(Checkbox.builder(Onboarding.submitMetrics, this.font) + .pos(contentLeft(), checkboxY()) + .onValueChange((checkbox, selected) -> { + this.submitMetricsSelected = selected; + this.rebuildWidgets(); + }) + .selected(this.submitMetricsSelected) + .build()); + + this.submitAdditionalMetricsWidget = this.addRenderableWidget(Checkbox.builder(this.additionalMetricsLabel(), this.font) + .pos(contentLeft(), checkboxY() + scaled(CHECKBOX_GAP)) + .onValueChange((checkbox, selected) -> submitAdditionalMetricsSelected.set(selected)) + .selected(submitAdditionalMetricsSelected.get() && this.submitMetricsSelected) + .build()); + this.submitAdditionalMetricsWidget.active = this.submitMetricsSelected; + this.submitAdditionalMetricsWidget.setAlpha(this.submitMetricsSelected ? 1.0F : 0.45F); + this.errorTrackingWidget = this.addRenderableWidget(Checkbox.builder(Onboarding.errorTracking, this.font) + .pos(contentLeft(), checkboxY() + scaled(CHECKBOX_GAP) * 2) + .onValueChange((checkbox, selected) -> errorTrackingSelected.set(selected)) + .selected(errorTrackingSelected.get()) + .build()); + + final var buttonGap = scaled(BUTTON_GAP); + final var buttonWidth = (contentWidth() - buttonGap) / 2; + this.declineButton = this.addRenderableWidget(Button.builder(Component.literal("Decline All").withColor(TextColor.RED), button -> { + // todo: disable all… close immediately or let the user also click "confirm"? + this.onClose(); + }).bounds(contentLeft(), buttonY(), buttonWidth, buttonHeight()).build()); + this.acceptButton = this.addRenderableWidget(Button.builder(Component.literal("Confirm Selection").withColor(TextColor.GREEN), button -> { + // todo: save selection + this.onClose(); + }).bounds(contentLeft() + buttonWidth + buttonGap, buttonY(), buttonWidth, buttonHeight()).build()); + this.updateWidgetPositions(); + } + + // fixme: this is hellish code, i bet i will forget what it does until tomorrow :) + @Override + public void extractRenderState(final GuiGraphicsExtractor graphics, final int mouseX, final int mouseY, final float a) { + super.extractRenderState(graphics, mouseX, mouseY, a); + + final int panelWidth = panelWidth(); + final int panelHeight = panelHeight(); + final int panelX = panelX(); + final int panelY = panelY(); + final int border = 0xFFFFFFFF; + final int text = 0xFFE0E0E0; + + graphics.outline(panelX, panelY, panelWidth, panelHeight, border); + graphics.centeredText(this.font, title, this.width / 2, panelY + scaled(TITLE_OFFSET), text); + + final int contentInset = contentInset(panelWidth); + final int descriptionX = panelX + contentInset; + final int descriptionY = descriptionY(); + final int descriptionWidth = panelWidth - contentInset * 2; + final int descriptionHeight = descriptionHeight(); + graphics.enableScissor(panelX + 1, scrollTop(), panelX + panelWidth - 1, scrollBottom()); + graphics.fill(descriptionX + 1, descriptionY + 1, descriptionX + descriptionWidth - 1, descriptionY + descriptionHeight - 1, 0xFF000000); + graphics.outline(descriptionX, descriptionY, descriptionWidth, descriptionHeight, border); + graphics.textWithWordWrap(this.font, description, descriptionX + scaled(BOX_PADDING_X), descriptionY + scaled(BOX_PADDING_Y), descriptionTextWidth(), text, false); + + final int infoY = descriptionY + descriptionHeight + scaled(BOX_GAP); + final int infoHeight = infoHeight(); + graphics.fill(descriptionX + 1, infoY + 1, descriptionX + descriptionWidth - 1, infoY + infoHeight - 1, 0xFF000000); + graphics.outline(descriptionX, infoY, descriptionWidth, infoHeight, border); + graphics.textWithWordWrap(this.font, infoDescription(), descriptionX + scaled(BOX_PADDING_X), infoY + scaled(BOX_PADDING_Y), descriptionTextWidth(), text, false); + graphics.disableScissor(); + this.renderScrollbar(graphics, panelX, panelY, panelWidth, panelHeight, border); + if (this.isHoveringClickableText(mouseX, mouseY)) { + graphics.requestCursor(CursorTypes.POINTING_HAND); + } + } + + // fixme: all of this sucks + @Override + public boolean mouseClicked(final MouseButtonEvent event, final boolean doubleClick) { + final Style clickedStyle = this.getStyleAt(description, event.x(), event.y(), descriptionTextX(), descriptionTextY(), descriptionTextWidth()); + if (clickedStyle != null && clickedStyle.getClickEvent() != null) { + defaultHandleClickEvent(clickedStyle.getClickEvent(), this.minecraft, this); + return true; + } + + if (event.y() < scrollTop() || event.y() > scrollBottom()) { + return super.mouseClicked(event, doubleClick); + } + + final Component info = infoDescription(); + final Style clickedInfoStyle = this.getStyleAt(info, event.x(), event.y(), infoTextX(), infoTextY(), descriptionTextWidth()); + if (clickedInfoStyle != null && clickedInfoStyle.getClickEvent() != null) { + defaultHandleClickEvent(clickedInfoStyle.getClickEvent(), this.minecraft, this); + return true; + } + + final String clickedInfoLine = this.getLineAt(info, event.x(), event.y(), infoTextX(), infoTextY(), descriptionTextWidth()); + if (clickedInfoLine != null) { + if (clickedInfoLine.contains("What data is collected?")) { + this.collectedDataExpanded = !this.collectedDataExpanded; + this.updateWidgetPositions(); + return true; + } + if (clickedInfoLine.contains("List of installed mods that use FastStats")) { + this.installedModsExpanded = !this.installedModsExpanded; + this.updateWidgetPositions(); + return true; + } + if (clickedInfoLine.contains("Additional Metrics")) { + expandedDetails.add(""); // todo: idk how to expand that crap + this.updateWidgetPositions(); + return true; + } + } + + return super.mouseClicked(event, doubleClick); + } + + @Override + public boolean mouseScrolled(final double mouseX, final double mouseY, final double horizontalAmount, final double verticalAmount) { + final int maxScrollOffset = this.maxScrollOffset(); + if (maxScrollOffset <= 0) { + return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount); + } + + final var b = this.scrollOffset - (int) (verticalAmount * 20); + this.scrollOffset = Math.clamp(b, 0, maxScrollOffset); + this.updateWidgetPositions(); + return true; + } + + private boolean isHoveringClickableText(final double mouseX, final double mouseY) { + if (mouseY < scrollTop() || mouseY > scrollBottom()) { + return false; + } + + final Style descriptionStyle = this.getStyleAt(description, mouseX, mouseY, descriptionTextX(), descriptionTextY(), descriptionTextWidth()); + if (descriptionStyle != null && descriptionStyle.getClickEvent() != null) { + return true; + } + + final Component info = infoDescription(); + final Style infoStyle = this.getStyleAt(info, mouseX, mouseY, infoTextX(), infoTextY(), descriptionTextWidth()); + if (infoStyle != null && infoStyle.getClickEvent() != null) { + return true; + } + + // fixme: do not hardcode any of this, we need a proper solution here + final String infoLine = this.getLineAt(info, mouseX, mouseY, infoTextX(), infoTextY(), descriptionTextWidth()); + return infoLine != null && (infoLine.contains("What data is collected?") + || infoLine.contains("List of installed mods that use FastStats") + || infoLine.contains("Additional Metrics")); + } + + // fixme: hacky bs + private @Nullable Style getStyleAt(final Component component, final double mouseX, final double mouseY, final int textX, final int textY, final int textWidth) { + final var line = this.getFormattedLineAt(component, mouseX, mouseY, textX, textY, textWidth); + if (line == null) { + return null; + } + + final var relativeX = mouseX - textX; + final var currentX = new double[]{0}; + final var style = line.visit((segmentStyle, text) -> { + final float segmentWidth = this.font.getSplitter().stringWidth(FormattedText.of(text, segmentStyle)); + if (relativeX >= currentX[0] && relativeX <= currentX[0] + segmentWidth) { + return Optional.of(segmentStyle); + } + + currentX[0] += segmentWidth; + return Optional.empty(); + }, Style.EMPTY); + + return style.orElse(null); + } + + private @Nullable String getLineAt(final Component component, final double mouseX, final double mouseY, final int textX, final int textY, final int textWidth) { + final FormattedText line = this.getFormattedLineAt(component, mouseX, mouseY, textX, textY, textWidth); + return line == null ? null : line.getString(); + } + + private @Nullable FormattedText getFormattedLineAt(final Component component, final double mouseX, final double mouseY, final int textX, final int textY, final int textWidth) { + if (mouseX < textX || mouseX > textX + textWidth || mouseY < textY) { + return null; + } + + final List lines = this.font.getSplitter().splitLines(component, textWidth, Style.EMPTY); + final int line = (int) ((mouseY - textY) / this.font.lineHeight); + if (line < 0 || line >= lines.size()) { + return null; + } + + return lines.get(line); + } + + private int descriptionTextX() { + final int panelWidth = panelWidth(); + final int panelX = panelX(); + final int contentInset = contentInset(panelWidth); + return panelX + contentInset + scaled(BOX_PADDING_X); + } + + private int descriptionTextY() { + return descriptionY() + scaled(BOX_PADDING_Y); + } + + private int infoTextX() { + return descriptionTextX(); + } + + private int infoTextY() { + return infoY() + scaled(BOX_PADDING_Y); + } + + private int contentLeft() { + final int panelWidth = panelWidth(); + final int panelX = panelX(); + final int contentInset = contentInset(panelWidth); + return panelX + contentInset + 2; + } + + private int contentWidth() { + final int panelWidth = panelWidth(); + final int contentInset = contentInset(panelWidth); + return panelWidth - contentInset * 2 - 4; + } + + private int descriptionY() { + return contentTop() - this.scrollOffset; + } + + private int baseInfoY() { + return contentTop() + descriptionHeight() + scaled(BOX_GAP) - this.scrollOffset; + } + + private int infoY() { + return this.baseInfoY(); + } + + private int scrollTop() { + return panelY() + scaled(SCROLL_TOP_OFFSET); + } + + private int scrollBottom() { + return panelY() + panelHeight() - 1; + } + + private int checkboxY() { + return this.infoY() + infoHeight() + scaled(CHECKBOX_TOP_GAP); + } + + private int buttonY() { + return checkboxY() + scaled(CHECKBOX_GAP) * 2 + buttonHeight() + scaled(BUTTON_TOP_GAP); + } + + private int contentBottom() { + return contentTop() + descriptionHeight() + scaled(BOX_GAP) + infoHeight() + scaled(CHECKBOX_TOP_GAP) + + scaled(CHECKBOX_GAP) * 2 + buttonHeight() + scaled(BUTTON_TOP_GAP) + buttonHeight(); + } + + private int maxScrollOffset() { + return Math.max(0, contentBottom() - scrollBottom()); + } + + private void updateWidgetPositions() { + this.scrollOffset = Math.min(this.scrollOffset, this.maxScrollOffset()); + + final int buttonGap = scaled(BUTTON_GAP); + final int buttonWidth = (contentWidth() - buttonGap) / 2; + this.submitMetricsWidget.setY(checkboxY()); + this.submitAdditionalMetricsWidget.setY(checkboxY() + scaled(CHECKBOX_GAP)); + this.errorTrackingWidget.setY(checkboxY() + scaled(CHECKBOX_GAP) * 2); + this.submitMetricsWidget.visible = isInsideScrollArea(this.submitMetricsWidget.getY(), this.submitMetricsWidget.getHeight()); + this.submitAdditionalMetricsWidget.visible = isInsideScrollArea(this.submitAdditionalMetricsWidget.getY(), this.submitAdditionalMetricsWidget.getHeight()); + this.errorTrackingWidget.visible = isInsideScrollArea(this.errorTrackingWidget.getY(), this.errorTrackingWidget.getHeight()); + this.declineButton.setX(contentLeft()); + this.declineButton.setY(buttonY()); + this.declineButton.setWidth(buttonWidth); + this.declineButton.setHeight(buttonHeight()); + this.acceptButton.setX(contentLeft() + buttonWidth + buttonGap); + this.acceptButton.setY(buttonY()); + this.acceptButton.setWidth(buttonWidth); + this.acceptButton.setHeight(buttonHeight()); + this.declineButton.visible = isInsideScrollArea(this.declineButton.getY(), this.declineButton.getHeight()); + this.acceptButton.visible = isInsideScrollArea(this.acceptButton.getY(), this.acceptButton.getHeight()); + } + + private Component additionalMetricsLabel() { + if (this.submitMetricsSelected) { + return Onboarding.submitAdditionalMetrics; + } + + return Onboarding.submitAdditionalMetrics.copy().withStyle(ChatFormatting.GRAY); + } + + private boolean isInsideScrollArea(final int y, final int height) { + return y + height > scrollTop() && y < scrollBottom(); + } + + // fixme: there has to be some inbuilt way to do this, right? RIGHT??? + private void renderScrollbar(final GuiGraphicsExtractor graphics, final int panelX, final int panelY, final int panelWidth, final int panelHeight, final int color) { + final int maxScrollOffset = this.maxScrollOffset(); + if (maxScrollOffset <= 0) { + return; + } + + final int trackX = panelX + panelWidth - 10; + final int trackY = scrollTop(); + final int trackHeight = scrollBottom() - scrollTop(); + final int contentHeight = contentBottom() - scrollTop(); + final int thumbHeight = Math.clamp((long) trackHeight * trackHeight / contentHeight, 18, trackHeight); + final int thumbY = trackY + this.scrollOffset * (trackHeight - thumbHeight) / maxScrollOffset; + graphics.fill(trackX, trackY, trackX + 2, trackY + trackHeight, 0x55FFFFFF); + graphics.fill(trackX - 1, thumbY, trackX + 3, thumbY + thumbHeight, color); + } + + private int descriptionTextWidth() { + final int panelWidth = panelWidth(); + final int contentInset = contentInset(panelWidth); + final int descriptionWidth = panelWidth - contentInset * 2; + return Math.max(1, descriptionWidth - scaled(BOX_PADDING_X) * 2); + } + + private int descriptionHeight() { + return Math.max(scaled(76), this.height / 5); + } + + private int infoHeight() { + return Math.max(scaled(60), this.font.wordWrapHeight(infoDescription(), descriptionTextWidth()) + scaled(BOX_PADDING_Y) * 2); + } + + // todo: maybe an object that keeps track of that? collapsed text and everything… + private Component infoDescription() { + final MutableComponent component = Component.literal("") + .append(collapsible("What data is collected?", this.collectedDataExpanded)); + if (this.collectedDataExpanded) { + for (final var item : alwaysCollectedData) { + component.append("\n· ").append(item); + } + component.append("\nRead more here: ") + .append(link(INFO_URL, INFO_URL)); + } + + component.append("\n\n") + .append(collapsible("List of installed mods that use FastStats", this.installedModsExpanded)); + if (this.installedModsExpanded) mods.forEach(mod -> { + component.append("\n· ").append(mod.name()); + + for (final var metric : mod.metrics()) { + component.append("\n ").append(metric); + } + + final var length = mod.additionalMetrics().length; + if (length == 0) return; + + final boolean expanded = expandedDetails.contains(mod.name() + "-additional"); // todo: still no toggle + final var collapsible = collapsible("Additional Metrics (" + length + ")", expanded); + component.append("\n ").append(collapsible); + if (expanded) for (final var metric : mod.additionalMetrics()) { + component.append("\n · ").append(metric); + } + }); + + return component; + } + + private int panelX() { + return (this.width - panelWidth()) / 2; + } + + private int panelY() { + return scaled(PANEL_MARGIN); + } + + private int panelWidth() { + final int margin = scaled(PANEL_SIDE_MARGIN); + final int availableWidth = Math.max(1, this.width - margin * 2); + return Math.min(availableWidth, scaled(MAX_PANEL_WIDTH)); + } + + private int panelHeight() { + return Math.max(1, this.height - panelY() * 2); + } + + private int contentTop() { + return panelY() + scaled(CONTENT_TOP_OFFSET); + } + + private int buttonHeight() { + return Math.max(BUTTON_HEIGHT, scaled(BUTTON_HEIGHT)); + } + + private int scaled(final int value) { + return Math.max(1, Math.round(value * this.layoutScale())); + } + + private float layoutScale() { + if (this.width <= 0 || this.height <= 0) { + return 1.0F; + } + + final float widthScale = this.width / (float) REFERENCE_WIDTH; + final float heightScale = this.height / (float) REFERENCE_HEIGHT; + return Math.clamp(Math.min(widthScale, heightScale), 0.65F, 1.25F); + } + + private int contentInset(final int panelWidth) { + return Math.clamp(panelWidth / 8, scaled(24), scaled(160)); + } + + private static Component collapsible(final String text, final boolean expanded) { + return Component.literal((expanded ? "˅ " : "˃ ") + text) + .withStyle(ChatFormatting.YELLOW); + } + + private static Component link(final String text, final String url) { + return Component.literal(text).withStyle(style -> style + .withClickEvent(new ClickEvent.OpenUrl(URI.create(url))) + .withColor(ChatFormatting.AQUA) + .withUnderlined(true)); + } + + @Override + public boolean shouldCloseOnEsc() { + return false; + } + } +} diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java index 4bf33bcb..0ab8359b 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java @@ -7,14 +7,16 @@ import dev.faststats.config.SimpleConfig; import dev.faststats.fabric.compat.CompatibilityLayer; import dev.faststats.internal.PlatformLoggerFactory; +import dev.faststats.screen.registry.FastStatsRegistry; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.ModContainer; import org.jetbrains.annotations.Contract; -import java.util.Set; +import java.nio.file.Path; import java.util.ServiceLoader; +import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -37,16 +39,15 @@ public final class FabricContext extends SimpleContext { private final ModContainer mod; private FabricContext(final Factory factory, final dev.faststats.internal.LoggerFactory loggerFactory, final String modId, @Token final String token) { - super(factory, loggerFactory, SimpleConfig.read(FabricLoader.getInstance().getConfigDir() - .resolve("faststats").resolve("config.properties"), loggerFactory - ), "fabric", token); + super(factory, loggerFactory, SimpleConfig.read(configPath(), loggerFactory), "fabric", token); this.mod = FabricLoader.getInstance().getModContainer(modId).orElseThrow(() -> { return new IllegalArgumentException("Mod not found: " + modId); }); this.compatibilityLayer = ServiceLoader.load(CompatibilityLayer.class) .findFirst() .orElseThrow(() -> new IllegalStateException("No Fabric compatibility layer found")); - initializeServices(factory); + initializeManagedServices(factory); + FastStatsRegistry.instance().register(this); switch (FabricLoader.getInstance().getEnvironmentType()) { case CLIENT -> { ready(); @@ -90,12 +91,17 @@ public void scheduleAtFixedRate(final Runnable task, final long initialDelay, fi @Override public void shutdown() { + FastStatsRegistry.instance().unregister(this); super.shutdown(); tasks.forEach(task -> task.cancel(true)); tasks.clear(); executor.shutdown(); } + private static Path configPath() { + return FabricLoader.getInstance().getConfigDir().resolve("faststats").resolve("config.properties"); + } + public static final class Factory extends SimpleContext.Factory { private final String modId; private final @Token String token; diff --git a/fabric/src/main/java/module-info.java b/fabric/src/main/java/module-info.java index 7a85bdcf..9f36466c 100644 --- a/fabric/src/main/java/module-info.java +++ b/fabric/src/main/java/module-info.java @@ -7,7 +7,9 @@ requires com.google.gson; requires dev.faststats.config; + requires dev.faststats.onboarding; requires dev.faststats; + requires java.desktop; requires java.logging; requires net.fabricmc.loader; requires org.slf4j; diff --git a/fabric/versions/1.18-1.18.2/build.gradle.kts b/fabric/versions/1.18-1.18.2/build.gradle.kts new file mode 100644 index 00000000..d34b6f31 --- /dev/null +++ b/fabric/versions/1.18-1.18.2/build.gradle.kts @@ -0,0 +1,24 @@ +extra.set("moduleName", "dev.faststats.fabric.compat.v1_18") +extra.set("publishVersionSuffix", "mc1.18-1.18.2") + +plugins { + id("net.fabricmc.fabric-loom-remap") +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(17) + +tasks.compileJava { + options.release.set(17) +} + +sourceSets.main { + java.srcDir(project(":fabric:versions:1.18-1.21.8").file("src/main/java")); + resources.srcDir(project(":fabric:versions:1.18-1.21.8").file("src/main/resources")) +} + +dependencies { + minecraft("com.mojang:minecraft:1.18.2"); + mappings(loom.officialMojangMappings()); + compileOnly("net.fabricmc.fabric-api:fabric-api:0.77.0+1.18.2"); + compileOnly("net.fabricmc:fabric-loader:0.19.3") +} diff --git a/fabric/versions/1.18-1.21.8/build.gradle.kts b/fabric/versions/1.18-1.21.8/build.gradle.kts index 4fe9a23c..37d94d5f 100644 --- a/fabric/versions/1.18-1.21.8/build.gradle.kts +++ b/fabric/versions/1.18-1.21.8/build.gradle.kts @@ -1,5 +1,5 @@ extra.set("moduleName", "dev.faststats.fabric.compat.v1_18") -extra.set("publishVersionSuffix", "mc1.18-1.21.8") +extra.set("publishVersionSuffix", "mc1.19.4-1.21.8") plugins { id("net.fabricmc.fabric-loom-remap") diff --git a/fabric/versions/1.19-1.19.3/build.gradle.kts b/fabric/versions/1.19-1.19.3/build.gradle.kts new file mode 100644 index 00000000..c479897a --- /dev/null +++ b/fabric/versions/1.19-1.19.3/build.gradle.kts @@ -0,0 +1,24 @@ +extra.set("moduleName", "dev.faststats.fabric.compat.v1_18") +extra.set("publishVersionSuffix", "mc1.19-1.19.3") + +plugins { + id("net.fabricmc.fabric-loom-remap") +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(17) + +tasks.compileJava { + options.release.set(17) +} + +sourceSets.main { + java.srcDir(project(":fabric:versions:1.18-1.21.8").file("src/main/java")) + resources.srcDir(project(":fabric:versions:1.18-1.21.8").file("src/main/resources")) +} + +dependencies { + minecraft("com.mojang:minecraft:1.19.3") + mappings(loom.officialMojangMappings()) + compileOnly("net.fabricmc.fabric-api:fabric-api:0.76.1+1.19.3") + compileOnly("net.fabricmc:fabric-loader:0.19.3") +} diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index 6c1139ec..b58c088a 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -9,4 +9,4 @@ toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd11 toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect -toolchainVersion=21 +toolchainVersion=25 diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java index ae756d8d..a326ca24 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java @@ -9,8 +9,8 @@ final class HytaleMetricsImpl extends SimpleMetrics { private final String pluginVersion; - - HytaleMetricsImpl(final Factory factory, JavaPlugin plugin) throws IllegalStateException { + + HytaleMetricsImpl(final Factory factory, final JavaPlugin plugin) throws IllegalStateException { super(factory); this.pluginVersion = plugin.getManifest().getVersion().toString(); } diff --git a/neoforge/build.gradle.kts b/neoforge/build.gradle.kts index 6d1a0caf..4f90d152 100644 --- a/neoforge/build.gradle.kts +++ b/neoforge/build.gradle.kts @@ -37,9 +37,22 @@ subprojects { apply { plugin("net.neoforged.moddev") } + val onboardingBand = when (project.name) { + "1.20.6-1.21.8" -> ":onboarding:versions:1.19.4-1.21.8" + "1.21.9-1.21.11" -> ":onboarding:versions:1.21.9-1.21.11" + "26.1-26.2" -> ":onboarding:versions:26.1-26.3" + else -> null + } + evaluationDependsOn(":onboarding") + onboardingBand?.let { evaluationDependsOn(it) } + dependencies { compileOnly("net.neoforged:bus:8.0.5") compileOnlyApi(project(":neoforge")) + compileOnlyApi(project(":onboarding")) + onboardingBand?.let { + compileOnlyApi(project(it)) + } } tasks.jar { @@ -47,6 +60,8 @@ subprojects { from(project(":neoforge").sourceSets["main"].output) from(project(":config").sourceSets["main"].output) from(project(":core").sourceSets["main"].output) + from(project(":onboarding").sourceSets["main"].output) + onboardingBand?.let { from(project(it).sourceSets["main"].output) } } } @@ -59,8 +74,7 @@ configurations.configureEach { } dependencies { - compileOnlyApi(project(":core")) - compileOnly(project(":config")) + compileOnlyApi(project(":onboarding")) compileOnly("net.neoforged:bus:8.0.5") } diff --git a/neoforge/example-mod/build.gradle.kts b/neoforge/example-mod/build.gradle.kts index 5b92927c..d6dcd294 100644 --- a/neoforge/example-mod/build.gradle.kts +++ b/neoforge/example-mod/build.gradle.kts @@ -12,6 +12,7 @@ configurations.configureEach { } dependencies { + compileOnly(project(":onboarding")) compileOnly(project(":neoforge:versions:26.1-26.2")) jarJar(project(":neoforge:versions:26.1-26.2")) } diff --git a/neoforge/src/main/java/dev/faststats/neoforge/NeoForgeContext.java b/neoforge/src/main/java/dev/faststats/neoforge/NeoForgeContext.java index bd88b98c..0dcd0563 100644 --- a/neoforge/src/main/java/dev/faststats/neoforge/NeoForgeContext.java +++ b/neoforge/src/main/java/dev/faststats/neoforge/NeoForgeContext.java @@ -1,5 +1,6 @@ package dev.faststats.neoforge; +import dev.faststats.screen.registry.FastStatsRegistry; import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; @@ -17,6 +18,7 @@ import net.neoforged.neoforgespi.language.IModInfo; import org.jetbrains.annotations.Contract; +import java.nio.file.Path; import java.util.ServiceLoader; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; @@ -41,16 +43,15 @@ public final class NeoForgeContext extends SimpleContext { private final IModInfo mod; private NeoForgeContext(final Factory factory, final LoggerFactory loggerFactory, final String modId, @Token final String token) { - super(factory, loggerFactory, SimpleConfig.read(FMLPaths.CONFIGDIR.get() - .resolve("faststats").resolve("config.properties"), loggerFactory - ), "neoforge", token); + super(factory, loggerFactory, SimpleConfig.read(configPath(), loggerFactory), "neoforge", token); this.mod = ModList.get().getModContainerById(modId).map(ModContainer::getModInfo).orElseThrow(() -> { return new IllegalArgumentException("Mod not found: " + modId); }); this.compatibilityLayer = ServiceLoader.load(CompatibilityLayer.class) .findFirst() .orElseThrow(() -> new IllegalStateException("No NeoForge compatibility layer found")); - initializeServices(factory); + initializeManagedServices(factory); + FastStatsRegistry.instance().register(this); switch (compatibilityLayer.getEnvironment()) { case CLIENT -> ready(); case SERVER -> { @@ -91,12 +92,17 @@ protected void scheduleAtFixedRate(final Runnable task, final long initialDelay, @Override public void shutdown() { + FastStatsRegistry.instance().unregister(this); super.shutdown(); tasks.forEach(task -> task.cancel(true)); tasks.clear(); executor.shutdown(); } + private static Path configPath() { + return FMLPaths.CONFIGDIR.get().resolve("faststats").resolve("config.properties"); + } + public static final class Factory extends SimpleContext.Factory { private final String modId; private final @Token String token; diff --git a/neoforge/src/main/java/module-info.java b/neoforge/src/main/java/module-info.java index cc654700..c39a651c 100644 --- a/neoforge/src/main/java/module-info.java +++ b/neoforge/src/main/java/module-info.java @@ -7,14 +7,15 @@ requires com.google.gson; requires dev.faststats.config; + requires dev.faststats.onboarding; requires dev.faststats; requires fml_loader; + requires java.net.http; requires net.neoforged.bus; requires org.slf4j; requires static org.jetbrains.annotations; requires static org.jspecify; - requires java.net.http; uses dev.faststats.neoforge.compat.CompatibilityLayer; } diff --git a/onboarding/build.gradle.kts b/onboarding/build.gradle.kts new file mode 100644 index 00000000..ba059bb4 --- /dev/null +++ b/onboarding/build.gradle.kts @@ -0,0 +1,13 @@ +extra.set("moduleName", "dev.faststats.onboarding") + +java.toolchain.languageVersion = JavaLanguageVersion.of(25) + +tasks.compileJava { + options.release.set(17) +} + +dependencies { + api(project(":config")) + api(project(":core")) + compileOnlyApi("org.jspecify:jspecify:1.0.0") +} diff --git a/onboarding/empty-mappings.tiny b/onboarding/empty-mappings.tiny new file mode 100644 index 00000000..3b2155b7 --- /dev/null +++ b/onboarding/empty-mappings.tiny @@ -0,0 +1 @@ +tiny 2 0 official intermediary named diff --git a/onboarding/src/main/java/dev/faststats/screen/Button.java b/onboarding/src/main/java/dev/faststats/screen/Button.java new file mode 100644 index 00000000..383160ee --- /dev/null +++ b/onboarding/src/main/java/dev/faststats/screen/Button.java @@ -0,0 +1,19 @@ +package dev.faststats.screen; + +import java.util.function.BiConsumer; + +public sealed interface Button extends Element