Compare commits

...
25 changed files with 306 additions and 77 deletions

No files matched your search

+27 -18
View File
@@ -1,4 +1,7 @@
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
import org.jackhuang.hmcl.gradle.mod.ParseModDataTask
import org.jackhuang.hmcl.gradle.utils.PropertiesUtils
import java.net.URI
import java.nio.file.FileSystems
import java.nio.file.Files
@@ -12,30 +15,36 @@ plugins {
alias(libs.plugins.shadow)
}
val isOfficial = System.getenv("HMCL_SIGNATURE_KEY") != null
|| (System.getenv("GITHUB_REPOSITORY_OWNER") == "HMCL-dev" && System.getenv("GITHUB_BASE_REF")
.isNullOrEmpty())
val projectConfig = PropertiesUtils.load(rootProject.file("config/project.properties").toPath())
val isOfficial = JenkinsUtils.IS_ON_CI || GitHubActionUtils.IS_ON_OFFICIAL_REPO
val buildNumber = System.getenv("BUILD_NUMBER")?.toInt().let { number ->
val offset = System.getenv("BUILD_NUMBER_OFFSET")?.toInt() ?: 0
if (number != null) {
(number - offset).toString()
} else {
val shortCommit = System.getenv("GITHUB_SHA")?.lowercase()?.substring(0, 7)
val prefix = if (isOfficial) "dev" else "unofficial"
if (!shortCommit.isNullOrEmpty()) "$prefix-$shortCommit" else "SNAPSHOT"
}
}
val versionRoot = System.getenv("VERSION_ROOT") ?: "3.6"
val versionType = System.getenv("VERSION_TYPE") ?: if (isOfficial) "nightly" else "unofficial"
val versionRoot = System.getenv("VERSION_ROOT") ?: projectConfig.getProperty("versionRoot") ?: "3"
val microsoftAuthId = System.getenv("MICROSOFT_AUTH_ID") ?: ""
val microsoftAuthSecret = System.getenv("MICROSOFT_AUTH_SECRET") ?: ""
val curseForgeApiKey = System.getenv("CURSEFORGE_API_KEY") ?: ""
val launcherExe = System.getenv("HMCL_LAUNCHER_EXE")
val launcherExe = System.getenv("HMCL_LAUNCHER_EXE") ?: ""
version = "$versionRoot.$buildNumber"
val buildNumber = System.getenv("BUILD_NUMBER")?.toInt()
if (buildNumber != null) {
version = if (JenkinsUtils.IS_ON_CI && versionType == "dev") {
"$versionRoot.0.$buildNumber"
} else {
"$versionRoot.$buildNumber"
}
} else {
val shortCommit = System.getenv("GITHUB_SHA")?.lowercase()?.substring(0, 7)
version = if (shortCommit.isNullOrBlank()) {
"$versionRoot.SNAPSHOT"
} else if (isOfficial) {
"$versionRoot.dev-$shortCommit"
} else {
"$versionRoot.unofficial-$shortCommit"
}
}
dependencies {
implementation(project(":HMCLCore"))
@@ -43,7 +52,7 @@ dependencies {
implementation(libs.twelvemonkeys.imageio.webp)
implementation(libs.java.info)
if (launcherExe == null) {
if (launcherExe.isBlank()) {
implementation(libs.hmclauncher)
}
}
@@ -162,7 +171,7 @@ tasks.shadowJar {
}
}
if (launcherExe != null) {
if (launcherExe.isNotBlank()) {
into("assets") {
from(file(launcherExe))
}
@@ -255,16 +255,15 @@ public final class JavaManager {
}
}
private static int compareJavaVersion(JavaRuntime java1, JavaRuntime java2, GameJavaVersion suggestedJavaVersion) {
if (suggestedJavaVersion != null) {
boolean b1 = java1.getParsedVersion() == suggestedJavaVersion.getMajorVersion();
boolean b2 = java2.getParsedVersion() == suggestedJavaVersion.getMajorVersion();
private static JavaRuntime chooseJava(@Nullable JavaRuntime java1, JavaRuntime java2) {
if (java1 == null)
return java2;
if (b1 != b2)
return b1 ? 1 : -1;
}
return java1.getVersionNumber().compareTo(java2.getVersionNumber());
if (java1.getParsedVersion() != java2.getParsedVersion())
// Prefer the Java version that is closer to the game's recommended Java version
return java1.getParsedVersion() < java2.getParsedVersion() ? java1 : java2;
else
return java1.getVersionNumber().compareTo(java2.getVersionNumber()) >= 0 ? java1 : java2;
}
@Nullable
@@ -280,9 +279,6 @@ public final class JavaManager {
&& (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
&& (gameVersion == null || gameVersion.compareTo("1.6") < 0);
GameJavaVersion suggestedJavaVersion =
(version != null && gameVersion != null && gameVersion.compareTo("1.7.10") >= 0) ? version.getJavaVersion() : null;
JavaRuntime mandatory = null;
JavaRuntime suggested = null;
for (JavaRuntime java : javaRuntimes) {
@@ -310,15 +306,10 @@ public final class JavaManager {
}
if (!violationMandatory) {
if (mandatory == null) mandatory = java;
else if (compareJavaVersion(java, mandatory, suggestedJavaVersion) > 0)
mandatory = java;
mandatory = chooseJava(mandatory, java);
if (!violationSuggested) {
if (suggested == null) suggested = java;
else if (compareJavaVersion(java, suggested, suggestedJavaVersion) > 0)
suggested = java;
}
if (!violationSuggested)
suggested = chooseJava(suggested, java);
}
}
@@ -59,7 +59,10 @@ public final class ProxyManager {
String password = config().getProxyPass();
if (username != null || password != null)
return new SimpleAuthenticator(username, password.toCharArray());
return new SimpleAuthenticator(
Lang.requireNonNullElse(username, ""),
Lang.requireNonNullElse(password, "").toCharArray()
);
else
return null;
} else
@@ -22,6 +22,7 @@ import javafx.beans.Observable;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -312,7 +313,9 @@ public class InstallerItem extends Control {
private static final class InstallerItemSkin extends SkinBase<InstallerItem> {
private static final PseudoClass LIST_ITEM = PseudoClass.getPseudoClass("list-item");
private static final PseudoClass CARD = PseudoClass.getPseudoClass("card");
private static final WeakListenerHolder holder = new WeakListenerHolder();
@SuppressWarnings({"FieldCanBeLocal", "unused"})
private final ChangeListener<Number> holder;
InstallerItemSkin(InstallerItem control) {
super(control);
@@ -320,9 +323,14 @@ public class InstallerItem extends Control {
Pane pane;
if (control.style == Style.CARD) {
pane = new VBox();
holder.add(FXUtils.onWeakChange(pane.widthProperty(), v -> FXUtils.setLimitHeight(pane, v.doubleValue() * 0.7)));
if (FXUtils.JAVAFX_MAJOR_VERSION == 8)
holder = FXUtils.onWeakChange(pane.widthProperty(), v -> FXUtils.setLimitHeight(pane, v.doubleValue() * 0.7));
else
holder = FXUtils.onWeakChangeAndOperate(pane.widthProperty(), v -> FXUtils.setLimitHeight(pane, v.doubleValue() * 0.7));
} else {
pane = new HBox();
holder = null;
}
pane.getStyleClass().add("installer-item");
RipplerContainer container = new RipplerContainer(pane);
@@ -78,15 +78,22 @@ public class AccountAdvancedListItem extends AdvancedListItem {
setActionButtonVisible(false);
setOnScroll(event -> {
double deltaY = event.getDeltaY();
if (deltaY == 0)
return;
Account current = account.get();
if (current == null) return;
ObservableList<Account> accounts = Accounts.getAccounts();
int currentIndex = accounts.indexOf(account.get());
if (event.getDeltaY() > 0) { // up
int currentIndex = accounts.indexOf(current);
if (currentIndex < 0) return;
if (deltaY > 0) // up
currentIndex--;
} else { // down
else // down
currentIndex++;
}
Accounts.setSelectedAccount(accounts.get((currentIndex + accounts.size()) % accounts.size()));
});
}
@@ -267,7 +267,7 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
}).executor(true);
};
if (factory instanceof OfflineAccountFactory && username != null && !USERNAME_CHECKER_PATTERN.matcher(username).matches()) {
if (factory instanceof OfflineAccountFactory && username != null && (!USERNAME_CHECKER_PATTERN.matcher(username).matches() || username.length() > 16)) {
JFXButton btnYes = new JFXButton(i18n("button.ok"));
btnYes.getStyleClass().add("dialog-error");
btnYes.setOnAction(e -> doCreate.run());
@@ -124,7 +124,7 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
libraryPane.setVgap(16);
libraryPane.setHgap(16);
if (libraries.length <= 8) {
if (FXUtils.JAVAFX_MAJOR_VERSION < 25 && libraries.length <= 8) {
BorderPane.setMargin(libraryPane, new Insets(16, 0, 16, 0));
root.setCenter(libraryPane);
} else {
@@ -133,6 +133,9 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
scrollPane.setFitToHeight(true);
BorderPane.setMargin(scrollPane, new Insets(16, 0, 16, 0));
root.setCenter(scrollPane);
if (libraries.length <= 8)
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
}
}
@@ -62,12 +62,12 @@ import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.upgrade.RemoteVersion;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.util.List;
import java.util.Objects;
import java.util.stream.IntStream;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.SINE;
@@ -197,15 +197,17 @@ public final class MainPage extends StackPane implements DecoratorPage {
launchPane.setMaxWidth(230);
launchPane.setMaxHeight(55);
launchPane.setOnScroll(event -> {
int index = IntStream.range(0, versions.size())
.filter(i -> versions.get(i).getId().equals(getCurrentGame()))
.findFirst().orElse(-1);
double deltaY = event.getDeltaY();
if (deltaY == 0)
return;
String currentId = getCurrentGame();
int index = Lang.indexWhere(versions, instance -> instance.getId().equals(currentId));
if (index < 0) return;
if (event.getDeltaY() > 0) {
if (deltaY > 0) // up
index--;
} else {
else // down
index++;
}
profile.setSelectedVersion(versions.get((index + versions.size()) % versions.size()).getId());
});
StackPane.setAlignment(launchPane, Pos.BOTTOM_RIGHT);
@@ -124,7 +124,7 @@ account.methods.microsoft.purchase=Buy Minecraft
account.methods.microsoft.snapshot=You are using an unofficial build of HMCL. Please download the official build to log in.
account.methods.microsoft.snapshot.website=Official Website
account.methods.offline=Offline
account.methods.offline.name.special_characters=Using only English letters, numbers, and underscores is recommended
account.methods.offline.name.special_characters=Use only letters, numbers, and underscores (max 16 chars)
account.methods.offline.name.invalid=It is recommended to use only English letters, numbers and underscores for the username, and the length should not exceed 16 characters.\n\
\n\
\ · Legitimate: HuangYu, huang_Yu, Huang_Yu_123;\n\
@@ -131,7 +131,7 @@ account.methods.ban_query=查詢帳戶是否被封禁
account.methods.microsoft.snapshot=你正在使用第三方提供的 HMCL。請下載官方版本進行登入。
account.methods.microsoft.snapshot.website=官方網站
account.methods.offline=離線模式
account.methods.offline.name.special_characters=建議使用英文字母、數字以及底線命名
account.methods.offline.name.special_characters=建議使用英文字母、數字以及底線命名,且長度不超過 16 個字元
account.methods.offline.name.invalid=遊戲使用者名稱建議僅使用英文字母、數字及底線,且長度不超過 16 個字元。\n\
\n\
\ · 一些有效的使用者名稱:HuangYu、huang_Yu、Huang_Yu_123;\n\
@@ -139,7 +139,7 @@ account.methods.ban_query=检测账户是否被封禁
account.methods.microsoft.snapshot=你正在使用第三方提供的 HMCL。请下载官方版本来登录微软账户。
account.methods.microsoft.snapshot.website=官方网站
account.methods.offline=离线模式
account.methods.offline.name.special_characters=建议使用英文字符、数字以及下划线命名
account.methods.offline.name.special_characters=建议使用英文字符、数字以及下划线命名,且长度不超过 16 个字符
account.methods.offline.name.invalid=游戏用户名建议仅使用英文字母、数字及下划线,且长度不超过 16 个字符。\n\
\n\
\ · 一些合法用户名:HuangYu、huang_Yu、Huang_Yu_123;\n\
@@ -78,7 +78,7 @@ public final class BMCLAPIDownloadProvider implements DownloadProvider {
pair("https://maven.fabricmc.net", apiRoot + "/maven"),
pair("https://authlib-injector.yushi.moe", apiRoot + "/mirrors/authlib-injector"),
pair("https://repo1.maven.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
pair("https://zkitefly.github.io/unlisted-versions-of-minecraft", "https://vip.123pan.cn/1821946486/unlisted-versions-of-minecraft")
pair("https://zkitefly.github.io/unlisted-versions-of-minecraft", "https://alist.8mi.tech/d/mirror/unlisted-versions-of-minecraft/Auto")
);
}
@@ -48,9 +48,10 @@ public final class Lang {
/**
* Construct a mutable map by given key-value pairs.
*
* @param pairs entries in the new map
* @param <K> the type of keys
* @param <V> the type of values
* @param <K> the type of keys
* @param <V> the type of values
* @return the map which contains data in {@code pairs}.
*/
@SafeVarargs
@@ -60,9 +61,10 @@ public final class Lang {
/**
* Construct a mutable map by given key-value pairs.
*
* @param pairs entries in the new map
* @param <K> the type of keys
* @param <V> the type of values
* @param <K> the type of keys
* @param <V> the type of values
* @return the map which contains data in {@code pairs}.
*/
public static <K, V> Map<K, V> mapOf(Iterable<Pair<K, V>> pairs) {
@@ -122,9 +124,10 @@ public final class Lang {
/**
* Cast {@code obj} to V dynamically.
* @param obj the object reference to be cast.
*
* @param obj the object reference to be cast.
* @param clazz the class reference of {@code V}.
* @param <V> the type that {@code obj} is being cast to.
* @param <V> the type that {@code obj} is being cast to.
* @return {@code obj} in the type of {@code V}.
*/
public static <V> Optional<V> tryCast(Object obj, Class<V> clazz) {
@@ -154,8 +157,8 @@ public final class Lang {
/**
* Join two collections into one list.
*
* @param a one collection, to be joined.
* @param b another collection to be joined.
* @param a one collection, to be joined.
* @param b another collection to be joined.
* @param <T> the super type of elements in {@code a} and {@code b}
* @return the joint collection
*/
@@ -172,6 +175,16 @@ public final class Lang {
return list == null ? null : list.isEmpty() ? null : new ArrayList<>(list);
}
public static <T> int indexWhere(List<T> list, Predicate<T> predicate) {
int idx = 0;
for (T value : list) {
if (predicate.test(value))
return idx;
idx++;
}
return -1;
}
public static void executeDelayed(Runnable runnable, TimeUnit timeUnit, long timeout, boolean isDaemon) {
thread(() -> {
try {
@@ -185,6 +198,7 @@ public final class Lang {
/**
* Start a thread invoking {@code runnable} immediately.
*
* @param runnable code to run.
* @return the reference of the started thread
*/
@@ -194,8 +208,9 @@ public final class Lang {
/**
* Start a thread invoking {@code runnable} immediately.
*
* @param runnable code to run
* @param name the name of thread
* @param name the name of thread
* @return the reference of the started thread
*/
public static Thread thread(Runnable runnable, String name) {
@@ -204,8 +219,9 @@ public final class Lang {
/**
* Start a thread invoking {@code runnable} immediately.
*
* @param runnable code to run
* @param name the name of thread
* @param name the name of thread
* @param isDaemon true if thread will be terminated when only daemon threads are running.
* @return the reference of the started thread
*/
@@ -258,7 +274,8 @@ public final class Lang {
/**
* Find the first non-null reference in given list.
* @param t nullable references list.
*
* @param t nullable references list.
* @param <T> the type of nullable references
* @return the first non-null reference.
*/
@@ -831,4 +831,19 @@
1.21.7-rc2
1.21.7
1.21.8-rc1
1.21.8
1.21.8
25w31a
25w32a
25w33a
25w34a
25w34b
25w35a
25w36a
25w36b
25w37a
1.21.9-pre1
1.21.9-pre2
1.21.9-pre3
1.21.9-pre4
1.21.9-rc1
1.21.9
@@ -13,6 +13,7 @@ public final class JavaRuntimeTest {
public void testParseVersion() {
assertEquals(8, parseVersion("1.8.0_302"));
assertEquals(8, parseVersion("1.8-internal"));
assertEquals(8, parseVersion("8.0"));
assertEquals(11, parseVersion("11"));
assertEquals(11, parseVersion("11.0.12"));
assertEquals(11, parseVersion("11-internal"));
+8 -1
View File
@@ -18,7 +18,14 @@ subprojects {
name = "libs"
dirs = setOf(rootProject.file("lib"))
}
mavenCentral()
System.getenv("MAVEN_CENTRAL_REPO").let { repo ->
if (repo.isNullOrBlank())
mavenCentral()
else
maven(url = repo)
}
maven(url = "https://jitpack.io")
maven(url = "https://libraries.minecraft.net")
}
+6 -1
View File
@@ -1,5 +1,10 @@
repositories {
mavenCentral()
System.getenv("MAVEN_CENTRAL_REPO").let { repo ->
if (repo.isNullOrBlank())
mavenCentral()
else
maven(url = repo)
}
}
dependencies {
@@ -0,0 +1,32 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <[email protected]> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.gradle.ci;
import java.util.Objects;
/// @author Glavo
public final class GitHubActionUtils {
private static final String OFFICIAL_ORGANIZATION = "HMCL-dev";
public static final boolean IS_ON_OFFICIAL_REPO =
OFFICIAL_ORGANIZATION.equalsIgnoreCase(System.getenv("GITHUB_REPOSITORY_OWNER"))
&& Objects.requireNonNullElse(System.getenv("GITHUB_BASE_REF"), "").isBlank();
private GitHubActionUtils() {
}
}
@@ -0,0 +1,27 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <[email protected]> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.gradle.ci;
/// @author Glavo
public final class JenkinsUtils {
public static final boolean IS_ON_CI = "1".equals(System.getenv("HMCL_CI"));
private JenkinsUtils() {
}
}
@@ -0,0 +1,39 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <[email protected]> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.gradle.utils;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
/// @author Glavo
public final class PropertiesUtils {
public static @NotNull Properties load(Path path) throws IOException {
Properties properties = new Properties();
try (var reader = Files.newBufferedReader(path)) {
properties.load(reader);
}
return properties;
}
private PropertiesUtils() {
}
}
-5
View File
@@ -1,5 +0,0 @@
#!/usr/bin/env bash
_HMCL_PROJECT_ROOT=$(dirname "${BASH_SOURCE[0]}")
sed -i 's,//services.gradle.org/distributions/,//mirrors.cloud.tencent.com/gradle/,g' "$_HMCL_PROJECT_ROOT/gradle/wrapper/gradle-wrapper.properties"
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
sed -i 's,//services.gradle.org/distributions/,//mirrors.cloud.tencent.com/gradle/,g' "$PWD/gradle/wrapper/gradle-wrapper.properties"
+23
View File
@@ -0,0 +1,23 @@
pipeline {
agent any
environment {
HMCL_CI = '1'
VERSION_TYPE = 'dev'
MICROSOFT_AUTH_ID = credentials('microsoft_auth_id')
MICROSOFT_AUTH_SECRET = credentials('microsoft_auth_secret')
CURSEFORGE_API_KEY = credentials('curseforge_api_key')
HMCL_SIGNATURE_KEY = credentials('hmcl_signature_key')
}
stages {
stage('Build') {
steps {
sh 'bash ./config/jenkins/config-jenkins.sh'
sh './gradlew clean makeExecutables --stacktrace --no-daemon'
archiveArtifacts artifacts: 'HMCL/build/libs/*'
}
}
}
}
+23
View File
@@ -0,0 +1,23 @@
pipeline {
agent any
environment {
HMCL_CI = '1'
VERSION_TYPE = 'stable'
MICROSOFT_AUTH_ID = credentials('microsoft_auth_id')
MICROSOFT_AUTH_SECRET = credentials('microsoft_auth_secret')
CURSEFORGE_API_KEY = credentials('curseforge_api_key')
HMCL_SIGNATURE_KEY = credentials('hmcl_signature_key')
}
stages {
stage('Build') {
steps {
sh 'bash ./config/jenkins/config-jenkins.sh'
sh './gradlew clean makeExecutables --stacktrace --no-daemon'
archiveArtifacts artifacts: 'HMCL/build/libs/*'
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
#
# Hello Minecraft! Launcher
# Copyright (C) 2025 huangyuhui <[email protected]> and contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
versionRoot=3.6