diff --git a/1.12.2/NOTICE b/1.12.2/NOTICE new file mode 100644 index 0000000..ee6501d --- /dev/null +++ b/1.12.2/NOTICE @@ -0,0 +1,4 @@ +Universal Config uses Gson from Google. + +Gson is licensed under the Apache License, Version 2.0. +The applicable license text is included in licenses/Apache-2.0.txt. diff --git a/1.12.2/build.gradle b/1.12.2/build.gradle index 2e56c52..41f4bff 100644 --- a/1.12.2/build.gradle +++ b/1.12.2/build.gradle @@ -13,7 +13,7 @@ apply plugin: 'net.minecraftforge.gradle' apply plugin: 'eclipse' apply plugin: 'maven-publish' -version = '1.0.0-mc1.12.2-forge' +version = '1.1.0-mc1.12.2-forge' group = 'com.example.universalconfig' archivesBaseName = 'universal_config' @@ -60,6 +60,7 @@ dependencies { // that the dep is a ForgeGradle 'patcher' dependency. And it's patches will be applied. // The userdev artifact is a special name and will get all sorts of transformations applied to it. minecraft 'net.minecraftforge:forge:1.12.2-14.23.5.2864' + testCompile 'junit:junit:4.13.2' // You may put jars on which you depend on in ./libs or you may define them like so.. // compile "some.group:artifact:version:classifier" @@ -134,4 +135,6 @@ jar { into 'META-INF' rename { 'LICENSE_universal_config' } } + from('NOTICE') { into 'META-INF' } + from('licenses') { into 'META-INF/licenses' } } diff --git a/1.12.2/licenses/Apache-2.0.txt b/1.12.2/licenses/Apache-2.0.txt new file mode 100644 index 0000000..b84f370 --- /dev/null +++ b/1.12.2/licenses/Apache-2.0.txt @@ -0,0 +1,175 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You meet + the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works + a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that + You distribute, all copyright, patent, trademark, and attribution + notices from the Source form of the Work, excluding those notices + that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing + the origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/1.12.2/src/main/java/com/example/universalconfig/core/CurrentProcessRestartService.java b/1.12.2/src/main/java/com/example/universalconfig/core/CurrentProcessRestartService.java index 439899d..8f119c1 100644 --- a/1.12.2/src/main/java/com/example/universalconfig/core/CurrentProcessRestartService.java +++ b/1.12.2/src/main/java/com/example/universalconfig/core/CurrentProcessRestartService.java @@ -28,7 +28,15 @@ private CurrentProcessRestartService() { } public static void scheduleRestartAfterCurrentProcessExit() throws UniversalConfigException { - scheduleRestartAfterCurrentProcessExit(currentWorkingDirectory(), Collections.emptyList()); + scheduleRestartAfterCurrentProcessExit(currentWorkingDirectory(), currentProcessArguments()); + } + + public static List currentProcessArguments() { + try { + return ProcessDiscovery.current().arguments(); + } catch (RuntimeException ignored) { + return Collections.emptyList(); + } } /** @@ -38,7 +46,7 @@ public static void scheduleRestartAfterCurrentProcessExit() throws UniversalConf */ public static void scheduleRestartAfterCurrentProcessExit(Path workingDirectory) throws UniversalConfigException { - scheduleRestartAfterCurrentProcessExit(workingDirectory, Collections.emptyList()); + scheduleRestartAfterCurrentProcessExit(workingDirectory, currentProcessArguments()); } /** @@ -52,12 +60,12 @@ public static void scheduleRestartAfterCurrentProcessExit( Path helperClasspath ) throws UniversalConfigException { scheduleRestartAfterCurrentProcessExit( - workingDirectory, Collections.emptyList(), helperClasspath); + workingDirectory, currentProcessArguments(), helperClasspath); } /** - * Schedules the current Minecraft process to be replaced, using loader-provided arguments when the operating - * system does not expose the current process arguments through its process metadata API. + * Schedules the current Minecraft process to be replaced. Unsupported launchers use the loader-resolved Java + * arguments supplied by the loader adapter instead of reconstructing a command line from process metadata. */ public static void scheduleRestartAfterCurrentProcessExit( Path workingDirectory, @@ -78,7 +86,6 @@ private static void scheduleRestartAfterCurrentProcessExit( } Path normalizedWorkingDirectory = normalizeWorkingDirectory(workingDirectory); List ancestors = ancestorCommands(current); - Optional> currentArguments = currentJavaArguments(current.arguments(), loaderResolvedArguments); // Modrinth's documented launch URL requires a database-only internal ID that is not inherited by the game. // Guessing it from the folder name could launch the wrong profile, so only self-identifying launchers are used. Optional replacementCandidate = prismFamilyLauncherCommand( @@ -86,13 +93,9 @@ private static void scheduleRestartAfterCurrentProcessExit( if (!replacementCandidate.isPresent()) { replacementCandidate = atLauncherCommand(normalizedWorkingDirectory, ancestors); } - if (!replacementCandidate.isPresent()) { - replacementCandidate = gdLauncherCommand( - normalizedWorkingDirectory, ancestors, executable, currentArguments); - } LaunchCommand replacement = replacementCandidate.isPresent() ? replacementCandidate.get() - : currentArguments.isPresent() ? new LaunchCommand(executable, currentArguments.get()) : null; + : unsupportedLauncherCommand(executable, loaderResolvedArguments).orElse(null); if (replacement == null) { throw new UniversalConfigException("Could not determine how to restart this launcher instance."); } @@ -125,23 +128,79 @@ public static List buildJavaLaunchArguments( } } - private static Optional> currentJavaArguments( - List processArguments, + /** + * Builds the default restart command for a launcher without a dedicated integration. + * + *

The launcher is intentionally not inspected here. Every launcher that is not recognized by a dedicated + * detector follows this path, including GDLauncher and the official launcher.

+ */ + static Optional unsupportedLauncherCommand( + String currentExecutable, List loaderResolvedArguments - ) throws UniversalConfigException { + ) { + if (!isJavaExecutable(currentExecutable)) { + return Optional.empty(); + } + return validJavaLaunchArguments(loaderResolvedArguments) + .map(new java.util.function.Function, LaunchCommand>() { + @Override + public LaunchCommand apply(List arguments) { + return new LaunchCommand(currentExecutable, arguments); + } + }); + } + + private static Optional> validJavaLaunchArguments(List arguments) { + if (arguments == null || arguments.isEmpty()) { + return Optional.empty(); + } try { - if (processArguments != null && !processArguments.isEmpty()) { - return Optional.of(immutableCopy(processArguments)); + List copied = immutableCopy(arguments); + for (String value : copied) { + if (value == null || value.trim().isEmpty()) { + return Optional.empty(); + } } - if (loaderResolvedArguments == null || loaderResolvedArguments.isEmpty()) { - return Optional.empty(); + for (int index = 0; index + 2 < copied.size(); index++) { + if (("-cp".equals(copied.get(index)) || "-classpath".equals(copied.get(index))) + && !copied.get(index + 1).trim().isEmpty()) { + return hasMainClassAfter(copied, index + 2) ? Optional.of(copied) : Optional.>empty(); + } } - return Optional.of(immutableCopy(loaderResolvedArguments)); + return Optional.empty(); } catch (RuntimeException ex) { - throw new UniversalConfigException("Could not determine the current Java arguments.", ex); + return Optional.empty(); } } + private static boolean hasMainClassAfter(List arguments, int startIndex) { + int index = startIndex; + while (index < arguments.size()) { + String value = arguments.get(index); + if (!value.startsWith("-")) return true; + if (isInlineJvmOption(value)) { + index++; + } else if (isJvmOptionWithSeparateValue(value) && index + 1 < arguments.size()) { + index += 2; + } else { + return false; + } + } + return false; + } + + private static boolean isInlineJvmOption(String value) { + return value.startsWith("-D") || value.startsWith("-X") + || (value.startsWith("--") && value.indexOf('=') > 2); + } + + private static boolean isJvmOptionWithSeparateValue(String value) { + return "--add-modules".equals(value) || "--add-exports".equals(value) + || "--add-opens".equals(value) || "--add-reads".equals(value) + || "--limit-modules".equals(value) || "-p".equals(value) || "--module-path".equals(value) + || "--upgrade-module-path".equals(value) || "--patch-module".equals(value); + } + private static void scheduleJavaHelper( long currentPid, String helperJavaExecutable, @@ -348,42 +407,6 @@ static Optional atLauncherCommand( return Optional.empty(); } - static Optional gdLauncherCommand( - Path workingDirectory, - List ancestorCommands, - String currentExecutable, - Optional> currentArguments - ) { - if (!isGdLauncherGameDirectory(workingDirectory) - || !ancestorCommands.stream().anyMatch(command -> isGdLauncherExecutable(command.executable())) - || !ancestorCommands.stream().anyMatch(command -> isGdCoreModuleExecutable(command.executable())) - || !isJavaExecutable(currentExecutable)) { - return Optional.empty(); - } - - // Carbon does not provide a stable command-line launch interface for an instance. The core module supervises - // the Java child, so reusing the already resolved Java command is the only way to preserve the selected - // loader, natives, assets, account, and instance-specific arguments without guessing launcher internals. - return currentArguments.map(arguments -> new LaunchCommand(currentExecutable, arguments)); - } - - private static boolean isGdLauncherGameDirectory(Path workingDirectory) { - try { - Path gameDirectory = workingDirectory.toAbsolutePath().normalize(); - Path instanceDirectory = gameDirectory.getParent(); - Path instancesDirectory = instanceDirectory == null ? null : instanceDirectory.getParent(); - return gameDirectory.getFileName() != null - && gameDirectory.getFileName().toString().equalsIgnoreCase("instance") - && instanceDirectory != null - && Files.isRegularFile(instanceDirectory.resolve("instance.json")) - && instancesDirectory != null - && instancesDirectory.getFileName() != null - && instancesDirectory.getFileName().toString().equalsIgnoreCase("instances"); - } catch (RuntimeException ex) { - return false; - } - } - private static Optional> atLauncherJarArguments( List ancestorArguments, List launchArguments @@ -575,16 +598,6 @@ private static boolean isAtLauncherExecutable(String command) { return fileName.equalsIgnoreCase("atlauncher.exe") || fileName.equalsIgnoreCase("atlauncher"); } - private static boolean isGdLauncherExecutable(String command) { - String fileName = fileName(command); - return fileName.equalsIgnoreCase("gdlauncher.exe") || fileName.equalsIgnoreCase("gdlauncher"); - } - - private static boolean isGdCoreModuleExecutable(String command) { - String fileName = fileName(command); - return fileName.equalsIgnoreCase("core_module.exe") || fileName.equalsIgnoreCase("core_module"); - } - private static boolean isJavaExecutable(String command) { String fileName = fileName(command); return fileName.equalsIgnoreCase("java.exe") diff --git a/1.12.2/src/main/java/com/example/universalconfig/core/FileOperationLogger.java b/1.12.2/src/main/java/com/example/universalconfig/core/FileOperationLogger.java index 8db6802..9be027b 100644 --- a/1.12.2/src/main/java/com/example/universalconfig/core/FileOperationLogger.java +++ b/1.12.2/src/main/java/com/example/universalconfig/core/FileOperationLogger.java @@ -7,14 +7,29 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.regex.Pattern; public final class FileOperationLogger { + private static final Pattern ABSOLUTE_WINDOWS_PATH = Pattern.compile( + "(?i)(?])/(?:[^\\s\\t,;:/]+/)*[^\\s\\t,;:/]+" + ); private static final String LAUNCH_ID = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS") .format(java.time.LocalDateTime.now()) + "-pid" + currentProcessId(); private static Path configuredRoot; + private static Path instanceRoot; + private static Path userHomeRoot; + private static Path appDataRoot; private static Path launchLogFile; private static Path latestLogFile; @@ -22,11 +37,23 @@ private FileOperationLogger() { } public static synchronized void configure(UniversalConfigSettings settings) { + configure(settings, null); + } + + public static synchronized void configure(UniversalConfigSettings settings, Path minecraftRunDirectory) { Path root = settings.rootDirectory().toAbsolutePath().normalize(); - if (root.equals(configuredRoot) && launchLogFile != null && latestLogFile != null) { + Path instance = normalizeOrNull(minecraftRunDirectory); + if (root.equals(configuredRoot) + && equalsPath(instance, instanceRoot) + && launchLogFile != null + && latestLogFile != null) { return; } configuredRoot = root; + instanceRoot = instance; + userHomeRoot = normalizeOrNull(Paths.get(System.getProperty("user.home", "."))); + String appData = System.getenv("APPDATA"); + appDataRoot = appData == null || appData.trim().isEmpty() ? null : normalizeOrNull(Paths.get(appData)); Path logsRoot = root.resolve(UniversalConfigFormat.LOGS_DIRECTORY_NAME); launchLogFile = logsRoot.resolve(UniversalConfigFormat.LAUNCH_LOGS_DIRECTORY_NAME) .resolve(UniversalConfigFormat.LAUNCH_LOG_FILE_PREFIX + LAUNCH_ID + ".log"); @@ -70,12 +97,12 @@ private static synchronized void write(String level, String operation, Path path .append(LAUNCH_ID).append('\t') .append(level).append('\t') .append(operation).append('\t') - .append(path == null ? "-" : path.toAbsolutePath().normalize()).append('\t') - .append(detail == null ? "" : detail.replace('\n', ' ').replace('\r', ' ')); + .append(sanitizePath(path)).append('\t') + .append(sanitizeText(detail)); if (throwable != null) { StringWriter writer = new StringWriter(); throwable.printStackTrace(new PrintWriter(writer)); - line.append('\t').append(writer.toString().replace('\n', ' ').replace('\r', ' ')); + line.append('\t').append(sanitizeText(writer.toString())); } line.append(System.lineSeparator()); byte[] encoded = line.toString().getBytes(StandardCharsets.UTF_8); @@ -88,6 +115,112 @@ private static synchronized void write(String level, String operation, Path path } } + static synchronized String sanitizePath(Path path) { + if (path == null) { + return "-"; + } + Path normalized = normalizeOrNull(path); + if (normalized == null) { + return ""; + } + List labels = new ArrayList(); + labels.add(new PathLabel(instanceRoot, "")); + labels.add(new PathLabel(configuredRoot, "")); + labels.add(new PathLabel(appDataRoot, "")); + labels.add(new PathLabel(userHomeRoot, "")); + List filtered = new ArrayList(); + for (PathLabel label : labels) { + if (label.root() != null) { + filtered.add(label); + } + } + // 深いパスほど先に一致させる。ファイル名キーは相対パス全体を含むため、 + // ルートが他のルートの接頭辞になる場合でもより具体的なラベルが優先される。 + Collections.sort(filtered, new Comparator() { + @Override + public int compare(PathLabel left, PathLabel right) { + return Integer.compare(right.root().getNameCount(), left.root().getNameCount()); + } + }); + for (PathLabel label : filtered) { + if (normalized.startsWith(label.root())) { + Path relative = label.root().relativize(normalized); + return relative.getNameCount() == 0 + ? label.label() + : label.label() + "/" + relative.toString().replace('\\', '/'); + } + } + Path fileName = normalized.getFileName(); + return fileName == null ? "" : "/" + fileName; + } + + static synchronized String sanitizeText(String value) { + if (value == null || value.trim().isEmpty()) { + return value == null ? "" : value; + } + String sanitized = value.replace('\n', ' ').replace('\r', ' '); + List labels = new ArrayList(); + labels.add(new PathLabel(instanceRoot, "")); + labels.add(new PathLabel(configuredRoot, "")); + labels.add(new PathLabel(appDataRoot, "")); + labels.add(new PathLabel(userHomeRoot, "")); + List filtered = new ArrayList(); + for (PathLabel label : labels) { + if (label.root() != null) { + filtered.add(label); + } + } + // 長いルート文字列ほど先に置換する。短いルートが先だと、共通接頭辞を持つ深いパスの + // 一部だけが置換され、残った相対部分が絶対パス正規表現に誤判定される可能性がある。 + Collections.sort(filtered, new Comparator() { + @Override + public int compare(PathLabel left, PathLabel right) { + return Integer.compare(right.root().toString().length(), left.root().toString().length()); + } + }); + for (PathLabel label : filtered) { + String nativeRoot = label.root().toString(); + sanitized = sanitized.replace(nativeRoot, label.label()) + .replace(nativeRoot.replace('\\', '/'), label.label()); + } + return ABSOLUTE_UNIX_PATH.matcher( + ABSOLUTE_WINDOWS_PATH.matcher(sanitized).replaceAll("") + ).replaceAll("").replace('\\', '/'); + } + + private static Path normalizeOrNull(Path path) { + if (path == null) { + return null; + } + try { + return path.toAbsolutePath().normalize(); + } catch (RuntimeException ex) { + return null; + } + } + + private static boolean equalsPath(Path left, Path right) { + return left == null ? right == null : left.equals(right); + } + + static final class PathLabel { + private final Path root; + private final String label; + + PathLabel(Path root, String label) { + this.root = root; + this.label = label; + } + + Path root() { + return root; + } + + String label() { + return label; + } + } + private static long currentProcessId() { String runtimeName = ManagementFactory.getRuntimeMXBean().getName(); int separator = runtimeName.indexOf('@'); diff --git a/1.12.2/src/main/java/com/example/universalconfig/core/ProfileCreateOptions.java b/1.12.2/src/main/java/com/example/universalconfig/core/ProfileCreateOptions.java index ffa7fc9..591bb34 100644 --- a/1.12.2/src/main/java/com/example/universalconfig/core/ProfileCreateOptions.java +++ b/1.12.2/src/main/java/com/example/universalconfig/core/ProfileCreateOptions.java @@ -7,6 +7,7 @@ public final class ProfileCreateOptions { public String name; public String description; public String icon = ProfileIcon.GRASS_BLOCK; + // 画面の初期選択はUI側で管理する。非UIの呼び出し元では従来どおり全項目を保存対象にする。 public boolean includeKeybinds = true; public boolean includeClientOptions = true; public boolean includeModConfigs = true; diff --git a/1.12.2/src/main/java/com/example/universalconfig/core/ProfileService.java b/1.12.2/src/main/java/com/example/universalconfig/core/ProfileService.java index a26512c..e786749 100644 --- a/1.12.2/src/main/java/com/example/universalconfig/core/ProfileService.java +++ b/1.12.2/src/main/java/com/example/universalconfig/core/ProfileService.java @@ -19,6 +19,8 @@ import java.util.Collections; import java.util.Comparator; import java.util.HashSet; +import java.util.Map; +import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -172,14 +174,145 @@ public List listProfiles() throws UniversalConfigException { } } summaries.sort(Comparator.comparing((ProfileSummary summary) -> safeUpdatedAt(summary.manifest())).reversed()); - FileOperationLogger.info("LIST_PROFILES", profiles, "count=" + summaries.size()); - return summaries; + List ordered = applyProfileOrder(summaries); + FileOperationLogger.info("LIST_PROFILES", profiles, "count=" + ordered.size()); + return ordered; } catch (IOException ex) { FileOperationLogger.failure("LIST_PROFILES", profiles, "failed", ex); throw new UniversalConfigException("Failed to list profiles.", ex); } } + public void renameProfile(Path profilePath, String name) throws UniversalConfigException { + Path normalized = validateProfilePath(profilePath); + String requestedName = name == null ? "" : name.trim(); + if (requestedName.trim().isEmpty()) { + throw new UniversalConfigException("Profile name is required."); + } + if (requestedName.length() > 128) { + throw new UniversalConfigException("Profile name is too long."); + } + + try (ProfileDirectoryLock ignored = lockProfileDirectory()) { + ProfileManifest manifest = readManifest(normalized); + String uniqueName = uniqueProfileName(requestedName, normalized); + if (Objects.equals(manifest.name, uniqueName)) { + return; + } + rewriteProfileName(normalized, uniqueName); + FileOperationLogger.info("RENAME_PROFILE", normalized, + "from=" + manifest.name + " to=" + uniqueName); + } + } + + public void moveProfile(Path instancePath, Path profilePath, int targetIndex) throws UniversalConfigException { + Path normalized = validateProfilePath(profilePath); + try (ProfileDirectoryLock ignored = lockProfileDirectory()) { + List summaries = listProfiles(); + int currentIndex = indexOfProfile(summaries, normalized); + if (currentIndex < 0) { + throw new UniversalConfigException("The selected profile is unavailable."); + } + if (targetIndex < 0 || targetIndex >= summaries.size()) { + throw new UniversalConfigException("The selected profile position is invalid."); + } + if (currentIndex == targetIndex) { + return; + } + + List order = new ArrayList(); + for (ProfileSummary summary : summaries) { + String key = profileOrderKey(summary); + if (key != null) { + order.add(key); + } + } + String movedKey = profileOrderKey(summaries.get(currentIndex)); + order.remove(movedKey); + order.add(targetIndex, movedKey); + settings.setProfileOrder(order); + UniversalConfigPaths.saveSettings(instancePath, settings); + FileOperationLogger.info("MOVE_PROFILE", normalized, "targetIndex=" + targetIndex); + } + } + + private List applyProfileOrder(List summaries) { + List configuredOrder = settings.profileOrder(); + if (configuredOrder.isEmpty()) { + return summaries; + } + + Map byKey = new HashMap(); + for (ProfileSummary summary : summaries) { + String key = profileOrderKey(summary); + if (key != null) { + byKey.putIfAbsent(key, summary); + } + } + + // 過去バージョンで id ベースのキーを保存済みの設定との互換性のため、id も補助インデックスとして残す。 + Map byLegacyIdKey = new HashMap(); + for (ProfileSummary summary : summaries) { + String legacyIdKey = legacyProfileOrderKey(summary); + if (legacyIdKey != null) { + byLegacyIdKey.putIfAbsent(legacyIdKey, summary); + } + } + + List ordered = new ArrayList(summaries.size()); + Set included = new HashSet(); + for (String key : configuredOrder) { + // ファイル名キーを優先し、無ければ旧 id キーで解決する。 + ProfileSummary summary = byKey.get(key); + if (summary == null) { + summary = byLegacyIdKey.get(key); + } + if (summary != null) { + String fileKey = profileOrderKey(summary); + if (fileKey != null && included.add(fileKey)) { + ordered.add(summary); + } + } + } + for (ProfileSummary summary : summaries) { + String key = profileOrderKey(summary); + if (key == null || included.add(key)) { + ordered.add(summary); + } + } + return ordered; + } + + private int indexOfProfile(List summaries, Path profilePath) { + for (int index = 0; index < summaries.size(); index++) { + if (summaries.get(index).path().toAbsolutePath().normalize().equals(profilePath)) { + return index; + } + } + return -1; + } + + private String profileOrderKey(ProfileSummary summary) { + if (summary == null) { + return null; + } + // ファイル名を並び順キーに使う。manifest.id は複製が元 id を保持するため一意性が保証されず、 + // id ベースの重複排除で複製が一覧から消える問題がある(PR #43 P1)。 + // rename や duplicate でもファイル名は変化しないため、ファイル名は安定した識別子になる。 + Path fileName = summary.path() == null ? null : summary.path().getFileName(); + return fileName == null ? null : "file:" + fileName; + } + + // 過去バージョンで settings.profileOrder に id ベースキーを保存していた設定との互換用。 + // 新規保存では使わない。applyProfileOrder のフォールバック解決でのみ参照する。 + private String legacyProfileOrderKey(ProfileSummary summary) { + if (summary == null || summary.manifest() == null) { + return null; + } + String id = summary.manifest().id; + return id == null || id.trim().isEmpty() ? null : "id:" + id; + } + public List listBackups() throws UniversalConfigException { Path backups = UniversalConfigPaths.backupsDirectory(settings); FileOperationLogger.info("LIST_BACKUPS", backups, "start"); diff --git a/1.12.2/src/main/java/com/example/universalconfig/core/UniversalConfigPaths.java b/1.12.2/src/main/java/com/example/universalconfig/core/UniversalConfigPaths.java index ef2767a..a76b4a4 100644 --- a/1.12.2/src/main/java/com/example/universalconfig/core/UniversalConfigPaths.java +++ b/1.12.2/src/main/java/com/example/universalconfig/core/UniversalConfigPaths.java @@ -12,6 +12,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Locale; +import java.util.List; public final class UniversalConfigPaths { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); @@ -40,14 +41,16 @@ public static UniversalConfigSettings loadOrCreateSettings(Path minecraftRunDire UniversalConfigSettings settings = new UniversalConfigSettings(Paths.get(dto.rootDirectory)); boolean invalidDefaultProfilePath = hasInvalidDefaultProfilePath(dto.defaultProfilePath); settings.setDefaultProfilePath(parseDefaultProfilePath(dto.defaultProfilePath)); + settings.setProfileOrder(dto.profileOrder); ensureDirectories(settings); - FileOperationLogger.configure(settings); + FileOperationLogger.configure(settings, minecraftRunDirectory); Path sharedSettingsFile = rootSettingsFile(settings); if (Files.isRegularFile(sharedSettingsFile)) { SettingsDto sharedDto = readSettingsDto(sharedSettingsFile); invalidDefaultProfilePath = hasInvalidDefaultProfilePath(sharedDto == null ? null : sharedDto.defaultProfilePath); settings.setDefaultProfilePath(parseDefaultProfilePath( sharedDto == null ? null : sharedDto.defaultProfilePath)); + settings.setProfileOrder(sharedDto == null ? null : sharedDto.profileOrder); } else { // Older versions stored the default only inside one instance. Migrate it to the shared root. saveRootSettings(settings); @@ -65,12 +68,13 @@ public static UniversalConfigSettings loadOrCreateSettings(Path minecraftRunDire UniversalConfigSettings settings = new UniversalConfigSettings(defaultRootDirectory()); ensureDirectories(settings); - FileOperationLogger.configure(settings); + FileOperationLogger.configure(settings, minecraftRunDirectory); Path sharedSettingsFile = rootSettingsFile(settings); if (Files.isRegularFile(sharedSettingsFile)) { SettingsDto sharedDto = readSettingsDto(sharedSettingsFile); settings.setDefaultProfilePath(parseDefaultProfilePath( sharedDto == null ? null : sharedDto.defaultProfilePath)); + settings.setProfileOrder(sharedDto == null ? null : sharedDto.profileOrder); } saveSettings(minecraftRunDirectory, settings); return settings; @@ -85,6 +89,7 @@ public static void saveSettings(Path minecraftRunDirectory, UniversalConfigSetti dto.defaultProfilePath = settings.defaultProfilePath() == null ? null : settings.defaultProfilePath().toAbsolutePath().normalize().toString(); + dto.profileOrder = settings.profileOrder(); try (Writer writer = Files.newBufferedWriter(localSettings, StandardCharsets.UTF_8)) { GSON.toJson(dto, writer); } @@ -160,6 +165,7 @@ public static String safeFileSlug(String value) { private static final class SettingsDto { String rootDirectory; String defaultProfilePath; + List profileOrder; } private static SettingsDto readSettingsDto(Path path) throws UniversalConfigException { @@ -179,6 +185,7 @@ private static void saveRootSettings(UniversalConfigSettings settings) throws Un dto.defaultProfilePath = settings.defaultProfilePath() == null ? null : settings.defaultProfilePath().toAbsolutePath().normalize().toString(); + dto.profileOrder = settings.profileOrder(); try (Writer writer = Files.newBufferedWriter(rootSettings, StandardCharsets.UTF_8)) { GSON.toJson(dto, writer); } diff --git a/1.12.2/src/main/java/com/example/universalconfig/core/UniversalConfigSettings.java b/1.12.2/src/main/java/com/example/universalconfig/core/UniversalConfigSettings.java index 174087c..0e94647 100644 --- a/1.12.2/src/main/java/com/example/universalconfig/core/UniversalConfigSettings.java +++ b/1.12.2/src/main/java/com/example/universalconfig/core/UniversalConfigSettings.java @@ -1,10 +1,14 @@ package com.example.universalconfig.core; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; public final class UniversalConfigSettings { private Path rootDirectory; private Path defaultProfilePath; + private List profileOrder = new ArrayList(); public UniversalConfigSettings(Path rootDirectory) { this.rootDirectory = rootDirectory; @@ -25,4 +29,20 @@ public Path defaultProfilePath() { public void setDefaultProfilePath(Path defaultProfilePath) { this.defaultProfilePath = defaultProfilePath; } + + public List profileOrder() { + return Collections.unmodifiableList(profileOrder); + } + + public void setProfileOrder(List profileOrder) { + this.profileOrder = new ArrayList(); + if (profileOrder == null) { + return; + } + for (String profileKey : profileOrder) { + if (profileKey != null && !profileKey.trim().isEmpty() && !this.profileOrder.contains(profileKey)) { + this.profileOrder.add(profileKey); + } + } + } } diff --git a/1.12.2/src/main/java/com/example/universalconfig/core/ZipArchiveReader.java b/1.12.2/src/main/java/com/example/universalconfig/core/ZipArchiveReader.java index 2f39b94..d1d917d 100644 --- a/1.12.2/src/main/java/com/example/universalconfig/core/ZipArchiveReader.java +++ b/1.12.2/src/main/java/com/example/universalconfig/core/ZipArchiveReader.java @@ -16,11 +16,25 @@ public final class ZipArchiveReader implements ProfileArchiveReader { public ZipArchiveReader(Path archivePath) throws IOException, UniversalConfigException { this.archivePath = archivePath; this.zipFile = new ZipFile(archivePath.toFile()); - FileOperationLogger.info("OPEN_ZIP", archivePath, "read"); - Enumeration entries = zipFile.entries(); - while (entries.hasMoreElements()) { - ZipEntry entry = entries.nextElement(); - ZipSecurity.validateRelativeEntryName(entry.getName()); + try { + FileOperationLogger.info("OPEN_ZIP", archivePath, "read"); + long totalUncompressedSize = 0; + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + ZipSecurity.validateRelativeEntryName(entry.getName()); + if (!entry.isDirectory()) { + totalUncompressedSize = ZipSecurity.validateEntrySizes( + entry.getName(), entry.getSize(), entry.getCompressedSize(), totalUncompressedSize); + } + } + } catch (UniversalConfigException | RuntimeException ex) { + try { + zipFile.close(); + } catch (IOException closeException) { + ex.addSuppressed(closeException); + } + throw ex; } } @@ -43,8 +57,14 @@ public InputStream open(String entryName) throws IOException, UniversalConfigExc @Override public List entries() { - List names = new ArrayList<>(); - zipFile.stream().filter(entry -> !entry.isDirectory()).forEach(entry -> names.add(entry.getName())); + List names = new ArrayList(); + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (!entry.isDirectory()) { + names.add(entry.getName()); + } + } return names; } diff --git a/1.12.2/src/main/java/com/example/universalconfig/core/ZipArchiveWriter.java b/1.12.2/src/main/java/com/example/universalconfig/core/ZipArchiveWriter.java index ac9c235..2cb34dd 100644 --- a/1.12.2/src/main/java/com/example/universalconfig/core/ZipArchiveWriter.java +++ b/1.12.2/src/main/java/com/example/universalconfig/core/ZipArchiveWriter.java @@ -18,8 +18,11 @@ public static void write(Path path, Map entries) throws IOExcept Files.createDirectories(path.getParent()); FileOperationLogger.info("CREATE_DIRECTORY", path.getParent(), "zip parent"); try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(path))) { + long totalUncompressedSize = 0; for (Map.Entry entry : entries.entrySet()) { ZipSecurity.validateRelativeEntryName(entry.getKey()); + totalUncompressedSize = ZipSecurity.validateUncompressedSize( + entry.getKey(), entry.getValue().length, totalUncompressedSize); output.putNextEntry(new ZipEntry(entry.getKey())); output.write(entry.getValue()); output.closeEntry(); diff --git a/1.12.2/src/main/java/com/example/universalconfig/core/ZipSecurity.java b/1.12.2/src/main/java/com/example/universalconfig/core/ZipSecurity.java index 5aad6d6..c6d9282 100644 --- a/1.12.2/src/main/java/com/example/universalconfig/core/ZipSecurity.java +++ b/1.12.2/src/main/java/com/example/universalconfig/core/ZipSecurity.java @@ -4,6 +4,15 @@ import java.nio.file.Paths; public final class ZipSecurity { + /** Maximum uncompressed size accepted for one profile or backup entry. */ + public static final long MAX_ENTRY_UNCOMPRESSED_BYTES = 32L * 1024L * 1024L; + + /** Maximum combined uncompressed size accepted for one archive. */ + public static final long MAX_TOTAL_UNCOMPRESSED_BYTES = 256L * 1024L * 1024L; + + /** Maximum declared expansion ratio accepted for a non-empty entry. */ + public static final long MAX_COMPRESSION_RATIO = 1_000L; + private ZipSecurity() { } @@ -44,4 +53,42 @@ public static Path safeResolve(Path destinationRoot, String entryName) throws Un } return resolved; } + + /** + * Validates central-directory size metadata before any archive payload is read. + * The returned value is the accumulated uncompressed size for the archive. + */ + public static long validateEntrySizes( + String entryName, + long uncompressedSize, + long compressedSize, + long currentTotal + ) throws UniversalConfigException { + if (uncompressedSize < 0 || compressedSize < 0) { + throw new UniversalConfigException("ZIP entry size metadata is missing: " + entryName); + } + long total = validateUncompressedSize(entryName, uncompressedSize, currentTotal); + if (uncompressedSize > 0 + && (compressedSize == 0 || uncompressedSize / compressedSize > MAX_COMPRESSION_RATIO)) { + throw new UniversalConfigException("ZIP entry compression ratio is unsafe: " + entryName); + } + return total; + } + + public static long validateUncompressedSize( + String entryName, + long uncompressedSize, + long currentTotal + ) throws UniversalConfigException { + if (uncompressedSize < 0) { + throw new UniversalConfigException("ZIP entry size is invalid: " + entryName); + } + if (uncompressedSize > MAX_ENTRY_UNCOMPRESSED_BYTES) { + throw new UniversalConfigException("ZIP entry exceeds the uncompressed size limit: " + entryName); + } + if (currentTotal < 0 || currentTotal > MAX_TOTAL_UNCOMPRESSED_BYTES - uncompressedSize) { + throw new UniversalConfigException("ZIP archive exceeds the total uncompressed size limit."); + } + return currentTotal + uncompressedSize; + } } diff --git a/1.12.2/src/main/java/com/example/universalconfig/forgelegacy/LegacyScreens.java b/1.12.2/src/main/java/com/example/universalconfig/forgelegacy/LegacyScreens.java index 1d9ed9f..1acc2e2 100644 --- a/1.12.2/src/main/java/com/example/universalconfig/forgelegacy/LegacyScreens.java +++ b/1.12.2/src/main/java/com/example/universalconfig/forgelegacy/LegacyScreens.java @@ -46,7 +46,9 @@ public final class LegacyScreens { private static final int PROFILE_ICON_SIZE = 28; private static final int MORE_MENU_WIDTH = 132; private static final int MORE_MENU_ITEM_GAP = 2; - private static final int MORE_MENU_ITEM_COUNT = 5; + private static final int MORE_MENU_ITEM_COUNT = 6; + private static final int REORDER_BUTTON_WIDTH = 20; + private static final int REORDER_BUTTON_HEIGHT = 16; private static final int SCROLLBAR_WIDTH = 3; private static final int PANEL = 0xD0181818; private static final int PANEL_INNER = 0xB00B0B0B; @@ -131,6 +133,10 @@ public static class ProfileList extends Base implements GuiYesNoCallback { private Path deleteConfirmationPath; private boolean draggingScrollbar; private int scrollbarDragOffset; + private int draggingProfileIndex = -1; + private int dragTargetIndex = -1; + private int dragStartY; + private boolean draggingProfile; public ProfileList(GuiScreen parent) { super(parent); @@ -152,16 +158,23 @@ public void initGui() { tr("screen.universal_config.use_profile"))); buttonList.add(createButton(4, actionX + useWidth + 4, actionY, moreWidth, BUTTON_HEIGHT, tr("screen.universal_config.more"))); + // moreMenuの項目順序は main 側の ProfileListScreen に合わせる。 + // default / open_folder / rename / duplicate / backups / delete の6項目。 buttonList.add(createButton(30, moreMenuLeft(), moreMenuButtonY(0), moreMenuWidth(), BUTTON_HEIGHT, "")); buttonList.add(createButton(31, moreMenuLeft(), moreMenuButtonY(1), moreMenuWidth(), BUTTON_HEIGHT, - tr("screen.universal_config.duplicate"))); - buttonList.add(createButton(32, moreMenuLeft(), moreMenuButtonY(2), moreMenuWidth(), BUTTON_HEIGHT, tr("screen.universal_config.open_folder"))); + buttonList.add(createButton(32, moreMenuLeft(), moreMenuButtonY(2), moreMenuWidth(), BUTTON_HEIGHT, + tr("screen.universal_config.rename"))); buttonList.add(createButton(33, moreMenuLeft(), moreMenuButtonY(3), moreMenuWidth(), BUTTON_HEIGHT, - tr("screen.universal_config.backups"))); + tr("screen.universal_config.duplicate"))); buttonList.add(createButton(34, moreMenuLeft(), moreMenuButtonY(4), moreMenuWidth(), BUTTON_HEIGHT, + tr("screen.universal_config.backups"))); + buttonList.add(createButton(35, moreMenuLeft(), moreMenuButtonY(5), moreMenuWidth(), BUTTON_HEIGHT, tr("screen.universal_config.delete"))); buttonList.add(createButton(0, width - 28, 6, 20, BUTTON_HEIGHT, "×")); + // 並べ替えボタン(↑/↓)は表示行ごとに追加する。scroll 後の位置を再計算できるように + // initGui の末尾で構築し、cardWidth の右端に配置する(PR #43)。 + addReorderButtons(); if (pending) { int y = HEADER_HEIGHT + 5; buttonList.add(createButton(9, rightPanelRight() - 194, y, 92, BUTTON_HEIGHT, @@ -282,7 +295,7 @@ private void updateButtons() { for (Object value : buttonList) { GuiButton button = (GuiButton) value; if (button.id == 2 || button.id == 4) button.enabled = has; - if (button.id >= 30 && button.id <= 34) { + if (button.id >= 30 && button.id <= 35) { button.visible = moreMenuOpen; button.enabled = has; } @@ -302,6 +315,51 @@ private GuiButton findButton(int id) { return null; } + private void addReorderButtons() { + // 並べ替えボタン(ID 40=↑, 41=↓)は cardWidth の右端に配置し、 + // 表示行ごとに初期化する。scroll 変化で位置を再計算するため initGui 末尾で呼ぶ(PR #43)。 + for (int i = scroll; i < Math.min(profiles.size(), scroll + visibleRows()); i++) { + int y = listTop() + (i - scroll) * CARD_STEP; + int x = cardLeft() + cardWidth() - REORDER_BUTTON_WIDTH - 4; + GuiButton up = createButton(40, x, y + 3, REORDER_BUTTON_WIDTH, REORDER_BUTTON_HEIGHT, "↑"); + up.enabled = i > 0; + GuiButton down = createButton(41, x, y + 3 + REORDER_BUTTON_HEIGHT + 1, + REORDER_BUTTON_WIDTH, REORDER_BUTTON_HEIGHT, "↓"); + down.enabled = i < profiles.size() - 1; + buttonList.add(up); + buttonList.add(down); + } + } + + private void moveProfile(int sourceIndex, int targetIndex) { + // main 側の ProfileListScreen.moveProfile に合わせ、範囲外・同一インデックスは無視する。 + if (sourceIndex < 0 || sourceIndex >= profiles.size() + || targetIndex < 0 || targetIndex >= profiles.size() + || sourceIndex == targetIndex) { + return; + } + Path path = profiles.get(sourceIndex).path(); + try { + service().moveProfile(LegacyPlatform.gameDirectory(), path, targetIndex); + initGui(); + selectProfile(path); + } catch (Exception ex) { + fail("screen.universal_config.action_failed", ex); + } + } + + private void selectProfile(Path path) { + // 並び替え前のインデックスは再読込後には別プロフィールを指す場合がある。 + // 操作対象を維持するため、安定したファイルパスで移動元を選択し直す。 + for (int i = 0; i < profiles.size(); i++) { + if (profiles.get(i).path().equals(path)) { + selected = i; + updateButtons(); + return; + } + } + } + private int moreMenuWidth() { return Math.min(MORE_MENU_WIDTH, rightPanelRight() - rightPanelLeft() - PANEL_PADDING * 2); } @@ -354,29 +412,37 @@ protected void actionPerformed(GuiButton button) { } break; case 31: + if (path != null) Desktop.getDesktop().browse(path.getParent().toUri()); + moreMenuOpen = false; + break; + case 32: + if (path != null) { + moreMenuOpen = false; + mc.displayGuiScreen(new ProfileRename(this, path, profileName())); + } + break; + case 33: if (path != null) { service().duplicateProfile(path); moreMenuOpen = false; initGui(); } break; - case 32: - if (path != null) Desktop.getDesktop().browse(path.getParent().toUri()); - moreMenuOpen = false; - break; - case 33: + case 34: moreMenuOpen = false; mc.displayGuiScreen(new Backups(this)); break; - case 34: + case 35: if (path != null) { deleteConfirmationPath = path; moreMenuOpen = false; mc.displayGuiScreen(new GuiYesNo(this, tr("screen.universal_config.delete_confirm", profileName()), - tr("screen.universal_config.delete_warning"), 34)); + tr("screen.universal_config.delete_warning"), 35)); } break; + case 40: moveProfile(selected, selected - 1); break; + case 41: moveProfile(selected, selected + 1); break; case 9: restart(); break; case 10: service().clearPendingImport(LegacyPlatform.gameDirectory()); initGui(); break; default: break; @@ -405,7 +471,7 @@ private void restart() { @Override public void confirmClicked(boolean result, int id) { mc.displayGuiScreen(this); - if (result && id == 34 && deleteConfirmationPath != null) { + if (result && id == 35 && deleteConfirmationPath != null) { try { service().deleteProfile(LegacyPlatform.gameDirectory(), deleteConfirmationPath); initGui(); @@ -418,7 +484,13 @@ public void confirmClicked(boolean result, int id) { @Override public void handleMouseInput() { + int eventButton = Mouse.getEventButton(); + boolean leftReleased = eventButton == 0 && !Mouse.getEventButtonState(); try { super.handleMouseInput(); } catch (Exception ex) { fail("screen.universal_config.action_failed", ex); } + if (leftReleased && draggingProfileIndex >= 0) { + finishProfileDrag(Mouse.getEventX() * width / mc.displayWidth, + height - Mouse.getEventY() * height / mc.displayHeight - 1); + } int wheel = Mouse.getEventDWheel(); if (wheel != 0) { scroll += wheel < 0 ? 1 : -1; @@ -429,6 +501,8 @@ public void handleMouseInput() { @Override protected void mouseClicked(int mouseX, int mouseY, int mouseButton) { draggingScrollbar = false; + draggingProfileIndex = -1; + draggingProfile = false; if (mouseButton == 0 && hasScrollbar() && mouseX >= scrollbarLeft() - 2 && mouseX < scrollbarLeft() + SCROLLBAR_WIDTH + 2 && mouseY >= listTop() && mouseY < listBottom()) { @@ -452,15 +526,21 @@ protected void mouseClicked(int mouseX, int mouseY, int mouseButton) { moreMenuOpen = false; updateButtons(); } - try { super.mouseClicked(mouseX, mouseY, mouseButton); } catch (Exception ex) { fail("screen.universal_config.action_failed", ex); } - if (mouseX >= cardLeft() && mouseX < cardLeft() + cardWidth() - && mouseY >= listTop() && mouseY < listBottom()) { - int index = scroll + (mouseY - listTop()) / CARD_STEP; - if (index >= 0 && index < profiles.size()) { - selected = index; - updateButtons(); + if (mouseButton == 0) { + int profileIndex = profileIndexAt(mouseX, mouseY); + int reorderDelta = reorderDeltaAt(mouseX, mouseY); + if (profileIndex >= 0 && reorderDelta != 0) { + moveProfile(profileIndex, profileIndex + reorderDelta); + return; + } + if (profileIndex >= 0) { + draggingProfileIndex = profileIndex; + dragTargetIndex = profileIndex; + dragStartY = mouseY; + return; } } + try { super.mouseClicked(mouseX, mouseY, mouseButton); } catch (Exception ex) { fail("screen.universal_config.action_failed", ex); } } @Override @@ -469,6 +549,15 @@ protected void mouseClickMove(int mouseX, int mouseY, int clickedMouseButton, lo scrollToThumbPosition(mouseY); return; } + if (draggingProfileIndex >= 0 && clickedMouseButton == 0) { + if (!draggingProfile && Math.abs(mouseY - dragStartY) > 3) { + draggingProfile = true; + } + if (draggingProfile) { + dragTargetIndex = dropIndexAt(mouseY); + } + return; + } try { super.mouseClickMove(mouseX, mouseY, clickedMouseButton, timeSinceLastClick); } catch (Exception ex) { @@ -476,6 +565,49 @@ protected void mouseClickMove(int mouseX, int mouseY, int clickedMouseButton, lo } } + private void finishProfileDrag(int mouseX, int mouseY) { + int sourceIndex = draggingProfileIndex; + int targetIndex = dragTargetIndex; + boolean wasDragging = draggingProfile; + draggingProfileIndex = -1; + dragTargetIndex = -1; + draggingProfile = false; + if (wasDragging) { + moveProfile(sourceIndex, targetIndex); + } else if (profileIndexAt(mouseX, mouseY) == sourceIndex) { + selected = sourceIndex; + updateButtons(); + } + } + + private int profileIndexAt(int mouseX, int mouseY) { + if (mouseX < cardLeft() || mouseX >= cardLeft() + cardWidth() + || mouseY < listTop() || mouseY >= listBottom()) { + return -1; + } + int rowOffset = mouseY - listTop(); + int index = scroll + rowOffset / CARD_STEP; + return index >= 0 && index < profiles.size() && rowOffset % CARD_STEP < CARD_HEIGHT ? index : -1; + } + + private int reorderDeltaAt(int mouseX, int mouseY) { + int index = profileIndexAt(mouseX, mouseY); + if (index < 0) return 0; + int x = cardLeft() + cardWidth() - REORDER_BUTTON_WIDTH - 4; + int y = listTop() + (index - scroll) * CARD_STEP + 3; + if (mouseX >= x && mouseX < x + REORDER_BUTTON_WIDTH + && mouseY >= y && mouseY < y + REORDER_BUTTON_HEIGHT) return -1; + int downY = y + REORDER_BUTTON_HEIGHT + 1; + return mouseX >= x && mouseX < x + REORDER_BUTTON_WIDTH + && mouseY >= downY && mouseY < downY + REORDER_BUTTON_HEIGHT ? 1 : 0; + } + + private int dropIndexAt(int mouseY) { + int relativeY = mouseY - listTop() - CARD_HEIGHT / 2; + int index = scroll + relativeY / CARD_STEP; + return Math.max(0, Math.min(profiles.size() - 1, index)); + } + @Override public void drawScreen(int mouseX, int mouseY, float partialTicks) { drawDefaultBackground(); @@ -503,7 +635,7 @@ public void drawScreen(int mouseX, int mouseY, float partialTicks) { int iconY = y + (CARD_HEIGHT - PROFILE_ICON_SIZE) / 2; LegacyVersionBridge.drawProfileIcon(mc, manifest.icon, iconX, iconY, PROFILE_ICON_SIZE); int textX = iconX + PROFILE_ICON_SIZE + 8; - int textWidth = cardLeft() + cardWidth() - textX - 7; + int textWidth = cardLeft() + cardWidth() - textX - REORDER_BUTTON_WIDTH - 12; if (isDefault(profiles.get(i).path())) { String marker = tr("screen.universal_config.default_marker"); int markerWidth = Math.min(font().getStringWidth(marker), textWidth); @@ -534,6 +666,11 @@ public void drawScreen(int mouseX, int mouseY, float partialTicks) { moreMenuBottom(), 0xFF181818); } super.drawScreen(mouseX, mouseY, partialTicks); + if (draggingProfile && dragTargetIndex >= 0 && dragTargetIndex != draggingProfileIndex) { + int indicatorY = listTop() + (dragTargetIndex - scroll) * CARD_STEP + + (dragTargetIndex > draggingProfileIndex ? CARD_HEIGHT - 1 : -1); + drawRect(cardLeft(), indicatorY, cardLeft() + cardWidth(), indicatorY + 2, 0xFFD8E3FF); + } drawStatus(); } @@ -571,6 +708,10 @@ private void drawDetails() { drawString(font(), font().trimStringToWidth(tr("screen.universal_config.loader_summary", loader, loaderVersion), contentWidth - PROFILE_ICON_SIZE - 8), infoX, line + 15, MUTED); line += 35; + // 詳細画面に .ucp ファイル名を表示し、複数プロファイルで実体を区別できるようにする(PR #43)。 + String fileName = profiles.get(selected).path().getFileName().toString(); + drawString(font(), font().trimStringToWidth(fileName, contentWidth), innerX, line, MUTED); + line += 12; String description = manifest.description == null || manifest.description.trim().isEmpty() ? tr("screen.universal_config.description_none") : manifest.description; for (Object value : font().listFormattedStringToWidth(description, contentWidth)) { @@ -581,18 +722,24 @@ private void drawDetails() { } if (manifest.includes != null) { line += 5; - drawRect(innerX, line, innerX + contentWidth, line + 1, DIVIDER); - line += 8; - drawString(font(), tr("screen.universal_config.included_settings"), innerX, line, TEXT); - line += 14; - line = drawIncluded(manifest.includes.keybinds, "screen.universal_config.target_keybinds", innerX, line); - line = drawIncluded(manifest.includes.clientOptions, "screen.universal_config.target_client", innerX, line); - drawIncluded(manifest.includes.modConfigs, "screen.universal_config.target_mods", innerX, line); + if (line + font().FONT_HEIGHT < bottom) { + drawRect(innerX, line, innerX + contentWidth, line + 1, DIVIDER); + line += 8; + drawString(font(), tr("screen.universal_config.included_settings"), innerX, line, TEXT); + line += 14; + line = drawIncluded(manifest.includes.keybinds, "screen.universal_config.target_keybinds", + innerX, line, bottom); + line = drawIncluded(manifest.includes.clientOptions, "screen.universal_config.target_client", + innerX, line, bottom); + drawIncluded(manifest.includes.modConfigs, "screen.universal_config.target_mods", + innerX, line, bottom); + } } } - private int drawIncluded(boolean included, String key, int x, int y) { - if (included) { + private int drawIncluded(boolean included, String key, int x, int y, int bottom) { + // 小さいウィンドウでは詳細欄の下に操作ボタンがある。内容をボタン領域へ描画しない。 + if (included && y + font().FONT_HEIGHT <= bottom) { drawString(font(), "✓", x, y, 0x55FF55); drawString(font(), tr(key), x + 14, y, TEXT); return y + 14; @@ -610,12 +757,96 @@ private String safe(String value) { } } + private static final class ProfileRename extends Base { + // main 側の ProfileRenameScreen を旧Forgeへ移植した画面。GuiTextField で新しい名前を入力し、 + // ProfileService.renameProfile でマニフェストを更新する(PR #43)。 + private static final int FORM_WIDTH = 300; + private static final int FIELD_Y = 64; + private final Path profilePath; + private GuiTextField nameField; + + private ProfileRename(GuiScreen parent, Path profilePath, String initialName) { + super(parent); + this.profilePath = profilePath; + } + + @Override + public void initGui() { + Keyboard.enableRepeatEvents(true); + buttonList.clear(); + int left = (width - FORM_WIDTH) / 2; + nameField = LegacyVersionBridge.textField(100, font(), left, FIELD_Y, FORM_WIDTH, 20); + nameField.setMaxStringLength(128); + // 親画面から渡された名前を初期値とし、空の場合はそのままにする。 + String initial = ""; + try { + ProfileManifest manifest = service().readManifest(profilePath); + initial = manifest.name == null ? "" : manifest.name; + } catch (Exception ignored) { + // マニフェスト読み込み失敗時は空欄から始め、ユーザーが入力できるようにする。 + } + nameField.setText(initial); + buttonList.add(createButton(1, width / 2 - 104, height - 32, 100, BUTTON_HEIGHT, + tr("screen.universal_config.rename"))); + buttonList.add(createButton(0, width / 2 + 4, height - 32, 100, BUTTON_HEIGHT, + tr("screen.universal_config.back"))); + } + + private void rename() { + String value = nameField.getText(); + if (value == null || value.trim().isEmpty()) { + status = tr("screen.universal_config.profile_name_required"); + return; + } + try { + service().renameProfile(profilePath, value); + back(); + } catch (Exception ex) { + fail("screen.universal_config.rename_failed", ex); + } + } + + @Override + public void updateScreen() { + nameField.updateCursorCounter(); + } + + @Override + protected void actionPerformed(GuiButton button) { + if (button.id == 0) back(); + if (button.id == 1) rename(); + } + + @Override + protected void keyTyped(char typedChar, int keyCode) { + if (keyCode == Keyboard.KEY_ESCAPE) { back(); return; } + nameField.textboxKeyTyped(typedChar, keyCode); + } + + @Override + protected void mouseClicked(int mouseX, int mouseY, int mouseButton) { + try { super.mouseClicked(mouseX, mouseY, mouseButton); } catch (Exception ex) { fail("screen.universal_config.action_failed", ex); } + nameField.mouseClicked(mouseX, mouseY, mouseButton); + } + + @Override + public void drawScreen(int mouseX, int mouseY, float partialTicks) { + drawDefaultBackground(); + drawCenteredString(font(), tr("screen.universal_config.profile_rename_title"), width / 2, 18, TEXT); + int left = (width - FORM_WIDTH) / 2; + drawString(font(), tr("screen.universal_config.profile_rename_label"), left, 44, MUTED); + nameField.drawTextBox(); + super.drawScreen(mouseX, mouseY, partialTicks); + drawStatus(); + } + } + private static final class ProfileCreate extends Base { private GuiTextField name; private GuiTextField description; private boolean keybinds = true; private boolean client = true; - private boolean mods = true; + private boolean mods = false; private int iconIndex; private static final String[] ICONS = {ProfileIcon.GRASS_BLOCK, ProfileIcon.CRAFTING_TABLE, ProfileIcon.BOOKSHELF, ProfileIcon.COBBLESTONE, ProfileIcon.TNT, ProfileIcon.CHEST, ProfileIcon.FURNACE, ProfileIcon.DIAMOND_BLOCK}; diff --git a/1.12.2/src/main/java/com/example/universalconfig/forgelegacy/LegacyTitleButtonHandler.java b/1.12.2/src/main/java/com/example/universalconfig/forgelegacy/LegacyTitleButtonHandler.java index 747439c..e8d802f 100644 --- a/1.12.2/src/main/java/com/example/universalconfig/forgelegacy/LegacyTitleButtonHandler.java +++ b/1.12.2/src/main/java/com/example/universalconfig/forgelegacy/LegacyTitleButtonHandler.java @@ -7,20 +7,46 @@ import net.minecraft.util.ResourceLocation; import net.minecraftforge.client.event.GuiScreenEvent; import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.FMLCommonHandler; import org.lwjgl.opengl.GL11; /** Adds a responsive lower-left main-menu entry while keeping Forge's credit area unobstructed. */ public final class LegacyTitleButtonHandler { private static final int BUTTON_ID = 0x5543; + private static final int BUTTON_SIZE = 20; + private static final int BUTTON_MARGIN = 4; + // バニラの左下システム文字列(バージョン表記など)の描画位置を基準にする。 + // 1.12.2のGuiMainMenuは height-10 の位置に文字列を描画するため、その上に最小限の間隔を確保する。 + private static final int SYSTEM_TEXT_BOTTOM_OFFSET = 10; + private static final int SYSTEM_TEXT_GAP = 2; + // title_screen_button.png の実寸。画像を差し替える場合は描画APIへ渡す実寸・UV領域と + // この定数を必ず一致させる。ボタンの位置・サイズ・描画先サイズは変更しない。 + private static final int ICON_TEXTURE_WIDTH = 15; + private static final int ICON_TEXTURE_HEIGHT = 15; @SubscribeEvent public void onInitGui(GuiScreenEvent.InitGuiEvent.Post event) { if (event.getGui() instanceof GuiMainMenu) { - event.getButtonList().add(new TitleIconButton(BUTTON_ID, 4, - Math.max(4, event.getGui().height - 70))); + // 画面初期化のたびにイベントが呼ばれるため、既存ボタンを除去してから追加し直す。 + // これによりタイトル画面の再初期化でボタンが重複登録されるのを防ぐ(PR #43)。 + event.getButtonList().removeIf(button -> button instanceof TitleIconButton); + int y = titleButtonY(event.getGui().height); + event.getButtonList().add(new TitleIconButton(BUTTON_ID, BUTTON_MARGIN, y)); } } + // バニラの左下システム文字列の上にボタンを配置する。 + // 固定の高さオフセットではなく文字列の描画位置から計算することで、画面サイズに応じた配置になる。 + private static int titleButtonY(int screenHeight) { + int systemTextY = screenHeight - SYSTEM_TEXT_BOTTOM_OFFSET; + int brandingLines = FMLCommonHandler.instance().getBrandings(true).size(); + int brandingStep = Minecraft.getMinecraft().fontRenderer.FONT_HEIGHT + 1; + // Forge places branding line 0 at systemTextY and each additional line one step above it. Anchoring the + // button bottom to the top line's Y removes the artificial blank row without overlapping any glyphs. + int topBrandingY = systemTextY - Math.max(0, brandingLines - 1) * brandingStep; + return Math.max(BUTTON_MARGIN, topBrandingY - BUTTON_SIZE - SYSTEM_TEXT_GAP); + } + @SubscribeEvent public void onAction(GuiScreenEvent.ActionPerformedEvent.Pre event) { if (event.getGui() instanceof GuiMainMenu && event.getButton() instanceof TitleIconButton @@ -47,7 +73,8 @@ public void drawButton(Minecraft minecraft, int mouseX, int mouseY, float partia GL11.glEnable(GL11.GL_BLEND); GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); Gui.drawScaledCustomSizeModalRect(x + 2, y + 2, - 0.0F, 0.0F, 128, 128, 16, 16, 128.0F, 128.0F); + 0.0F, 0.0F, ICON_TEXTURE_WIDTH, ICON_TEXTURE_HEIGHT, 16, 16, + (float) ICON_TEXTURE_WIDTH, (float) ICON_TEXTURE_HEIGHT); GL11.glDisable(GL11.GL_BLEND); } } diff --git a/1.12.2/src/main/java/com/example/universalconfig/forgelegacy/UniversalConfigLegacyMod.java b/1.12.2/src/main/java/com/example/universalconfig/forgelegacy/UniversalConfigLegacyMod.java index b11c591..c2eaa67 100644 --- a/1.12.2/src/main/java/com/example/universalconfig/forgelegacy/UniversalConfigLegacyMod.java +++ b/1.12.2/src/main/java/com/example/universalconfig/forgelegacy/UniversalConfigLegacyMod.java @@ -7,24 +7,17 @@ import com.example.universalconfig.core.UniversalConfigFormat; import com.example.universalconfig.core.UniversalConfigPaths; import com.example.universalconfig.core.UniversalConfigSettings; -import net.minecraft.client.Minecraft; -import net.minecraft.client.settings.KeyBinding; -import net.minecraftforge.fml.client.registry.ClientRegistry; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.common.event.FMLInitializationEvent; import net.minecraftforge.fml.common.event.FMLPreInitializationEvent; -import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; -import net.minecraftforge.fml.common.gameevent.TickEvent; -import org.lwjgl.input.Keyboard; import java.nio.file.Path; -@Mod(modid = UniversalConfigLegacyMod.MOD_ID, name = "Universal Config", version = "1.0.0", +@Mod(modid = UniversalConfigLegacyMod.MOD_ID, name = "Universal Config", version = "1.1.0", acceptedMinecraftVersions = "[1.12.2]", clientSideOnly = true, guiFactory = "com.example.universalconfig.forgelegacy.UniversalConfigGuiFactory") public final class UniversalConfigLegacyMod { public static final String MOD_ID = UniversalConfigFormat.MOD_ID; - private static KeyBinding openKey; @Mod.EventHandler public void preInit(FMLPreInitializationEvent event) { @@ -56,9 +49,9 @@ public void preInit(FMLPreInitializationEvent event) { @Mod.EventHandler public void init(FMLInitializationEvent event) { - openKey = new KeyBinding("key.universal_config.open", Keyboard.KEY_U, "category.universal_config"); - ClientRegistry.registerKeyBinding(openKey); - net.minecraftforge.fml.common.FMLCommonHandler.instance().bus().register(this); + // 設定画面はタイトル画面のボタンから開けるため、キーバインドは登録しない(Issue #35)。 + // ゲーム中に頻繁に開く用途ではなく、未割り当てのキーがキー設定一覧に残ると混乱を招く。 + // 過去に登録していたキーが options.txt に残っていても、未登録の KeyBinding は無視される。 net.minecraftforge.common.MinecraftForge.EVENT_BUS.register(new LegacyTitleButtonHandler()); try { // Always normalize the loaded locale. A profile from a newer Minecraft version can leave a lowercase @@ -68,12 +61,4 @@ public void init(FMLInitializationEvent event) { FileOperationLogger.failure("STARTUP_RELOAD_OPTIONS", LegacyPlatform.gameDirectory(), "failed", ex); } } - - @SubscribeEvent - public void onClientTick(TickEvent.ClientTickEvent event) { - if (event.phase == TickEvent.Phase.END && openKey != null && openKey.isPressed()) { - Minecraft minecraft = Minecraft.getMinecraft(); - minecraft.displayGuiScreen(new LegacyScreens.ProfileList(minecraft.currentScreen)); - } - } } diff --git a/1.12.2/src/main/resources/assets/universal_config/icon.png b/1.12.2/src/main/resources/assets/universal_config/icon.png index 2ed8a92..d3f6dd6 100644 Binary files a/1.12.2/src/main/resources/assets/universal_config/icon.png and b/1.12.2/src/main/resources/assets/universal_config/icon.png differ diff --git a/1.12.2/src/main/resources/assets/universal_config/lang/en_us.json b/1.12.2/src/main/resources/assets/universal_config/lang/en_us.json index 856d064..79db6b3 100644 --- a/1.12.2/src/main/resources/assets/universal_config/lang/en_us.json +++ b/1.12.2/src/main/resources/assets/universal_config/lang/en_us.json @@ -1,6 +1,4 @@ { - "key.universal_config.open": "Open Universal Config", - "category.universal_config": "Universal Config", "button.universal_config.open": "Universal Config", "screen.universal_config.title": "Universal Config", "screen.universal_config.subtitle": "Use your Minecraft settings in another environment", @@ -9,11 +7,14 @@ "screen.universal_config.includes_summary": "Includes: %s", "screen.universal_config.use_profile": "Use these settings", "screen.universal_config.more": "…", + "screen.universal_config.rename": "Rename", + "screen.universal_config.move_up": "Move profile up", + "screen.universal_config.move_down": "Move profile down", "screen.universal_config.restart_now": "Restart now", "screen.universal_config.pending_named": "⚠ “%s” is waiting to be applied", "screen.universal_config.minecraft_version": "Minecraft %s", "screen.universal_config.common_folder": "Shared folder: %s", - "screen.universal_config.pending_import": "Scheduled for next start: %s", + "screen.universal_config.pending_import": "To be applied next start: %s", "screen.universal_config.profile_list": "Profiles", "screen.universal_config.profile_count": "%s profiles", "screen.universal_config.profile_detail": "Profile details", @@ -52,6 +53,10 @@ "screen.universal_config.description_none": "No description", "screen.universal_config.targets": "Includes", "screen.universal_config.profile_create_title": "Create profile", + "screen.universal_config.profile_rename_title": "Rename profile", + "screen.universal_config.profile_rename_label": "New profile name", + "screen.universal_config.profile_name_required": "Enter a profile name.", + "screen.universal_config.rename_failed": "Could not rename this profile.", "screen.universal_config.profile_name_label": "Profile name", "screen.universal_config.profile_name_placeholder": "Profile name", "screen.universal_config.profile_description_label": "Description", @@ -83,7 +88,7 @@ "screen.universal_config.show_details": "Show details", "screen.universal_config.hide_details": "Hide details", "screen.universal_config.confirm_load_failed": "Could not load the profile information.", - "screen.universal_config.schedule_failed": "Could not schedule these settings.", + "screen.universal_config.schedule_failed": "Could not prepare these settings for the next launch.", "screen.universal_config.back": "Back", "screen.universal_config.confirm_profile": "Profile: %s", "screen.universal_config.confirm_description": "Description: %s", @@ -94,7 +99,7 @@ "screen.universal_config.confirm_risk": "Risk: %s", "screen.universal_config.confirm_apply_mode": "Apply mode: Load on next launch", "screen.universal_config.confirm_safety": "Safety: A .ucbackup is created before applying on next launch", - "screen.universal_config.confirm_after_action": "After scheduling: Quit Minecraft and launch it again", + "screen.universal_config.confirm_after_action": "After this: Quit Minecraft and launch it again", "screen.universal_config.confirm_warnings": "Warnings:", "screen.universal_config.confirm_checksums": "Checksums:", "screen.universal_config.confirm_changed_keybinds": "Changed keybindings:", @@ -105,23 +110,23 @@ "screen.universal_config.risk_high": "HIGH", "screen.universal_config.risk_medium": "MEDIUM", "screen.universal_config.risk_low": "LOW", - "screen.universal_config.confirm_schedule_high": "Schedule despite risk", - "screen.universal_config.confirm_schedule_medium": "Schedule backup restore", + "screen.universal_config.confirm_schedule_high": "Apply despite risk", + "screen.universal_config.confirm_schedule_medium": "Apply from backup", "screen.universal_config.confirm_schedule_low": "Load on next launch", - "screen.universal_config.apply_scheduled_title": "Profile scheduled", + "screen.universal_config.apply_scheduled_title": "Ready to apply profile", "screen.universal_config.apply_scheduled_line1": "Settings will be applied the next time Minecraft starts.", "screen.universal_config.apply_scheduled_line2": "Restart Minecraft to apply the settings.", "screen.universal_config.apply_scheduled_later": "Restart later", "screen.universal_config.apply_scheduled_cancel": "Cancel", "screen.universal_config.cancel_failed": "Check access to the game folder and try again.", "screen.universal_config.quit": "Quit", - "screen.universal_config.pending_invalid_title": "Could not find the scheduled settings", - "screen.universal_config.pending_invalid_line1": "The scheduled apply is missing or invalid.", - "screen.universal_config.pending_invalid_line2": "Return to profiles and schedule it again.", + "screen.universal_config.pending_invalid_title": "Could not find the pending settings", + "screen.universal_config.pending_invalid_line1": "The pending settings are missing or invalid.", + "screen.universal_config.pending_invalid_line2": "Return to profiles and apply again.", "screen.universal_config.restart_failed_title": "Could not restart Minecraft", "screen.universal_config.restart_failed_line1": "Only some launchers support automatic restart.", "screen.universal_config.restart_failed_line2": "Restart Minecraft manually from your launcher.", - "screen.universal_config.restart_failed_line3": "Your scheduled settings are kept and apply on the next manual launch.", + "screen.universal_config.restart_failed_line3": "The settings will be applied on the next manual launch.", "screen.universal_config.ok": "OK", "screen.universal_config.profile_pending_restart": "Settings will be applied after restarting Minecraft", "screen.universal_config.environment_unknown": "No environment information", @@ -132,5 +137,12 @@ "screen.universal_config.target_mods": "Mods", "screen.universal_config.include_toggle": "%s: %s", "screen.universal_config.include_enabled": "Include", - "screen.universal_config.include_disabled": "Exclude" + "screen.universal_config.include_disabled": "Exclude", + "screen.universal_config.backups": "Backups", + "screen.universal_config.yes": "Yes", + "screen.universal_config.no": "No", + "screen.universal_config.action_failed": "Could not complete this action.", + "screen.universal_config.create_failed": "Could not create the profile.", + "screen.universal_config.restore_complete": "Backup restored.", + "screen.universal_config.restore_failed": "Could not restore the backup." } diff --git a/1.12.2/src/main/resources/assets/universal_config/lang/en_us.lang b/1.12.2/src/main/resources/assets/universal_config/lang/en_us.lang index cd0404f..935a606 100644 --- a/1.12.2/src/main/resources/assets/universal_config/lang/en_us.lang +++ b/1.12.2/src/main/resources/assets/universal_config/lang/en_us.lang @@ -1,5 +1,3 @@ -key.universal_config.open=Open Universal Config -category.universal_config=Universal Config button.universal_config.open=Universal Config screen.universal_config.title=Universal Config screen.universal_config.subtitle=Use your Minecraft settings in another environment @@ -8,11 +6,14 @@ screen.universal_config.included_settings=Includes screen.universal_config.includes_summary=Includes: %s screen.universal_config.use_profile=Use these settings screen.universal_config.more=… +screen.universal_config.rename=Rename +screen.universal_config.move_up=Move profile up +screen.universal_config.move_down=Move profile down screen.universal_config.restart_now=Restart now screen.universal_config.pending_named=⚠ “%s” is waiting to be applied screen.universal_config.minecraft_version=Minecraft %s screen.universal_config.common_folder=Shared folder: %s -screen.universal_config.pending_import=Scheduled for next start: %s +screen.universal_config.pending_import=To be applied next start: %s screen.universal_config.profile_list=Profiles screen.universal_config.profile_count=%s profiles screen.universal_config.profile_detail=Profile details @@ -51,6 +52,10 @@ screen.universal_config.loader_summary=Mod Loader: %s %s screen.universal_config.description_none=No description screen.universal_config.targets=Includes screen.universal_config.profile_create_title=Create profile +screen.universal_config.profile_rename_title=Rename profile +screen.universal_config.profile_rename_label=New profile name +screen.universal_config.profile_name_required=Enter a profile name. +screen.universal_config.rename_failed=Could not rename this profile. screen.universal_config.profile_name_label=Profile name screen.universal_config.profile_name_placeholder=Profile name screen.universal_config.profile_description_label=Description @@ -82,7 +87,7 @@ screen.universal_config.confirm_restart_short=Minecraft must be restarted to app screen.universal_config.show_details=Show details screen.universal_config.hide_details=Hide details screen.universal_config.confirm_load_failed=Could not load the profile information. -screen.universal_config.schedule_failed=Could not schedule these settings. +screen.universal_config.schedule_failed=Could not prepare these settings for the next launch. screen.universal_config.back=Back screen.universal_config.confirm_profile=Profile: %s screen.universal_config.confirm_description=Description: %s @@ -93,7 +98,7 @@ screen.universal_config.confirm_tested=Tested: %s screen.universal_config.confirm_risk=Risk: %s screen.universal_config.confirm_apply_mode=Apply mode: Load on next launch screen.universal_config.confirm_safety=Safety: A .ucbackup is created before applying on next launch -screen.universal_config.confirm_after_action=After scheduling: Quit Minecraft and launch it again +screen.universal_config.confirm_after_action=After this: Quit Minecraft and launch it again screen.universal_config.confirm_warnings=Warnings: screen.universal_config.confirm_checksums=Checksums: screen.universal_config.confirm_changed_keybinds=Changed keybindings: @@ -104,23 +109,23 @@ screen.universal_config.confirm_list_item=- %s screen.universal_config.risk_high=HIGH screen.universal_config.risk_medium=MEDIUM screen.universal_config.risk_low=LOW -screen.universal_config.confirm_schedule_high=Schedule despite risk -screen.universal_config.confirm_schedule_medium=Schedule backup restore +screen.universal_config.confirm_schedule_high=Apply despite risk +screen.universal_config.confirm_schedule_medium=Apply from backup screen.universal_config.confirm_schedule_low=Load on next launch -screen.universal_config.apply_scheduled_title=Profile scheduled +screen.universal_config.apply_scheduled_title=Ready to apply profile screen.universal_config.apply_scheduled_line1=Settings will be applied the next time Minecraft starts. screen.universal_config.apply_scheduled_line2=Restart Minecraft to apply the settings. screen.universal_config.apply_scheduled_later=Restart later screen.universal_config.apply_scheduled_cancel=Cancel screen.universal_config.cancel_failed=Check access to the game folder and try again. screen.universal_config.quit=Quit -screen.universal_config.pending_invalid_title=Could not find the scheduled settings -screen.universal_config.pending_invalid_line1=The scheduled apply is missing or invalid. -screen.universal_config.pending_invalid_line2=Return to profiles and schedule it again. +screen.universal_config.pending_invalid_title=Could not find the pending settings +screen.universal_config.pending_invalid_line1=The pending settings are missing or invalid. +screen.universal_config.pending_invalid_line2=Return to profiles and apply again. screen.universal_config.restart_failed_title=Could not restart Minecraft screen.universal_config.restart_failed_line1=Only some launchers support automatic restart. screen.universal_config.restart_failed_line2=Restart Minecraft manually from your launcher. -screen.universal_config.restart_failed_line3=Your scheduled settings are kept and apply on the next manual launch. +screen.universal_config.restart_failed_line3=The settings will be applied on the next manual launch. screen.universal_config.ok=OK screen.universal_config.profile_pending_restart=Settings will be applied after restarting Minecraft screen.universal_config.environment_unknown=No environment information @@ -132,7 +137,6 @@ screen.universal_config.target_mods=Mods screen.universal_config.include_toggle=%s: %s screen.universal_config.include_enabled=Include screen.universal_config.include_disabled=Exclude -key.categories.universal_config.main=Universal Config screen.universal_config.backups=Backups screen.universal_config.yes=Yes screen.universal_config.no=No diff --git a/1.12.2/src/main/resources/assets/universal_config/lang/ja_jp.json b/1.12.2/src/main/resources/assets/universal_config/lang/ja_jp.json index 3ea1ae5..e970c88 100644 --- a/1.12.2/src/main/resources/assets/universal_config/lang/ja_jp.json +++ b/1.12.2/src/main/resources/assets/universal_config/lang/ja_jp.json @@ -1,6 +1,4 @@ { - "key.universal_config.open": "Universal Config を開く", - "category.universal_config": "Universal Config", "button.universal_config.open": "Universal Config", "screen.universal_config.title": "Universal Config", "screen.universal_config.subtitle": "Minecraftの設定を別の環境でも使用できます", @@ -9,11 +7,14 @@ "screen.universal_config.includes_summary": "含まれる設定: %s", "screen.universal_config.use_profile": "この設定を使う", "screen.universal_config.more": "…", + "screen.universal_config.rename": "名前を変更", + "screen.universal_config.move_up": "プロファイルを上へ移動", + "screen.universal_config.move_down": "プロファイルを下へ移動", "screen.universal_config.restart_now": "再起動する", "screen.universal_config.pending_named": "⚠ 『%s』の適用待ちです", "screen.universal_config.minecraft_version": "Minecraft %s", "screen.universal_config.common_folder": "共通フォルダ: %s", - "screen.universal_config.pending_import": "次回起動で読み込み予約済み: %s", + "screen.universal_config.pending_import": "次回起動で適用: %s", "screen.universal_config.profile_list": "プロファイル一覧", "screen.universal_config.profile_count": "%s件", "screen.universal_config.profile_detail": "プロファイル詳細", @@ -52,6 +53,10 @@ "screen.universal_config.description_none": "説明なし", "screen.universal_config.targets": "対象", "screen.universal_config.profile_create_title": "プロファイル作成", + "screen.universal_config.profile_rename_title": "プロファイル名を変更", + "screen.universal_config.profile_rename_label": "新しいプロファイル名", + "screen.universal_config.profile_name_required": "プロファイル名を入力してください。", + "screen.universal_config.rename_failed": "プロファイル名を変更できませんでした。", "screen.universal_config.profile_name_label": "プロファイル名", "screen.universal_config.profile_name_placeholder": "プロファイル名", "screen.universal_config.profile_description_label": "説明", @@ -83,7 +88,7 @@ "screen.universal_config.show_details": "詳細を見る", "screen.universal_config.hide_details": "詳細を閉じる", "screen.universal_config.confirm_load_failed": "プロファイル情報を読み込めませんでした。", - "screen.universal_config.schedule_failed": "適用を予約できませんでした。", + "screen.universal_config.schedule_failed": "次回起動時の適用を準備できませんでした。", "screen.universal_config.back": "戻る", "screen.universal_config.confirm_profile": "プロファイル: %s", "screen.universal_config.confirm_description": "説明: %s", @@ -94,7 +99,7 @@ "screen.universal_config.confirm_risk": "リスク: %s", "screen.universal_config.confirm_apply_mode": "適用方式: 次回起動時に読み込みます", "screen.universal_config.confirm_safety": "安全対策: 次回起動時の適用前に .ucbackup を必ず作成します", - "screen.universal_config.confirm_after_action": "操作後: Minecraftを終了し、もう一度起動してください", + "screen.universal_config.confirm_after_action": "この後: Minecraftを終了し、もう一度起動してください", "screen.universal_config.confirm_warnings": "警告:", "screen.universal_config.confirm_checksums": "Checksum:", "screen.universal_config.confirm_changed_keybinds": "変更されるキー設定:", @@ -105,23 +110,23 @@ "screen.universal_config.risk_high": "HIGH", "screen.universal_config.risk_medium": "MEDIUM", "screen.universal_config.risk_low": "LOW", - "screen.universal_config.confirm_schedule_high": "危険を理解して予約", - "screen.universal_config.confirm_schedule_medium": "バックアップ読み込み予約", + "screen.universal_config.confirm_schedule_high": "危険を理解して適用", + "screen.universal_config.confirm_schedule_medium": "バックアップから適用", "screen.universal_config.confirm_schedule_low": "次回起動で読み込む", - "screen.universal_config.apply_scheduled_title": "プロフィールの適用を予約しました", + "screen.universal_config.apply_scheduled_title": "プロフィールの適用準備ができました", "screen.universal_config.apply_scheduled_line1": "設定は次回のMinecraft起動時に適用されます。", "screen.universal_config.apply_scheduled_line2": "設定を反映するにはMinecraftを再起動してください。", "screen.universal_config.apply_scheduled_later": "あとで再起動する", "screen.universal_config.apply_scheduled_cancel": "キャンセル", "screen.universal_config.cancel_failed": "保存先へのアクセスを確認して、もう一度試してください。", "screen.universal_config.quit": "終了する", - "screen.universal_config.pending_invalid_title": "適用予約を確認できませんでした", - "screen.universal_config.pending_invalid_line1": "適用予約が見つからないか、壊れています。", - "screen.universal_config.pending_invalid_line2": "プロフィール画面に戻り、もう一度予約してください。", + "screen.universal_config.pending_invalid_title": "適用待ちの設定を確認できませんでした", + "screen.universal_config.pending_invalid_line1": "適用待ちの設定が見つからないか、壊れています。", + "screen.universal_config.pending_invalid_line2": "プロフィール画面に戻り、もう一度適用してください。", "screen.universal_config.restart_failed_title": "Minecraftを再起動できませんでした", "screen.universal_config.restart_failed_line1": "自動再起動は一部のランチャーのみ対応しています。", "screen.universal_config.restart_failed_line2": "ランチャーから手動で再起動してください。", - "screen.universal_config.restart_failed_line3": "予約した設定は保持され、次回の手動起動で適用されます。", + "screen.universal_config.restart_failed_line3": "設定は保持され、次回の手動起動時に適用されます。", "screen.universal_config.ok": "OK", "screen.universal_config.profile_pending_restart": "再起動すると設定が適用されます", "screen.universal_config.environment_unknown": "環境情報なし", @@ -132,5 +137,12 @@ "screen.universal_config.target_mods": "MOD設定", "screen.universal_config.include_toggle": "%s: %s", "screen.universal_config.include_enabled": "含める", - "screen.universal_config.include_disabled": "含めない" + "screen.universal_config.include_disabled": "含めない", + "screen.universal_config.backups": "バックアップ", + "screen.universal_config.yes": "はい", + "screen.universal_config.no": "いいえ", + "screen.universal_config.action_failed": "操作を完了できませんでした。", + "screen.universal_config.create_failed": "プロファイルを作成できませんでした。", + "screen.universal_config.restore_complete": "バックアップを復元しました。", + "screen.universal_config.restore_failed": "バックアップを復元できませんでした。" } diff --git a/1.12.2/src/main/resources/assets/universal_config/lang/ja_jp.lang b/1.12.2/src/main/resources/assets/universal_config/lang/ja_jp.lang index 028c3c5..65ee125 100644 --- a/1.12.2/src/main/resources/assets/universal_config/lang/ja_jp.lang +++ b/1.12.2/src/main/resources/assets/universal_config/lang/ja_jp.lang @@ -1,5 +1,3 @@ -key.universal_config.open=Universal Config を開く -category.universal_config=Universal Config button.universal_config.open=Universal Config screen.universal_config.title=Universal Config screen.universal_config.subtitle=Minecraftの設定を別の環境でも使用できます @@ -8,11 +6,14 @@ screen.universal_config.included_settings=含まれる設定 screen.universal_config.includes_summary=含まれる設定: %s screen.universal_config.use_profile=この設定を使う screen.universal_config.more=… +screen.universal_config.rename=名前を変更 +screen.universal_config.move_up=プロファイルを上へ移動 +screen.universal_config.move_down=プロファイルを下へ移動 screen.universal_config.restart_now=再起動する screen.universal_config.pending_named=⚠ 『%s』の適用待ちです screen.universal_config.minecraft_version=Minecraft %s screen.universal_config.common_folder=共通フォルダ: %s -screen.universal_config.pending_import=次回起動で読み込み予約済み: %s +screen.universal_config.pending_import=次回起動で適用: %s screen.universal_config.profile_list=プロファイル一覧 screen.universal_config.profile_count=%s件 screen.universal_config.profile_detail=プロファイル詳細 @@ -51,6 +52,10 @@ screen.universal_config.loader_summary=Mod Loader: %s %s screen.universal_config.description_none=説明なし screen.universal_config.targets=対象 screen.universal_config.profile_create_title=プロファイル作成 +screen.universal_config.profile_rename_title=プロファイル名を変更 +screen.universal_config.profile_rename_label=新しいプロファイル名 +screen.universal_config.profile_name_required=プロファイル名を入力してください。 +screen.universal_config.rename_failed=プロファイル名を変更できませんでした。 screen.universal_config.profile_name_label=プロファイル名 screen.universal_config.profile_name_placeholder=プロファイル名 screen.universal_config.profile_description_label=説明 @@ -82,7 +87,7 @@ screen.universal_config.confirm_restart_short=適用にはMinecraftの再起動 screen.universal_config.show_details=詳細を見る screen.universal_config.hide_details=詳細を閉じる screen.universal_config.confirm_load_failed=プロファイル情報を読み込めませんでした。 -screen.universal_config.schedule_failed=適用を予約できませんでした。 +screen.universal_config.schedule_failed=次回起動時の適用を準備できませんでした。 screen.universal_config.back=戻る screen.universal_config.confirm_profile=プロファイル: %s screen.universal_config.confirm_description=説明: %s @@ -93,7 +98,7 @@ screen.universal_config.confirm_tested=テスト済み: %s screen.universal_config.confirm_risk=リスク: %s screen.universal_config.confirm_apply_mode=適用方式: 次回起動時に読み込みます screen.universal_config.confirm_safety=安全対策: 次回起動時の適用前に .ucbackup を必ず作成します -screen.universal_config.confirm_after_action=操作後: Minecraftを終了し、もう一度起動してください +screen.universal_config.confirm_after_action=この後: Minecraftを終了し、もう一度起動してください screen.universal_config.confirm_warnings=警告: screen.universal_config.confirm_checksums=Checksum: screen.universal_config.confirm_changed_keybinds=変更されるキー設定: @@ -104,23 +109,23 @@ screen.universal_config.confirm_list_item=- %s screen.universal_config.risk_high=HIGH screen.universal_config.risk_medium=MEDIUM screen.universal_config.risk_low=LOW -screen.universal_config.confirm_schedule_high=危険を理解して予約 -screen.universal_config.confirm_schedule_medium=バックアップ読み込み予約 +screen.universal_config.confirm_schedule_high=危険を理解して適用 +screen.universal_config.confirm_schedule_medium=バックアップから適用 screen.universal_config.confirm_schedule_low=次回起動で読み込む -screen.universal_config.apply_scheduled_title=プロフィールの適用を予約しました +screen.universal_config.apply_scheduled_title=プロフィールの適用準備ができました screen.universal_config.apply_scheduled_line1=設定は次回のMinecraft起動時に適用されます。 screen.universal_config.apply_scheduled_line2=設定を反映するにはMinecraftを再起動してください。 screen.universal_config.apply_scheduled_later=あとで再起動する screen.universal_config.apply_scheduled_cancel=キャンセル screen.universal_config.cancel_failed=保存先へのアクセスを確認して、もう一度試してください。 screen.universal_config.quit=終了する -screen.universal_config.pending_invalid_title=適用予約を確認できませんでした -screen.universal_config.pending_invalid_line1=適用予約が見つからないか、壊れています。 -screen.universal_config.pending_invalid_line2=プロフィール画面に戻り、もう一度予約してください。 +screen.universal_config.pending_invalid_title=適用待ちの設定を確認できませんでした +screen.universal_config.pending_invalid_line1=適用待ちの設定が見つからないか、壊れています。 +screen.universal_config.pending_invalid_line2=プロフィール画面に戻り、もう一度適用してください。 screen.universal_config.restart_failed_title=Minecraftを再起動できませんでした screen.universal_config.restart_failed_line1=自動再起動は一部のランチャーのみ対応しています。 screen.universal_config.restart_failed_line2=ランチャーから手動で再起動してください。 -screen.universal_config.restart_failed_line3=予約した設定は保持され、次回の手動起動で適用されます。 +screen.universal_config.restart_failed_line3=設定は保持され、次回の手動起動時に適用されます。 screen.universal_config.ok=OK screen.universal_config.profile_pending_restart=再起動すると設定が適用されます screen.universal_config.environment_unknown=環境情報なし @@ -132,7 +137,6 @@ screen.universal_config.target_mods=MOD設定 screen.universal_config.include_toggle=%s: %s screen.universal_config.include_enabled=含める screen.universal_config.include_disabled=含めない -key.categories.universal_config.main=Universal Config screen.universal_config.backups=バックアップ screen.universal_config.yes=はい screen.universal_config.no=いいえ diff --git a/1.12.2/src/main/resources/assets/universal_config/lang/ko_kr.json b/1.12.2/src/main/resources/assets/universal_config/lang/ko_kr.json index dec4f34..f38f8ce 100644 --- a/1.12.2/src/main/resources/assets/universal_config/lang/ko_kr.json +++ b/1.12.2/src/main/resources/assets/universal_config/lang/ko_kr.json @@ -1,6 +1,4 @@ { - "key.universal_config.open": "Universal Config 열기", - "category.universal_config": "Universal Config", "button.universal_config.open": "Universal Config", "screen.universal_config.title": "Universal Config", "screen.universal_config.subtitle": "Minecraft 설정을 다른 환경에서도 사용하세요", @@ -9,11 +7,14 @@ "screen.universal_config.includes_summary": "포함 항목: %s", "screen.universal_config.use_profile": "이 설정 사용", "screen.universal_config.more": "…", + "screen.universal_config.rename": "이름 변경", + "screen.universal_config.move_up": "프로필 위로 이동", + "screen.universal_config.move_down": "프로필 아래로 이동", "screen.universal_config.restart_now": "지금 재시작", "screen.universal_config.pending_named": "⚠ ‘%s’ 적용 대기 중", "screen.universal_config.minecraft_version": "Minecraft %s", "screen.universal_config.common_folder": "공유 폴더: %s", - "screen.universal_config.pending_import": "다음 실행 시 불러오기 예약됨: %s", + "screen.universal_config.pending_import": "다음 실행 시 적용: %s", "screen.universal_config.profile_list": "프로필", "screen.universal_config.profile_count": "프로필 %s개", "screen.universal_config.profile_detail": "프로필 정보", @@ -52,6 +53,10 @@ "screen.universal_config.description_none": "설명 없음", "screen.universal_config.targets": "포함 항목", "screen.universal_config.profile_create_title": "프로필 만들기", + "screen.universal_config.profile_rename_title": "프로필 이름 변경", + "screen.universal_config.profile_rename_label": "새 프로필 이름", + "screen.universal_config.profile_name_required": "프로필 이름을 입력하세요.", + "screen.universal_config.rename_failed": "이 프로필의 이름을 변경하지 못했습니다.", "screen.universal_config.profile_name_label": "프로필 이름", "screen.universal_config.profile_name_placeholder": "프로필 이름", "screen.universal_config.profile_description_label": "설명", @@ -83,7 +88,7 @@ "screen.universal_config.show_details": "자세히 보기", "screen.universal_config.hide_details": "자세히 숨기기", "screen.universal_config.confirm_load_failed": "프로필 정보를 불러오지 못했습니다.", - "screen.universal_config.schedule_failed": "이 설정의 적용을 예약하지 못했습니다.", + "screen.universal_config.schedule_failed": "다음 실행 시 적용하도록 설정하지 못했습니다.", "screen.universal_config.back": "뒤로", "screen.universal_config.confirm_profile": "프로필: %s", "screen.universal_config.confirm_description": "설명: %s", @@ -94,7 +99,7 @@ "screen.universal_config.confirm_risk": "위험도: %s", "screen.universal_config.confirm_apply_mode": "적용 방식: 다음 실행 시 불러오기", "screen.universal_config.confirm_safety": "안전 조치: 다음 실행 시 적용 전에 .ucbackup 생성", - "screen.universal_config.confirm_after_action": "예약 후: Minecraft를 종료하고 다시 실행하세요", + "screen.universal_config.confirm_after_action": "이후: Minecraft를 종료하고 다시 실행하세요", "screen.universal_config.confirm_warnings": "경고:", "screen.universal_config.confirm_checksums": "체크섬:", "screen.universal_config.confirm_changed_keybinds": "변경되는 키 설정:", @@ -105,23 +110,23 @@ "screen.universal_config.risk_high": "높음", "screen.universal_config.risk_medium": "보통", "screen.universal_config.risk_low": "낮음", - "screen.universal_config.confirm_schedule_high": "위험을 감수하고 예약", - "screen.universal_config.confirm_schedule_medium": "백업 복원 예약", + "screen.universal_config.confirm_schedule_high": "위험을 감수하고 적용", + "screen.universal_config.confirm_schedule_medium": "백업에서 적용", "screen.universal_config.confirm_schedule_low": "다음 실행 시 불러오기", - "screen.universal_config.apply_scheduled_title": "프로필 적용 예약됨", + "screen.universal_config.apply_scheduled_title": "프로필 적용 준비가 완료되었습니다", "screen.universal_config.apply_scheduled_line1": "다음 Minecraft 실행 시 설정이 적용됩니다.", "screen.universal_config.apply_scheduled_line2": "설정을 적용하려면 Minecraft를 재시작하세요.", "screen.universal_config.apply_scheduled_later": "나중에 재시작", "screen.universal_config.apply_scheduled_cancel": "취소", "screen.universal_config.cancel_failed": "게임 폴더 접근 권한을 확인하고 다시 시도하세요.", "screen.universal_config.quit": "종료", - "screen.universal_config.pending_invalid_title": "예약된 설정을 찾지 못했습니다", - "screen.universal_config.pending_invalid_line1": "적용 예약이 없거나 올바르지 않습니다.", - "screen.universal_config.pending_invalid_line2": "프로필로 돌아가 다시 예약하세요.", + "screen.universal_config.pending_invalid_title": "적용 대기 중인 설정을 찾지 못했습니다", + "screen.universal_config.pending_invalid_line1": "적용 대기 중인 설정이 없거나 올바르지 않습니다.", + "screen.universal_config.pending_invalid_line2": "프로필로 돌아가 다시 적용하세요.", "screen.universal_config.restart_failed_title": "Minecraft를 재시작하지 못했습니다", "screen.universal_config.restart_failed_line1": "일부 런처만 자동 재시작을 지원합니다.", "screen.universal_config.restart_failed_line2": "런처에서 Minecraft를 직접 재시작하세요.", - "screen.universal_config.restart_failed_line3": "예약된 설정은 유지되며 다음 수동 실행 시 적용됩니다.", + "screen.universal_config.restart_failed_line3": "설정은 유지되며 다음 수동 실행 시 적용됩니다.", "screen.universal_config.ok": "확인", "screen.universal_config.profile_pending_restart": "Minecraft 재시작 후 설정이 적용됩니다", "screen.universal_config.environment_unknown": "환경 정보 없음", @@ -132,5 +137,12 @@ "screen.universal_config.target_mods": "모드 설정", "screen.universal_config.include_toggle": "%s: %s", "screen.universal_config.include_enabled": "포함", - "screen.universal_config.include_disabled": "제외" + "screen.universal_config.include_disabled": "제외", + "screen.universal_config.backups": "백업", + "screen.universal_config.yes": "예", + "screen.universal_config.no": "아니요", + "screen.universal_config.action_failed": "작업을 완료하지 못했습니다.", + "screen.universal_config.create_failed": "프로필을 만들지 못했습니다.", + "screen.universal_config.restore_complete": "백업을 복원했습니다.", + "screen.universal_config.restore_failed": "백업을 복원하지 못했습니다." } diff --git a/1.12.2/src/main/resources/assets/universal_config/lang/ko_kr.lang b/1.12.2/src/main/resources/assets/universal_config/lang/ko_kr.lang index a6159e3..6984d5a 100644 --- a/1.12.2/src/main/resources/assets/universal_config/lang/ko_kr.lang +++ b/1.12.2/src/main/resources/assets/universal_config/lang/ko_kr.lang @@ -1,5 +1,3 @@ -key.universal_config.open=Universal Config 열기 -category.universal_config=Universal Config button.universal_config.open=Universal Config screen.universal_config.title=Universal Config screen.universal_config.subtitle=Minecraft 설정을 다른 환경에서도 사용하세요 @@ -8,11 +6,14 @@ screen.universal_config.included_settings=포함 항목 screen.universal_config.includes_summary=포함 항목: %s screen.universal_config.use_profile=이 설정 사용 screen.universal_config.more=… +screen.universal_config.rename=이름 변경 +screen.universal_config.move_up=프로필 위로 이동 +screen.universal_config.move_down=프로필 아래로 이동 screen.universal_config.restart_now=지금 재시작 screen.universal_config.pending_named=⚠ ‘%s’ 적용 대기 중 screen.universal_config.minecraft_version=Minecraft %s screen.universal_config.common_folder=공유 폴더: %s -screen.universal_config.pending_import=다음 실행 시 불러오기 예약됨: %s +screen.universal_config.pending_import=다음 실행 시 적용: %s screen.universal_config.profile_list=프로필 screen.universal_config.profile_count=프로필 %s개 screen.universal_config.profile_detail=프로필 정보 @@ -51,6 +52,10 @@ screen.universal_config.loader_summary=모드 로더: %s %s screen.universal_config.description_none=설명 없음 screen.universal_config.targets=포함 항목 screen.universal_config.profile_create_title=프로필 만들기 +screen.universal_config.profile_rename_title=프로필 이름 변경 +screen.universal_config.profile_rename_label=새 프로필 이름 +screen.universal_config.profile_name_required=프로필 이름을 입력하세요. +screen.universal_config.rename_failed=이 프로필의 이름을 변경하지 못했습니다. screen.universal_config.profile_name_label=프로필 이름 screen.universal_config.profile_name_placeholder=프로필 이름 screen.universal_config.profile_description_label=설명 @@ -82,7 +87,7 @@ screen.universal_config.confirm_restart_short=적용하려면 Minecraft를 재 screen.universal_config.show_details=자세히 보기 screen.universal_config.hide_details=자세히 숨기기 screen.universal_config.confirm_load_failed=프로필 정보를 불러오지 못했습니다. -screen.universal_config.schedule_failed=이 설정의 적용을 예약하지 못했습니다. +screen.universal_config.schedule_failed=다음 실행 시 적용하도록 설정하지 못했습니다. screen.universal_config.back=뒤로 screen.universal_config.confirm_profile=프로필: %s screen.universal_config.confirm_description=설명: %s @@ -93,7 +98,7 @@ screen.universal_config.confirm_tested=테스트됨: %s screen.universal_config.confirm_risk=위험도: %s screen.universal_config.confirm_apply_mode=적용 방식: 다음 실행 시 불러오기 screen.universal_config.confirm_safety=안전 조치: 다음 실행 시 적용 전에 .ucbackup 생성 -screen.universal_config.confirm_after_action=예약 후: Minecraft를 종료하고 다시 실행하세요 +screen.universal_config.confirm_after_action=이후: Minecraft를 종료하고 다시 실행하세요 screen.universal_config.confirm_warnings=경고: screen.universal_config.confirm_checksums=체크섬: screen.universal_config.confirm_changed_keybinds=변경되는 키 설정: @@ -104,23 +109,23 @@ screen.universal_config.confirm_list_item=- %s screen.universal_config.risk_high=높음 screen.universal_config.risk_medium=보통 screen.universal_config.risk_low=낮음 -screen.universal_config.confirm_schedule_high=위험을 감수하고 예약 -screen.universal_config.confirm_schedule_medium=백업 복원 예약 +screen.universal_config.confirm_schedule_high=위험을 감수하고 적용 +screen.universal_config.confirm_schedule_medium=백업에서 적용 screen.universal_config.confirm_schedule_low=다음 실행 시 불러오기 -screen.universal_config.apply_scheduled_title=프로필 적용 예약됨 +screen.universal_config.apply_scheduled_title=프로필 적용 준비가 완료되었습니다 screen.universal_config.apply_scheduled_line1=다음 Minecraft 실행 시 설정이 적용됩니다. screen.universal_config.apply_scheduled_line2=설정을 적용하려면 Minecraft를 재시작하세요. screen.universal_config.apply_scheduled_later=나중에 재시작 screen.universal_config.apply_scheduled_cancel=취소 screen.universal_config.cancel_failed=게임 폴더 접근 권한을 확인하고 다시 시도하세요. screen.universal_config.quit=종료 -screen.universal_config.pending_invalid_title=예약된 설정을 찾지 못했습니다 -screen.universal_config.pending_invalid_line1=적용 예약이 없거나 올바르지 않습니다. -screen.universal_config.pending_invalid_line2=프로필로 돌아가 다시 예약하세요. +screen.universal_config.pending_invalid_title=적용 대기 중인 설정을 찾지 못했습니다 +screen.universal_config.pending_invalid_line1=적용 대기 중인 설정이 없거나 올바르지 않습니다. +screen.universal_config.pending_invalid_line2=프로필로 돌아가 다시 적용하세요. screen.universal_config.restart_failed_title=Minecraft를 재시작하지 못했습니다 screen.universal_config.restart_failed_line1=일부 런처만 자동 재시작을 지원합니다. screen.universal_config.restart_failed_line2=런처에서 Minecraft를 직접 재시작하세요. -screen.universal_config.restart_failed_line3=예약된 설정은 유지되며 다음 수동 실행 시 적용됩니다. +screen.universal_config.restart_failed_line3=설정은 유지되며 다음 수동 실행 시 적용됩니다. screen.universal_config.ok=확인 screen.universal_config.profile_pending_restart=Minecraft 재시작 후 설정이 적용됩니다 screen.universal_config.environment_unknown=환경 정보 없음 @@ -132,7 +137,6 @@ screen.universal_config.target_mods=모드 설정 screen.universal_config.include_toggle=%s: %s screen.universal_config.include_enabled=포함 screen.universal_config.include_disabled=제외 -key.categories.universal_config.main=Universal Config screen.universal_config.backups=백업 screen.universal_config.yes=예 screen.universal_config.no=아니요 diff --git a/1.12.2/src/main/resources/assets/universal_config/lang/zh_cn.json b/1.12.2/src/main/resources/assets/universal_config/lang/zh_cn.json index d53488c..93c63eb 100644 --- a/1.12.2/src/main/resources/assets/universal_config/lang/zh_cn.json +++ b/1.12.2/src/main/resources/assets/universal_config/lang/zh_cn.json @@ -1,6 +1,4 @@ { - "key.universal_config.open": "打开 Universal Config", - "category.universal_config": "Universal Config", "button.universal_config.open": "Universal Config", "screen.universal_config.title": "Universal Config", "screen.universal_config.subtitle": "在其他环境中使用 Minecraft 设置", @@ -9,6 +7,9 @@ "screen.universal_config.includes_summary": "包含:%s", "screen.universal_config.use_profile": "使用这些设置", "screen.universal_config.more": "…", + "screen.universal_config.rename": "重命名", + "screen.universal_config.move_up": "上移配置档案", + "screen.universal_config.move_down": "下移配置档案", "screen.universal_config.restart_now": "立即重启", "screen.universal_config.pending_named": "⚠“%s”正在等待应用", "screen.universal_config.minecraft_version": "Minecraft %s", @@ -52,6 +53,10 @@ "screen.universal_config.description_none": "无描述", "screen.universal_config.targets": "包含内容", "screen.universal_config.profile_create_title": "创建配置档案", + "screen.universal_config.profile_rename_title": "重命名配置档案", + "screen.universal_config.profile_rename_label": "新的配置档案名称", + "screen.universal_config.profile_name_required": "请输入配置档案名称。", + "screen.universal_config.rename_failed": "无法重命名此配置档案。", "screen.universal_config.profile_name_label": "配置档案名称", "screen.universal_config.profile_name_placeholder": "配置档案名称", "screen.universal_config.profile_description_label": "描述", @@ -83,7 +88,7 @@ "screen.universal_config.show_details": "显示详情", "screen.universal_config.hide_details": "隐藏详情", "screen.universal_config.confirm_load_failed": "无法加载配置档案信息。", - "screen.universal_config.schedule_failed": "无法安排应用这些设置。", + "screen.universal_config.schedule_failed": "无法将这些设置设为下次启动时应用。", "screen.universal_config.back": "返回", "screen.universal_config.confirm_profile": "配置档案:%s", "screen.universal_config.confirm_description": "描述:%s", @@ -94,7 +99,7 @@ "screen.universal_config.confirm_risk": "风险:%s", "screen.universal_config.confirm_apply_mode": "应用方式:下次启动时加载", "screen.universal_config.confirm_safety": "安全措施:下次启动应用前创建 .ucbackup", - "screen.universal_config.confirm_after_action": "安排后:退出并重新启动 Minecraft", + "screen.universal_config.confirm_after_action": "之后:退出并重新启动 Minecraft", "screen.universal_config.confirm_warnings": "警告:", "screen.universal_config.confirm_checksums": "校验和:", "screen.universal_config.confirm_changed_keybinds": "更改的按键绑定:", @@ -105,23 +110,23 @@ "screen.universal_config.risk_high": "高", "screen.universal_config.risk_medium": "中", "screen.universal_config.risk_low": "低", - "screen.universal_config.confirm_schedule_high": "仍要安排应用", - "screen.universal_config.confirm_schedule_medium": "安排备份恢复", + "screen.universal_config.confirm_schedule_high": "仍要应用", + "screen.universal_config.confirm_schedule_medium": "从备份应用", "screen.universal_config.confirm_schedule_low": "下次启动时加载", - "screen.universal_config.apply_scheduled_title": "已安排应用配置档案", + "screen.universal_config.apply_scheduled_title": "配置档案已准备好应用", "screen.universal_config.apply_scheduled_line1": "设置将在下次启动 Minecraft 时应用。", "screen.universal_config.apply_scheduled_line2": "请重启 Minecraft 以应用设置。", "screen.universal_config.apply_scheduled_later": "稍后重启", "screen.universal_config.apply_scheduled_cancel": "取消", "screen.universal_config.cancel_failed": "请检查游戏文件夹的访问权限后重试。", "screen.universal_config.quit": "退出", - "screen.universal_config.pending_invalid_title": "找不到已安排的设置", - "screen.universal_config.pending_invalid_line1": "安排的应用内容缺失或无效。", - "screen.universal_config.pending_invalid_line2": "请返回配置档案并重新安排。", + "screen.universal_config.pending_invalid_title": "找不到待应用的设置", + "screen.universal_config.pending_invalid_line1": "待应用的设置缺失或无效。", + "screen.universal_config.pending_invalid_line2": "请返回配置档案并重新应用。", "screen.universal_config.restart_failed_title": "无法重启 Minecraft", "screen.universal_config.restart_failed_line1": "仅部分启动器支持自动重启。", "screen.universal_config.restart_failed_line2": "请从启动器手动重启 Minecraft。", - "screen.universal_config.restart_failed_line3": "已安排的设置将保留,并在下次手动启动时应用。", + "screen.universal_config.restart_failed_line3": "待应用的设置将保留,并在下次手动启动时应用。", "screen.universal_config.ok": "确定", "screen.universal_config.profile_pending_restart": "重启 Minecraft 后将应用设置", "screen.universal_config.environment_unknown": "无环境信息", @@ -132,5 +137,12 @@ "screen.universal_config.target_mods": "模组配置", "screen.universal_config.include_toggle": "%s:%s", "screen.universal_config.include_enabled": "包含", - "screen.universal_config.include_disabled": "排除" + "screen.universal_config.include_disabled": "排除", + "screen.universal_config.backups": "备份", + "screen.universal_config.yes": "是", + "screen.universal_config.no": "否", + "screen.universal_config.action_failed": "无法完成此操作。", + "screen.universal_config.create_failed": "无法创建配置文件。", + "screen.universal_config.restore_complete": "已恢复备份。", + "screen.universal_config.restore_failed": "无法恢复备份。" } diff --git a/1.12.2/src/main/resources/assets/universal_config/lang/zh_cn.lang b/1.12.2/src/main/resources/assets/universal_config/lang/zh_cn.lang index 1ea4a99..7089a3d 100644 --- a/1.12.2/src/main/resources/assets/universal_config/lang/zh_cn.lang +++ b/1.12.2/src/main/resources/assets/universal_config/lang/zh_cn.lang @@ -1,5 +1,3 @@ -key.universal_config.open=打开 Universal Config -category.universal_config=Universal Config button.universal_config.open=Universal Config screen.universal_config.title=Universal Config screen.universal_config.subtitle=在其他环境中使用 Minecraft 设置 @@ -8,6 +6,9 @@ screen.universal_config.included_settings=包含内容 screen.universal_config.includes_summary=包含:%s screen.universal_config.use_profile=使用这些设置 screen.universal_config.more=… +screen.universal_config.rename=重命名 +screen.universal_config.move_up=上移配置档案 +screen.universal_config.move_down=下移配置档案 screen.universal_config.restart_now=立即重启 screen.universal_config.pending_named=⚠“%s”正在等待应用 screen.universal_config.minecraft_version=Minecraft %s @@ -51,6 +52,10 @@ screen.universal_config.loader_summary=模组加载器:%s %s screen.universal_config.description_none=无描述 screen.universal_config.targets=包含内容 screen.universal_config.profile_create_title=创建配置档案 +screen.universal_config.profile_rename_title=重命名配置档案 +screen.universal_config.profile_rename_label=新的配置档案名称 +screen.universal_config.profile_name_required=请输入配置档案名称。 +screen.universal_config.rename_failed=无法重命名此配置档案。 screen.universal_config.profile_name_label=配置档案名称 screen.universal_config.profile_name_placeholder=配置档案名称 screen.universal_config.profile_description_label=描述 @@ -82,7 +87,7 @@ screen.universal_config.confirm_restart_short=需要重启 Minecraft 才能应 screen.universal_config.show_details=显示详情 screen.universal_config.hide_details=隐藏详情 screen.universal_config.confirm_load_failed=无法加载配置档案信息。 -screen.universal_config.schedule_failed=无法安排应用这些设置。 +screen.universal_config.schedule_failed=无法将这些设置设为下次启动时应用。 screen.universal_config.back=返回 screen.universal_config.confirm_profile=配置档案:%s screen.universal_config.confirm_description=描述:%s @@ -93,7 +98,7 @@ screen.universal_config.confirm_tested=已测试:%s screen.universal_config.confirm_risk=风险:%s screen.universal_config.confirm_apply_mode=应用方式:下次启动时加载 screen.universal_config.confirm_safety=安全措施:下次启动应用前创建 .ucbackup -screen.universal_config.confirm_after_action=安排后:退出并重新启动 Minecraft +screen.universal_config.confirm_after_action=之后:退出并重新启动 Minecraft screen.universal_config.confirm_warnings=警告: screen.universal_config.confirm_checksums=校验和: screen.universal_config.confirm_changed_keybinds=更改的按键绑定: @@ -104,23 +109,23 @@ screen.universal_config.confirm_list_item=- %s screen.universal_config.risk_high=高 screen.universal_config.risk_medium=中 screen.universal_config.risk_low=低 -screen.universal_config.confirm_schedule_high=仍要安排应用 -screen.universal_config.confirm_schedule_medium=安排备份恢复 +screen.universal_config.confirm_schedule_high=仍要应用 +screen.universal_config.confirm_schedule_medium=从备份应用 screen.universal_config.confirm_schedule_low=下次启动时加载 -screen.universal_config.apply_scheduled_title=已安排应用配置档案 +screen.universal_config.apply_scheduled_title=配置档案已准备好应用 screen.universal_config.apply_scheduled_line1=设置将在下次启动 Minecraft 时应用。 screen.universal_config.apply_scheduled_line2=请重启 Minecraft 以应用设置。 screen.universal_config.apply_scheduled_later=稍后重启 screen.universal_config.apply_scheduled_cancel=取消 screen.universal_config.cancel_failed=请检查游戏文件夹的访问权限后重试。 screen.universal_config.quit=退出 -screen.universal_config.pending_invalid_title=找不到已安排的设置 -screen.universal_config.pending_invalid_line1=安排的应用内容缺失或无效。 -screen.universal_config.pending_invalid_line2=请返回配置档案并重新安排。 +screen.universal_config.pending_invalid_title=找不到待应用的设置 +screen.universal_config.pending_invalid_line1=待应用的设置缺失或无效。 +screen.universal_config.pending_invalid_line2=请返回配置档案并重新应用。 screen.universal_config.restart_failed_title=无法重启 Minecraft screen.universal_config.restart_failed_line1=仅部分启动器支持自动重启。 screen.universal_config.restart_failed_line2=请从启动器手动重启 Minecraft。 -screen.universal_config.restart_failed_line3=已安排的设置将保留,并在下次手动启动时应用。 +screen.universal_config.restart_failed_line3=待应用的设置将保留,并在下次手动启动时应用。 screen.universal_config.ok=确定 screen.universal_config.profile_pending_restart=重启 Minecraft 后将应用设置 screen.universal_config.environment_unknown=无环境信息 @@ -132,7 +137,6 @@ screen.universal_config.target_mods=模组配置 screen.universal_config.include_toggle=%s:%s screen.universal_config.include_enabled=包含 screen.universal_config.include_disabled=排除 -key.categories.universal_config.main=Universal Config screen.universal_config.backups=备份 screen.universal_config.yes=是 screen.universal_config.no=否 diff --git a/1.12.2/src/main/resources/assets/universal_config/lang/zh_tw.json b/1.12.2/src/main/resources/assets/universal_config/lang/zh_tw.json index 0ed9bb8..78b1f2f 100644 --- a/1.12.2/src/main/resources/assets/universal_config/lang/zh_tw.json +++ b/1.12.2/src/main/resources/assets/universal_config/lang/zh_tw.json @@ -1,6 +1,4 @@ { - "key.universal_config.open": "開啟 Universal Config", - "category.universal_config": "Universal Config", "button.universal_config.open": "Universal Config", "screen.universal_config.title": "Universal Config", "screen.universal_config.subtitle": "在其他環境使用你的 Minecraft 設定", @@ -9,6 +7,9 @@ "screen.universal_config.includes_summary": "包含:%s", "screen.universal_config.use_profile": "使用此設定", "screen.universal_config.more": "…", + "screen.universal_config.rename": "重新命名", + "screen.universal_config.move_up": "上移設定檔", + "screen.universal_config.move_down": "下移設定檔", "screen.universal_config.restart_now": "立即重新啟動", "screen.universal_config.pending_named": "⚠「%s」正在等待套用", "screen.universal_config.minecraft_version": "Minecraft %s", @@ -52,6 +53,10 @@ "screen.universal_config.description_none": "無說明", "screen.universal_config.targets": "包含內容", "screen.universal_config.profile_create_title": "建立設定檔", + "screen.universal_config.profile_rename_title": "重新命名設定檔", + "screen.universal_config.profile_rename_label": "新的設定檔名稱", + "screen.universal_config.profile_name_required": "請輸入設定檔名稱。", + "screen.universal_config.rename_failed": "無法重新命名此設定檔。", "screen.universal_config.profile_name_label": "設定檔名稱", "screen.universal_config.profile_name_placeholder": "設定檔名稱", "screen.universal_config.profile_description_label": "說明", @@ -83,7 +88,7 @@ "screen.universal_config.show_details": "顯示詳細資料", "screen.universal_config.hide_details": "隱藏詳細資料", "screen.universal_config.confirm_load_failed": "無法載入設定檔資訊。", - "screen.universal_config.schedule_failed": "無法排程套用此設定。", + "screen.universal_config.schedule_failed": "無法將此設定設為下次啟動時套用。", "screen.universal_config.back": "返回", "screen.universal_config.confirm_profile": "設定檔:%s", "screen.universal_config.confirm_description": "說明:%s", @@ -94,7 +99,7 @@ "screen.universal_config.confirm_risk": "風險:%s", "screen.universal_config.confirm_apply_mode": "套用方式:下次啟動時載入", "screen.universal_config.confirm_safety": "安全措施:下次啟動套用前會建立 .ucbackup", - "screen.universal_config.confirm_after_action": "排程後:結束 Minecraft 並再次啟動", + "screen.universal_config.confirm_after_action": "之後:結束 Minecraft 並再次啟動", "screen.universal_config.confirm_warnings": "警告:", "screen.universal_config.confirm_checksums": "總和檢查碼:", "screen.universal_config.confirm_changed_keybinds": "變更的按鍵設定:", @@ -105,23 +110,23 @@ "screen.universal_config.risk_high": "高", "screen.universal_config.risk_medium": "中", "screen.universal_config.risk_low": "低", - "screen.universal_config.confirm_schedule_high": "仍要排程", - "screen.universal_config.confirm_schedule_medium": "排程備份還原", + "screen.universal_config.confirm_schedule_high": "仍要套用", + "screen.universal_config.confirm_schedule_medium": "從備份套用", "screen.universal_config.confirm_schedule_low": "下次啟動時載入", - "screen.universal_config.apply_scheduled_title": "已排程設定檔", + "screen.universal_config.apply_scheduled_title": "設定檔已準備好套用", "screen.universal_config.apply_scheduled_line1": "設定將在下次啟動 Minecraft 時套用。", "screen.universal_config.apply_scheduled_line2": "請重新啟動 Minecraft 以套用設定。", "screen.universal_config.apply_scheduled_later": "稍後重新啟動", "screen.universal_config.apply_scheduled_cancel": "取消", "screen.universal_config.cancel_failed": "請檢查遊戲資料夾的存取權限後再試一次。", "screen.universal_config.quit": "結束", - "screen.universal_config.pending_invalid_title": "找不到排程的設定", - "screen.universal_config.pending_invalid_line1": "排程的套用項目遺失或無效。", - "screen.universal_config.pending_invalid_line2": "返回設定檔並重新排程。", + "screen.universal_config.pending_invalid_title": "找不到待套用的設定", + "screen.universal_config.pending_invalid_line1": "待套用的設定遺失或無效。", + "screen.universal_config.pending_invalid_line2": "返回設定檔並重新套用。", "screen.universal_config.restart_failed_title": "無法重新啟動 Minecraft", "screen.universal_config.restart_failed_line1": "僅部分啟動器支援自動重新啟動。", "screen.universal_config.restart_failed_line2": "請從啟動器手動重新啟動 Minecraft。", - "screen.universal_config.restart_failed_line3": "排程的設定會保留,並在下次手動啟動時套用。", + "screen.universal_config.restart_failed_line3": "待套用的設定會保留,並在下次手動啟動時套用。", "screen.universal_config.ok": "確定", "screen.universal_config.profile_pending_restart": "重新啟動 Minecraft 後將套用設定", "screen.universal_config.environment_unknown": "無環境資訊", @@ -132,5 +137,12 @@ "screen.universal_config.target_mods": "模組", "screen.universal_config.include_toggle": "%s:%s", "screen.universal_config.include_enabled": "包含", - "screen.universal_config.include_disabled": "排除" + "screen.universal_config.include_disabled": "排除", + "screen.universal_config.backups": "備份", + "screen.universal_config.yes": "是", + "screen.universal_config.no": "否", + "screen.universal_config.action_failed": "無法完成此操作。", + "screen.universal_config.create_failed": "無法建立設定檔。", + "screen.universal_config.restore_complete": "已還原備份。", + "screen.universal_config.restore_failed": "無法還原備份。" } diff --git a/1.12.2/src/main/resources/assets/universal_config/lang/zh_tw.lang b/1.12.2/src/main/resources/assets/universal_config/lang/zh_tw.lang index a8038d8..0549fad 100644 --- a/1.12.2/src/main/resources/assets/universal_config/lang/zh_tw.lang +++ b/1.12.2/src/main/resources/assets/universal_config/lang/zh_tw.lang @@ -1,5 +1,3 @@ -key.universal_config.open=開啟 Universal Config -category.universal_config=Universal Config button.universal_config.open=Universal Config screen.universal_config.title=Universal Config screen.universal_config.subtitle=在其他環境使用你的 Minecraft 設定 @@ -8,6 +6,9 @@ screen.universal_config.included_settings=包含內容 screen.universal_config.includes_summary=包含:%s screen.universal_config.use_profile=使用此設定 screen.universal_config.more=… +screen.universal_config.rename=重新命名 +screen.universal_config.move_up=上移設定檔 +screen.universal_config.move_down=下移設定檔 screen.universal_config.restart_now=立即重新啟動 screen.universal_config.pending_named=⚠「%s」正在等待套用 screen.universal_config.minecraft_version=Minecraft %s @@ -51,6 +52,10 @@ screen.universal_config.loader_summary=模組載入器:%s %s screen.universal_config.description_none=無說明 screen.universal_config.targets=包含內容 screen.universal_config.profile_create_title=建立設定檔 +screen.universal_config.profile_rename_title=重新命名設定檔 +screen.universal_config.profile_rename_label=新的設定檔名稱 +screen.universal_config.profile_name_required=請輸入設定檔名稱。 +screen.universal_config.rename_failed=無法重新命名此設定檔。 screen.universal_config.profile_name_label=設定檔名稱 screen.universal_config.profile_name_placeholder=設定檔名稱 screen.universal_config.profile_description_label=說明 @@ -82,7 +87,7 @@ screen.universal_config.confirm_restart_short=必須重新啟動 Minecraft 才 screen.universal_config.show_details=顯示詳細資料 screen.universal_config.hide_details=隱藏詳細資料 screen.universal_config.confirm_load_failed=無法載入設定檔資訊。 -screen.universal_config.schedule_failed=無法排程套用此設定。 +screen.universal_config.schedule_failed=無法將此設定設為下次啟動時套用。 screen.universal_config.back=返回 screen.universal_config.confirm_profile=設定檔:%s screen.universal_config.confirm_description=說明:%s @@ -93,7 +98,7 @@ screen.universal_config.confirm_tested=已測試:%s screen.universal_config.confirm_risk=風險:%s screen.universal_config.confirm_apply_mode=套用方式:下次啟動時載入 screen.universal_config.confirm_safety=安全措施:下次啟動套用前會建立 .ucbackup -screen.universal_config.confirm_after_action=排程後:結束 Minecraft 並再次啟動 +screen.universal_config.confirm_after_action=之後:結束 Minecraft 並再次啟動 screen.universal_config.confirm_warnings=警告: screen.universal_config.confirm_checksums=總和檢查碼: screen.universal_config.confirm_changed_keybinds=變更的按鍵設定: @@ -104,23 +109,23 @@ screen.universal_config.confirm_list_item=- %s screen.universal_config.risk_high=高 screen.universal_config.risk_medium=中 screen.universal_config.risk_low=低 -screen.universal_config.confirm_schedule_high=仍要排程 -screen.universal_config.confirm_schedule_medium=排程備份還原 +screen.universal_config.confirm_schedule_high=仍要套用 +screen.universal_config.confirm_schedule_medium=從備份套用 screen.universal_config.confirm_schedule_low=下次啟動時載入 -screen.universal_config.apply_scheduled_title=已排程設定檔 +screen.universal_config.apply_scheduled_title=設定檔已準備好套用 screen.universal_config.apply_scheduled_line1=設定將在下次啟動 Minecraft 時套用。 screen.universal_config.apply_scheduled_line2=請重新啟動 Minecraft 以套用設定。 screen.universal_config.apply_scheduled_later=稍後重新啟動 screen.universal_config.apply_scheduled_cancel=取消 screen.universal_config.cancel_failed=請檢查遊戲資料夾的存取權限後再試一次。 screen.universal_config.quit=結束 -screen.universal_config.pending_invalid_title=找不到排程的設定 -screen.universal_config.pending_invalid_line1=排程的套用項目遺失或無效。 -screen.universal_config.pending_invalid_line2=返回設定檔並重新排程。 +screen.universal_config.pending_invalid_title=找不到待套用的設定 +screen.universal_config.pending_invalid_line1=待套用的設定遺失或無效。 +screen.universal_config.pending_invalid_line2=返回設定檔並重新套用。 screen.universal_config.restart_failed_title=無法重新啟動 Minecraft screen.universal_config.restart_failed_line1=僅部分啟動器支援自動重新啟動。 screen.universal_config.restart_failed_line2=請從啟動器手動重新啟動 Minecraft。 -screen.universal_config.restart_failed_line3=排程的設定會保留,並在下次手動啟動時套用。 +screen.universal_config.restart_failed_line3=待套用的設定會保留,並在下次手動啟動時套用。 screen.universal_config.ok=確定 screen.universal_config.profile_pending_restart=重新啟動 Minecraft 後將套用設定 screen.universal_config.environment_unknown=無環境資訊 @@ -132,7 +137,6 @@ screen.universal_config.target_mods=模組 screen.universal_config.include_toggle=%s:%s screen.universal_config.include_enabled=包含 screen.universal_config.include_disabled=排除 -key.categories.universal_config.main=Universal Config screen.universal_config.backups=備份 screen.universal_config.yes=是 screen.universal_config.no=否 diff --git a/1.12.2/src/main/resources/assets/universal_config/title_screen_button.png b/1.12.2/src/main/resources/assets/universal_config/title_screen_button.png index 4efa548..08aa6ca 100644 Binary files a/1.12.2/src/main/resources/assets/universal_config/title_screen_button.png and b/1.12.2/src/main/resources/assets/universal_config/title_screen_button.png differ diff --git a/1.12.2/src/main/resources/mcmod.info b/1.12.2/src/main/resources/mcmod.info index 5ba87e4..e2bf42a 100644 --- a/1.12.2/src/main/resources/mcmod.info +++ b/1.12.2/src/main/resources/mcmod.info @@ -3,7 +3,7 @@ "modid": "universal_config", "name": "Universal Config", "description": "Share Minecraft keybinds, client settings, and mod configuration profiles across Minecraft instances.", - "version": "1.0.0-mc1.12.2-forge", + "version": "1.1.0-mc1.12.2-forge", "mcversion": "1.12.2", "url": "https://modrinth.com/project/universal-config", "updateUrl": "", diff --git a/1.12.2/src/main/resources/universal_config.png b/1.12.2/src/main/resources/universal_config.png index 2ed8a92..d3f6dd6 100644 Binary files a/1.12.2/src/main/resources/universal_config.png and b/1.12.2/src/main/resources/universal_config.png differ diff --git a/1.12.2/src/test/java/com/example/universalconfig/core/ZipSecurityTest.java b/1.12.2/src/test/java/com/example/universalconfig/core/ZipSecurityTest.java new file mode 100644 index 0000000..cb3fa20 --- /dev/null +++ b/1.12.2/src/test/java/com/example/universalconfig/core/ZipSecurityTest.java @@ -0,0 +1,69 @@ +package com.example.universalconfig.core; + +import org.junit.Test; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public class ZipSecurityTest { + @Test + public void rejectsTraversalAbsoluteAndUnsafeSizes() throws Exception { + assertRejected(new CheckedAction() { + @Override + public void run() throws Exception { + ZipSecurity.validateRelativeEntryName("../options.txt"); + } + }); + assertRejected(new CheckedAction() { + @Override + public void run() throws Exception { + ZipSecurity.validateRelativeEntryName("C:\\Windows\\system.ini"); + } + }); + assertRejected(new CheckedAction() { + @Override + public void run() throws Exception { + ZipSecurity.validateEntrySizes( + "large.txt", ZipSecurity.MAX_ENTRY_UNCOMPRESSED_BYTES + 1, 1, 0); + } + }); + assertRejected(new CheckedAction() { + @Override + public void run() throws Exception { + ZipSecurity.validateEntrySizes( + "ratio.txt", ZipSecurity.MAX_COMPRESSION_RATIO + 1, 1, 0); + } + }); + assertRejected(new CheckedAction() { + @Override + public void run() throws Exception { + ZipSecurity.validateEntrySizes( + "total.txt", 1, 1, ZipSecurity.MAX_TOTAL_UNCOMPRESSED_BYTES); + } + }); + } + + @Test + public void acceptsSafeEntryWithinLimits() throws Exception { + Path root = Paths.get("build", "tmp", "zip-root"); + assertEquals(root.toAbsolutePath().normalize().resolve("profile/config.json"), + ZipSecurity.safeResolve(root, "profile/config.json")); + assertEquals(300L, ZipSecurity.validateEntrySizes("safe.txt", 300, 3, 0)); + } + + private static void assertRejected(CheckedAction action) throws Exception { + try { + action.run(); + fail("Expected unsafe ZIP metadata to be rejected"); + } catch (UniversalConfigException expected) { + // The specific message is intentionally not asserted because it is an internal diagnostic. + } + } + + private interface CheckedAction { + void run() throws Exception; + } +} diff --git a/1.16.5/NOTICE b/1.16.5/NOTICE new file mode 100644 index 0000000..7960e1a --- /dev/null +++ b/1.16.5/NOTICE @@ -0,0 +1,5 @@ +Universal Config includes Gson 2.11.0 from Google. + +Gson is licensed under the Apache License, Version 2.0. +The applicable license text is included in licenses/Apache-2.0.txt and in +the META-INF/licenses directory of the binary distribution. diff --git a/1.16.5/build.gradle b/1.16.5/build.gradle index 9311bec..7d07385 100644 --- a/1.16.5/build.gradle +++ b/1.16.5/build.gradle @@ -119,6 +119,7 @@ dependencies { // If the group id is "net.minecraft" and the artifact id is one of ["client", "server", "joined"], // then special handling is done to allow a setup of a vanilla dependency without the use of an external repository. minecraft "net.minecraftforge:forge:${minecraft_version}-${forge_version}" + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.3' // Real mod deobf dependency examples - these get remapped to your current mappings // compileOnly fg.deobf("mezz.jei:jei-${mc_version}:${jei_version}:api") // Adds JEI API as a compile dependency @@ -135,6 +136,8 @@ dependencies { // http://www.gradle.org/docs/current/userguide/dependency_management.html } +tasks.named('test', Test).configure { useJUnitPlatform() } + // This block of code expands all declared replace properties in the specified resource targets. // A missing property will result in an error. Properties are expanded using ${} Groovy notation. // When "copyIdeResources" is enabled, this will also run before the game launches in IDE environments. @@ -174,6 +177,8 @@ tasks.named('jar', Jar).configure { into 'META-INF' rename { 'LICENSE_universal_config' } } + from('NOTICE') { into 'META-INF' } + from('licenses') { into 'META-INF/licenses' } } // However if you are in a multi-project build, dev time needs unobfed jar files, so you can delay the obfuscation until publishing by doing: diff --git a/1.16.5/gradle.properties b/1.16.5/gradle.properties index 2fe8cea..1422be2 100644 --- a/1.16.5/gradle.properties +++ b/1.16.5/gradle.properties @@ -48,7 +48,7 @@ mod_name=Universal Config # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=MIT # The mod version. See https://semver.org/ -mod_version=1.0.0 +mod_version=1.1.0 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. # See https://maven.apache.org/guides/mini/guide-naming-conventions.html diff --git a/1.16.5/licenses/Apache-2.0.txt b/1.16.5/licenses/Apache-2.0.txt new file mode 100644 index 0000000..b84f370 --- /dev/null +++ b/1.16.5/licenses/Apache-2.0.txt @@ -0,0 +1,175 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You meet + the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works + a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that + You distribute, all copyright, patent, trademark, and attribution + notices from the Source form of the Work, excluding those notices + that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing + the origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/1.16.5/src/main/java/com/example/universalconfig/core/CurrentProcessRestartService.java b/1.16.5/src/main/java/com/example/universalconfig/core/CurrentProcessRestartService.java index 439899d..23b8b25 100644 --- a/1.16.5/src/main/java/com/example/universalconfig/core/CurrentProcessRestartService.java +++ b/1.16.5/src/main/java/com/example/universalconfig/core/CurrentProcessRestartService.java @@ -28,7 +28,22 @@ private CurrentProcessRestartService() { } public static void scheduleRestartAfterCurrentProcessExit() throws UniversalConfigException { - scheduleRestartAfterCurrentProcessExit(currentWorkingDirectory(), Collections.emptyList()); + scheduleRestartAfterCurrentProcessExit(currentWorkingDirectory(), currentProcessArguments()); + } + + /** + * ゲームプロセスが公開している起動引数を、ローダーが解決した引数の代替として返す。 + * Java 9 以降の ProcessHandle API が使える環境では OS が引数境界を保持したまま公開し、 + * Java 8 環境では ProcessDiscovery が OS メタデータから読み取る。引数は再起動先へそのまま渡すため、 + * ローダー独自の引数解決がなくても安全に現在の Java 起動を再現できる。 + */ + public static List currentProcessArguments() { + try { + List arguments = ProcessDiscovery.current().arguments(); + return arguments == null ? Collections.emptyList() : arguments; + } catch (RuntimeException ignored) { + return Collections.emptyList(); + } } /** @@ -38,7 +53,7 @@ public static void scheduleRestartAfterCurrentProcessExit() throws UniversalConf */ public static void scheduleRestartAfterCurrentProcessExit(Path workingDirectory) throws UniversalConfigException { - scheduleRestartAfterCurrentProcessExit(workingDirectory, Collections.emptyList()); + scheduleRestartAfterCurrentProcessExit(workingDirectory, currentProcessArguments()); } /** @@ -52,12 +67,12 @@ public static void scheduleRestartAfterCurrentProcessExit( Path helperClasspath ) throws UniversalConfigException { scheduleRestartAfterCurrentProcessExit( - workingDirectory, Collections.emptyList(), helperClasspath); + workingDirectory, currentProcessArguments(), helperClasspath); } /** - * Schedules the current Minecraft process to be replaced, using loader-provided arguments when the operating - * system does not expose the current process arguments through its process metadata API. + * Schedules the current Minecraft process to be replaced. Unsupported launchers use the loader-resolved Java + * arguments supplied by the loader adapter instead of reconstructing a command line from process metadata. */ public static void scheduleRestartAfterCurrentProcessExit( Path workingDirectory, @@ -66,7 +81,7 @@ public static void scheduleRestartAfterCurrentProcessExit( scheduleRestartAfterCurrentProcessExit(workingDirectory, loaderResolvedArguments, null); } - private static void scheduleRestartAfterCurrentProcessExit( + public static void scheduleRestartAfterCurrentProcessExit( Path workingDirectory, List loaderResolvedArguments, Path helperClasspath @@ -78,7 +93,6 @@ private static void scheduleRestartAfterCurrentProcessExit( } Path normalizedWorkingDirectory = normalizeWorkingDirectory(workingDirectory); List ancestors = ancestorCommands(current); - Optional> currentArguments = currentJavaArguments(current.arguments(), loaderResolvedArguments); // Modrinth's documented launch URL requires a database-only internal ID that is not inherited by the game. // Guessing it from the folder name could launch the wrong profile, so only self-identifying launchers are used. Optional replacementCandidate = prismFamilyLauncherCommand( @@ -86,13 +100,9 @@ private static void scheduleRestartAfterCurrentProcessExit( if (!replacementCandidate.isPresent()) { replacementCandidate = atLauncherCommand(normalizedWorkingDirectory, ancestors); } - if (!replacementCandidate.isPresent()) { - replacementCandidate = gdLauncherCommand( - normalizedWorkingDirectory, ancestors, executable, currentArguments); - } LaunchCommand replacement = replacementCandidate.isPresent() ? replacementCandidate.get() - : currentArguments.isPresent() ? new LaunchCommand(executable, currentArguments.get()) : null; + : unsupportedLauncherCommand(executable, loaderResolvedArguments).orElse(null); if (replacement == null) { throw new UniversalConfigException("Could not determine how to restart this launcher instance."); } @@ -125,21 +135,75 @@ public static List buildJavaLaunchArguments( } } - private static Optional> currentJavaArguments( - List processArguments, + /** + * Builds the default restart command for a launcher without a dedicated integration. + * + *

The launcher is intentionally not inspected here. Every launcher that is not recognized by a dedicated + * detector follows this path, including GDLauncher and the official launcher.

+ */ + static Optional unsupportedLauncherCommand( + String currentExecutable, List loaderResolvedArguments - ) throws UniversalConfigException { + ) { + if (!isJavaExecutable(currentExecutable)) { + return Optional.empty(); + } + return validJavaLaunchArguments(loaderResolvedArguments) + .map(new java.util.function.Function, LaunchCommand>() { + @Override + public LaunchCommand apply(List arguments) { + return new LaunchCommand(currentExecutable, arguments); + } + }); + } + + private static Optional> validJavaLaunchArguments(List arguments) { + if (arguments == null || arguments.isEmpty()) { + return Optional.empty(); + } try { - if (processArguments != null && !processArguments.isEmpty()) { - return Optional.of(immutableCopy(processArguments)); + List copied = immutableCopy(arguments); + for (String value : copied) { + if (value == null || value.trim().isEmpty()) return Optional.empty(); } - if (loaderResolvedArguments == null || loaderResolvedArguments.isEmpty()) { - return Optional.empty(); + for (int index = 0; index + 2 < copied.size(); index++) { + if (("-cp".equals(copied.get(index)) || "-classpath".equals(copied.get(index))) + && !copied.get(index + 1).trim().isEmpty()) { + return hasMainClassAfter(copied, index + 2) ? Optional.of(copied) : Optional.empty(); + } } - return Optional.of(immutableCopy(loaderResolvedArguments)); + return Optional.empty(); } catch (RuntimeException ex) { - throw new UniversalConfigException("Could not determine the current Java arguments.", ex); + return Optional.empty(); + } + } + + private static boolean hasMainClassAfter(List arguments, int startIndex) { + int index = startIndex; + while (index < arguments.size()) { + String value = arguments.get(index); + if (!value.startsWith("-")) return true; + if (isInlineJvmOption(value)) { + index++; + } else if (isJvmOptionWithSeparateValue(value) && index + 1 < arguments.size()) { + index += 2; + } else { + return false; + } } + return false; + } + + private static boolean isInlineJvmOption(String value) { + return value.startsWith("-D") || value.startsWith("-X") + || (value.startsWith("--") && value.indexOf('=') > 2); + } + + private static boolean isJvmOptionWithSeparateValue(String value) { + return "--add-modules".equals(value) || "--add-exports".equals(value) + || "--add-opens".equals(value) || "--add-reads".equals(value) + || "--limit-modules".equals(value) || "-p".equals(value) || "--module-path".equals(value) + || "--upgrade-module-path".equals(value) || "--patch-module".equals(value); } private static void scheduleJavaHelper( @@ -348,42 +412,6 @@ static Optional atLauncherCommand( return Optional.empty(); } - static Optional gdLauncherCommand( - Path workingDirectory, - List ancestorCommands, - String currentExecutable, - Optional> currentArguments - ) { - if (!isGdLauncherGameDirectory(workingDirectory) - || !ancestorCommands.stream().anyMatch(command -> isGdLauncherExecutable(command.executable())) - || !ancestorCommands.stream().anyMatch(command -> isGdCoreModuleExecutable(command.executable())) - || !isJavaExecutable(currentExecutable)) { - return Optional.empty(); - } - - // Carbon does not provide a stable command-line launch interface for an instance. The core module supervises - // the Java child, so reusing the already resolved Java command is the only way to preserve the selected - // loader, natives, assets, account, and instance-specific arguments without guessing launcher internals. - return currentArguments.map(arguments -> new LaunchCommand(currentExecutable, arguments)); - } - - private static boolean isGdLauncherGameDirectory(Path workingDirectory) { - try { - Path gameDirectory = workingDirectory.toAbsolutePath().normalize(); - Path instanceDirectory = gameDirectory.getParent(); - Path instancesDirectory = instanceDirectory == null ? null : instanceDirectory.getParent(); - return gameDirectory.getFileName() != null - && gameDirectory.getFileName().toString().equalsIgnoreCase("instance") - && instanceDirectory != null - && Files.isRegularFile(instanceDirectory.resolve("instance.json")) - && instancesDirectory != null - && instancesDirectory.getFileName() != null - && instancesDirectory.getFileName().toString().equalsIgnoreCase("instances"); - } catch (RuntimeException ex) { - return false; - } - } - private static Optional> atLauncherJarArguments( List ancestorArguments, List launchArguments @@ -575,16 +603,6 @@ private static boolean isAtLauncherExecutable(String command) { return fileName.equalsIgnoreCase("atlauncher.exe") || fileName.equalsIgnoreCase("atlauncher"); } - private static boolean isGdLauncherExecutable(String command) { - String fileName = fileName(command); - return fileName.equalsIgnoreCase("gdlauncher.exe") || fileName.equalsIgnoreCase("gdlauncher"); - } - - private static boolean isGdCoreModuleExecutable(String command) { - String fileName = fileName(command); - return fileName.equalsIgnoreCase("core_module.exe") || fileName.equalsIgnoreCase("core_module"); - } - private static boolean isJavaExecutable(String command) { String fileName = fileName(command); return fileName.equalsIgnoreCase("java.exe") diff --git a/1.16.5/src/main/java/com/example/universalconfig/core/FileOperationLogger.java b/1.16.5/src/main/java/com/example/universalconfig/core/FileOperationLogger.java index 8db6802..9be027b 100644 --- a/1.16.5/src/main/java/com/example/universalconfig/core/FileOperationLogger.java +++ b/1.16.5/src/main/java/com/example/universalconfig/core/FileOperationLogger.java @@ -7,14 +7,29 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.regex.Pattern; public final class FileOperationLogger { + private static final Pattern ABSOLUTE_WINDOWS_PATH = Pattern.compile( + "(?i)(?])/(?:[^\\s\\t,;:/]+/)*[^\\s\\t,;:/]+" + ); private static final String LAUNCH_ID = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS") .format(java.time.LocalDateTime.now()) + "-pid" + currentProcessId(); private static Path configuredRoot; + private static Path instanceRoot; + private static Path userHomeRoot; + private static Path appDataRoot; private static Path launchLogFile; private static Path latestLogFile; @@ -22,11 +37,23 @@ private FileOperationLogger() { } public static synchronized void configure(UniversalConfigSettings settings) { + configure(settings, null); + } + + public static synchronized void configure(UniversalConfigSettings settings, Path minecraftRunDirectory) { Path root = settings.rootDirectory().toAbsolutePath().normalize(); - if (root.equals(configuredRoot) && launchLogFile != null && latestLogFile != null) { + Path instance = normalizeOrNull(minecraftRunDirectory); + if (root.equals(configuredRoot) + && equalsPath(instance, instanceRoot) + && launchLogFile != null + && latestLogFile != null) { return; } configuredRoot = root; + instanceRoot = instance; + userHomeRoot = normalizeOrNull(Paths.get(System.getProperty("user.home", "."))); + String appData = System.getenv("APPDATA"); + appDataRoot = appData == null || appData.trim().isEmpty() ? null : normalizeOrNull(Paths.get(appData)); Path logsRoot = root.resolve(UniversalConfigFormat.LOGS_DIRECTORY_NAME); launchLogFile = logsRoot.resolve(UniversalConfigFormat.LAUNCH_LOGS_DIRECTORY_NAME) .resolve(UniversalConfigFormat.LAUNCH_LOG_FILE_PREFIX + LAUNCH_ID + ".log"); @@ -70,12 +97,12 @@ private static synchronized void write(String level, String operation, Path path .append(LAUNCH_ID).append('\t') .append(level).append('\t') .append(operation).append('\t') - .append(path == null ? "-" : path.toAbsolutePath().normalize()).append('\t') - .append(detail == null ? "" : detail.replace('\n', ' ').replace('\r', ' ')); + .append(sanitizePath(path)).append('\t') + .append(sanitizeText(detail)); if (throwable != null) { StringWriter writer = new StringWriter(); throwable.printStackTrace(new PrintWriter(writer)); - line.append('\t').append(writer.toString().replace('\n', ' ').replace('\r', ' ')); + line.append('\t').append(sanitizeText(writer.toString())); } line.append(System.lineSeparator()); byte[] encoded = line.toString().getBytes(StandardCharsets.UTF_8); @@ -88,6 +115,112 @@ private static synchronized void write(String level, String operation, Path path } } + static synchronized String sanitizePath(Path path) { + if (path == null) { + return "-"; + } + Path normalized = normalizeOrNull(path); + if (normalized == null) { + return ""; + } + List labels = new ArrayList(); + labels.add(new PathLabel(instanceRoot, "")); + labels.add(new PathLabel(configuredRoot, "")); + labels.add(new PathLabel(appDataRoot, "")); + labels.add(new PathLabel(userHomeRoot, "")); + List filtered = new ArrayList(); + for (PathLabel label : labels) { + if (label.root() != null) { + filtered.add(label); + } + } + // 深いパスほど先に一致させる。ファイル名キーは相対パス全体を含むため、 + // ルートが他のルートの接頭辞になる場合でもより具体的なラベルが優先される。 + Collections.sort(filtered, new Comparator() { + @Override + public int compare(PathLabel left, PathLabel right) { + return Integer.compare(right.root().getNameCount(), left.root().getNameCount()); + } + }); + for (PathLabel label : filtered) { + if (normalized.startsWith(label.root())) { + Path relative = label.root().relativize(normalized); + return relative.getNameCount() == 0 + ? label.label() + : label.label() + "/" + relative.toString().replace('\\', '/'); + } + } + Path fileName = normalized.getFileName(); + return fileName == null ? "" : "/" + fileName; + } + + static synchronized String sanitizeText(String value) { + if (value == null || value.trim().isEmpty()) { + return value == null ? "" : value; + } + String sanitized = value.replace('\n', ' ').replace('\r', ' '); + List labels = new ArrayList(); + labels.add(new PathLabel(instanceRoot, "")); + labels.add(new PathLabel(configuredRoot, "")); + labels.add(new PathLabel(appDataRoot, "")); + labels.add(new PathLabel(userHomeRoot, "")); + List filtered = new ArrayList(); + for (PathLabel label : labels) { + if (label.root() != null) { + filtered.add(label); + } + } + // 長いルート文字列ほど先に置換する。短いルートが先だと、共通接頭辞を持つ深いパスの + // 一部だけが置換され、残った相対部分が絶対パス正規表現に誤判定される可能性がある。 + Collections.sort(filtered, new Comparator() { + @Override + public int compare(PathLabel left, PathLabel right) { + return Integer.compare(right.root().toString().length(), left.root().toString().length()); + } + }); + for (PathLabel label : filtered) { + String nativeRoot = label.root().toString(); + sanitized = sanitized.replace(nativeRoot, label.label()) + .replace(nativeRoot.replace('\\', '/'), label.label()); + } + return ABSOLUTE_UNIX_PATH.matcher( + ABSOLUTE_WINDOWS_PATH.matcher(sanitized).replaceAll("") + ).replaceAll("").replace('\\', '/'); + } + + private static Path normalizeOrNull(Path path) { + if (path == null) { + return null; + } + try { + return path.toAbsolutePath().normalize(); + } catch (RuntimeException ex) { + return null; + } + } + + private static boolean equalsPath(Path left, Path right) { + return left == null ? right == null : left.equals(right); + } + + static final class PathLabel { + private final Path root; + private final String label; + + PathLabel(Path root, String label) { + this.root = root; + this.label = label; + } + + Path root() { + return root; + } + + String label() { + return label; + } + } + private static long currentProcessId() { String runtimeName = ManagementFactory.getRuntimeMXBean().getName(); int separator = runtimeName.indexOf('@'); diff --git a/1.16.5/src/main/java/com/example/universalconfig/core/ProfileCreateOptions.java b/1.16.5/src/main/java/com/example/universalconfig/core/ProfileCreateOptions.java index ffa7fc9..591bb34 100644 --- a/1.16.5/src/main/java/com/example/universalconfig/core/ProfileCreateOptions.java +++ b/1.16.5/src/main/java/com/example/universalconfig/core/ProfileCreateOptions.java @@ -7,6 +7,7 @@ public final class ProfileCreateOptions { public String name; public String description; public String icon = ProfileIcon.GRASS_BLOCK; + // 画面の初期選択はUI側で管理する。非UIの呼び出し元では従来どおり全項目を保存対象にする。 public boolean includeKeybinds = true; public boolean includeClientOptions = true; public boolean includeModConfigs = true; diff --git a/1.16.5/src/main/java/com/example/universalconfig/core/ProfileService.java b/1.16.5/src/main/java/com/example/universalconfig/core/ProfileService.java index a26512c..e786749 100644 --- a/1.16.5/src/main/java/com/example/universalconfig/core/ProfileService.java +++ b/1.16.5/src/main/java/com/example/universalconfig/core/ProfileService.java @@ -19,6 +19,8 @@ import java.util.Collections; import java.util.Comparator; import java.util.HashSet; +import java.util.Map; +import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -172,14 +174,145 @@ public List listProfiles() throws UniversalConfigException { } } summaries.sort(Comparator.comparing((ProfileSummary summary) -> safeUpdatedAt(summary.manifest())).reversed()); - FileOperationLogger.info("LIST_PROFILES", profiles, "count=" + summaries.size()); - return summaries; + List ordered = applyProfileOrder(summaries); + FileOperationLogger.info("LIST_PROFILES", profiles, "count=" + ordered.size()); + return ordered; } catch (IOException ex) { FileOperationLogger.failure("LIST_PROFILES", profiles, "failed", ex); throw new UniversalConfigException("Failed to list profiles.", ex); } } + public void renameProfile(Path profilePath, String name) throws UniversalConfigException { + Path normalized = validateProfilePath(profilePath); + String requestedName = name == null ? "" : name.trim(); + if (requestedName.trim().isEmpty()) { + throw new UniversalConfigException("Profile name is required."); + } + if (requestedName.length() > 128) { + throw new UniversalConfigException("Profile name is too long."); + } + + try (ProfileDirectoryLock ignored = lockProfileDirectory()) { + ProfileManifest manifest = readManifest(normalized); + String uniqueName = uniqueProfileName(requestedName, normalized); + if (Objects.equals(manifest.name, uniqueName)) { + return; + } + rewriteProfileName(normalized, uniqueName); + FileOperationLogger.info("RENAME_PROFILE", normalized, + "from=" + manifest.name + " to=" + uniqueName); + } + } + + public void moveProfile(Path instancePath, Path profilePath, int targetIndex) throws UniversalConfigException { + Path normalized = validateProfilePath(profilePath); + try (ProfileDirectoryLock ignored = lockProfileDirectory()) { + List summaries = listProfiles(); + int currentIndex = indexOfProfile(summaries, normalized); + if (currentIndex < 0) { + throw new UniversalConfigException("The selected profile is unavailable."); + } + if (targetIndex < 0 || targetIndex >= summaries.size()) { + throw new UniversalConfigException("The selected profile position is invalid."); + } + if (currentIndex == targetIndex) { + return; + } + + List order = new ArrayList(); + for (ProfileSummary summary : summaries) { + String key = profileOrderKey(summary); + if (key != null) { + order.add(key); + } + } + String movedKey = profileOrderKey(summaries.get(currentIndex)); + order.remove(movedKey); + order.add(targetIndex, movedKey); + settings.setProfileOrder(order); + UniversalConfigPaths.saveSettings(instancePath, settings); + FileOperationLogger.info("MOVE_PROFILE", normalized, "targetIndex=" + targetIndex); + } + } + + private List applyProfileOrder(List summaries) { + List configuredOrder = settings.profileOrder(); + if (configuredOrder.isEmpty()) { + return summaries; + } + + Map byKey = new HashMap(); + for (ProfileSummary summary : summaries) { + String key = profileOrderKey(summary); + if (key != null) { + byKey.putIfAbsent(key, summary); + } + } + + // 過去バージョンで id ベースのキーを保存済みの設定との互換性のため、id も補助インデックスとして残す。 + Map byLegacyIdKey = new HashMap(); + for (ProfileSummary summary : summaries) { + String legacyIdKey = legacyProfileOrderKey(summary); + if (legacyIdKey != null) { + byLegacyIdKey.putIfAbsent(legacyIdKey, summary); + } + } + + List ordered = new ArrayList(summaries.size()); + Set included = new HashSet(); + for (String key : configuredOrder) { + // ファイル名キーを優先し、無ければ旧 id キーで解決する。 + ProfileSummary summary = byKey.get(key); + if (summary == null) { + summary = byLegacyIdKey.get(key); + } + if (summary != null) { + String fileKey = profileOrderKey(summary); + if (fileKey != null && included.add(fileKey)) { + ordered.add(summary); + } + } + } + for (ProfileSummary summary : summaries) { + String key = profileOrderKey(summary); + if (key == null || included.add(key)) { + ordered.add(summary); + } + } + return ordered; + } + + private int indexOfProfile(List summaries, Path profilePath) { + for (int index = 0; index < summaries.size(); index++) { + if (summaries.get(index).path().toAbsolutePath().normalize().equals(profilePath)) { + return index; + } + } + return -1; + } + + private String profileOrderKey(ProfileSummary summary) { + if (summary == null) { + return null; + } + // ファイル名を並び順キーに使う。manifest.id は複製が元 id を保持するため一意性が保証されず、 + // id ベースの重複排除で複製が一覧から消える問題がある(PR #43 P1)。 + // rename や duplicate でもファイル名は変化しないため、ファイル名は安定した識別子になる。 + Path fileName = summary.path() == null ? null : summary.path().getFileName(); + return fileName == null ? null : "file:" + fileName; + } + + // 過去バージョンで settings.profileOrder に id ベースキーを保存していた設定との互換用。 + // 新規保存では使わない。applyProfileOrder のフォールバック解決でのみ参照する。 + private String legacyProfileOrderKey(ProfileSummary summary) { + if (summary == null || summary.manifest() == null) { + return null; + } + String id = summary.manifest().id; + return id == null || id.trim().isEmpty() ? null : "id:" + id; + } + public List listBackups() throws UniversalConfigException { Path backups = UniversalConfigPaths.backupsDirectory(settings); FileOperationLogger.info("LIST_BACKUPS", backups, "start"); diff --git a/1.16.5/src/main/java/com/example/universalconfig/core/UniversalConfigPaths.java b/1.16.5/src/main/java/com/example/universalconfig/core/UniversalConfigPaths.java index ef2767a..a76b4a4 100644 --- a/1.16.5/src/main/java/com/example/universalconfig/core/UniversalConfigPaths.java +++ b/1.16.5/src/main/java/com/example/universalconfig/core/UniversalConfigPaths.java @@ -12,6 +12,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Locale; +import java.util.List; public final class UniversalConfigPaths { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); @@ -40,14 +41,16 @@ public static UniversalConfigSettings loadOrCreateSettings(Path minecraftRunDire UniversalConfigSettings settings = new UniversalConfigSettings(Paths.get(dto.rootDirectory)); boolean invalidDefaultProfilePath = hasInvalidDefaultProfilePath(dto.defaultProfilePath); settings.setDefaultProfilePath(parseDefaultProfilePath(dto.defaultProfilePath)); + settings.setProfileOrder(dto.profileOrder); ensureDirectories(settings); - FileOperationLogger.configure(settings); + FileOperationLogger.configure(settings, minecraftRunDirectory); Path sharedSettingsFile = rootSettingsFile(settings); if (Files.isRegularFile(sharedSettingsFile)) { SettingsDto sharedDto = readSettingsDto(sharedSettingsFile); invalidDefaultProfilePath = hasInvalidDefaultProfilePath(sharedDto == null ? null : sharedDto.defaultProfilePath); settings.setDefaultProfilePath(parseDefaultProfilePath( sharedDto == null ? null : sharedDto.defaultProfilePath)); + settings.setProfileOrder(sharedDto == null ? null : sharedDto.profileOrder); } else { // Older versions stored the default only inside one instance. Migrate it to the shared root. saveRootSettings(settings); @@ -65,12 +68,13 @@ public static UniversalConfigSettings loadOrCreateSettings(Path minecraftRunDire UniversalConfigSettings settings = new UniversalConfigSettings(defaultRootDirectory()); ensureDirectories(settings); - FileOperationLogger.configure(settings); + FileOperationLogger.configure(settings, minecraftRunDirectory); Path sharedSettingsFile = rootSettingsFile(settings); if (Files.isRegularFile(sharedSettingsFile)) { SettingsDto sharedDto = readSettingsDto(sharedSettingsFile); settings.setDefaultProfilePath(parseDefaultProfilePath( sharedDto == null ? null : sharedDto.defaultProfilePath)); + settings.setProfileOrder(sharedDto == null ? null : sharedDto.profileOrder); } saveSettings(minecraftRunDirectory, settings); return settings; @@ -85,6 +89,7 @@ public static void saveSettings(Path minecraftRunDirectory, UniversalConfigSetti dto.defaultProfilePath = settings.defaultProfilePath() == null ? null : settings.defaultProfilePath().toAbsolutePath().normalize().toString(); + dto.profileOrder = settings.profileOrder(); try (Writer writer = Files.newBufferedWriter(localSettings, StandardCharsets.UTF_8)) { GSON.toJson(dto, writer); } @@ -160,6 +165,7 @@ public static String safeFileSlug(String value) { private static final class SettingsDto { String rootDirectory; String defaultProfilePath; + List profileOrder; } private static SettingsDto readSettingsDto(Path path) throws UniversalConfigException { @@ -179,6 +185,7 @@ private static void saveRootSettings(UniversalConfigSettings settings) throws Un dto.defaultProfilePath = settings.defaultProfilePath() == null ? null : settings.defaultProfilePath().toAbsolutePath().normalize().toString(); + dto.profileOrder = settings.profileOrder(); try (Writer writer = Files.newBufferedWriter(rootSettings, StandardCharsets.UTF_8)) { GSON.toJson(dto, writer); } diff --git a/1.16.5/src/main/java/com/example/universalconfig/core/UniversalConfigSettings.java b/1.16.5/src/main/java/com/example/universalconfig/core/UniversalConfigSettings.java index 174087c..0e94647 100644 --- a/1.16.5/src/main/java/com/example/universalconfig/core/UniversalConfigSettings.java +++ b/1.16.5/src/main/java/com/example/universalconfig/core/UniversalConfigSettings.java @@ -1,10 +1,14 @@ package com.example.universalconfig.core; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; public final class UniversalConfigSettings { private Path rootDirectory; private Path defaultProfilePath; + private List profileOrder = new ArrayList(); public UniversalConfigSettings(Path rootDirectory) { this.rootDirectory = rootDirectory; @@ -25,4 +29,20 @@ public Path defaultProfilePath() { public void setDefaultProfilePath(Path defaultProfilePath) { this.defaultProfilePath = defaultProfilePath; } + + public List profileOrder() { + return Collections.unmodifiableList(profileOrder); + } + + public void setProfileOrder(List profileOrder) { + this.profileOrder = new ArrayList(); + if (profileOrder == null) { + return; + } + for (String profileKey : profileOrder) { + if (profileKey != null && !profileKey.trim().isEmpty() && !this.profileOrder.contains(profileKey)) { + this.profileOrder.add(profileKey); + } + } + } } diff --git a/1.16.5/src/main/java/com/example/universalconfig/core/ZipArchiveReader.java b/1.16.5/src/main/java/com/example/universalconfig/core/ZipArchiveReader.java index 2f39b94..d1d917d 100644 --- a/1.16.5/src/main/java/com/example/universalconfig/core/ZipArchiveReader.java +++ b/1.16.5/src/main/java/com/example/universalconfig/core/ZipArchiveReader.java @@ -16,11 +16,25 @@ public final class ZipArchiveReader implements ProfileArchiveReader { public ZipArchiveReader(Path archivePath) throws IOException, UniversalConfigException { this.archivePath = archivePath; this.zipFile = new ZipFile(archivePath.toFile()); - FileOperationLogger.info("OPEN_ZIP", archivePath, "read"); - Enumeration entries = zipFile.entries(); - while (entries.hasMoreElements()) { - ZipEntry entry = entries.nextElement(); - ZipSecurity.validateRelativeEntryName(entry.getName()); + try { + FileOperationLogger.info("OPEN_ZIP", archivePath, "read"); + long totalUncompressedSize = 0; + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + ZipSecurity.validateRelativeEntryName(entry.getName()); + if (!entry.isDirectory()) { + totalUncompressedSize = ZipSecurity.validateEntrySizes( + entry.getName(), entry.getSize(), entry.getCompressedSize(), totalUncompressedSize); + } + } + } catch (UniversalConfigException | RuntimeException ex) { + try { + zipFile.close(); + } catch (IOException closeException) { + ex.addSuppressed(closeException); + } + throw ex; } } @@ -43,8 +57,14 @@ public InputStream open(String entryName) throws IOException, UniversalConfigExc @Override public List entries() { - List names = new ArrayList<>(); - zipFile.stream().filter(entry -> !entry.isDirectory()).forEach(entry -> names.add(entry.getName())); + List names = new ArrayList(); + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (!entry.isDirectory()) { + names.add(entry.getName()); + } + } return names; } diff --git a/1.16.5/src/main/java/com/example/universalconfig/core/ZipArchiveWriter.java b/1.16.5/src/main/java/com/example/universalconfig/core/ZipArchiveWriter.java index ac9c235..2cb34dd 100644 --- a/1.16.5/src/main/java/com/example/universalconfig/core/ZipArchiveWriter.java +++ b/1.16.5/src/main/java/com/example/universalconfig/core/ZipArchiveWriter.java @@ -18,8 +18,11 @@ public static void write(Path path, Map entries) throws IOExcept Files.createDirectories(path.getParent()); FileOperationLogger.info("CREATE_DIRECTORY", path.getParent(), "zip parent"); try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(path))) { + long totalUncompressedSize = 0; for (Map.Entry entry : entries.entrySet()) { ZipSecurity.validateRelativeEntryName(entry.getKey()); + totalUncompressedSize = ZipSecurity.validateUncompressedSize( + entry.getKey(), entry.getValue().length, totalUncompressedSize); output.putNextEntry(new ZipEntry(entry.getKey())); output.write(entry.getValue()); output.closeEntry(); diff --git a/1.16.5/src/main/java/com/example/universalconfig/core/ZipSecurity.java b/1.16.5/src/main/java/com/example/universalconfig/core/ZipSecurity.java index 5aad6d6..c6d9282 100644 --- a/1.16.5/src/main/java/com/example/universalconfig/core/ZipSecurity.java +++ b/1.16.5/src/main/java/com/example/universalconfig/core/ZipSecurity.java @@ -4,6 +4,15 @@ import java.nio.file.Paths; public final class ZipSecurity { + /** Maximum uncompressed size accepted for one profile or backup entry. */ + public static final long MAX_ENTRY_UNCOMPRESSED_BYTES = 32L * 1024L * 1024L; + + /** Maximum combined uncompressed size accepted for one archive. */ + public static final long MAX_TOTAL_UNCOMPRESSED_BYTES = 256L * 1024L * 1024L; + + /** Maximum declared expansion ratio accepted for a non-empty entry. */ + public static final long MAX_COMPRESSION_RATIO = 1_000L; + private ZipSecurity() { } @@ -44,4 +53,42 @@ public static Path safeResolve(Path destinationRoot, String entryName) throws Un } return resolved; } + + /** + * Validates central-directory size metadata before any archive payload is read. + * The returned value is the accumulated uncompressed size for the archive. + */ + public static long validateEntrySizes( + String entryName, + long uncompressedSize, + long compressedSize, + long currentTotal + ) throws UniversalConfigException { + if (uncompressedSize < 0 || compressedSize < 0) { + throw new UniversalConfigException("ZIP entry size metadata is missing: " + entryName); + } + long total = validateUncompressedSize(entryName, uncompressedSize, currentTotal); + if (uncompressedSize > 0 + && (compressedSize == 0 || uncompressedSize / compressedSize > MAX_COMPRESSION_RATIO)) { + throw new UniversalConfigException("ZIP entry compression ratio is unsafe: " + entryName); + } + return total; + } + + public static long validateUncompressedSize( + String entryName, + long uncompressedSize, + long currentTotal + ) throws UniversalConfigException { + if (uncompressedSize < 0) { + throw new UniversalConfigException("ZIP entry size is invalid: " + entryName); + } + if (uncompressedSize > MAX_ENTRY_UNCOMPRESSED_BYTES) { + throw new UniversalConfigException("ZIP entry exceeds the uncompressed size limit: " + entryName); + } + if (currentTotal < 0 || currentTotal > MAX_TOTAL_UNCOMPRESSED_BYTES - uncompressedSize) { + throw new UniversalConfigException("ZIP archive exceeds the total uncompressed size limit."); + } + return currentTotal + uncompressedSize; + } } diff --git a/1.16.5/src/main/java/com/example/universalconfig/forge/ForgeRestartService.java b/1.16.5/src/main/java/com/example/universalconfig/forge/ForgeRestartService.java index a0c1aca..682e4c0 100644 --- a/1.16.5/src/main/java/com/example/universalconfig/forge/ForgeRestartService.java +++ b/1.16.5/src/main/java/com/example/universalconfig/forge/ForgeRestartService.java @@ -2,11 +2,10 @@ import com.example.universalconfig.core.CurrentProcessRestartService; import com.example.universalconfig.core.UniversalConfigException; -import net.minecraftforge.fml.ModList; import net.minecraftforge.fml.loading.FMLPaths; -import net.minecraftforge.fml.loading.moddiscovery.ModFileInfo; +import net.minecraftforge.fml.ModList; -import java.nio.file.Path; +import java.util.List; public final class ForgeRestartService { private ForgeRestartService() { @@ -14,13 +13,15 @@ private ForgeRestartService() { public static void scheduleRestartAfterCurrentProcessExit() throws UniversalConfigException { try { - ModFileInfo modFileInfo = ModList.get().getModFileById(UniversalConfigMod.MOD_ID); - if (modFileInfo == null || modFileInfo.getFile() == null) { - throw new UniversalConfigException("Could not locate the Universal Config Forge mod file."); - } - Path modFile = modFileInfo.getFile().getFilePath(); + // Forge does not expose FabricLoader-style launch arguments. Reuse the already resolved current Java + // arguments, as the v1.0 Forge builds did, and let the shared service validate the launch shape. + List javaArguments = CurrentProcessRestartService.currentProcessArguments(); + // ModLauncherのunion CodeSourceは独立Javaプロセスのclasspathとして使えない。 + // Forgeが検出済みの物理ModFileを渡し、RestartHelperを配布Jarから確実に起動する。 + java.nio.file.Path helperClasspath = ModList.get().getModFileById("universal_config") + .getFile().getFilePath(); CurrentProcessRestartService.scheduleRestartAfterCurrentProcessExit( - FMLPaths.GAMEDIR.get(), modFile); + FMLPaths.GAMEDIR.get(), javaArguments, helperClasspath); } catch (UniversalConfigException ex) { throw ex; } catch (RuntimeException ex) { diff --git a/1.16.5/src/main/java/com/example/universalconfig/forge/UniversalConfigMod.java b/1.16.5/src/main/java/com/example/universalconfig/forge/UniversalConfigMod.java index a20234b..fee95d1 100644 --- a/1.16.5/src/main/java/com/example/universalconfig/forge/UniversalConfigMod.java +++ b/1.16.5/src/main/java/com/example/universalconfig/forge/UniversalConfigMod.java @@ -8,11 +8,8 @@ import com.example.universalconfig.core.UniversalConfigPaths; import com.example.universalconfig.core.UniversalConfigSettings; import com.example.universalconfig.forge.screen.ProfileListScreen; -import net.minecraft.client.util.InputMappings; -import net.minecraft.client.settings.KeyBinding; import net.minecraft.client.Minecraft; import com.mojang.blaze3d.matrix.MatrixStack; -import com.mojang.blaze3d.systems.RenderSystem; import net.minecraft.client.gui.widget.button.Button; import net.minecraft.client.gui.AbstractGui; import net.minecraft.client.gui.screen.MainMenuScreen; @@ -22,11 +19,9 @@ import net.minecraft.util.text.IFormattableTextComponent; import net.minecraft.util.ResourceLocation; import net.minecraftforge.api.distmarker.Dist; -import net.minecraftforge.fml.client.registry.ClientRegistry; import net.minecraftforge.client.event.GuiScreenEvent; import net.minecraftforge.fml.ExtensionPoint; import net.minecraftforge.common.MinecraftForge; -import net.minecraftforge.event.TickEvent; import net.minecraftforge.eventbus.api.IEventBus; import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.fml.DistExecutor; @@ -35,7 +30,7 @@ import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent; import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; import net.minecraftforge.fml.loading.FMLPaths; -import org.lwjgl.glfw.GLFW; +import net.minecraftforge.fml.BrandingControl; import java.nio.file.Path; @@ -47,9 +42,13 @@ public final class UniversalConfigMod { private static final int TITLE_SCREEN_BUTTON_SIZE = 20; private static final int TITLE_SCREEN_ICON_PADDING = 3; private static final int TITLE_SCREEN_BUTTON_MARGIN = 4; - private static final int TITLE_SCREEN_BOTTOM_BRANDING_CLEARANCE = 54; + private static final int TITLE_SCREEN_SYSTEM_TEXT_BOTTOM_OFFSET = 10; + private static final int TITLE_SCREEN_SYSTEM_TEXT_GAP = 2; + // title_screen_button.png の実寸。画像を差し替える場合は描画APIへ渡す実寸・UV領域と + // この定数を必ず一致させる。ボタンの位置・サイズ・描画先サイズは変更しない。 + private static final int TITLE_SCREEN_ICON_TEXTURE_WIDTH = 15; + private static final int TITLE_SCREEN_ICON_TEXTURE_HEIGHT = 15; - private static KeyBinding openKey; private boolean pendingImportLogged; private boolean startupChangedOptions; @@ -69,18 +68,7 @@ public static Minecraft client() { return Minecraft.getInstance(); } - private void registerKeyBindings() { - openKey = new KeyBinding( - "key.universal_config.open", - InputMappings.Type.KEYSYM, - GLFW.GLFW_KEY_UNKNOWN, - "category.universal_config" - ); - ClientRegistry.registerKeyBinding(openKey); - } - private void clientSetup(FMLClientSetupEvent event) { - registerKeyBindings(); if (!startupChangedOptions) { return; } @@ -94,19 +82,6 @@ private void clientSetup(FMLClientSetupEvent event) { }); } - @SubscribeEvent - public void onClientTick(TickEvent.ClientTickEvent event) { - if (event.phase != TickEvent.Phase.END || openKey == null) { - return; - } - Minecraft minecraft = Minecraft.getInstance(); - while (openKey.consumeClick()) { - if (minecraft.screen == null || minecraft.screen instanceof MainMenuScreen) { - minecraft.setScreen(new ProfileListScreen(minecraft.screen)); - } - } - } - @SubscribeEvent public void onScreenInit(GuiScreenEvent.InitGuiEvent.Post event) { if (!(event.getGui() instanceof MainMenuScreen)) { @@ -115,13 +90,32 @@ public void onScreenInit(GuiScreenEvent.InitGuiEvent.Post event) { Minecraft minecraft = Minecraft.getInstance(); reloadStartupOptionsAtTitleScreen(); logPendingImportStateOnce(); + // Forge can re-run screen initialization after resize or resource reload. Reusing the same screen must not + // accumulate overlapping icon buttons. + for (net.minecraft.client.gui.widget.Widget widget : event.getWidgetList()) { + if (widget instanceof IconButton) { + return; + } + } ITextComponent narration = new TranslationTextComponent("button.universal_config.open"); - int y = event.getGui().height - TITLE_SCREEN_BUTTON_SIZE - TITLE_SCREEN_BOTTOM_BRANDING_CLEARANCE; + int y = titleScreenButtonY(minecraft, event.getGui().height); IconButton button = new IconButton(TITLE_SCREEN_BUTTON_MARGIN, y, ignored -> minecraft.setScreen(new ProfileListScreen(event.getGui())), narration); event.addWidget(button); } + private static int titleScreenButtonY(Minecraft minecraft, int screenHeight) { + int systemTextY = screenHeight - TITLE_SCREEN_SYSTEM_TEXT_BOTTOM_OFFSET; + int[] brandingLines = {0}; + BrandingControl.forEachLine(true, false, (index, line) -> brandingLines[0]++); + int brandingStep = minecraft.font.lineHeight + 1; + // Forge draws line 0 at systemTextY. Only the intervals between lines belong above it; reserving one full + // extra line creates a conspicuous blank row between the icon and the highest branding line. + int topBrandingY = systemTextY - Math.max(0, brandingLines[0] - 1) * brandingStep; + return Math.max(TITLE_SCREEN_BUTTON_MARGIN, + topBrandingY - TITLE_SCREEN_BUTTON_SIZE - TITLE_SCREEN_SYSTEM_TEXT_GAP); + } + private void reloadStartupOptionsAtTitleScreen() { if (!startupChangedOptions) { return; @@ -212,7 +206,9 @@ public void renderButton(MatrixStack graphics, int mouseX, int mouseY, float par x + TITLE_SCREEN_ICON_PADDING, y + TITLE_SCREEN_ICON_PADDING, iconSize, iconSize, - 0.0F, 0.0F, 128, 128, 128, 128); + 0.0F, 0.0F, + TITLE_SCREEN_ICON_TEXTURE_WIDTH, TITLE_SCREEN_ICON_TEXTURE_HEIGHT, + TITLE_SCREEN_ICON_TEXTURE_WIDTH, TITLE_SCREEN_ICON_TEXTURE_HEIGHT); } @Override diff --git a/1.16.5/src/main/java/com/example/universalconfig/forge/screen/Button.java b/1.16.5/src/main/java/com/example/universalconfig/forge/screen/Button.java index 6cce16e..b8c56de 100644 --- a/1.16.5/src/main/java/com/example/universalconfig/forge/screen/Button.java +++ b/1.16.5/src/main/java/com/example/universalconfig/forge/screen/Button.java @@ -7,10 +7,16 @@ import net.minecraft.util.text.ITextComponent; /** - * 1.20で導入されたButton.Builder相当を1.19系へ提供する。 - * 画面コードのレイアウト計算を世代間で揃え、狭い画面での配置差を防ぐため、この層だけでAPI差を吸収する。 + * 1.20で導入されたButton.Builderとnine-slice描画相当を1.16系へ提供する。 + * 画面コードのレイアウトとボタンの縁・長文表示を世代間で揃えるため、この層だけでAPI差を吸収する。 */ class Button extends net.minecraft.client.gui.widget.button.Button { + private static final int SOURCE_WIDTH = 200; + private static final int SOURCE_HEIGHT = 20; + private static final int HORIZONTAL_BORDER = 20; + private static final int VERTICAL_BORDER = 4; + private static final int TEXT_MARGIN = 2; + protected Button(int x, int y, int width, int height, ITextComponent message, IPressable onPress) { super(x, y, width, height, message, onPress); } @@ -24,25 +30,95 @@ public void renderButton(MatrixStack graphics, int mouseX, int mouseY, float par Minecraft minecraft = Minecraft.getInstance(); minecraft.getTextureManager().bind(WIDGETS_LOCATION); RenderSystem.color4f(1.0F, 1.0F, 1.0F, alpha); - int state = getYImage(isHovered()); + // 1.20.1と同様、マウスだけでなくキーボードフォーカスもホバー表示にする。 + int state = getYImage(isHovered() || isFocused()); RenderSystem.enableBlend(); RenderSystem.defaultBlendFunc(); RenderSystem.enableDepthTest(); - // Vanilla 1.16 copies `width / 2` pixels from each half of a 200px source. Wider responsive buttons therefore - // read outside the widget texture and split into disconnected bars. Stretch the two valid 100px halves instead. - int leftWidth = width / 2; - int rightWidth = width - leftWidth; int sourceY = 46 + state * 20; - AbstractGui.blit(graphics, x, y, leftWidth, height, - 0.0F, sourceY, 100, 20, 256, 256); - AbstractGui.blit(graphics, x + leftWidth, y, rightWidth, height, - 100.0F, sourceY, 100, 20, 256, 256); + drawNineSlicedBackground(graphics, sourceY); renderBg(graphics, minecraft, mouseX, mouseY); int textColor = getFGColor() | ((int) Math.ceil(alpha * 255.0F) << 24); - AbstractGui.drawCenteredString(graphics, minecraft.font, getMessage(), - x + width / 2, y + (height - 8) / 2, textColor); + drawScrollingText(graphics, minecraft, textColor); + } + + private void drawNineSlicedBackground(MatrixStack graphics, int sourceY) { + int left = Math.min(HORIZONTAL_BORDER, width / 2); + int right = Math.min(HORIZONTAL_BORDER, width / 2); + int top = Math.min(VERTICAL_BORDER, height / 2); + int bottom = Math.min(VERTICAL_BORDER, height / 2); + int centerWidth = width - left - right; + int centerHeight = height - top - bottom; + int sourceCenterWidth = SOURCE_WIDTH - HORIZONTAL_BORDER * 2; + int sourceCenterHeight = SOURCE_HEIGHT - VERTICAL_BORDER * 2; + + blitTile(graphics, x, y, left, top, 0, sourceY, left, top); + blitTiled(graphics, x + left, y, centerWidth, top, + HORIZONTAL_BORDER, sourceY, sourceCenterWidth, top); + blitTile(graphics, x + width - right, y, right, top, + SOURCE_WIDTH - right, sourceY, right, top); + blitTiled(graphics, x, y + top, left, centerHeight, + 0, sourceY + VERTICAL_BORDER, left, sourceCenterHeight); + blitTiled(graphics, x + left, y + top, centerWidth, centerHeight, + HORIZONTAL_BORDER, sourceY + VERTICAL_BORDER, sourceCenterWidth, sourceCenterHeight); + blitTiled(graphics, x + width - right, y + top, right, centerHeight, + SOURCE_WIDTH - right, sourceY + VERTICAL_BORDER, right, sourceCenterHeight); + blitTile(graphics, x, y + height - bottom, left, bottom, + 0, sourceY + SOURCE_HEIGHT - bottom, left, bottom); + blitTiled(graphics, x + left, y + height - bottom, centerWidth, bottom, + HORIZONTAL_BORDER, sourceY + SOURCE_HEIGHT - bottom, sourceCenterWidth, bottom); + blitTile(graphics, x + width - right, y + height - bottom, right, bottom, + SOURCE_WIDTH - right, sourceY + SOURCE_HEIGHT - bottom, right, bottom); + } + + private static void blitTiled(MatrixStack graphics, int destinationX, int destinationY, + int destinationWidth, int destinationHeight, + int sourceX, int sourceY, int tileWidth, int tileHeight) { + for (int offsetY = 0; offsetY < destinationHeight; offsetY += tileHeight) { + int drawHeight = Math.min(tileHeight, destinationHeight - offsetY); + for (int offsetX = 0; offsetX < destinationWidth; offsetX += tileWidth) { + int drawWidth = Math.min(tileWidth, destinationWidth - offsetX); + blitTile(graphics, destinationX + offsetX, destinationY + offsetY, + drawWidth, drawHeight, sourceX, sourceY, drawWidth, drawHeight); + } + } + } + + private static void blitTile(MatrixStack graphics, int destinationX, int destinationY, + int destinationWidth, int destinationHeight, + int sourceX, int sourceY, int sourceWidth, int sourceHeight) { + if (destinationWidth <= 0 || destinationHeight <= 0) { + return; + } + AbstractGui.blit(graphics, destinationX, destinationY, destinationWidth, destinationHeight, + sourceX, sourceY, sourceWidth, sourceHeight, 256, 256); + } + + private void drawScrollingText(MatrixStack graphics, Minecraft minecraft, int color) { + int left = x + TEXT_MARGIN; + int right = x + width - TEXT_MARGIN; + int textY = y + (height - 9) / 2 + 1; + int textWidth = minecraft.font.width(getMessage()); + int availableWidth = right - left; + if (textWidth <= availableWidth) { + AbstractGui.drawCenteredString(graphics, minecraft.font, getMessage(), x + width / 2, textY, color); + return; + } + + // 1.20.1と同じ往復式で長い翻訳をスクロールし、ボタン外へ文字を漏らさない。 + int overflow = textWidth - availableWidth; + double duration = Math.max(overflow * 0.5D, 3.0D); + double time = System.currentTimeMillis() / 1000.0D; + double progress = Math.sin((Math.PI / 2.0D) * Math.cos(2.0D * Math.PI * time / duration)) / 2.0D + 0.5D; + int offset = (int) (progress * overflow); + ScreenUtil.enableScissor(left, y, right, y + height); + try { + AbstractGui.drawString(graphics, minecraft.font, getMessage(), left - offset, textY, color); + } finally { + ScreenUtil.disableScissor(); + } } static final class Builder { diff --git a/1.16.5/src/main/java/com/example/universalconfig/forge/screen/ProfileCreateScreen.java b/1.16.5/src/main/java/com/example/universalconfig/forge/screen/ProfileCreateScreen.java index 871b9cc..1677335 100644 --- a/1.16.5/src/main/java/com/example/universalconfig/forge/screen/ProfileCreateScreen.java +++ b/1.16.5/src/main/java/com/example/universalconfig/forge/screen/ProfileCreateScreen.java @@ -50,7 +50,7 @@ protected void init() { String currentDescription = descriptionField == null ? "" : descriptionField.getValue(); boolean includeKeybinds = keybindsCheckboxButton == null || keybindsCheckboxButton.selected(); boolean includeClientOptions = clientOptionsCheckboxButton == null || clientOptionsCheckboxButton.selected(); - boolean includeModConfigs = modConfigsCheckboxButton == null || modConfigsCheckboxButton.selected(); + boolean includeModConfigs = modConfigsCheckboxButton != null && modConfigsCheckboxButton.selected(); int formLeft = width / 2 - FORM_LEFT_OFFSET; addButton(new BlockIconButton(formLeft, NAME_FIELD_Y, ICON_BUTTON_SIZE, ICON_BUTTON_SIZE, new TranslationTextComponent("screen.universal_config.profile_icon_change", iconLabel(selectedIconId)), diff --git a/1.16.5/src/main/java/com/example/universalconfig/forge/screen/ProfileListScreen.java b/1.16.5/src/main/java/com/example/universalconfig/forge/screen/ProfileListScreen.java index d025140..6caa611 100644 --- a/1.16.5/src/main/java/com/example/universalconfig/forge/screen/ProfileListScreen.java +++ b/1.16.5/src/main/java/com/example/universalconfig/forge/screen/ProfileListScreen.java @@ -55,10 +55,12 @@ public final class ProfileListScreen extends Screen { private static final int DETAIL_LINE_HEIGHT = 12; private static final int PROFILE_ICON_SIZE = 28; private static final int SCROLLBAR_WIDTH = 3; + private static final int REORDER_BUTTON_WIDTH = 16; + private static final int REORDER_BUTTON_HEIGHT = 16; private static final int MORE_BUTTON_WIDTH = 34; private static final int MENU_WIDTH = 132; private static final int MENU_ITEM_GAP = 2; - private static final int MENU_ITEM_COUNT = 5; + private static final int MENU_ITEM_COUNT = 6; private static final int MENU_HEIGHT = MENU_ITEM_COUNT * BUTTON_HEIGHT + (MENU_ITEM_COUNT - 1) * MENU_ITEM_GAP + 8; private static final int CLOSE_BUTTON_SIZE = 20; private static final String SAFE_DATE_PATTERN = "yyyy/MM/dd HH:mm"; @@ -86,12 +88,17 @@ public final class ProfileListScreen extends Screen { private Path defaultProfilePath; private ITextComponent status = StringTextComponent.EMPTY; private final List