From ca06ac8ded2a273e3c61b7dc598c83b0575c971a Mon Sep 17 00:00:00 2001 From: yoima-jp <28yoima72@gmail.com> Date: Fri, 14 Aug 2026 22:29:55 +0900 Subject: [PATCH] =?UTF-8?q?=E5=85=AC=E9=96=8Bmain=E3=81=B8Forge=2026.2?= =?UTF-8?q?=E3=82=BD=E3=83=BC=E3=82=B9=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- forge/NOTICE | 5 + forge/build.gradle | 4 +- forge/licenses/Apache-2.0.txt | 175 ++++++++ .../core/CurrentProcessRestartService.java | 394 +++++++----------- .../core/FileOperationLogger.java | 116 +++++- .../core/ProfileCreateOptions.java | 1 + .../universalconfig/core/ProfileService.java | 186 +++++++-- .../core/UniversalConfigPaths.java | 32 +- .../core/UniversalConfigSettings.java | 19 + .../core/ZipArchiveReader.java | 23 +- .../core/ZipArchiveWriter.java | 6 +- .../universalconfig/core/ZipSecurity.java | 52 ++- .../forge/ForgeRestartService.java | 10 +- .../forge/UniversalConfigMod.java | 60 ++- .../forge/screen/ProfileCreateScreen.java | 2 +- .../forge/screen/ProfileListScreen.java | 185 +++++++- .../forge/screen/ProfileRenameScreen.java | 71 ++++ forge/src/main/resources/META-INF/mods.toml | 2 +- .../assets/universal_config/icon.png | Bin 32658 -> 3907 bytes .../assets/universal_config/lang/en_us.json | 32 +- .../assets/universal_config/lang/ja_jp.json | 32 +- .../assets/universal_config/lang/ko_kr.json | 32 +- .../assets/universal_config/lang/zh_cn.json | 30 +- .../assets/universal_config/lang/zh_tw.json | 30 +- .../universal_config/title_screen_button.png | Bin 24132 -> 240 bytes forge/src/main/resources/universal_config.png | Bin 32658 -> 3907 bytes .../CurrentProcessRestartServiceTest.java | 134 +++--- .../core/FileOperationLoggerTest.java | 58 +++ .../core/ProfileOrderingTest.java | 98 +++++ .../universalconfig/core/ZipSecurityTest.java | 18 + 30 files changed, 1274 insertions(+), 533 deletions(-) create mode 100644 forge/NOTICE create mode 100644 forge/licenses/Apache-2.0.txt create mode 100644 forge/src/main/java/com/example/universalconfig/forge/screen/ProfileRenameScreen.java create mode 100644 forge/src/test/java/com/example/universalconfig/core/FileOperationLoggerTest.java create mode 100644 forge/src/test/java/com/example/universalconfig/core/ProfileOrderingTest.java diff --git a/forge/NOTICE b/forge/NOTICE new file mode 100644 index 0000000..7960e1a --- /dev/null +++ b/forge/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/forge/build.gradle b/forge/build.gradle index c7da0c0..8a77444 100644 --- a/forge/build.gradle +++ b/forge/build.gradle @@ -5,7 +5,7 @@ plugins { id 'net.minecraftforge.gradle' version '7.0.32' } -version = '1.0.0-mc26.2-forge' +version = '1.1.0-mc26.2-forge' group = 'com.example.universalconfig' base.archivesName = 'universal-config' java.toolchain.languageVersion = JavaLanguageVersion.of(25) @@ -42,6 +42,8 @@ tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } tasks.named('test', Test) { useJUnitPlatform() } tasks.named('jar', Jar) { from('LICENSE.txt') { into 'META-INF'; rename { 'LICENSE_universal_config' } } + from('NOTICE') { into 'META-INF' } + from('licenses') { into 'META-INF/licenses' } manifest { attributes('Specification-Title': 'universal_config', 'Specification-Vendor': 'yoima-jp, tofuturu, tanzana7', diff --git a/forge/licenses/Apache-2.0.txt b/forge/licenses/Apache-2.0.txt new file mode 100644 index 0000000..b84f370 --- /dev/null +++ b/forge/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/forge/src/main/java/com/example/universalconfig/core/CurrentProcessRestartService.java b/forge/src/main/java/com/example/universalconfig/core/CurrentProcessRestartService.java index 439899d..df2e41a 100644 --- a/forge/src/main/java/com/example/universalconfig/core/CurrentProcessRestartService.java +++ b/forge/src/main/java/com/example/universalconfig/core/CurrentProcessRestartService.java @@ -8,8 +8,6 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Map; @@ -28,7 +26,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 List.of(); + } } /** @@ -38,26 +44,12 @@ public static void scheduleRestartAfterCurrentProcessExit() throws UniversalConf */ public static void scheduleRestartAfterCurrentProcessExit(Path workingDirectory) throws UniversalConfigException { - scheduleRestartAfterCurrentProcessExit(workingDirectory, Collections.emptyList()); - } - - /** - * Schedules a restart with a loader-resolved physical Jar or classes directory for the standalone helper. - * - *

Some ModLauncher generations intentionally omit the mod class CodeSource. Loader integrations should use - * this overload when they can resolve their own ModFile reliably.

- */ - public static void scheduleRestartAfterCurrentProcessExit( - Path workingDirectory, - Path helperClasspath - ) throws UniversalConfigException { - scheduleRestartAfterCurrentProcessExit( - workingDirectory, Collections.emptyList(), helperClasspath); + scheduleRestartAfterCurrentProcessExit(workingDirectory, currentProcessArguments()); } /** - * 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,39 +58,66 @@ public static void scheduleRestartAfterCurrentProcessExit( scheduleRestartAfterCurrentProcessExit(workingDirectory, loaderResolvedArguments, null); } - private static void scheduleRestartAfterCurrentProcessExit( + public static void scheduleRestartAfterCurrentProcessExit( Path workingDirectory, List loaderResolvedArguments, Path helperClasspath ) throws UniversalConfigException { - ProcessDiscovery.Snapshot current = ProcessDiscovery.current(); - String executable = current.executable(); - if (executable == null || executable.trim().isEmpty()) { - throw new UniversalConfigException("Could not determine the current Java executable."); - } + ProcessHandle current = ProcessHandle.current(); + ProcessHandle.Info processInfo = current.info(); + String executable = processInfo.command() + .filter(value -> !value.isBlank()) + .orElseThrow(() -> new UniversalConfigException("Could not determine the current Java executable.")); 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( - System.getenv(), normalizedWorkingDirectory, ancestors); - 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; + LaunchCommand replacement = prismFamilyLauncherCommand(System.getenv(), normalizedWorkingDirectory, ancestors) + .or(() -> atLauncherCommand(normalizedWorkingDirectory, ancestors)) + .orElseGet(() -> unsupportedLauncherCommand(executable, loaderResolvedArguments) + .orElse(null)); if (replacement == null) { throw new UniversalConfigException("Could not determine how to restart this launcher instance."); } scheduleJavaHelper(current.pid(), executable, replacement, normalizedWorkingDirectory, helperClasspath); } + /** + * 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 + ) { + if (!isJavaExecutable(currentExecutable)) { + return Optional.empty(); + } + return validJavaLaunchArguments(loaderResolvedArguments) + .map(arguments -> new LaunchCommand(currentExecutable, arguments)); + } + + private static Optional> validJavaLaunchArguments(List arguments) { + if (arguments == null || arguments.isEmpty()) { + return Optional.empty(); + } + try { + List copied = List.copyOf(arguments); + if (copied.stream().anyMatch(value -> value == null || value.isBlank())) 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).isBlank()) { + return hasMainClassAfter(copied, index + 2) ? Optional.of(copied) : Optional.empty(); + } + } + return Optional.empty(); + } catch (RuntimeException ex) { + return Optional.empty(); + } + } + /** * Builds a Java command from argument sources that preserve their original boundaries. */ @@ -109,7 +128,7 @@ public static List buildJavaLaunchArguments( List applicationArguments ) throws UniversalConfigException { try { - if (classPath == null || classPath.trim().isEmpty() || mainClass == null || mainClass.trim().isEmpty()) { + if (classPath == null || classPath.isBlank() || mainClass == null || mainClass.isBlank()) { throw new IllegalArgumentException("Java classpath and main class are required"); } List arguments = new ArrayList<>( @@ -119,24 +138,7 @@ public static List buildJavaLaunchArguments( arguments.add(classPath); arguments.add(mainClass); arguments.addAll(applicationArguments); - return immutableCopy(arguments); - } catch (RuntimeException ex) { - throw new UniversalConfigException("Could not determine the current Java arguments.", ex); - } - } - - private static Optional> currentJavaArguments( - List processArguments, - List loaderResolvedArguments - ) throws UniversalConfigException { - try { - if (processArguments != null && !processArguments.isEmpty()) { - return Optional.of(immutableCopy(processArguments)); - } - if (loaderResolvedArguments == null || loaderResolvedArguments.isEmpty()) { - return Optional.empty(); - } - return Optional.of(immutableCopy(loaderResolvedArguments)); + return List.copyOf(arguments); } catch (RuntimeException ex) { throw new UniversalConfigException("Could not determine the current Java arguments.", ex); } @@ -168,14 +170,11 @@ private static void scheduleJavaHelper( // A plain Java child keeps argument boundaries intact on every OS. It also avoids generated scripts and // Windows administration tools whose delayed-process patterns can trigger security heuristics. helper = new ProcessBuilder(buildHelperCommand( - helperExecutable(helperJavaExecutable), - helperClasspathEntry(loaderResolvedHelperClasspath))) + helperExecutable(helperJavaExecutable), helperClasspathEntry(loaderResolvedHelperClasspath))) .directory(workingDirectory.toFile()) .redirectInput(ProcessBuilder.Redirect.PIPE) - // The helper command carries no account or launch arguments. Preserve its bootstrap diagnostics so - // a missing/invalid packaged classpath is actionable instead of surfacing only as "stopped early". - .redirectOutput(ProcessBuilder.Redirect.appendTo(diagnosticLog.toFile())) - .redirectError(ProcessBuilder.Redirect.appendTo(diagnosticLog.toFile())) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) .start(); // Loader arguments can contain an access token. Send the launch plan through the helper's private pipe // and close it so the helper can validate the complete payload before publishing its ready marker. @@ -201,14 +200,16 @@ private static void scheduleJavaHelper( deleteQuietly(readyPath); } - private static List ancestorCommands(ProcessDiscovery.Snapshot current) { - List commands = new ArrayList(); - for (ProcessDiscovery.ProcessInfo ancestor : current.ancestors()) { - if (ancestor.executable() != null && !ancestor.executable().trim().isEmpty()) { - commands.add(new ProcessCommand(ancestor.executable(), ancestor.arguments())); - } + private static List ancestorCommands(ProcessHandle current) { + List commands = new ArrayList<>(); + ProcessHandle ancestor = current.parent().orElse(null); + for (int depth = 0; ancestor != null && depth < 16; depth++) { + ProcessHandle.Info info = ancestor.info(); + info.command().filter(value -> !value.isBlank()).ifPresent(command -> commands.add( + new ProcessCommand(command, info.arguments().map(List::of).orElseGet(List::of)))); + ancestor = ancestor.parent().orElse(null); } - return Collections.unmodifiableList(commands); + return List.copyOf(commands); } static Optional prismLauncherCommand( @@ -223,7 +224,7 @@ static Optional prismLauncherCommand( return ancestorCommands.stream() .filter(CurrentProcessRestartService::isPrismLauncherExecutable) .findFirst() - .map(command -> new LaunchCommand(command, Arrays.asList("--launch", instanceId))); + .map(command -> new LaunchCommand(command, List.of("--launch", instanceId))); } static Optional prismFamilyLauncherCommand( @@ -239,7 +240,7 @@ static Optional prismFamilyLauncherCommand( .map(ProcessCommand::executable) .filter(CurrentProcessRestartService::isPrismFamilyLauncherExecutable) .findFirst() - .map(command -> new LaunchCommand(command, Arrays.asList("--launch", instanceId))); + .map(command -> new LaunchCommand(command, List.of("--launch", instanceId))); } private static Optional validatedPrismFamilyInstanceId( @@ -249,63 +250,26 @@ private static Optional validatedPrismFamilyInstanceId( String instanceId = environment.get("INST_ID"); String instanceDirectoryValue = environment.get("INST_DIR"); String minecraftDirectoryValue = environment.get("INST_MC_DIR"); + if (instanceId == null || instanceId.isBlank() + || instanceDirectoryValue == null || instanceDirectoryValue.isBlank() + || minecraftDirectoryValue == null || minecraftDirectoryValue.isBlank()) { + return Optional.empty(); + } try { - Path minecraftWorkingDirectory = workingDirectory.toAbsolutePath().normalize(); - Path instanceDirectory = minecraftWorkingDirectory.getParent(); - Path minecraftDirectoryName = minecraftWorkingDirectory.getFileName(); - if (instanceDirectory == null - || minecraftDirectoryName == null - || !minecraftDirectoryName.toString().equalsIgnoreCase("minecraft")) { - return Optional.empty(); - } + Path instanceDirectory = Path.of(instanceDirectoryValue).toAbsolutePath().normalize(); + Path minecraftDirectory = Path.of(minecraftDirectoryValue).toAbsolutePath().normalize(); Path instanceFolderName = instanceDirectory.getFileName(); - if (instanceFolderName == null || instanceFolderName.toString().trim().isEmpty()) { + if (!minecraftDirectory.equals(workingDirectory.toAbsolutePath().normalize()) + || instanceFolderName == null + || !instanceFolderName.toString().equals(instanceId)) { return Optional.empty(); } - - boolean hasCompleteLauncherEnvironment = !isBlank(instanceId) - && !isBlank(instanceDirectoryValue) - && !isBlank(minecraftDirectoryValue); - if (hasCompleteLauncherEnvironment) { - Path environmentInstanceDirectory = Paths.get(instanceDirectoryValue).toAbsolutePath().normalize(); - Path environmentMinecraftDirectory = Paths.get(minecraftDirectoryValue).toAbsolutePath().normalize(); - if (!samePath(environmentInstanceDirectory, instanceDirectory) - || !samePath(environmentMinecraftDirectory, minecraftWorkingDirectory) - || !instanceFolderName.toString().equals(instanceId)) { - return Optional.empty(); - } - } else if (!Files.isRegularFile(instanceDirectory.resolve("instance.cfg"))) { - return Optional.empty(); - } - - // Prism documents INST_ID for custom commands, but not every launcher/version exports those variables - // to the game JVM. The launcher ancestor plus instance.cfg and the conventional minecraft directory - // identify the same instance without reusing short-lived account arguments from the Java process. - return Optional.of(instanceFolderName.toString()); + return Optional.of(instanceId); } catch (RuntimeException ex) { return Optional.empty(); } } - private static boolean samePath(Path left, Path right) { - try { - if (Files.exists(left) && Files.exists(right)) { - return Files.isSameFile(left, right); - } - } catch (IOException | RuntimeException ignored) { - // Fall back to normalized text below; the paths were already constrained to the active instance. - } - String leftValue = left.toAbsolutePath().normalize().toString(); - String rightValue = right.toAbsolutePath().normalize().toString(); - return isWindows(System.getProperty("os.name", "")) - ? leftValue.equalsIgnoreCase(rightValue) - : leftValue.equals(rightValue); - } - - private static boolean isBlank(String value) { - return value == null || value.trim().isEmpty(); - } - static Optional atLauncherCommand( Path workingDirectory, List ancestorCommands @@ -331,7 +295,7 @@ static Optional atLauncherCommand( if (launcherWorkingDirectory == null) { return Optional.empty(); } - List launchArguments = Arrays.asList( + List launchArguments = List.of( "--working-dir", launcherWorkingDirectory.toString(), "--launch", instanceName.toString()); for (ProcessCommand ancestor : ancestorCommands) { @@ -341,49 +305,13 @@ static Optional atLauncherCommand( if (isJavaExecutable(ancestor.executable())) { Optional> jarArguments = atLauncherJarArguments(ancestor.arguments(), launchArguments); if (jarArguments.isPresent()) { - return Optional.of(new LaunchCommand(ancestor.executable(), jarArguments.get())); + return Optional.of(new LaunchCommand(ancestor.executable(), jarArguments.orElseThrow())); } } } 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 @@ -399,13 +327,13 @@ private static Optional> atLauncherJarArguments( } List arguments = new ArrayList<>(ancestorArguments.subList(0, index + 2)); arguments.addAll(launchArguments); - return Optional.of(immutableCopy(arguments)); + return Optional.of(List.copyOf(arguments)); } return Optional.empty(); } static List buildHelperCommand(String javaExecutable, Path helperClasspath) { - return Arrays.asList( + return List.of( javaExecutable, "-cp", helperClasspath.toString(), @@ -431,7 +359,7 @@ private static void waitUntilHelperIsReady(Process helper, Path readyPath) private static Path currentWorkingDirectory() throws UniversalConfigException { try { - return normalizeWorkingDirectory(Paths.get(System.getProperty("user.dir", "."))); + return normalizeWorkingDirectory(Path.of(System.getProperty("user.dir", "."))); } catch (UniversalConfigException ex) { throw ex; } catch (RuntimeException ex) { @@ -456,13 +384,40 @@ private static Path helperClasspathEntry() throws UniversalConfigException { || RestartHelper.class.getProtectionDomain().getCodeSource() == null) { throw new UniversalConfigException("Could not locate the restart helper code."); } - return helperClasspathEntry( - RestartHelper.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + return helperClasspathEntry(RestartHelper.class.getProtectionDomain().getCodeSource().getLocation().toURI()); } catch (URISyntaxException | RuntimeException ex) { throw new UniversalConfigException("Could not locate the restart helper code.", ex); } } + 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); + } + static Path helperClasspathEntry(Path loaderResolvedPath) throws UniversalConfigException { if (loaderResolvedPath == null) { return helperClasspathEntry(); @@ -473,48 +428,29 @@ static Path helperClasspathEntry(Path loaderResolvedPath) throws UniversalConfig throw new UniversalConfigException("The loader-resolved restart helper code does not exist."); } return normalized; + } catch (UniversalConfigException ex) { + throw ex; } catch (RuntimeException ex) { - throw new UniversalConfigException("Could not locate the loader-resolved restart helper code.", ex); + throw new UniversalConfigException("Could not locate the restart helper code.", ex); } } - /** - * Resolves the physical classpath entry behind a loader URL. - * - *

Forge 1.17+ loads mod classes through SecureJarHandler's {@code union:} filesystem. The - * protection-domain location therefore is not directly accepted by {@link Paths#get(URI)}, even though the - * standalone restart JVM needs the underlying mod Jar. Keep this conversion in the loader-independent core so - * every Forge generation, including launchers that reuse the Java command, starts the same verified helper.

- */ static Path helperClasspathEntry(URI location) throws UniversalConfigException { - if (location == null || location.getScheme() == null) { - throw new UniversalConfigException("Could not locate the restart helper code."); - } + if (location == null || location.getScheme() == null) throw new UniversalConfigException("Could not locate the restart helper code."); try { String scheme = location.getScheme().toLowerCase(Locale.ROOT); - if (scheme.equals("file")) { - return Paths.get(location).toAbsolutePath().normalize(); - } + if (scheme.equals("file")) return Paths.get(location).toAbsolutePath().normalize(); if (scheme.equals("jar")) { - String nestedLocation = location.getRawSchemeSpecificPart(); - int entrySeparator = nestedLocation.indexOf("!/"); - if (entrySeparator >= 0) { - nestedLocation = nestedLocation.substring(0, entrySeparator); - } - return helperClasspathEntry(URI.create(nestedLocation)); + String nested = location.getRawSchemeSpecificPart(); + int separator = nested.indexOf("!/"); + return helperClasspathEntry(URI.create(separator < 0 ? nested : nested.substring(0, separator))); } if (scheme.equals("union")) { String physicalPath = location.getPath(); - int entrySeparator = physicalPath.indexOf("!/"); - if (entrySeparator >= 0) { - physicalPath = physicalPath.substring(0, entrySeparator); - } - // SecureJarHandler appends an encoded "#" after the Jar or classes directory. - // It is an internal mount identifier, not part of the physical filename. - int mountMarker = physicalPath.lastIndexOf('#'); - if (mountMarker >= 0 && isDecimal(physicalPath.substring(mountMarker + 1))) { - physicalPath = physicalPath.substring(0, mountMarker); - } + int separator = physicalPath.indexOf("!/"); + if (separator >= 0) physicalPath = physicalPath.substring(0, separator); + int marker = physicalPath.lastIndexOf('#'); + if (marker >= 0 && physicalPath.substring(marker + 1).chars().allMatch(Character::isDigit)) physicalPath = physicalPath.substring(0, marker); return Paths.get(new URI("file", null, physicalPath, null)).toAbsolutePath().normalize(); } throw new UniversalConfigException("Unsupported restart helper code location: " + scheme); @@ -523,24 +459,12 @@ static Path helperClasspathEntry(URI location) throws UniversalConfigException { } } - private static boolean isDecimal(String value) { - if (value.isEmpty()) { - return false; - } - for (int index = 0; index < value.length(); index++) { - if (!Character.isDigit(value.charAt(index))) { - return false; - } - } - return true; - } - private static String helperExecutable(String currentExecutable) { if (!isWindows(System.getProperty("os.name", ""))) { return currentExecutable; } try { - Path executablePath = Paths.get(currentExecutable).toAbsolutePath().normalize(); + Path executablePath = Path.of(currentExecutable).toAbsolutePath().normalize(); Path fileName = executablePath.getFileName(); if (fileName != null && fileName.toString().equalsIgnoreCase("java.exe")) { Path javaw = executablePath.resolveSibling("javaw.exe"); @@ -575,16 +499,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") @@ -593,10 +507,10 @@ private static boolean isJavaExecutable(String command) { } private static String fileName(String command) { - if (command == null || command.trim().isEmpty()) { + if (command == null || command.isBlank()) { return ""; } - // Process metadata can expose a Windows-style path while tests or tooling run on another OS, + // ProcessHandle can expose a Windows-style path while tests or tooling run on another OS, // so normalize both separator styles before checking launcher executable names. String normalized = command.replace('\\', '/'); int separator = normalized.lastIndexOf('/'); @@ -617,43 +531,15 @@ private static void deleteQuietly(Path path) { } } - static final class LaunchCommand { - private final String executable; - private final List arguments; - - LaunchCommand(String executable, List arguments) { - this.executable = executable; - this.arguments = immutableCopy(arguments); - } - - String executable() { - return executable; - } - - List arguments() { - return arguments; + record LaunchCommand(String executable, List arguments) { + LaunchCommand { + arguments = List.copyOf(arguments); } } - static final class ProcessCommand { - private final String executable; - private final List arguments; - - ProcessCommand(String executable, List arguments) { - this.executable = executable; - this.arguments = immutableCopy(arguments); - } - - String executable() { - return executable; + record ProcessCommand(String executable, List arguments) { + ProcessCommand { + arguments = List.copyOf(arguments); } - - List arguments() { - return arguments; - } - } - - private static List immutableCopy(List values) { - return Collections.unmodifiableList(new ArrayList(values)); } } diff --git a/forge/src/main/java/com/example/universalconfig/core/FileOperationLogger.java b/forge/src/main/java/com/example/universalconfig/core/FileOperationLogger.java index 8db6802..eb0b8aa 100644 --- a/forge/src/main/java/com/example/universalconfig/core/FileOperationLogger.java +++ b/forge/src/main/java/com/example/universalconfig/core/FileOperationLogger.java @@ -3,18 +3,30 @@ import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; -import java.lang.management.ManagementFactory; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +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(); + .format(java.time.LocalDateTime.now()) + "-pid" + ProcessHandle.current().pid(); 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 +34,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(Path.of(System.getProperty("user.home", "."))); + String appData = System.getenv("APPDATA"); + appDataRoot = appData == null || appData.isBlank() ? null : normalizeOrNull(Path.of(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"); @@ -34,7 +58,7 @@ public static synchronized void configure(UniversalConfigSettings settings) { try { Files.createDirectories(launchLogFile.getParent()); Files.createDirectories(latestLogFile.getParent()); - Files.write(latestLogFile, new byte[0], + Files.writeString(latestLogFile, "", StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); } catch (IOException ignored) { // The later write path also tolerates logging failures. @@ -70,31 +94,91 @@ 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); - Files.write(launchLogFile, encoded, + Files.writeString(launchLogFile, line.toString(), StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND); - Files.write(latestLogFile, encoded, + Files.writeString(latestLogFile, line.toString(), StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND); } catch (IOException ignored) { // Logging must never break profile application or backup recovery. } } - private static long currentProcessId() { - String runtimeName = ManagementFactory.getRuntimeMXBean().getName(); - int separator = runtimeName.indexOf('@'); + static synchronized String sanitizePath(Path path) { + if (path == null) { + return "-"; + } + Path normalized = normalizeOrNull(path); + if (normalized == null) { + return ""; + } + List labels = List.of( + new PathLabel(instanceRoot, ""), + new PathLabel(configuredRoot, ""), + new PathLabel(appDataRoot, ""), + new PathLabel(userHomeRoot, "") + ).stream() + .filter(label -> label.root() != null) + .sorted(Comparator.comparingInt((PathLabel label) -> label.root().getNameCount()).reversed()) + .toList(); + for (PathLabel label : labels) { + 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.isBlank()) { + 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, "")); + labels = labels.stream() + .filter(label -> label.root() != null) + .sorted(Comparator.comparingInt((PathLabel label) -> label.root().toString().length()).reversed()) + .toList(); + for (PathLabel label : labels) { + 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 Long.parseLong(separator < 0 ? runtimeName : runtimeName.substring(0, separator)); - } catch (NumberFormatException ignored) { - return -1L; + 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); + } + + private record PathLabel(Path root, String label) { + } } diff --git a/forge/src/main/java/com/example/universalconfig/core/ProfileCreateOptions.java b/forge/src/main/java/com/example/universalconfig/core/ProfileCreateOptions.java index ffa7fc9..591bb34 100644 --- a/forge/src/main/java/com/example/universalconfig/core/ProfileCreateOptions.java +++ b/forge/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/forge/src/main/java/com/example/universalconfig/core/ProfileService.java b/forge/src/main/java/com/example/universalconfig/core/ProfileService.java index a26512c..9bdeaed 100644 --- a/forge/src/main/java/com/example/universalconfig/core/ProfileService.java +++ b/forge/src/main/java/com/example/universalconfig/core/ProfileService.java @@ -10,26 +10,23 @@ import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.nio.file.StandardCopyOption; 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.HashSet; +import java.util.HashMap; +import java.util.Map; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.concurrent.locks.ReentrantLock; -import java.util.stream.Collectors; -import java.util.stream.Stream; public final class ProfileService { private static final ReentrantLock PROFILE_OPERATION_PROCESS_LOCK = new ReentrantLock(); - private static final int MAX_MANIFEST_BYTES = 1_048_576; private final UniversalConfigSettings settings; private final AdapterRegistry adapterRegistry = new AdapterRegistry(); @@ -146,7 +143,7 @@ boolean isFirstMinecraftStart(Path instancePath) throws UniversalConfigException private void writeDefaultProfileAppliedMarker(Path marker) throws UniversalConfigException { try { Files.createDirectories(marker.getParent()); - Files.write(marker, new byte[0], + Files.writeString(marker, "", StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); } catch (IOException ex) { throw new UniversalConfigException("Default profile was applied, but its marker could not be saved.", ex); @@ -158,11 +155,11 @@ public List listProfiles() throws UniversalConfigException { FileOperationLogger.info("LIST_PROFILES", profiles, "start"); if (!Files.isDirectory(profiles)) { FileOperationLogger.info("LIST_PROFILES", profiles, "directory missing"); - return Collections.emptyList(); + return List.of(); } - try (Stream stream = Files.list(profiles)) { + try (var stream = Files.list(profiles)) { List summaries = new ArrayList<>(); - for (Path profile : stream.filter(path -> path.getFileName().toString().endsWith(UniversalConfigFormat.PROFILE_FILE_EXTENSION)).collect(Collectors.toList())) { + for (Path profile : stream.filter(path -> path.getFileName().toString().endsWith(UniversalConfigFormat.PROFILE_FILE_EXTENSION)).toList()) { try (ZipArchiveReader reader = new ZipArchiveReader(profile)) { if (reader.exists(UniversalConfigFormat.MANIFEST_ENTRY)) { summaries.add(new ProfileSummary(profile, JsonDocuments.read(reader, UniversalConfigFormat.MANIFEST_ENTRY, ProfileManifest.class))); @@ -172,24 +169,155 @@ 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.isBlank()) { + 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.isBlank() ? null : "id:" + id; + } + public List listBackups() throws UniversalConfigException { Path backups = UniversalConfigPaths.backupsDirectory(settings); FileOperationLogger.info("LIST_BACKUPS", backups, "start"); if (!Files.isDirectory(backups)) { FileOperationLogger.info("LIST_BACKUPS", backups, "directory missing"); - return Collections.emptyList(); + return List.of(); } - try (Stream stream = Files.list(backups)) { + try (var stream = Files.list(backups)) { List summaries = new ArrayList<>(); - for (Path backup : stream.filter(path -> path.getFileName().toString().endsWith(UniversalConfigFormat.BACKUP_FILE_EXTENSION)).collect(Collectors.toList())) { + for (Path backup : stream.filter(path -> path.getFileName().toString().endsWith(UniversalConfigFormat.BACKUP_FILE_EXTENSION)).toList()) { try (ZipArchiveReader reader = new ZipArchiveReader(backup)) { BackupManifest manifest = reader.exists(UniversalConfigFormat.BACKUP_MANIFEST_ENTRY) ? JsonDocuments.read(reader, UniversalConfigFormat.BACKUP_MANIFEST_ENTRY, BackupManifest.class) @@ -311,7 +439,7 @@ public PendingImport readPendingImport(Path instancePath) throws UniversalConfig || !UniversalConfigFormat.PENDING_IMPORT_FORMAT.equals(pending.format) || pending.formatVersion != UniversalConfigFormat.FORMAT_VERSION || pending.profilePath == null - || pending.profilePath.trim().isEmpty()) { + || pending.profilePath.isBlank()) { throw new UniversalConfigException("Invalid pending import file: " + pendingPath); } return pending; @@ -323,7 +451,7 @@ public ApplyResult applyPendingImport(Path instancePath, MinecraftEnvironment en if (pending == null) { return null; } - Path profilePath = Paths.get(pending.profilePath); + Path profilePath = Path.of(pending.profilePath); FileOperationLogger.info("APPLY_PENDING_IMPORT", pendingPath, "profile=" + profilePath.toAbsolutePath().normalize()); ApplyResult result = apply(instancePath, profilePath, environment); try { @@ -542,7 +670,7 @@ private String uniqueProfileName(String name) throws UniversalConfigException { } private String uniqueProfileName(String name, Path excludedProfilePath) throws UniversalConfigException { - if (name == null || name.trim().isEmpty()) { + if (name == null || name.isBlank()) { return name; } @@ -556,7 +684,7 @@ private String uniqueProfileName(String name, Path excludedProfilePath) throws U continue; } ProfileManifest existing = summary.manifest(); - if (existing != null && existing.name != null && !existing.name.trim().isEmpty()) { + if (existing != null && existing.name != null && !existing.name.isBlank()) { usedNames.add(existing.name); } } @@ -617,8 +745,7 @@ private void rewriteProfileName(Path profilePath, String name) throws UniversalC try { try (ZipArchiveReader reader = new ZipArchiveReader(profilePath); InputStream input = reader.open(UniversalConfigFormat.MANIFEST_ENTRY)) { - byte[] originalManifest = IoStreams.readLimited( - input, MAX_MANIFEST_BYTES, "Profile manifest"); + byte[] originalManifest = input.readAllBytes(); JsonObject manifest = JsonDocuments.GSON.fromJson( new String(originalManifest, StandardCharsets.UTF_8), JsonObject.class); if (manifest == null) { @@ -709,7 +836,7 @@ private void validateCreateOptions(ProfileCreateOptions options) throws Universa if (options == null) { throw new UniversalConfigException("Profile options are required."); } - if (options.name == null || options.name.trim().isEmpty()) { + if (options.name == null || options.name.isBlank()) { throw new UniversalConfigException("Profile name is required."); } if (!options.includeKeybinds && !options.includeClientOptions && !options.includeModConfigs) { @@ -725,22 +852,7 @@ private String safeCreatedAt(BackupManifest manifest) { return manifest == null || manifest.createdAt == null ? "" : manifest.createdAt; } - public static final class ApplyResult { - private final Path backupPath; - private final ProfileDiff diff; - - public ApplyResult(Path backupPath, ProfileDiff diff) { - this.backupPath = backupPath; - this.diff = diff; - } - - public Path backupPath() { - return backupPath; - } - - public ProfileDiff diff() { - return diff; - } + public record ApplyResult(Path backupPath, ProfileDiff diff) { } /** diff --git a/forge/src/main/java/com/example/universalconfig/core/UniversalConfigPaths.java b/forge/src/main/java/com/example/universalconfig/core/UniversalConfigPaths.java index ef2767a..2db8570 100644 --- a/forge/src/main/java/com/example/universalconfig/core/UniversalConfigPaths.java +++ b/forge/src/main/java/com/example/universalconfig/core/UniversalConfigPaths.java @@ -10,8 +10,8 @@ import java.nio.file.Files; import java.nio.file.InvalidPathException; 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(); @@ -21,11 +21,11 @@ private UniversalConfigPaths() { public static Path defaultRootDirectory() { String appData = System.getenv("APPDATA"); - if (appData != null && !appData.trim().isEmpty()) { - return Paths.get(appData, UniversalConfigFormat.ROOT_DIRECTORY_NAME); + if (appData != null && !appData.isBlank()) { + return Path.of(appData, UniversalConfigFormat.ROOT_DIRECTORY_NAME); } String userHome = System.getProperty("user.home", "."); - return Paths.get(userHome, UniversalConfigFormat.ROOT_DIRECTORY_NAME); + return Path.of(userHome, UniversalConfigFormat.ROOT_DIRECTORY_NAME); } public static UniversalConfigSettings loadOrCreateSettings(Path minecraftRunDirectory) throws UniversalConfigException { @@ -36,18 +36,20 @@ public static UniversalConfigSettings loadOrCreateSettings(Path minecraftRunDire try (Reader reader = Files.newBufferedReader(localSettings, StandardCharsets.UTF_8)) { dto = GSON.fromJson(reader, SettingsDto.class); } - if (dto != null && dto.rootDirectory != null && !dto.rootDirectory.trim().isEmpty()) { - UniversalConfigSettings settings = new UniversalConfigSettings(Paths.get(dto.rootDirectory)); + if (dto != null && dto.rootDirectory != null && !dto.rootDirectory.isBlank()) { + UniversalConfigSettings settings = new UniversalConfigSettings(Path.of(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 +67,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 +88,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); } @@ -154,12 +158,13 @@ public static String safeFileSlug(String value) { ? UniversalConfigFormat.DEFAULT_PROFILE_SLUG : value.toLowerCase(Locale.ROOT).trim(); String slug = lower.replaceAll("[^a-z0-9._-]+", "-").replaceAll("(^-+|-+$)", ""); - return slug.trim().isEmpty() ? UniversalConfigFormat.DEFAULT_PROFILE_SLUG : slug; + return slug.isBlank() ? UniversalConfigFormat.DEFAULT_PROFILE_SLUG : slug; } private static final class SettingsDto { String rootDirectory; String defaultProfilePath; + List profileOrder; } private static SettingsDto readSettingsDto(Path path) throws UniversalConfigException { @@ -179,6 +184,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); } @@ -189,22 +195,22 @@ private static void saveRootSettings(UniversalConfigSettings settings) throws Un } private static Path parseDefaultProfilePath(String value) { - if (value == null || value.trim().isEmpty()) { + if (value == null || value.isBlank()) { return null; } try { - return Paths.get(value); + return Path.of(value); } catch (InvalidPathException ex) { return null; } } private static boolean hasInvalidDefaultProfilePath(String value) { - if (value == null || value.trim().isEmpty()) { + if (value == null || value.isBlank()) { return false; } try { - Paths.get(value); + Path.of(value); return false; } catch (InvalidPathException ex) { return true; diff --git a/forge/src/main/java/com/example/universalconfig/core/UniversalConfigSettings.java b/forge/src/main/java/com/example/universalconfig/core/UniversalConfigSettings.java index 174087c..fd7f9c2 100644 --- a/forge/src/main/java/com/example/universalconfig/core/UniversalConfigSettings.java +++ b/forge/src/main/java/com/example/universalconfig/core/UniversalConfigSettings.java @@ -1,10 +1,13 @@ package com.example.universalconfig.core; import java.nio.file.Path; +import java.util.ArrayList; +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 +28,20 @@ public Path defaultProfilePath() { public void setDefaultProfilePath(Path defaultProfilePath) { this.defaultProfilePath = defaultProfilePath; } + + public List profileOrder() { + return List.copyOf(profileOrder); + } + + public void setProfileOrder(List profileOrder) { + this.profileOrder = new ArrayList<>(); + if (profileOrder == null) { + return; + } + for (String profileKey : profileOrder) { + if (profileKey != null && !profileKey.isBlank() && !this.profileOrder.contains(profileKey)) { + this.profileOrder.add(profileKey); + } + } + } } diff --git a/forge/src/main/java/com/example/universalconfig/core/ZipArchiveReader.java b/forge/src/main/java/com/example/universalconfig/core/ZipArchiveReader.java index 2f39b94..2852a67 100644 --- a/forge/src/main/java/com/example/universalconfig/core/ZipArchiveReader.java +++ b/forge/src/main/java/com/example/universalconfig/core/ZipArchiveReader.java @@ -4,7 +4,6 @@ import java.io.InputStream; import java.nio.file.Path; import java.util.ArrayList; -import java.util.Enumeration; import java.util.List; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; @@ -16,11 +15,23 @@ 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; + for (ZipEntry entry : zipFile.stream().toList()) { + 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; } } diff --git a/forge/src/main/java/com/example/universalconfig/core/ZipArchiveWriter.java b/forge/src/main/java/com/example/universalconfig/core/ZipArchiveWriter.java index ac9c235..6a9e4f1 100644 --- a/forge/src/main/java/com/example/universalconfig/core/ZipArchiveWriter.java +++ b/forge/src/main/java/com/example/universalconfig/core/ZipArchiveWriter.java @@ -6,6 +6,7 @@ import java.nio.file.Path; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; import java.util.Map; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -18,8 +19,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(); @@ -81,7 +85,7 @@ private static String copyAndHash(InputStream input, ZipOutputStream output) output.write(buffer, 0, read); digest.update(buffer, 0, read); } - return Checksums.toHex(digest.digest()); + return HexFormat.of().formatHex(digest.digest()); } catch (NoSuchAlgorithmException ex) { throw new UniversalConfigException("SHA-256 is not available.", ex); } diff --git a/forge/src/main/java/com/example/universalconfig/core/ZipSecurity.java b/forge/src/main/java/com/example/universalconfig/core/ZipSecurity.java index 5aad6d6..2dc1743 100644 --- a/forge/src/main/java/com/example/universalconfig/core/ZipSecurity.java +++ b/forge/src/main/java/com/example/universalconfig/core/ZipSecurity.java @@ -1,14 +1,22 @@ package com.example.universalconfig.core; import java.nio.file.Path; -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() { } public static void validateRelativeEntryName(String entryName) throws UniversalConfigException { - if (entryName == null || entryName.trim().isEmpty()) { + if (entryName == null || entryName.isBlank()) { throw new UniversalConfigException("ZIP entry name is empty."); } String normalizedSlashes = entryName.replace('\\', '/'); @@ -18,7 +26,7 @@ public static void validateRelativeEntryName(String entryName) throws UniversalC || normalizedSlashes.equals("..")) { throw new UniversalConfigException("Unsafe ZIP entry path rejected: " + entryName); } - Path path = Paths.get(normalizedSlashes); + Path path = Path.of(normalizedSlashes); if (path.isAbsolute()) { throw new UniversalConfigException("Absolute ZIP entry path rejected: " + entryName); } @@ -44,4 +52,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/forge/src/main/java/com/example/universalconfig/forge/ForgeRestartService.java b/forge/src/main/java/com/example/universalconfig/forge/ForgeRestartService.java index 5e09c27..08185be 100644 --- a/forge/src/main/java/com/example/universalconfig/forge/ForgeRestartService.java +++ b/forge/src/main/java/com/example/universalconfig/forge/ForgeRestartService.java @@ -3,6 +3,9 @@ import com.example.universalconfig.core.CurrentProcessRestartService; import com.example.universalconfig.core.UniversalConfigException; import net.minecraftforge.fml.loading.FMLPaths; +import net.minecraftforge.fml.ModList; + +import java.util.List; public final class ForgeRestartService { private ForgeRestartService() { @@ -10,8 +13,13 @@ private ForgeRestartService() { public static void scheduleRestartAfterCurrentProcessExit() throws UniversalConfigException { try { + // 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(); + java.nio.file.Path helperClasspath = ModList.getModFileById("universal_config") + .getFile().getFilePath(); CurrentProcessRestartService.scheduleRestartAfterCurrentProcessExit( - FMLPaths.GAMEDIR.get()); + FMLPaths.GAMEDIR.get(), javaArguments, helperClasspath); } catch (UniversalConfigException ex) { throw ex; } catch (RuntimeException ex) { diff --git a/forge/src/main/java/com/example/universalconfig/forge/UniversalConfigMod.java b/forge/src/main/java/com/example/universalconfig/forge/UniversalConfigMod.java index c9661d3..5b0b51a 100644 --- a/forge/src/main/java/com/example/universalconfig/forge/UniversalConfigMod.java +++ b/forge/src/main/java/com/example/universalconfig/forge/UniversalConfigMod.java @@ -9,8 +9,6 @@ import com.example.universalconfig.core.UniversalConfigSettings; import com.example.universalconfig.forge.screen.ProfileListScreen; import com.example.universalconfig.forge.screen.ProfileIconRenderer; -import com.mojang.blaze3d.platform.InputConstants; -import net.minecraft.client.KeyMapping; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.Button; @@ -21,16 +19,14 @@ import net.minecraft.network.chat.MutableComponent; import net.minecraft.client.renderer.RenderPipelines; import net.minecraft.resources.Identifier; -import net.minecraftforge.client.event.RegisterKeyMappingsEvent; import net.minecraftforge.client.event.RegisterPictureInPictureRendererEvent; import net.minecraftforge.client.event.ScreenEvent; import net.minecraftforge.common.MinecraftForge; -import net.minecraftforge.event.TickEvent; import net.minecraftforge.fml.common.Mod; 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.internal.BrandingControl; import java.nio.file.Path; @@ -39,23 +35,22 @@ public final class UniversalConfigMod { public static final String MOD_ID = UniversalConfigFormat.MOD_ID; private static final Identifier TITLE_SCREEN_BUTTON_TEXTURE = Identifier.fromNamespaceAndPath(MOD_ID, "title_screen_button.png"); - private static final KeyMapping.Category KEY_CATEGORY = KeyMapping.Category.register( - Identifier.fromNamespaceAndPath(MOD_ID, "main")); private static final int TITLE_SCREEN_BUTTON_SIZE = 20; private static final int TITLE_SCREEN_ICON_PADDING = 3; + // title_screen_button.png の実寸。画像差し替え時は必ずこの定数と描画へ渡す実寸・UV領域を一致させること。 + // 描画先サイズ(iconSize)とは独立で、ここはテクスチャ原本のピクセルサイズを表す。 + private static final int TITLE_SCREEN_ICON_TEXTURE_SIZE = 15; 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; - private static KeyMapping openKey; private boolean pendingImportLogged; private boolean startupChangedOptions; public UniversalConfigMod(FMLJavaModLoadingContext context) { runStartupImport(); - RegisterKeyMappingsEvent.BUS.addListener(this::registerKeyMappings); RegisterPictureInPictureRendererEvent.BUS.addListener(this::registerPictureInPictureRenderers); FMLClientSetupEvent.getBus(context.getModBusGroup()).addListener(this::clientSetup); - TickEvent.ClientTickEvent.Post.BUS.addListener(this::onClientTick); ScreenEvent.Init.Post.BUS.addListener(this::onScreenInit); MinecraftForge.registerConfigScreen((minecraft, parent) -> new ProfileListScreen(parent)); } @@ -64,16 +59,6 @@ public static Minecraft client() { return Minecraft.getInstance(); } - private void registerKeyMappings(RegisterKeyMappingsEvent event) { - openKey = new KeyMapping( - "key.universal_config.open", - InputConstants.Type.KEYSYM, - GLFW.GLFW_KEY_UNKNOWN, - KEY_CATEGORY - ); - event.register(openKey); - } - private void registerPictureInPictureRenderers(RegisterPictureInPictureRendererEvent event) { event.register(new ProfileIconRenderer()); } @@ -92,18 +77,6 @@ private void clientSetup(FMLClientSetupEvent event) { }); } - public void onClientTick(TickEvent.ClientTickEvent.Post event) { - if (openKey == null) { - return; - } - Minecraft minecraft = Minecraft.getInstance(); - while (openKey.consumeClick()) { - if (minecraft.gui.screen() == null || minecraft.gui.screen() instanceof TitleScreen) { - minecraft.setScreenAndShow(new ProfileListScreen(minecraft.gui.screen())); - } - } - } - public void onScreenInit(ScreenEvent.Init.Post event) { if (!(event.getScreen() instanceof TitleScreen)) { return; @@ -111,14 +84,29 @@ public void onScreenInit(ScreenEvent.Init.Post event) { Minecraft minecraft = Minecraft.getInstance(); reloadStartupOptionsAtTitleScreen(); logPendingImportStateOnce(); + if (event.getScreen().children().stream().anyMatch(IconButton.class::isInstance)) { + return; + } Component narration = Component.translatable("button.universal_config.open"); - int y = event.getScreen().height - TITLE_SCREEN_BUTTON_SIZE - TITLE_SCREEN_BOTTOM_BRANDING_CLEARANCE; + int y = titleScreenButtonY(minecraft, event.getScreen().height); IconButton button = new IconButton(TITLE_SCREEN_BUTTON_MARGIN, y, ignored -> minecraft.setScreenAndShow(new ProfileListScreen(event.getScreen())), narration); button.setTooltip(Tooltip.create(narration)); event.addListener(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, (line, index) -> 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; @@ -204,7 +192,9 @@ protected void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mo graphics.blit(RenderPipelines.GUI_TEXTURED, TITLE_SCREEN_BUTTON_TEXTURE, getX() + TITLE_SCREEN_ICON_PADDING, getY() + TITLE_SCREEN_ICON_PADDING, - 0.0F, 0.0F, iconSize, iconSize, 128, 128, 128, 128); + 0.0F, 0.0F, iconSize, iconSize, + TITLE_SCREEN_ICON_TEXTURE_SIZE, TITLE_SCREEN_ICON_TEXTURE_SIZE, + TITLE_SCREEN_ICON_TEXTURE_SIZE, TITLE_SCREEN_ICON_TEXTURE_SIZE); } } } diff --git a/forge/src/main/java/com/example/universalconfig/forge/screen/ProfileCreateScreen.java b/forge/src/main/java/com/example/universalconfig/forge/screen/ProfileCreateScreen.java index 8567710..84804a6 100644 --- a/forge/src/main/java/com/example/universalconfig/forge/screen/ProfileCreateScreen.java +++ b/forge/src/main/java/com/example/universalconfig/forge/screen/ProfileCreateScreen.java @@ -47,7 +47,7 @@ protected void init() { String currentDescription = descriptionField == null ? "" : descriptionField.getValue(); boolean includeKeybinds = keybindsCheckbox == null || keybindsCheckbox.selected(); boolean includeClientOptions = clientOptionsCheckbox == null || clientOptionsCheckbox.selected(); - boolean includeModConfigs = modConfigsCheckbox == null || modConfigsCheckbox.selected(); + boolean includeModConfigs = modConfigsCheckbox != null && modConfigsCheckbox.selected(); int formLeft = width / 2 - FORM_LEFT_OFFSET; addRenderableWidget(new BlockIconButton(formLeft, NAME_FIELD_Y, ICON_BUTTON_SIZE, ICON_BUTTON_SIZE, Component.translatable("screen.universal_config.profile_icon_change", iconLabel(selectedIconId)), diff --git a/forge/src/main/java/com/example/universalconfig/forge/screen/ProfileListScreen.java b/forge/src/main/java/com/example/universalconfig/forge/screen/ProfileListScreen.java index 364c368..bc1e781 100644 --- a/forge/src/main/java/com/example/universalconfig/forge/screen/ProfileListScreen.java +++ b/forge/src/main/java/com/example/universalconfig/forge/screen/ProfileListScreen.java @@ -50,12 +50,16 @@ public final class ProfileListScreen extends Screen { private static final int LIST_SCROLL_STEP = 18; private static final int DETAIL_SCROLL_STEP = 18; private static final int DETAIL_LINE_HEIGHT = 12; + // 26.1.2のGUIアイテムアトラスは16px固定だが、ScreenUtil.drawProfileIconはPiP経路で + // ターゲット解像度(size*guiScale)のテクスチャへ直接描画するため、28pxでも荒れない。 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"; @@ -83,12 +87,17 @@ public final class ProfileListScreen extends Screen { private Path defaultProfilePath; private Component status = Component.empty(); private final List