From 78db75d3778e7c635194e0f26f8d9fdc9cb603cc Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Mon, 23 Mar 2026 23:20:56 +0800 Subject: [PATCH 01/54] =?UTF-8?q?feat:=20=E7=8E=B0=E5=9C=A8=E5=A4=8D?= =?UTF-8?q?=E7=94=A8WorldManagePage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/ui/Controllers.java | 8 +++ .../hmcl/ui/versions/DatapackListPage.java | 17 +++--- .../ui/versions/DatapackListPageSkin.java | 2 +- .../hmcl/ui/versions/WorldBackupsPage.java | 15 +++--- .../hmcl/ui/versions/WorldInfoPage.java | 4 +- .../hmcl/ui/versions/WorldListPage.java | 3 +- .../hmcl/ui/versions/WorldManagePage.java | 54 ++++++++++--------- 7 files changed, 61 insertions(+), 42 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java index c94a86accc..09f353c765 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -64,6 +64,7 @@ import org.jackhuang.hmcl.ui.versions.GameListPage; import org.jackhuang.hmcl.ui.versions.VersionPage; import org.jackhuang.hmcl.ui.versions.Versions; +import org.jackhuang.hmcl.ui.versions.WorldManagePage; import org.jackhuang.hmcl.util.*; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.i18n.SupportedLocale; @@ -123,6 +124,7 @@ public final class Controllers { }); private static LauncherSettingsPage settingsPage; private static Lazy terracottaPage = new Lazy<>(TerracottaPage::new); + private static Lazy worldManagePage = new Lazy<>(WorldManagePage::new); private Controllers() { } @@ -203,6 +205,11 @@ public static Node getTerracottaPage() { return terracottaPage.get(); } + // FXThread + public static WorldManagePage getWorldManagePage() { + return worldManagePage.get(); + } + // FXThread public static DecoratorController getDecorator() { return decorator; @@ -630,6 +637,7 @@ public static void shutdown() { accountListPage = null; settingsPage = null; terracottaPage = null; + worldManagePage = null; decorator = null; stage = null; scene = null; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java index 66745e5ad7..bdecff0d78 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java @@ -45,15 +45,13 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class DatapackListPage extends ListPageBase implements WorldManagePage.WorldRefreshable { - private final World world; - private final Datapack datapack; - final BooleanProperty readOnly; + private final WorldManagePage worldManagePage; + private World world; + private Datapack datapack; + BooleanProperty readOnly; public DatapackListPage(WorldManagePage worldManagePage) { - world = worldManagePage.getWorld(); - datapack = new Datapack(world.getFile().resolve("datapacks")); - setItems(MappedObservableList.create(datapack.getPacks(), DatapackListPageSkin.DatapackInfoObject::new)); - readOnly = worldManagePage.readOnlyProperty(); + this.worldManagePage = worldManagePage; FXUtils.applyDragListener(this, it -> Objects.equals("zip", FileUtils.getExtension(it)), this::installMultiDatapack, this::refresh); @@ -80,8 +78,13 @@ protected Skin createDefaultSkin() { return new DatapackListPageSkin(this); } + @Override public void refresh() { setLoading(true); + world = worldManagePage.getWorld(); + datapack = new Datapack(world.getFile().resolve("datapacks")); + readOnly = worldManagePage.readOnlyProperty(); + setItems(MappedObservableList.create(datapack.getPacks(), DatapackListPageSkin.DatapackInfoObject::new)); Task.runAsync(datapack::loadFromDir) .withRunAsync(Schedulers.javafx(), () -> setLoading(false)) .start(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java index 822b16564e..5c1da73ef9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java @@ -95,7 +95,7 @@ final class DatapackListPageSkin extends SkinBase { ComponentList root = new ComponentList(); root.getStyleClass().add("no-padding"); listView = new JFXListView<>(); - filteredList = new FilteredList<>(skinnable.getItems()); + filteredList = new FilteredList<>(skinnable.itemsProperty()); { toolbarPane = new TransitionPane(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java index 0a93925726..0fd583b640 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java @@ -65,22 +65,25 @@ public final class WorldBackupsPage extends ListPageBase implements WorldManagePage.WorldRefreshable { static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"); - private final World world; - private final Path backupsDir; + private final WorldManagePage worldManagePage; + private World world; + private Path backupsDir; private final BooleanProperty readOnly; - private final Pattern backupFileNamePattern; + private Pattern backupFileNamePattern; public WorldBackupsPage(WorldManagePage worldManagePage) { - this.world = worldManagePage.getWorld(); - this.backupsDir = worldManagePage.getBackupsDir(); + this.worldManagePage = worldManagePage; this.readOnly = worldManagePage.readOnlyProperty(); - this.backupFileNamePattern = Pattern.compile("(?[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2})_" + Pattern.quote(world.getFileName()) + "( (?[0-9]+))?\\.zip"); refresh(); } + @Override public void refresh() { setLoading(true); + this.world = worldManagePage.getWorld(); + this.backupsDir = worldManagePage.getBackupsDir(); + this.backupFileNamePattern = Pattern.compile("(?[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2})_" + Pattern.quote(world.getFileName()) + "( (?[0-9]+))?\\.zip"); Task.supplyAsync(() -> { if (Files.isDirectory(backupsDir)) { try (Stream paths = Files.list(backupsDir)) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java index 16c633fbc1..440fa07412 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java @@ -63,7 +63,7 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.WorldRefreshable { private final WorldManagePage worldManagePage; private boolean isReadOnly; - private final World world; + private World world; private CompoundTag levelData; private CompoundTag playerData; @@ -71,7 +71,6 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage. public WorldInfoPage(WorldManagePage worldManagePage) { this.worldManagePage = worldManagePage; - this.world = worldManagePage.getWorld(); refresh(); } @@ -522,6 +521,7 @@ private void saveWorldData() { @Override public void refresh() { + this.world = worldManagePage.getWorld(); setFailedReason(null); try { this.isReadOnly = worldManagePage.isReadOnly(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java index b5f0f355ad..72b2cc1161 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java @@ -177,7 +177,8 @@ else if (e instanceof IOException && e.getCause() instanceof InvalidPathExceptio } private void showManagePage(World world) { - Controllers.navigate(new WorldManagePage(world, profile, instanceId)); + //Controllers.navigate(new WorldManagePage(world, profile, instanceId)); + Controllers.navigate(Controllers.getWorldManagePage().setWorld(world, profile, instanceId)); } public void export(World world) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index 0e3ada32ba..8152366d22 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -51,15 +51,14 @@ */ public final class WorldManagePage extends DecoratorAnimatedPage implements DecoratorPage { - private final World world; - private final Path backupsDir; - private final Profile profile; - private final String instanceId; - private final boolean supportQuickPlay; + private World world; + private Path backupsDir; + private Profile profile; + private String instanceId; + private boolean supportQuickPlay; private FileChannel sessionLockChannel; - private final ObjectProperty state; - private boolean isFirstNavigation = true; + private final ObjectProperty state = new SimpleObjectProperty<>(); private final BooleanProperty refreshable = new SimpleBooleanProperty(true); private final BooleanProperty readOnly = new SimpleBooleanProperty(false); @@ -69,7 +68,21 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco private final TabHeader.Tab worldBackupsTab = new TabHeader.Tab<>("worldBackupsPage"); private final TabHeader.Tab datapackTab = new TabHeader.Tab<>("datapackListPage"); - public WorldManagePage(World world, Profile profile, String instanceId) { + public WorldManagePage() { + worldInfoTab.setNodeSupplier(() -> new WorldInfoPage(this)); + worldBackupsTab.setNodeSupplier(() -> new WorldBackupsPage(this)); + datapackTab.setNodeSupplier(() -> new DatapackListPage(this)); + + this.addEventHandler(Navigator.NavigationEvent.EXITED, this::onExited); + this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); + } + + @Override + protected @NotNull Skin createDefaultSkin() { + return new Skin(this); + } + + public WorldManagePage setWorld(World world, Profile profile, String instanceId) { this.world = world; this.backupsDir = profile.getRepository().getBackupsDirectory(instanceId); this.profile = profile; @@ -84,26 +97,20 @@ public WorldManagePage(World world, Profile profile, String instanceId) { this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> closePageForLoadingFail()); } - worldInfoTab.setNodeSupplier(() -> new WorldInfoPage(this)); - worldBackupsTab.setNodeSupplier(() -> new WorldBackupsPage(this)); - datapackTab.setNodeSupplier(() -> new DatapackListPage(this)); - - this.state = new SimpleObjectProperty<>(new State(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())), null, true, true, true)); + this.state.set(new State(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())), null, true, true, true)); Optional gameVersion = profile.getRepository().getGameVersion(instanceId); supportQuickPlay = World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion)); - - this.addEventHandler(Navigator.NavigationEvent.EXITED, this::onExited); - this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); - } - - @Override - protected @NotNull Skin createDefaultSkin() { - return new Skin(this); + return this; } @Override public void refresh() { + + if (world == null) { + throw new IllegalStateException("World is not initialized"); + } + updateSessionLockChannel(); try { world.reloadWorldData(); @@ -135,10 +142,7 @@ private void updateSessionLockChannel() { } private void onNavigated(Navigator.NavigationEvent event) { - if (isFirstNavigation) - isFirstNavigation = false; - else - refresh(); + refresh(); } public void onExited(Navigator.NavigationEvent event) { From efdc26315165017ad90203dba081c5a13f1a30fa Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Mon, 23 Mar 2026 23:30:36 +0800 Subject: [PATCH 02/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E6=A8=A1=E5=BC=8F=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/ui/versions/DatapackListPage.java | 8 +++++--- .../jackhuang/hmcl/ui/versions/DatapackListPageSkin.java | 8 ++++---- .../org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java | 8 +++++--- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java index bdecff0d78..2835348854 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java @@ -48,7 +48,6 @@ public final class DatapackListPage extends ListPageBase datapackPath) { datapackPath.forEach(this::installSingleDatapack); - if (readOnly.get()) { + if (readOnlyProperty().get()) { Controllers.showToast(i18n("datapack.reload.toast")); } } @@ -83,13 +82,16 @@ public void refresh() { setLoading(true); world = worldManagePage.getWorld(); datapack = new Datapack(world.getFile().resolve("datapacks")); - readOnly = worldManagePage.readOnlyProperty(); setItems(MappedObservableList.create(datapack.getPacks(), DatapackListPageSkin.DatapackInfoObject::new)); Task.runAsync(datapack::loadFromDir) .withRunAsync(Schedulers.javafx(), () -> setLoading(false)) .start(); } + public BooleanProperty readOnlyProperty() { + return worldManagePage.readOnlyProperty(); + } + public void add() { FileChooser chooser = new FileChooser(); chooser.setTitle(i18n("datapack.add.title")); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java index 5c1da73ef9..93eabe8b52 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java @@ -119,9 +119,9 @@ final class DatapackListPageSkin extends SkinBase { skinnable.enableSelected(listView.getSelectionModel().getSelectedItems())); JFXButton disableButton = createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () -> skinnable.disableSelected(listView.getSelectionModel().getSelectedItems())); - removeButton.disableProperty().bind(getSkinnable().readOnly); - enableButton.disableProperty().bind(getSkinnable().readOnly); - disableButton.disableProperty().bind(getSkinnable().readOnly); + removeButton.disableProperty().bind(getSkinnable().readOnlyProperty()); + enableButton.disableProperty().bind(getSkinnable().readOnlyProperty()); + disableButton.disableProperty().bind(getSkinnable().readOnlyProperty()); selectingToolbar.getChildren().addAll( removeButton, @@ -181,7 +181,7 @@ final class DatapackListPageSkin extends SkinBase { ComponentList.setVgrow(center, Priority.ALWAYS); center.loadingProperty().bind(skinnable.loadingProperty()); - listView.setCellFactory(x -> new DatapackInfoListCell(listView, getSkinnable().readOnly)); + listView.setCellFactory(x -> new DatapackInfoListCell(listView, getSkinnable().readOnlyProperty())); listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); this.listView.setItems(filteredList); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java index 0fd583b640..70d81ed0f5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java @@ -68,12 +68,10 @@ public final class WorldBackupsPage extends ListPageBase createDefaultSkin() { return new WorldBackupsPageSkin(); @@ -168,7 +170,7 @@ private final class WorldBackupsPageSkin extends ToolbarListPageSkin initializeToolbar(WorldBackupsPage skinnable) { JFXButton createBackup = createToolbarButton2(i18n("world.backup.create.new_one"), SVG.ARCHIVE, skinnable::createBackup); - createBackup.disableProperty().bind(getSkinnable().readOnly); + createBackup.disableProperty().bind(getSkinnable().readOnlyProperty()); return Arrays.asList( createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh), From 0a2225ebd2159993f2168c129f7cd7869450b63a Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Mon, 23 Mar 2026 23:34:33 +0800 Subject: [PATCH 03/54] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E5=8D=95?= =?UTF-8?q?=E5=A4=8D=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/ui/versions/WorldListPage.java | 2 +- .../org/jackhuang/hmcl/ui/versions/WorldManagePage.java | 4 ++-- HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java | 6 +++--- .../java/org/jackhuang/hmcl/launch/DefaultLauncher.java | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java index 72b2cc1161..05d148b63a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java @@ -124,7 +124,7 @@ public void refresh() { } Optional gameVersion = profile.getRepository().getGameVersion(instanceId); - supportQuickPlay.set(World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion))); + supportQuickPlay.set(World.supportsQuickPlay(GameVersionNumber.asGameVersion(gameVersion))); worlds = result; updateWorldList(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index 8152366d22..8acf014b5a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -100,7 +100,7 @@ public WorldManagePage setWorld(World world, Profile profile, String instanceId) this.state.set(new State(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())), null, true, true, true)); Optional gameVersion = profile.getRepository().getGameVersion(instanceId); - supportQuickPlay = World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion)); + supportQuickPlay = World.supportsQuickPlay(GameVersionNumber.asGameVersion(gameVersion)); return this; } @@ -222,7 +222,7 @@ private AdvancedListBox getTabBar() { tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldInfoTab, i18n("world.info"), SVG.INFO, SVG.INFO_FILL) .addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL); - if (getSkinnable().world.supportDatapacks()) { + if (getSkinnable().world.supportsDatapacks()) { getSkinnable().header.getTabs().add(getSkinnable().datapackTab); tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().datapackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index e3f4d57da9..b7abe7178c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -222,15 +222,15 @@ public boolean isLocked() { return isLocked(getSessionLockFile()); } - public boolean supportDatapacks() { + public boolean supportsDatapacks() { return getGameVersion() != null && getGameVersion().isAtLeast("1.13", "17w43a"); } - public boolean supportQuickPlay() { + public boolean supportsQuickPlay() { return getGameVersion() != null && getGameVersion().isAtLeast("1.20", "23w14a"); } - public static boolean supportQuickPlay(GameVersionNumber gameVersionNumber) { + public static boolean supportsQuickPlay(GameVersionNumber gameVersionNumber) { return gameVersionNumber != null && gameVersionNumber.isAtLeast("1.20", "23w14a"); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java index 565ddb5db9..7def9c0f64 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -322,7 +322,7 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { try { ServerAddress parsed = ServerAddress.parse(address); - if (World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion))) { + if (World.supportsQuickPlay(GameVersionNumber.asGameVersion(gameVersion))) { res.add("--quickPlayMultiplayer"); res.add(parsed.getPort() >= 0 ? address : parsed.getHost() + ":25565"); } else { @@ -335,11 +335,11 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { LOG.warning("Invalid server address: " + address, e); } } else if (options.getQuickPlayOption() instanceof QuickPlayOption.SinglePlayer singlePlayer - && World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion))) { + && World.supportsQuickPlay(GameVersionNumber.asGameVersion(gameVersion))) { res.add("--quickPlaySingleplayer"); res.add(singlePlayer.worldFolderName()); } else if (options.getQuickPlayOption() instanceof QuickPlayOption.Realm realm - && World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion))) { + && World.supportsQuickPlay(GameVersionNumber.asGameVersion(gameVersion))) { res.add("--quickPlayRealms"); res.add(realm.realmID()); } From 320a9f4bb3c030bea649422aa10f0556be903872 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Tue, 24 Mar 2026 10:43:39 +0800 Subject: [PATCH 04/54] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=87=8D?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E6=96=87=E4=BB=B6=E5=8A=9F=E8=83=BD=EF=BC=88?= =?UTF-8?q?=E8=BF=98=E6=9C=89bug=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/WorldInfoPage.java | 41 +++--- .../hmcl/ui/versions/WorldListPage.java | 8 ++ .../hmcl/ui/versions/WorldManagePage.java | 10 +- .../hmcl/ui/versions/WorldManageUIUtils.java | 122 +++++++++++------- .../java/org/jackhuang/hmcl/game/World.java | 10 +- 5 files changed, 119 insertions(+), 72 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java index 440fa07412..f5573085af 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java @@ -20,7 +20,6 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXTextField; import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.property.SimpleStringProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.geometry.Pos; @@ -95,25 +94,35 @@ private void updateControls() { var worldNamePane = new LinePane(); { worldNamePane.setTitle(i18n("world.name")); - JFXTextField worldNameField = new JFXTextField(); - setRightTextField(worldNamePane, worldNameField, 200); + // JFXTextField worldNameField = new JFXTextField(); + Label worldNameLabel = new Label(); + JFXButton editIconButton = FXUtils.newToggleButton4(SVG.EDIT, 20); + + HBox hBox = new HBox(8); + hBox.setAlignment(Pos.CENTER_LEFT); + hBox.getChildren().addAll(worldNameLabel, editIconButton); + worldNamePane.setRight(hBox); if (dataTag.get("LevelName") instanceof StringTag worldNameTag) { - var worldName = new SimpleStringProperty(worldNameTag.get()); - FXUtils.bindString(worldNameField, worldName); - worldNameField.getProperties().put(WorldInfoPage.class.getName() + ".worldNameProperty", worldName); - worldName.addListener((observable, oldValue, newValue) -> { - if (StringUtils.isNotBlank(newValue)) { - try { - world.setWorldName(newValue); - worldManagePage.setTitle(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName()))); - } catch (Exception e) { - LOG.warning("Failed to set world name", e); - } - } + worldNameLabel.setText(worldNameTag.get()); + editIconButton.setOnAction(event -> { + WorldManageUIUtils.renameWorld(world, + newWorldName -> { + worldNameLabel.setText(newWorldName); + worldManagePage.setTitle(i18n("world.manage.title", StringUtils.parseColorEscapes(newWorldName))); + }, + newWorldPath -> { + try { + Controllers.getWorldManagePage().setWorld(new World(newWorldPath), worldManagePage.getProfile(), worldManagePage.getInstanceId()); + } catch (IOException e) { + worldManagePage.closePageForLoadingFail(); + } + worldManagePage.refresh(); + } + ); }); } else { - worldNameField.setDisable(true); + editIconButton.setDisable(true); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java index 05d148b63a..af80f942c1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java @@ -193,6 +193,10 @@ public void copy(World world) { WorldManageUIUtils.copyWorld(world, this::refresh); } + public void rename(World world) { + WorldManageUIUtils.renameWorld(world, this::refresh); + } + public void reveal(World world) { FXUtils.openFolder(world.getFile()); } @@ -389,10 +393,14 @@ public void showPopupMenu(World world, boolean supportQuickPlay, JFXPopup.PopupH IconedMenuItem duplicateMenuItem = new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> page.copy(world), popup); duplicateMenuItem.setDisable(worldLocked); + IconedMenuItem renameMenuItem = new IconedMenuItem(null, "rename", () -> page.rename(world), popup); + renameMenuItem.setDisable(worldLocked); + popupMenu.getContent().addAll( new MenuSeparator(), exportMenuItem, deleteMenuItem, + renameMenuItem, duplicateMenuItem ); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index 8acf014b5a..3d8941a9e8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -127,7 +127,7 @@ public void refresh() { } } - private void closePageForLoadingFail() { + public void closePageForLoadingFail() { Platform.runLater(() -> { fireEvent(new PageCloseEvent()); Controllers.dialog(i18n("world.load.fail"), null, MessageDialogPane.MessageType.ERROR); @@ -178,6 +178,14 @@ public Path getBackupsDir() { return backupsDir; } + public Profile getProfile() { + return profile; + } + + public String getInstanceId() { + return instanceId; + } + public boolean isReadOnly() { return readOnly.get(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java index 89010afd58..9a41c91195 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java @@ -25,6 +25,7 @@ import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.construct.InputDialogPane; import org.jackhuang.hmcl.ui.construct.MessageDialogPane; +import org.jackhuang.hmcl.ui.construct.PromptDialogPane; import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.FileUtils; @@ -33,6 +34,7 @@ import java.nio.channels.FileChannel; import java.nio.file.Files; import java.nio.file.Path; +import java.util.function.Consumer; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -46,22 +48,15 @@ public static void delete(World world, Runnable runnable) { } public static void delete(World world, Runnable runnable, FileChannel sessionLockChannel) { - Controllers.confirm( - i18n("button.remove.confirm"), - i18n("world.delete"), - () -> Task.runAsync(() -> closeSessionLockChannel(world, sessionLockChannel)) - .thenRunAsync(world::delete) - .whenComplete(Schedulers.javafx(), (result, exception) -> { - if (exception == null) { - runnable.run(); - } else if (exception instanceof WorldLockedException) { - Controllers.dialog(i18n("world.locked.failed"), null, MessageDialogPane.MessageType.WARNING); - } else { - Controllers.dialog(i18n("world.delete.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING); - } - }).start(), - null - ); + Controllers.confirm(i18n("button.remove.confirm"), i18n("world.delete"), () -> Task.runAsync(() -> closeSessionLockChannel(world, sessionLockChannel)).thenRunAsync(world::delete).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + runnable.run(); + } else if (exception instanceof WorldLockedException) { + Controllers.dialog(i18n("world.locked.failed"), null, MessageDialogPane.MessageType.WARNING); + } else { + Controllers.dialog(i18n("world.delete.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING); + } + }).start(), null); } public static void export(World world) { @@ -89,43 +84,70 @@ public static void export(World world, FileChannel sessionLockChannel) { public static void copyWorld(World world, Runnable runnable) { Path worldPath = world.getFile(); - Controllers.dialog(new InputDialogPane( - i18n("world.duplicate.prompt"), - "", - (result, handler) -> { - if (StringUtils.isBlank(result)) { - handler.reject(i18n("world.duplicate.failed.empty_name")); - return; - } + Controllers.dialog(new InputDialogPane(i18n("world.duplicate.prompt"), "", (result, handler) -> { + if (StringUtils.isBlank(result)) { + handler.reject(i18n("world.duplicate.failed.empty_name")); + return; + } - if (result.contains("/") || result.contains("\\") || !FileUtils.isNameValid(result)) { - handler.reject(i18n("world.duplicate.failed.invalid_name")); - return; - } + if (result.contains("/") || result.contains("\\") || !FileUtils.isNameValid(result)) { + handler.reject(i18n("world.duplicate.failed.invalid_name")); + return; + } - Path targetDir = worldPath.resolveSibling(result); - if (Files.exists(targetDir)) { - handler.reject(i18n("world.duplicate.failed.already_exists")); - return; - } + Path targetDir = worldPath.resolveSibling(result); + if (Files.exists(targetDir)) { + handler.reject(i18n("world.duplicate.failed.already_exists")); + return; + } - Task.runAsync(Schedulers.io(), () -> world.copy(result)) - .thenAcceptAsync(Schedulers.javafx(), (Void) -> Controllers.showToast(i18n("world.duplicate.success.toast"))) - .thenAcceptAsync(Schedulers.javafx(), (Void) -> { - if (runnable != null) { - runnable.run(); - } - } - ).whenComplete(Schedulers.javafx(), (throwable) -> { - if (throwable == null) { - handler.resolve(); - } else { - handler.reject(i18n("world.duplicate.failed")); - LOG.warning("Failed to duplicate world " + world.getFile(), throwable); - } - }) - .start(); - })); + Task.runAsync(Schedulers.io(), () -> world.copy(result)).thenAcceptAsync(Schedulers.javafx(), (Void) -> Controllers.showToast(i18n("world.duplicate.success.toast"))).thenAcceptAsync(Schedulers.javafx(), (Void) -> { + if (runnable != null) { + runnable.run(); + } + }).whenComplete(Schedulers.javafx(), (throwable) -> { + if (throwable == null) { + handler.resolve(); + } else { + handler.reject(i18n("world.duplicate.failed")); + LOG.warning("Failed to duplicate world " + world.getFile(), throwable); + } + }).start(); + })); + } + + public static void renameWorld(World world, Runnable runnable) { + Consumer notRenameFolderConsumer = newWorldName -> runnable.run(); + Consumer renameFolderConsumer = newWorldPath -> runnable.run(); + renameWorld(world, notRenameFolderConsumer, renameFolderConsumer); + } + + public static void renameWorld(World world, Consumer notRenameFolderConsumer, Consumer renameFolderConsumer) { + Controllers.prompt(new PromptDialogPane.Builder(i18n("version.manage.duplicate.prompt"), (res, handler) -> { + String newWorldName = ((PromptDialogPane.Builder.StringQuestion) res.get(0)).getValue(); + boolean renameFolder = ((PromptDialogPane.Builder.BooleanQuestion) res.get(1)).getValue(); + if (StringUtils.isNotBlank(newWorldName)) { + + try { + if (renameFolder) { + if (renameFolderConsumer != null) { + renameFolderConsumer.accept(world.renameFolder(newWorldName)); + } + } else { + world.setWorldName(newWorldName); + if (notRenameFolderConsumer != null) { + notRenameFolderConsumer.accept(newWorldName); + } + } + handler.resolve(); + } catch (IOException e) { + LOG.warning("Failed to set world name", e); + handler.reject(i18n("world.duplicate.failed")); + } + } else { + handler.reject(i18n("world.duplicate.failed")); + } + }).addQuestion(new PromptDialogPane.Builder.StringQuestion(null, "")).addQuestion(new PromptDialogPane.Builder.BooleanQuestion("重命名世界文件夹", false))); } public static void closeSessionLockChannel(World world, FileChannel sessionLockChannel) throws IOException { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index b7abe7178c..b9578d8828 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -299,9 +299,8 @@ public void reloadWorldData() throws IOException { loadAndCheckWorldData(); } - // The rename method is used to rename temporary world object during installation and copying, - // so there is no need to modify the `file` field. - public void rename(String newName) throws IOException { + // The moveTo method do not modify the `file` field. + public Path renameFolder(String newName) throws IOException { if (!Files.isDirectory(file)) throw new IOException("Not a valid world directory"); @@ -311,6 +310,7 @@ public void rename(String newName) throws IOException { // then change the folder's name Files.move(file, file.resolveSibling(newName)); + return file.resolveSibling(newName); } public void install(Path savesDir, String name) throws IOException { @@ -346,7 +346,7 @@ public void install(Path savesDir, String name) throws IOException { } } - new World(worldDir).rename(name); + new World(worldDir).renameFolder(name); } else if (Files.isDirectory(file)) { FileUtils.copyDirectory(file, worldDir); } @@ -380,7 +380,7 @@ public void copy(String newName) throws IOException { Path newPath = file.resolveSibling(newName); FileUtils.copyDirectory(file, newPath, path -> !path.contains("session.lock")); World newWorld = new World(newPath); - newWorld.rename(newName); + newWorld.renameFolder(newName); } public FileChannel lock() throws WorldLockedException { From ff993ebdaf264bb1bbed43fd51f1802f10686774 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Tue, 24 Mar 2026 10:52:33 +0800 Subject: [PATCH 05/54] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E4=B8=96?= =?UTF-8?q?=E7=95=8C=E6=97=B6=E5=85=B3=E9=97=AD=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/ui/versions/WorldManagePage.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index 3d8941a9e8..996d72b394 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -88,6 +88,11 @@ public WorldManagePage setWorld(World world, Profile profile, String instanceId) this.profile = profile; this.instanceId = instanceId; + try { + closeSessionLockChannel(); + } catch (IOException e) { + LOG.warning("Can not close session lock channel of world: " + this.world.getFile(), e); + } updateSessionLockChannel(); try { @@ -141,6 +146,10 @@ private void updateSessionLockChannel() { } } + private void closeSessionLockChannel() throws IOException { + WorldManageUIUtils.closeSessionLockChannel(world, sessionLockChannel); + } + private void onNavigated(Navigator.NavigationEvent event) { refresh(); } From 72a1d76f52dbe68e07dff630ecfe9e546e9c5f0b Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Tue, 24 Mar 2026 12:47:42 +0800 Subject: [PATCH 06/54] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E4=B8=96?= =?UTF-8?q?=E7=95=8C=E9=94=81=E7=AE=A1=E7=90=86=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/WorldBackupTask.java | 72 +++---- .../hmcl/ui/versions/WorldBackupsPage.java | 2 +- .../hmcl/ui/versions/WorldListPage.java | 4 +- .../hmcl/ui/versions/WorldManagePage.java | 29 +-- .../hmcl/ui/versions/WorldManageUIUtils.java | 41 +--- .../java/org/jackhuang/hmcl/game/World.java | 183 +++++++++++++----- 6 files changed, 192 insertions(+), 139 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupTask.java index 79d704bd90..f7b90dc213 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupTask.java @@ -23,7 +23,6 @@ import java.io.BufferedOutputStream; import java.io.IOException; import java.io.OutputStream; -import java.nio.channels.FileChannel; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; import java.time.LocalDateTime; @@ -37,54 +36,55 @@ public final class WorldBackupTask extends Task { private final World world; private final Path backupsDir; - private final boolean needLock; - public WorldBackupTask(World world, Path backupsDir, boolean needLock) { + public WorldBackupTask(World world, Path backupsDir) { this.world = world; this.backupsDir = backupsDir; - this.needLock = needLock; } @Override public void execute() throws Exception { - try (FileChannel lockChannel = needLock ? world.lock() : null) { - Files.createDirectories(backupsDir); - String time = LocalDateTime.now().format(WorldBackupsPage.TIME_FORMATTER); - String baseName = time + "_" + world.getFileName(); - Path backupFile = null; - OutputStream outputStream = null; + boolean hasLocked = world.getWorldLock().getLockState() == World.WorldLock.LockState.LOCKED_BY_SELF; + world.getWorldLock().lock(); - int count; - for (count = 0; count < 256; count++) { - try { - backupFile = backupsDir.resolve(baseName + (count == 0 ? "" : " " + count) + ".zip").toAbsolutePath(); - outputStream = Files.newOutputStream(backupFile, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW); - break; - } catch (FileAlreadyExistsException ignored) { - } + Files.createDirectories(backupsDir); + String time = LocalDateTime.now().format(WorldBackupsPage.TIME_FORMATTER); + String baseName = time + "_" + world.getFileName(); + Path backupFile = null; + OutputStream outputStream = null; + + int count; + for (count = 0; count < 256; count++) { + try { + backupFile = backupsDir.resolve(baseName + (count == 0 ? "" : " " + count) + ".zip").toAbsolutePath(); + outputStream = Files.newOutputStream(backupFile, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW); + break; + } catch (FileAlreadyExistsException ignored) { } + } - if (outputStream == null) - throw new IOException("Too many attempts"); + if (outputStream == null) + throw new IOException("Too many attempts"); - try (ZipOutputStream zipOutputStream = new ZipOutputStream(new BufferedOutputStream(outputStream))) { - String rootName = world.getFileName(); - Path rootDir = this.world.getFile(); - Files.walkFileTree(this.world.getFile(), new SimpleFileVisitor() { - @Override - public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) throws IOException { - if (path.endsWith("session.lock")) { - return FileVisitResult.CONTINUE; - } - zipOutputStream.putNextEntry(new ZipEntry(rootName + "/" + rootDir.relativize(path).toString().replace('\\', '/'))); - Files.copy(path, zipOutputStream); - zipOutputStream.closeEntry(); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(new BufferedOutputStream(outputStream))) { + String rootName = world.getFileName(); + Path rootDir = this.world.getFile(); + Files.walkFileTree(this.world.getFile(), new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) throws IOException { + if (path.endsWith("session.lock")) { return FileVisitResult.CONTINUE; } - }); - } - - setResult(backupFile); + zipOutputStream.putNextEntry(new ZipEntry(rootName + "/" + rootDir.relativize(path).toString().replace('\\', '/'))); + Files.copy(path, zipOutputStream); + zipOutputStream.closeEntry(); + return FileVisitResult.CONTINUE; + } + }); } + + setResult(backupFile); + + world.getWorldLock().releaseLock(hasLocked); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java index 70d81ed0f5..f9e62384be 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java @@ -133,7 +133,7 @@ protected Skin createDefaultSkin() { } void createBackup() { - Controllers.taskDialog(new WorldBackupTask(world, backupsDir, false).setName(i18n("world.backup.processing")).thenApplyAsync(path -> { + Controllers.taskDialog(new WorldBackupTask(world, backupsDir).setName(i18n("world.backup.processing")).thenApplyAsync(path -> { Matcher matcher = backupFileNamePattern.matcher(path.getFileName().toString()); if (!matcher.matches()) { throw new AssertionError("Wrong backup file name" + path); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java index af80f942c1..f36946e564 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java @@ -336,7 +336,7 @@ protected void updateItem(World world, boolean empty) { if (world.getGameVersion() != null) content.addTag(I18n.getDisplayVersion(world.getGameVersion())); - if (world.isLocked()) { + if (world.getWorldLock().isLocked()) { content.addTag(i18n("world.locked")); btnLaunch.setDisable(true); } else { @@ -352,7 +352,7 @@ protected void updateItem(World world, boolean empty) { // Popup Menu public void showPopupMenu(World world, boolean supportQuickPlay, JFXPopup.PopupHPosition hPosition, double initOffsetX, double initOffsetY) { - boolean worldLocked = world.isLocked(); + boolean worldLocked = world.getWorldLock().isLocked(); PopupMenu popupMenu = new PopupMenu(); JFXPopup popup = new JFXPopup(popupMenu); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index 996d72b394..c1841e1669 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -39,7 +39,6 @@ import org.jetbrains.annotations.NotNull; import java.io.IOException; -import java.nio.channels.FileChannel; import java.nio.file.Path; import java.util.Optional; @@ -56,7 +55,6 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco private Profile profile; private String instanceId; private boolean supportQuickPlay; - private FileChannel sessionLockChannel; private final ObjectProperty state = new SimpleObjectProperty<>(); private final BooleanProperty refreshable = new SimpleBooleanProperty(true); @@ -83,16 +81,17 @@ public WorldManagePage() { } public WorldManagePage setWorld(World world, Profile profile, String instanceId) { - this.world = world; - this.backupsDir = profile.getRepository().getBackupsDirectory(instanceId); - this.profile = profile; - this.instanceId = instanceId; - try { closeSessionLockChannel(); } catch (IOException e) { LOG.warning("Can not close session lock channel of world: " + this.world.getFile(), e); } + + this.world = world; + this.backupsDir = profile.getRepository().getBackupsDirectory(instanceId); + this.profile = profile; + this.instanceId = instanceId; + updateSessionLockChannel(); try { @@ -140,14 +139,16 @@ public void closePageForLoadingFail() { } private void updateSessionLockChannel() { - if (sessionLockChannel == null || !sessionLockChannel.isOpen()) { - sessionLockChannel = WorldManageUIUtils.getSessionLockChannel(world); - readOnly.set(sessionLockChannel == null); + if (world != null) { + world.getWorldLock().lock(); + readOnly.set(world.getWorldLock().getLockState() != World.WorldLock.LockState.LOCKED_BY_SELF); } } private void closeSessionLockChannel() throws IOException { - WorldManageUIUtils.closeSessionLockChannel(world, sessionLockChannel); + if (world != null) { + world.getWorldLock().releaseLock(); + } } private void onNavigated(Navigator.NavigationEvent event) { @@ -156,7 +157,7 @@ private void onNavigated(Navigator.NavigationEvent event) { public void onExited(Navigator.NavigationEvent event) { try { - WorldManageUIUtils.closeSessionLockChannel(world, sessionLockChannel); + closeSessionLockChannel(); } catch (IOException ignored) { } } @@ -293,8 +294,8 @@ private AdvancedListBox getToolBar() { } managePopupMenu.getContent().addAll( - new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> WorldManageUIUtils.export(getSkinnable().world, getSkinnable().sessionLockChannel), managePopup), - new IconedMenuItem(SVG.DELETE_FOREVER, i18n("world.delete"), () -> WorldManageUIUtils.delete(getSkinnable().world, () -> getSkinnable().fireEvent(new PageCloseEvent()), getSkinnable().sessionLockChannel), managePopup), + new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> WorldManageUIUtils.export(getSkinnable().world), managePopup), + new IconedMenuItem(SVG.DELETE_FOREVER, i18n("world.delete"), () -> WorldManageUIUtils.delete(getSkinnable().world, () -> getSkinnable().fireEvent(new PageCloseEvent())), managePopup), new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> WorldManageUIUtils.copyWorld(getSkinnable().world, null), managePopup) ); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java index 9a41c91195..51de53a52e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java @@ -31,7 +31,6 @@ import org.jackhuang.hmcl.util.io.FileUtils; import java.io.IOException; -import java.nio.channels.FileChannel; import java.nio.file.Files; import java.nio.file.Path; import java.util.function.Consumer; @@ -44,11 +43,7 @@ private WorldManageUIUtils() { } public static void delete(World world, Runnable runnable) { - delete(world, runnable, null); - } - - public static void delete(World world, Runnable runnable, FileChannel sessionLockChannel) { - Controllers.confirm(i18n("button.remove.confirm"), i18n("world.delete"), () -> Task.runAsync(() -> closeSessionLockChannel(world, sessionLockChannel)).thenRunAsync(world::delete).whenComplete(Schedulers.javafx(), (result, exception) -> { + Controllers.confirm(i18n("button.remove.confirm"), i18n("world.delete"), () -> Task.runAsync(world::delete).whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { runnable.run(); } else if (exception instanceof WorldLockedException) { @@ -60,10 +55,6 @@ public static void delete(World world, Runnable runnable, FileChannel sessionLoc } public static void export(World world) { - export(world, null); - } - - public static void export(World world, FileChannel sessionLockChannel) { FileChooser fileChooser = new FileChooser(); fileChooser.setTitle(i18n("world.export.title")); fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("world"), "*.zip")); @@ -73,12 +64,6 @@ public static void export(World world, FileChannel sessionLockChannel) { return; } - try { - closeSessionLockChannel(world, sessionLockChannel); - } catch (IOException e) { - return; - } - Controllers.getDecorator().startWizard(new SinglePageWizardProvider(controller -> new WorldExportPage(world, file, controller::onFinish))); } @@ -131,7 +116,7 @@ public static void renameWorld(World world, Consumer notRenameFolderCons try { if (renameFolder) { if (renameFolderConsumer != null) { - renameFolderConsumer.accept(world.renameFolder(newWorldName)); + renameFolderConsumer.accept(world.renameWorld(newWorldName)); } } else { world.setWorldName(newWorldName); @@ -149,26 +134,4 @@ public static void renameWorld(World world, Consumer notRenameFolderCons } }).addQuestion(new PromptDialogPane.Builder.StringQuestion(null, "")).addQuestion(new PromptDialogPane.Builder.BooleanQuestion("重命名世界文件夹", false))); } - - public static void closeSessionLockChannel(World world, FileChannel sessionLockChannel) throws IOException { - if (sessionLockChannel != null) { - try { - sessionLockChannel.close(); - LOG.info("Closed session lock channel of the world " + world.getFileName()); - } catch (IOException e) { - throw new IOException("Failed to close session lock channel of the world " + world.getFile(), e); - } - } - } - - public static FileChannel getSessionLockChannel(World world) { - try { - FileChannel lock = world.lock(); - LOG.info("Acquired lock on world " + world.getFileName()); - return lock; - } catch (WorldLockedException ignored) { - return null; - } - } - } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index b9578d8828..790f69eb9b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -56,6 +56,8 @@ public final class World { private CompoundTag playerData; // Use for both reading/modification and writing back to the file private Path playerDataPath; + private WorldLock lock; + public World(Path file) throws IOException { this.file = file; @@ -121,6 +123,13 @@ public World(Path file) throws IOException { throw new IOException("Path " + file + " cannot be recognized as a Minecraft world"); } + public WorldLock getWorldLock() { + if (lock == null) { + lock = new WorldLock(); + } + return lock; + } + public Path getFile() { return file; } @@ -144,10 +153,6 @@ public void setWorldName(String worldName) throws IOException { } } - public Path getSessionLockFile() { - return file.resolve("session.lock"); - } - public CompoundTag getLevelData() { return levelData; } @@ -218,10 +223,6 @@ public Image getIcon() { return icon; } - public boolean isLocked() { - return isLocked(getSessionLockFile()); - } - public boolean supportsDatapacks() { return getGameVersion() != null && getGameVersion().isAtLeast("1.13", "17w43a"); } @@ -299,10 +300,18 @@ public void reloadWorldData() throws IOException { loadAndCheckWorldData(); } - // The moveTo method do not modify the `file` field. - public Path renameFolder(String newName) throws IOException { + // The renameWorld method do not modify the `file` field. + public Path renameWorld(String newName) throws IOException { if (!Files.isDirectory(file)) throw new IOException("Not a valid world directory"); + boolean hasLocked = false; + WorldLock.LockState lockState = getWorldLock().getLockState(); + if (lockState == WorldLock.LockState.LOCKED_BY_OTHER) { + throw new IOException("World is locked by other process"); + } else if (lockState == WorldLock.LockState.LOCKED_BY_SELF) { + hasLocked = true; + getWorldLock().releaseLock(); + } // Change the name recorded in level.dat dataTag.setString("LevelName", newName); @@ -310,6 +319,7 @@ public Path renameFolder(String newName) throws IOException { // then change the folder's name Files.move(file, file.resolveSibling(newName)); + getWorldLock().lock(hasLocked); return file.resolveSibling(newName); } @@ -346,7 +356,7 @@ public void install(Path savesDir, String name) throws IOException { } } - new World(worldDir).renameFolder(name); + new World(worldDir).renameWorld(name); } else if (Files.isDirectory(file)) { FileUtils.copyDirectory(file, worldDir); } @@ -355,15 +365,25 @@ public void install(Path savesDir, String name) throws IOException { public void export(Path zip, String worldName) throws IOException { if (!Files.isDirectory(file)) throw new IOException(); + if (getWorldLock().getLockState() == WorldLock.LockState.LOCKED_BY_OTHER) { + throw new WorldLockedException("The world " + getFile() + " has been locked"); + } + + boolean hasLocked = getWorldLock().getLockState() == WorldLock.LockState.LOCKED_BY_SELF; + getWorldLock().releaseLock(); try (Zipper zipper = new Zipper(zip)) { zipper.putDirectory(file, worldName); } + + getWorldLock().lock(hasLocked); } public void delete() throws IOException { - if (isLocked()) { + if (getWorldLock().getLockState() == WorldLock.LockState.LOCKED_BY_OTHER) { throw new WorldLockedException("The world " + getFile() + " has been locked"); + } else if (getWorldLock().getLockState() == WorldLock.LockState.LOCKED_BY_SELF) { + getWorldLock().releaseLock(); } FileUtils.forceDelete(file); } @@ -373,34 +393,14 @@ public void copy(String newName) throws IOException { throw new IOException("Not a valid world directory"); } - if (isLocked()) { + if (getWorldLock().getLockState() == WorldLock.LockState.LOCKED_BY_OTHER) { throw new WorldLockedException("The world " + getFile() + " has been locked"); } Path newPath = file.resolveSibling(newName); FileUtils.copyDirectory(file, newPath, path -> !path.contains("session.lock")); World newWorld = new World(newPath); - newWorld.renameFolder(newName); - } - - public FileChannel lock() throws WorldLockedException { - Path lockFile = getSessionLockFile(); - FileChannel channel = null; - try { - channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE); - channel.write(ByteBuffer.wrap("\u2603".getBytes(StandardCharsets.UTF_8))); - channel.force(true); - FileLock fileLock = channel.tryLock(); - if (fileLock != null) { - return channel; - } else { - IOUtils.closeQuietly(channel); - throw new WorldLockedException("The world " + getFile() + " has been locked"); - } - } catch (IOException e) { - IOUtils.closeQuietly(channel); - throw new WorldLockedException(e); - } + newWorld.renameWorld(newName); } public void writeWorldData() throws IOException { @@ -430,19 +430,6 @@ private void writeTag(CompoundTag nbt, Path path) throws IOException { }); } - private static boolean isLocked(Path sessionLockFile) { - try (FileChannel fileChannel = FileChannel.open(sessionLockFile, StandardOpenOption.WRITE)) { - return fileChannel.tryLock() == null; - } catch (AccessDeniedException | OverlappingFileLockException accessDeniedException) { - return true; - } catch (NoSuchFileException noSuchFileException) { - return false; - } catch (IOException e) { - LOG.warning("Failed to open the lock file " + sessionLockFile, e); - return false; - } - } - public static List getWorlds(Path savesDir) { if (Files.exists(savesDir)) { try (Stream stream = Files.list(savesDir)) { @@ -463,4 +450,106 @@ public static List getWorlds(Path savesDir) { } return List.of(); } + + public class WorldLock { + private FileChannel sessionLockChannel; + private final Path lockFile; + + public enum LockState { + LOCKED_BY_OTHER, + LOCKED_BY_SELF, + UNLOCKED; + } + + public WorldLock() { + this.lockFile = file.resolve("session.lock"); + this.sessionLockChannel = null; + } + + public LockState getLockState() { + if (sessionLockChannel != null && sessionLockChannel.isOpen()) { + return LockState.LOCKED_BY_SELF; + } else if (isLocked(lockFile)) { + return LockState.LOCKED_BY_OTHER; + } else { + return LockState.UNLOCKED; + } + } + + public boolean lock() { + LockState lockState = getLockState(); + if (lockState == LockState.LOCKED_BY_OTHER) { + return false; + } else if (lockState == LockState.LOCKED_BY_SELF) { + return true; + } else { + try { + sessionLockChannel = getLock(); + } catch (WorldLockedException e) { + return false; + } + return true; + } + } + + public boolean lock(boolean lock) { + if (lock) { + return lock(); + } else { + return getLockState() == LockState.LOCKED_BY_SELF; + } + } + + public void lockStrict() throws WorldLockedException { + if (!lock()) { + throw new WorldLockedException("Failed to lock world " + World.this.getFile()); + } + } + + public FileChannel getLock() throws WorldLockedException { + FileChannel channel = null; + try { + channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE); + channel.write(ByteBuffer.wrap("\u2603".getBytes(StandardCharsets.UTF_8))); + channel.force(true); + FileLock fileLock = channel.tryLock(); + if (fileLock != null) { + return channel; + } else { + IOUtils.closeQuietly(channel); + throw new WorldLockedException("The world " + getFile() + " has been locked"); + } + } catch (IOException e) { + IOUtils.closeQuietly(channel); + throw new WorldLockedException(e); + } + } + + public boolean isLocked() { + return isLocked(lockFile); + } + + private static boolean isLocked(Path sessionLockFile) { + try (FileChannel fileChannel = FileChannel.open(sessionLockFile, StandardOpenOption.WRITE)) { + return fileChannel.tryLock() == null; + } catch (AccessDeniedException | OverlappingFileLockException accessDeniedException) { + return true; + } catch (NoSuchFileException noSuchFileException) { + return false; + } catch (IOException e) { + LOG.warning("Failed to open the lock file " + sessionLockFile, e); + return false; + } + } + + public void releaseLock() throws IOException { + sessionLockChannel.close(); + } + + public void releaseLock(boolean lock) throws IOException { + if (!lock) { + sessionLockChannel.close(); + } + } + } } From e60d3ab8dae3472f0775f20131ad4f0022969ada Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Tue, 24 Mar 2026 14:12:29 +0800 Subject: [PATCH 07/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=87=8D?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E4=B8=96=E7=95=8C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/WorldListPage.java | 2 +- .../hmcl/ui/versions/WorldManageUIUtils.java | 6 +++-- .../resources/assets/lang/I18N.properties | 2 ++ .../resources/assets/lang/I18N_zh.properties | 2 ++ .../assets/lang/I18N_zh_CN.properties | 2 ++ .../java/org/jackhuang/hmcl/game/World.java | 15 ++++++++--- .../org/jackhuang/hmcl/util/io/FileUtils.java | 25 +++++++++++++++++++ 7 files changed, 48 insertions(+), 6 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java index f36946e564..50766cf46a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java @@ -393,7 +393,7 @@ public void showPopupMenu(World world, boolean supportQuickPlay, JFXPopup.PopupH IconedMenuItem duplicateMenuItem = new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> page.copy(world), popup); duplicateMenuItem.setDisable(worldLocked); - IconedMenuItem renameMenuItem = new IconedMenuItem(null, "rename", () -> page.rename(world), popup); + IconedMenuItem renameMenuItem = new IconedMenuItem(SVG.EDIT, i18n("world.rename"), () -> page.rename(world), popup); renameMenuItem.setDisable(worldLocked); popupMenu.getContent().addAll( diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java index 51de53a52e..073bcf9cd9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java @@ -108,7 +108,7 @@ public static void renameWorld(World world, Runnable runnable) { } public static void renameWorld(World world, Consumer notRenameFolderConsumer, Consumer renameFolderConsumer) { - Controllers.prompt(new PromptDialogPane.Builder(i18n("version.manage.duplicate.prompt"), (res, handler) -> { + Controllers.prompt(new PromptDialogPane.Builder(i18n("world.rename.prompt"), (res, handler) -> { String newWorldName = ((PromptDialogPane.Builder.StringQuestion) res.get(0)).getValue(); boolean renameFolder = ((PromptDialogPane.Builder.BooleanQuestion) res.get(1)).getValue(); if (StringUtils.isNotBlank(newWorldName)) { @@ -132,6 +132,8 @@ public static void renameWorld(World world, Consumer notRenameFolderCons } else { handler.reject(i18n("world.duplicate.failed")); } - }).addQuestion(new PromptDialogPane.Builder.StringQuestion(null, "")).addQuestion(new PromptDialogPane.Builder.BooleanQuestion("重命名世界文件夹", false))); + }) + .addQuestion(new PromptDialogPane.Builder.StringQuestion(null, "")) + .addQuestion(new PromptDialogPane.Builder.BooleanQuestion("重命名世界文件夹", false))); } } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 605e328b8c..4790c52e5d 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1228,6 +1228,8 @@ world.manage.button=World Management world.manage.title=World - %s world.name=World Name world.name.enter=Enter the world name +world.rename=Rename World +world.rename.prompt=Please enter the new world name world.show_all=Show All profile=Game Directories diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 0f7d9c6633..e45acea272 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -1022,6 +1022,8 @@ world.manage.button=世界管理 world.manage.title=世界管理 - %s world.name=世界名稱 world.name.enter=輸入世界名稱 +world.rename=重新命名世界 +world.rename.prompt=請輸入新世界名稱 world.show_all=全部顯示 profile=遊戲目錄 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 9e408399c3..c26330c34b 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -1027,6 +1027,8 @@ world.manage.button=世界管理 world.manage.title=世界管理 - %s world.name=世界名称 world.name.enter=输入世界名称 +world.rename=重命名此世界 +world.rename.prompt=请输入新世界名称 world.show_all=显示全部 profile=游戏文件夹 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 790f69eb9b..253b589885 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -301,6 +301,7 @@ public void reloadWorldData() throws IOException { } // The renameWorld method do not modify the `file` field. + // A new World object needs to be created to obtain the renamed world. public Path renameWorld(String newName) throws IOException { if (!Files.isDirectory(file)) throw new IOException("Not a valid world directory"); @@ -317,10 +318,18 @@ public Path renameWorld(String newName) throws IOException { dataTag.setString("LevelName", newName); writeLevelData(); - // then change the folder's name - Files.move(file, file.resolveSibling(newName)); + // Then change the folder's name + String safeName = FileUtils.getSafeWorldFolderName(newName); + Path newPath = null; + for (int count = 0; count < 256; count++) { + newPath = file.resolveSibling(count == 0 ? safeName : safeName + " (" + count + ")"); + if (!Files.exists(newPath)) { + Files.move(file, newPath); + break; + } + } getWorldLock().lock(hasLocked); - return file.resolveSibling(newName); + return newPath; } public void install(Path savesDir, String name) throws IOException { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java index 97290f6280..1582a28a88 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java @@ -209,6 +209,31 @@ public static boolean isNameValid(OperatingSystem os, String name) { return true; } + public static String getSafeWorldFolderName(String name) { + if (StringUtils.isBlank(name)) { + return "New Name"; + } + + // 1. Replace invalid characters with underscores + // Note: The handling of `.` here is to align with Minecraft's processing logic. + String sanitized = name.replaceAll("[\\x00-\\x1f\\\\/:*?\"<>|.]", "_"); + + // 2. Handle Windows reserved keywords + if (INVALID_WINDOWS_RESOURCE_BASE_NAMES.contains(sanitized.toLowerCase(Locale.ROOT))) { + sanitized = "_" + sanitized + "_"; + } + + // 3. Ensure the name does not start or end with a space + sanitized = sanitized.strip(); + + // 4. Provide a default value if the sanitized string is empty + if (sanitized.isEmpty()) { + return "New Name"; + } + + return sanitized; + } + /// Safely get the file size. Returns `0` if the file does not exist or the size cannot be obtained. public static long size(Path file) { try { From ce3bb09dc9d168dfd0f2710073235c5a9d7ba478 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Tue, 24 Mar 2026 15:34:03 +0800 Subject: [PATCH 08/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E4=B8=96=E7=95=8C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/WorldManagePage.java | 19 ++++++++---------- .../hmcl/ui/versions/WorldManageUIUtils.java | 20 +------------------ .../java/org/jackhuang/hmcl/game/World.java | 19 ++++++++++++------ 3 files changed, 22 insertions(+), 36 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index c1841e1669..5fcdfda6f4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -81,11 +81,7 @@ public WorldManagePage() { } public WorldManagePage setWorld(World world, Profile profile, String instanceId) { - try { - closeSessionLockChannel(); - } catch (IOException e) { - LOG.warning("Can not close session lock channel of world: " + this.world.getFile(), e); - } + closeSessionLockChannel(); this.world = world; this.backupsDir = profile.getRepository().getBackupsDirectory(instanceId); @@ -145,9 +141,13 @@ private void updateSessionLockChannel() { } } - private void closeSessionLockChannel() throws IOException { + private void closeSessionLockChannel() { if (world != null) { - world.getWorldLock().releaseLock(); + try { + world.getWorldLock().releaseLock(); + } catch (IOException e) { + LOG.warning("Can not close session lock channel of world: " + this.world.getFile(), e); + } } } @@ -156,10 +156,7 @@ private void onNavigated(Navigator.NavigationEvent event) { } public void onExited(Navigator.NavigationEvent event) { - try { - closeSessionLockChannel(); - } catch (IOException ignored) { - } + closeSessionLockChannel(); } public void launch() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java index 073bcf9cd9..1719cfb6af 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java @@ -31,7 +31,6 @@ import org.jackhuang.hmcl.util.io.FileUtils; import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; import java.util.function.Consumer; @@ -68,24 +67,7 @@ public static void export(World world) { } public static void copyWorld(World world, Runnable runnable) { - Path worldPath = world.getFile(); Controllers.dialog(new InputDialogPane(i18n("world.duplicate.prompt"), "", (result, handler) -> { - if (StringUtils.isBlank(result)) { - handler.reject(i18n("world.duplicate.failed.empty_name")); - return; - } - - if (result.contains("/") || result.contains("\\") || !FileUtils.isNameValid(result)) { - handler.reject(i18n("world.duplicate.failed.invalid_name")); - return; - } - - Path targetDir = worldPath.resolveSibling(result); - if (Files.exists(targetDir)) { - handler.reject(i18n("world.duplicate.failed.already_exists")); - return; - } - Task.runAsync(Schedulers.io(), () -> world.copy(result)).thenAcceptAsync(Schedulers.javafx(), (Void) -> Controllers.showToast(i18n("world.duplicate.success.toast"))).thenAcceptAsync(Schedulers.javafx(), (Void) -> { if (runnable != null) { runnable.run(); @@ -116,7 +98,7 @@ public static void renameWorld(World world, Consumer notRenameFolderCons try { if (renameFolder) { if (renameFolderConsumer != null) { - renameFolderConsumer.accept(world.renameWorld(newWorldName)); + renameFolderConsumer.accept(world.rename(newWorldName)); } } else { world.setWorldName(newWorldName); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 253b589885..5748c2bd45 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -302,7 +302,7 @@ public void reloadWorldData() throws IOException { // The renameWorld method do not modify the `file` field. // A new World object needs to be created to obtain the renamed world. - public Path renameWorld(String newName) throws IOException { + public Path rename(String newName) throws IOException { if (!Files.isDirectory(file)) throw new IOException("Not a valid world directory"); boolean hasLocked = false; @@ -365,7 +365,7 @@ public void install(Path savesDir, String name) throws IOException { } } - new World(worldDir).renameWorld(name); + new World(worldDir).rename(name); } else if (Files.isDirectory(file)) { FileUtils.copyDirectory(file, worldDir); } @@ -406,10 +406,17 @@ public void copy(String newName) throws IOException { throw new WorldLockedException("The world " + getFile() + " has been locked"); } - Path newPath = file.resolveSibling(newName); - FileUtils.copyDirectory(file, newPath, path -> !path.contains("session.lock")); - World newWorld = new World(newPath); - newWorld.renameWorld(newName); + String safeName = FileUtils.getSafeWorldFolderName(newName); + Path newPath; + for (int count = 0; count < 256; count++) { + newPath = file.resolveSibling(count == 0 ? safeName : safeName + " (" + count + ")"); + if (!Files.exists(newPath)) { + FileUtils.copyDirectory(file, newPath, path -> !path.contains("session.lock")); + World newWorld = new World(newPath); + newWorld.setWorldName(newName); + break; + } + } } public void writeWorldData() throws IOException { From bedf2d97b203b9621cf52a9c178baffb4036ce41 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Tue, 24 Mar 2026 15:55:10 +0800 Subject: [PATCH 09/54] =?UTF-8?q?feat:=20=E7=8E=B0=E5=9C=A8=E5=BF=AB?= =?UTF-8?q?=E9=80=9F=E5=90=AF=E5=8A=A8=E4=B8=8D=E4=BC=9A=E9=80=80=E5=87=BA?= =?UTF-8?q?=E4=B8=96=E7=95=8C=E7=AE=A1=E7=90=86=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/WorldInfoPage.java | 24 +++++++++++-------- .../hmcl/ui/versions/WorldManagePage.java | 3 ++- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java index f5573085af..d9d8c86285 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java @@ -19,6 +19,7 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXTextField; +import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; @@ -61,7 +62,6 @@ */ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.WorldRefreshable { private final WorldManagePage worldManagePage; - private boolean isReadOnly; private World world; private CompoundTag levelData; private CompoundTag playerData; @@ -73,6 +73,10 @@ public WorldInfoPage(WorldManagePage worldManagePage) { refresh(); } + private BooleanProperty readOnlyProperty() { + return worldManagePage.readOnlyProperty(); + } + private void updateControls() { CompoundTag dataTag = (CompoundTag) levelData.get("Data"); CompoundTag playerTag = playerData; @@ -105,6 +109,7 @@ private void updateControls() { if (dataTag.get("LevelName") instanceof StringTag worldNameTag) { worldNameLabel.setText(worldNameTag.get()); + editIconButton.disableProperty().bind(readOnlyProperty()); editIconButton.setOnAction(event -> { WorldManageUIUtils.renameWorld(world, newWorldName -> { @@ -143,7 +148,7 @@ private void updateControls() { JFXButton editIconButton = FXUtils.newToggleButton4(SVG.EDIT, 20); JFXButton resetIconButton = FXUtils.newToggleButton4(SVG.RESTORE, 20); { - editIconButton.setDisable(isReadOnly); + editIconButton.disableProperty().bind(readOnlyProperty()); editIconButton.setOnAction(event -> Controllers.confirm( I18n.i18n("world.icon.change.tip"), I18n.i18n("world.icon.change"), @@ -153,7 +158,7 @@ private void updateControls() { )); FXUtils.installFastTooltip(editIconButton, i18n("button.edit")); - resetIconButton.setDisable(isReadOnly); + resetIconButton.disableProperty().bind(readOnlyProperty()); resetIconButton.setOnAction(event -> this.clearWorldIcon()); FXUtils.installFastTooltip(resetIconButton, i18n("button.reset")); } @@ -242,7 +247,7 @@ else if (dataTag.get("SpawnX") instanceof IntTag intX var allowCheatsButton = new LineToggleButton(); { allowCheatsButton.setTitle(i18n("world.info.allow_cheats")); - allowCheatsButton.setDisable(isReadOnly); + allowCheatsButton.disableProperty().bind(readOnlyProperty()); bindTagAndToggleButton(dataTag.get("allowCommands"), allowCheatsButton); } @@ -250,7 +255,7 @@ else if (dataTag.get("SpawnX") instanceof IntTag intX var generateFeaturesButton = new LineToggleButton(); { generateFeaturesButton.setTitle(i18n("world.info.generate_features")); - generateFeaturesButton.setDisable(isReadOnly); + generateFeaturesButton.disableProperty().bind(readOnlyProperty()); // Valid before (1.16)20w20a if (dataTag.get("MapFeatures") instanceof ByteTag generateFeaturesTag) { bindTagAndToggleButton(generateFeaturesTag, generateFeaturesButton); @@ -270,7 +275,7 @@ else if (world.getNormalizedWorldGenSettingsData() != null) { var difficultyButton = new LineSelectButton(); { difficultyButton.setTitle(i18n("world.info.difficulty")); - difficultyButton.setDisable(isReadOnly); + difficultyButton.disableProperty().bind(readOnlyProperty()); difficultyButton.setItems(Difficulty.items); Difficulty difficulty; @@ -304,7 +309,7 @@ else if (dataTag.get("difficulty_settings") instanceof CompoundTag difficultySet var difficultyLockPane = new LineToggleButton(); { difficultyLockPane.setTitle(i18n("world.info.difficulty_lock")); - difficultyLockPane.setDisable(isReadOnly); + difficultyLockPane.disableProperty().bind(readOnlyProperty()); // Valid before 26.1-snapshot-6 if (dataTag.get("DifficultyLocked") instanceof ByteTag difficultyLockedTag) { bindTagAndToggleButton(difficultyLockedTag, difficultyLockPane); @@ -369,7 +374,7 @@ else if (dataTag.get("difficulty_settings") instanceof CompoundTag difficultySet var playerGameTypePane = new LineSelectButton(); { playerGameTypePane.setTitle(i18n("world.info.player.game_type")); - playerGameTypePane.setDisable(worldManagePage.isReadOnly()); + playerGameTypePane.disableProperty().bind(readOnlyProperty()); playerGameTypePane.setItems(GameType.items); // Valid before 26.1-snapshot-6 @@ -450,7 +455,7 @@ private void setRightTextField(LinePane linePane, int perfWidth, Tag tag) { } private void setRightTextField(LinePane linePane, JFXTextField textField, int perfWidth) { - textField.setDisable(isReadOnly); + textField.disableProperty().bind(readOnlyProperty()); textField.setPrefWidth(perfWidth); linePane.setRight(textField); } @@ -533,7 +538,6 @@ public void refresh() { this.world = worldManagePage.getWorld(); setFailedReason(null); try { - this.isReadOnly = worldManagePage.isReadOnly(); this.levelData = world.getLevelData(); this.playerData = world.getPlayerData(); updateControls(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index 5fcdfda6f4..98e1aadfaa 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -160,7 +160,8 @@ public void onExited(Navigator.NavigationEvent event) { } public void launch() { - fireEvent(new PageCloseEvent()); + closeSessionLockChannel(); + readOnly.set(true); Versions.launchAndEnterWorld(profile, instanceId, world.getFileName()); } From 7b8ced58c82a100b246debf953df6036d5d103f0 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Tue, 24 Mar 2026 16:55:11 +0800 Subject: [PATCH 10/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=94=81?= =?UTF-8?q?=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/WorldBackupTask.java | 67 ++++---- .../hmcl/ui/versions/WorldListPage.java | 4 +- .../java/org/jackhuang/hmcl/game/World.java | 157 +++++++++++------- 3 files changed, 135 insertions(+), 93 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupTask.java index f7b90dc213..6a282fcc1b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupTask.java @@ -44,47 +44,44 @@ public WorldBackupTask(World world, Path backupsDir) { @Override public void execute() throws Exception { - boolean hasLocked = world.getWorldLock().getLockState() == World.WorldLock.LockState.LOCKED_BY_SELF; - world.getWorldLock().lock(); + try (World.WorldLock.Guard guard = world.getWorldLock().guard()) { + Files.createDirectories(backupsDir); + String time = LocalDateTime.now().format(WorldBackupsPage.TIME_FORMATTER); + String baseName = time + "_" + world.getFileName(); + Path backupFile = null; + OutputStream outputStream = null; - Files.createDirectories(backupsDir); - String time = LocalDateTime.now().format(WorldBackupsPage.TIME_FORMATTER); - String baseName = time + "_" + world.getFileName(); - Path backupFile = null; - OutputStream outputStream = null; - - int count; - for (count = 0; count < 256; count++) { - try { - backupFile = backupsDir.resolve(baseName + (count == 0 ? "" : " " + count) + ".zip").toAbsolutePath(); - outputStream = Files.newOutputStream(backupFile, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW); - break; - } catch (FileAlreadyExistsException ignored) { + int count; + for (count = 0; count < 256; count++) { + try { + backupFile = backupsDir.resolve(baseName + (count == 0 ? "" : " " + count) + ".zip").toAbsolutePath(); + outputStream = Files.newOutputStream(backupFile, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW); + break; + } catch (FileAlreadyExistsException ignored) { + } } - } - if (outputStream == null) - throw new IOException("Too many attempts"); + if (outputStream == null) + throw new IOException("Too many attempts"); - try (ZipOutputStream zipOutputStream = new ZipOutputStream(new BufferedOutputStream(outputStream))) { - String rootName = world.getFileName(); - Path rootDir = this.world.getFile(); - Files.walkFileTree(this.world.getFile(), new SimpleFileVisitor() { - @Override - public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) throws IOException { - if (path.endsWith("session.lock")) { + try (ZipOutputStream zipOutputStream = new ZipOutputStream(new BufferedOutputStream(outputStream))) { + String rootName = world.getFileName(); + Path rootDir = this.world.getFile(); + Files.walkFileTree(this.world.getFile(), new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) throws IOException { + if (path.endsWith("session.lock")) { + return FileVisitResult.CONTINUE; + } + zipOutputStream.putNextEntry(new ZipEntry(rootName + "/" + rootDir.relativize(path).toString().replace('\\', '/'))); + Files.copy(path, zipOutputStream); + zipOutputStream.closeEntry(); return FileVisitResult.CONTINUE; } - zipOutputStream.putNextEntry(new ZipEntry(rootName + "/" + rootDir.relativize(path).toString().replace('\\', '/'))); - Files.copy(path, zipOutputStream); - zipOutputStream.closeEntry(); - return FileVisitResult.CONTINUE; - } - }); - } - - setResult(backupFile); + }); + } - world.getWorldLock().releaseLock(hasLocked); + setResult(backupFile); + } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java index 50766cf46a..12b922ab82 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java @@ -336,7 +336,7 @@ protected void updateItem(World world, boolean empty) { if (world.getGameVersion() != null) content.addTag(I18n.getDisplayVersion(world.getGameVersion())); - if (world.getWorldLock().isLocked()) { + if (world.getWorldLock().getLockState() == World.WorldLock.LockState.LOCKED_BY_OTHER) { content.addTag(i18n("world.locked")); btnLaunch.setDisable(true); } else { @@ -352,7 +352,7 @@ protected void updateItem(World world, boolean empty) { // Popup Menu public void showPopupMenu(World world, boolean supportQuickPlay, JFXPopup.PopupHPosition hPosition, double initOffsetX, double initOffsetY) { - boolean worldLocked = world.getWorldLock().isLocked(); + boolean worldLocked = world.getWorldLock().getLockState() == World.WorldLock.LockState.LOCKED_BY_OTHER; PopupMenu popupMenu = new PopupMenu(); JFXPopup popup = new JFXPopup(popupMenu); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 5748c2bd45..7aa3c0b7a9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -305,13 +305,9 @@ public void reloadWorldData() throws IOException { public Path rename(String newName) throws IOException { if (!Files.isDirectory(file)) throw new IOException("Not a valid world directory"); - boolean hasLocked = false; - WorldLock.LockState lockState = getWorldLock().getLockState(); - if (lockState == WorldLock.LockState.LOCKED_BY_OTHER) { + + if (getWorldLock().getLockState() == WorldLock.LockState.LOCKED_BY_OTHER) { throw new IOException("World is locked by other process"); - } else if (lockState == WorldLock.LockState.LOCKED_BY_SELF) { - hasLocked = true; - getWorldLock().releaseLock(); } // Change the name recorded in level.dat @@ -320,16 +316,17 @@ public Path rename(String newName) throws IOException { // Then change the folder's name String safeName = FileUtils.getSafeWorldFolderName(newName); - Path newPath = null; + Path newPath; for (int count = 0; count < 256; count++) { newPath = file.resolveSibling(count == 0 ? safeName : safeName + " (" + count + ")"); if (!Files.exists(newPath)) { - Files.move(file, newPath); - break; + try (WorldLock.Suspension ignored = getWorldLock().suspend()) { + Files.move(file, newPath); + return newPath; + } } } - getWorldLock().lock(hasLocked); - return newPath; + throw new IOException("Too many attempts"); } public void install(Path savesDir, String name) throws IOException { @@ -378,14 +375,11 @@ public void export(Path zip, String worldName) throws IOException { throw new WorldLockedException("The world " + getFile() + " has been locked"); } - boolean hasLocked = getWorldLock().getLockState() == WorldLock.LockState.LOCKED_BY_SELF; - getWorldLock().releaseLock(); - - try (Zipper zipper = new Zipper(zip)) { - zipper.putDirectory(file, worldName); + try (WorldLock.Suspension ignored = getWorldLock().suspend()) { + try (Zipper zipper = new Zipper(zip)) { + zipper.putDirectory(file, worldName); + } } - - getWorldLock().lock(hasLocked); } public void delete() throws IOException { @@ -467,9 +461,9 @@ public static List getWorlds(Path savesDir) { return List.of(); } - public class WorldLock { + public class WorldLock implements AutoCloseable { private FileChannel sessionLockChannel; - private final Path lockFile; + private final Path sessionLockFile; public enum LockState { LOCKED_BY_OTHER, @@ -478,42 +472,35 @@ public enum LockState { } public WorldLock() { - this.lockFile = file.resolve("session.lock"); + this.sessionLockFile = file.resolve("session.lock"); this.sessionLockChannel = null; } - public LockState getLockState() { + public synchronized LockState getLockState() { if (sessionLockChannel != null && sessionLockChannel.isOpen()) { return LockState.LOCKED_BY_SELF; - } else if (isLocked(lockFile)) { + } else if (isLockedExternally()) { return LockState.LOCKED_BY_OTHER; } else { return LockState.UNLOCKED; } } - public boolean lock() { + public synchronized boolean lock() { LockState lockState = getLockState(); - if (lockState == LockState.LOCKED_BY_OTHER) { - return false; - } else if (lockState == LockState.LOCKED_BY_SELF) { - return true; - } else { - try { - sessionLockChannel = getLock(); - } catch (WorldLockedException e) { - return false; + return switch (lockState) { + case LOCKED_BY_OTHER -> false; + case LOCKED_BY_SELF -> true; + case UNLOCKED -> { + try { + acquireInternal(); + } catch (WorldLockedException e) { + LOG.warning("Failed to acquire world lock for " + file, e); + yield false; + } + yield true; } - return true; - } - } - - public boolean lock(boolean lock) { - if (lock) { - return lock(); - } else { - return getLockState() == LockState.LOCKED_BY_SELF; - } + }; } public void lockStrict() throws WorldLockedException { @@ -522,15 +509,15 @@ public void lockStrict() throws WorldLockedException { } } - public FileChannel getLock() throws WorldLockedException { + public void acquireInternal() throws WorldLockedException { FileChannel channel = null; try { - channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE); + channel = FileChannel.open(sessionLockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE); channel.write(ByteBuffer.wrap("\u2603".getBytes(StandardCharsets.UTF_8))); channel.force(true); FileLock fileLock = channel.tryLock(); if (fileLock != null) { - return channel; + this.sessionLockChannel = channel; } else { IOUtils.closeQuietly(channel); throw new WorldLockedException("The world " + getFile() + " has been locked"); @@ -541,11 +528,7 @@ public FileChannel getLock() throws WorldLockedException { } } - public boolean isLocked() { - return isLocked(lockFile); - } - - private static boolean isLocked(Path sessionLockFile) { + private boolean isLockedExternally() { try (FileChannel fileChannel = FileChannel.open(sessionLockFile, StandardOpenOption.WRITE)) { return fileChannel.tryLock() == null; } catch (AccessDeniedException | OverlappingFileLockException accessDeniedException) { @@ -558,13 +541,75 @@ private static boolean isLocked(Path sessionLockFile) { } } - public void releaseLock() throws IOException { - sessionLockChannel.close(); + public synchronized void releaseLock() throws IOException { + if (sessionLockChannel != null) { + sessionLockChannel.close(); + sessionLockChannel = null; + } } - public void releaseLock(boolean lock) throws IOException { - if (!lock) { - sessionLockChannel.close(); + @Override + public void close() throws IOException { + releaseLock(); + } + + public Guard guard() throws WorldLockedException { + return new Guard(); + } + + public Suspension suspend() throws IOException { + return new Suspension(); + } + + public class Guard implements AutoCloseable { + private final boolean wasAlreadyLocked; + + private Guard() throws WorldLockedException { + synchronized (WorldLock.this) { + this.wasAlreadyLocked = (getLockState() == LockState.LOCKED_BY_SELF); + if (!wasAlreadyLocked) { + lockStrict(); + } + } + } + + @Override + public void close() { + synchronized (WorldLock.this) { + if (!wasAlreadyLocked) { + try { + releaseLock(); + } catch (IOException e) { + LOG.warning("Failed to release temporary lock", e); + } + } + } + } + } + + public class Suspension implements AutoCloseable { + private final boolean hadLock; + + private Suspension() throws IOException { + synchronized (WorldLock.this) { + this.hadLock = (getLockState() == LockState.LOCKED_BY_SELF); + if (hadLock) { + releaseLock(); + } + } + } + + @Override + public void close() { + synchronized (WorldLock.this) { + if (hadLock) { + try { + lock(); + } catch (Exception e) { + LOG.warning("Failed to resume lock after suspension", e); + } + } + } } } } From 5f80dc6f18741cd81b5d758137ebc716107b6682 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Tue, 24 Mar 2026 17:34:03 +0800 Subject: [PATCH 11/54] =?UTF-8?q?feat:=20=E5=A4=8D=E5=88=B6/=E9=87=8D?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E4=B8=96=E7=95=8C=E5=BC=B9=E7=AA=97=E4=BC=9A?= =?UTF-8?q?=E5=B0=86=E4=B8=96=E7=95=8C=E9=BB=98=E8=AE=A4=E5=90=8D=E7=A7=B0?= =?UTF-8?q?=E8=AE=BE=E4=B8=BA=E9=BB=98=E8=AE=A4=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/WorldManageUIUtils.java | 12 ++++++++++-- .../src/main/java/org/jackhuang/hmcl/game/World.java | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java index 1719cfb6af..a22fac7e6e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java @@ -67,7 +67,11 @@ public static void export(World world) { } public static void copyWorld(World world, Runnable runnable) { - Controllers.dialog(new InputDialogPane(i18n("world.duplicate.prompt"), "", (result, handler) -> { + Controllers.dialog(new InputDialogPane(i18n("world.duplicate.prompt"), world.getWorldName(), (result, handler) -> { + if (result.equals(world.getWorldName())) { + handler.resolve(); + return; + } Task.runAsync(Schedulers.io(), () -> world.copy(result)).thenAcceptAsync(Schedulers.javafx(), (Void) -> Controllers.showToast(i18n("world.duplicate.success.toast"))).thenAcceptAsync(Schedulers.javafx(), (Void) -> { if (runnable != null) { runnable.run(); @@ -94,6 +98,10 @@ public static void renameWorld(World world, Consumer notRenameFolderCons String newWorldName = ((PromptDialogPane.Builder.StringQuestion) res.get(0)).getValue(); boolean renameFolder = ((PromptDialogPane.Builder.BooleanQuestion) res.get(1)).getValue(); if (StringUtils.isNotBlank(newWorldName)) { + if (newWorldName.equals(world.getWorldName())) { + handler.resolve(); + return; + } try { if (renameFolder) { @@ -115,7 +123,7 @@ public static void renameWorld(World world, Consumer notRenameFolderCons handler.reject(i18n("world.duplicate.failed")); } }) - .addQuestion(new PromptDialogPane.Builder.StringQuestion(null, "")) + .addQuestion(new PromptDialogPane.Builder.StringQuestion(null, world.getWorldName())) .addQuestion(new PromptDialogPane.Builder.BooleanQuestion("重命名世界文件夹", false))); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 7aa3c0b7a9..4db5b33f62 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -561,7 +561,7 @@ public Suspension suspend() throws IOException { return new Suspension(); } - public class Guard implements AutoCloseable { + public final class Guard implements AutoCloseable { private final boolean wasAlreadyLocked; private Guard() throws WorldLockedException { @@ -587,7 +587,7 @@ public void close() { } } - public class Suspension implements AutoCloseable { + public final class Suspension implements AutoCloseable { private final boolean hadLock; private Suspension() throws IOException { From bbbe526fc58f32a00f139582a1edbbc77983799a Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Tue, 24 Mar 2026 17:39:21 +0800 Subject: [PATCH 12/54] =?UTF-8?q?feat:=20=E5=A4=8D=E5=88=B6=E4=B8=96?= =?UTF-8?q?=E7=95=8C=E5=BC=B9=E7=AA=97=E5=BD=93=E5=80=BC=E4=B8=BA=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E5=80=BC=E6=97=B6=E4=B8=8D=E5=86=8D=E5=8F=96=E6=B6=88?= =?UTF-8?q?=E5=A4=8D=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java index a22fac7e6e..672b19e7f8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java @@ -68,10 +68,6 @@ public static void export(World world) { public static void copyWorld(World world, Runnable runnable) { Controllers.dialog(new InputDialogPane(i18n("world.duplicate.prompt"), world.getWorldName(), (result, handler) -> { - if (result.equals(world.getWorldName())) { - handler.resolve(); - return; - } Task.runAsync(Schedulers.io(), () -> world.copy(result)).thenAcceptAsync(Schedulers.javafx(), (Void) -> Controllers.showToast(i18n("world.duplicate.success.toast"))).thenAcceptAsync(Schedulers.javafx(), (Void) -> { if (runnable != null) { runnable.run(); From a7814fb9adde0d4e34fa02b1a5a3099d4417ce34 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Tue, 24 Mar 2026 18:57:36 +0800 Subject: [PATCH 13/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/ui/versions/WorldInfoPage.java | 2 +- .../org/jackhuang/hmcl/ui/versions/WorldManagePage.java | 8 ++++---- .../jackhuang/hmcl/ui/versions/WorldManageUIUtils.java | 4 +--- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java index d9d8c86285..e81b1f6157 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java @@ -114,7 +114,7 @@ private void updateControls() { WorldManageUIUtils.renameWorld(world, newWorldName -> { worldNameLabel.setText(newWorldName); - worldManagePage.setTitle(i18n("world.manage.title", StringUtils.parseColorEscapes(newWorldName))); + worldManagePage.setTitle(newWorldName); }, newWorldPath -> { try { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index 98e1aadfaa..fb75e24cb9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -97,8 +97,6 @@ public WorldManagePage setWorld(World world, Profile profile, String instanceId) this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> closePageForLoadingFail()); } - this.state.set(new State(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())), null, true, true, true)); - Optional gameVersion = profile.getRepository().getGameVersion(instanceId); supportQuickPlay = World.supportsQuickPlay(GameVersionNumber.asGameVersion(gameVersion)); return this; @@ -120,6 +118,8 @@ public void refresh() { return; } + setTitle(world.getWorldName()); + for (var tab : header.getTabs()) { if (tab.getNode() instanceof WorldRefreshable r) { r.refresh(); @@ -174,8 +174,8 @@ public ReadOnlyObjectProperty stateProperty() { return state; } - public void setTitle(String title) { - this.state.set(new DecoratorPage.State(title, null, true, true, true)); + public void setTitle(String worldName) { + this.state.set(new DecoratorPage.State(i18n("world.manage.title", StringUtils.parseColorEscapes(worldName)), null, true, true, true)); } public World getWorld() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java index 672b19e7f8..c089a0cb94 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java @@ -84,9 +84,7 @@ public static void copyWorld(World world, Runnable runnable) { } public static void renameWorld(World world, Runnable runnable) { - Consumer notRenameFolderConsumer = newWorldName -> runnable.run(); - Consumer renameFolderConsumer = newWorldPath -> runnable.run(); - renameWorld(world, notRenameFolderConsumer, renameFolderConsumer); + renameWorld(world, newWorldName -> runnable.run(), newWorldPath -> runnable.run()); } public static void renameWorld(World world, Consumer notRenameFolderConsumer, Consumer renameFolderConsumer) { From ed0eddfa163e3497090d43960b98eded3c3af23e Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Tue, 24 Mar 2026 20:21:05 +0800 Subject: [PATCH 14/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/DatapackListPage.java | 6 +++ .../ui/versions/DatapackListPageSkin.java | 2 + .../hmcl/ui/versions/WorldInfoPage.java | 32 ++++++------- .../hmcl/ui/versions/WorldManagePage.java | 48 ++++++++++--------- .../java/org/jackhuang/hmcl/game/World.java | 4 ++ 5 files changed, 52 insertions(+), 40 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java index 2835348854..79ade298e6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java @@ -80,7 +80,13 @@ protected Skin createDefaultSkin() { @Override public void refresh() { setLoading(true); + setFailedReason(null); world = worldManagePage.getWorld(); + if (!world.supportsDatapacks()) { + setFailedReason("此版本不支持数据包"); + setLoading(false); + return; + } datapack = new Datapack(world.getFile().resolve("datapacks")); setItems(MappedObservableList.create(datapack.getPacks(), DatapackListPageSkin.DatapackInfoObject::new)); Task.runAsync(datapack::loadFromDir) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java index 93eabe8b52..03b5d9d2df 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java @@ -163,6 +163,7 @@ final class DatapackListPageSkin extends SkinBase { FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(), selectedItem -> isSelecting.set(selectedItem != null)); + toolbarPane.disableProperty().bind(skinnable.loadingProperty().or(skinnable.failedReasonProperty().isNotNull())); root.getContent().add(toolbarPane); updateBarByStateWeakListener = FXUtils.observeWeak(() -> { @@ -180,6 +181,7 @@ final class DatapackListPageSkin extends SkinBase { SpinnerPane center = new SpinnerPane(); ComponentList.setVgrow(center, Priority.ALWAYS); center.loadingProperty().bind(skinnable.loadingProperty()); + center.failedReasonProperty().bind(skinnable.failedReasonProperty()); listView.setCellFactory(x -> new DatapackInfoListCell(listView, getSkinnable().readOnlyProperty())); listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java index e81b1f6157..1e036cd1b5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java @@ -40,7 +40,6 @@ import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.io.FileUtils; @@ -63,7 +62,7 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.WorldRefreshable { private final WorldManagePage worldManagePage; private World world; - private CompoundTag levelData; + private CompoundTag dataTag; private CompoundTag playerData; private final ImageContainer iconImageView = new ImageContainer(32); @@ -77,8 +76,21 @@ private BooleanProperty readOnlyProperty() { return worldManagePage.readOnlyProperty(); } + @Override + public void refresh() { + this.world = worldManagePage.getWorld(); + setFailedReason(null); + try { + this.dataTag = world.getDataTag(); + this.playerData = world.getPlayerData(); + updateControls(); + } catch (Exception e) { + LOG.warning("Failed to refresh world info", e); + setFailedReason(i18n("world.info.failed")); + } + } + private void updateControls() { - CompoundTag dataTag = (CompoundTag) levelData.get("Data"); CompoundTag playerTag = playerData; ScrollPane scrollPane = new ScrollPane(); @@ -533,20 +545,6 @@ private void saveWorldData() { } } - @Override - public void refresh() { - this.world = worldManagePage.getWorld(); - setFailedReason(null); - try { - this.levelData = world.getLevelData(); - this.playerData = world.getPlayerData(); - updateControls(); - } catch (Exception e) { - LOG.warning("Failed to refresh world info", e); - setFailedReason(i18n("world.info.failed")); - } - } - private record Dimension(String name) { static final Dimension OVERWORLD = new Dimension(null); static final Dimension THE_NETHER = new Dimension(i18n("world.info.dimension.the_nether")); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index fb75e24cb9..060d2fffac 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -54,7 +54,9 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco private Path backupsDir; private Profile profile; private String instanceId; - private boolean supportQuickPlay; + + private final BooleanProperty currentWorldSupportQuickPlay = new SimpleBooleanProperty(false); + private final BooleanProperty currentWorldSupportDataPack = new SimpleBooleanProperty(false); private final ObjectProperty state = new SimpleObjectProperty<>(); private final BooleanProperty refreshable = new SimpleBooleanProperty(true); @@ -90,15 +92,9 @@ public WorldManagePage setWorld(World world, Profile profile, String instanceId) updateSessionLockChannel(); - try { - this.world.reloadWorldData(); - } catch (IOException e) { - LOG.warning("Can not load world data of world: " + this.world.getFile(), e); - this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> closePageForLoadingFail()); - } - Optional gameVersion = profile.getRepository().getGameVersion(instanceId); - supportQuickPlay = World.supportsQuickPlay(GameVersionNumber.asGameVersion(gameVersion)); + currentWorldSupportQuickPlay.set(World.supportsQuickPlay(GameVersionNumber.asGameVersion(gameVersion))); + currentWorldSupportDataPack.set(world.supportsDatapacks()); return this; } @@ -238,10 +234,8 @@ private AdvancedListBox getTabBar() { tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldInfoTab, i18n("world.info"), SVG.INFO, SVG.INFO_FILL) .addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL); - if (getSkinnable().world.supportsDatapacks()) { - getSkinnable().header.getTabs().add(getSkinnable().datapackTab); - tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().datapackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL); - } + getSkinnable().header.getTabs().add(getSkinnable().datapackTab); + tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().datapackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL); } return tabBar; @@ -251,9 +245,10 @@ private AdvancedListBox getToolBar() { AdvancedListBox toolbar = new AdvancedListBox(); BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0)); { - if (getSkinnable().supportQuickPlay) { - toolbar.addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, () -> getSkinnable().launch(), advancedListItem -> advancedListItem.disableProperty().bind(getSkinnable().readOnlyProperty())); - } + toolbar.addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, () -> getSkinnable().launch(), advancedListItem -> { + advancedListItem.disableProperty().bind(getSkinnable().readOnlyProperty()); + advancedListItem.visibleProperty().bind(getSkinnable().currentWorldSupportQuickPlay); + }); if (ChunkBaseApp.isSupported(getSkinnable().world)) { PopupMenu chunkBasePopupMenu = new PopupMenu(); @@ -283,13 +278,20 @@ private AdvancedListBox getToolBar() { PopupMenu managePopupMenu = new PopupMenu(); JFXPopup managePopup = new JFXPopup(managePopupMenu); - if (getSkinnable().supportQuickPlay) { - managePopupMenu.getContent().addAll( - new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch"), () -> getSkinnable().launch(), managePopup), - new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), () -> getSkinnable().generateLaunchScript(), managePopup), - new MenuSeparator() - ); - } + IconedMenuItem launchItem = new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch"), () -> getSkinnable().launch(), managePopup); + launchItem.visibleProperty().bind(getSkinnable().currentWorldSupportQuickPlay); + launchItem.managedProperty().bind(getSkinnable().currentWorldSupportQuickPlay); + + IconedMenuItem launchScriptItem = new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), () -> getSkinnable().generateLaunchScript(), managePopup); + launchScriptItem.visibleProperty().bind(getSkinnable().currentWorldSupportQuickPlay); + launchScriptItem.managedProperty().bind(getSkinnable().currentWorldSupportQuickPlay); + + MenuSeparator menuSeparator = new MenuSeparator(); + menuSeparator.visibleProperty().bind(getSkinnable().currentWorldSupportQuickPlay); + menuSeparator.managedProperty().bind(getSkinnable().currentWorldSupportQuickPlay); + + managePopupMenu.getContent().addAll(launchItem, launchScriptItem, menuSeparator); + managePopupMenu.getContent().addAll( new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> WorldManageUIUtils.export(getSkinnable().world), managePopup), diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 4db5b33f62..f323e43802 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -157,6 +157,10 @@ public CompoundTag getLevelData() { return levelData; } + public CompoundTag getDataTag() { + return dataTag; + } + public @Nullable CompoundTag getNormalizedWorldGenSettingsData() { return normalizedWorldGenSettingsData; } From 00ddeb1e4df7afa23f2b9484e6e7bd0a0c7797e9 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Wed, 25 Mar 2026 10:17:11 +0800 Subject: [PATCH 15/54] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=BF=98?= =?UTF-8?q?=E5=8E=9F=E5=AD=98=E6=A1=A3=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/WorldBackupsPage.java | 28 ++++++++- .../hmcl/ui/versions/WorldInfoPage.java | 3 +- .../hmcl/ui/versions/WorldManagePage.java | 6 ++ .../hmcl/ui/versions/WorldRestoreTask.java | 59 +++++++++++++++++++ .../resources/assets/lang/I18N.properties | 7 +++ .../resources/assets/lang/I18N_zh.properties | 7 +++ .../assets/lang/I18N_zh_CN.properties | 7 +++ 7 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java index f9e62384be..709b8882e8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java @@ -214,6 +214,25 @@ void onDelete() { Task.runAsync(() -> Files.delete(file)).start(); } + void onRestore() { + Controllers.confirm(i18n("world.restore.confirm"), i18n("world.restore"), () -> { + Controllers.taskDialog( + new WorldRestoreTask(file, world).setName(i18n("world.restore.processing")) + .whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + Controllers.getWorldManagePage().setWorldAndRefresh(new World(result), worldManagePage.getProfile(), worldManagePage.getInstanceId()); + Controllers.dialog(i18n("world.restore.success"), null, MessageDialogPane.MessageType.INFO); + } else if (exception instanceof WorldLockedException) { + Controllers.dialog(i18n("world.locked.failed"), null, MessageDialogPane.MessageType.WARNING); + } else { + LOG.warning("Failed to restore backup", exception); + Controllers.dialog(i18n("world.restore.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING); + } + }), + i18n("world.restore"), null); + }, null); + } + @Override public int compareTo(@NotNull WorldBackupsPage.BackupInfo that) { int c = this.backupTime.compareTo(that.backupTime); @@ -246,8 +265,8 @@ private static final class BackupInfoSkin extends SkinBase { TwoLineListItem item = new TwoLineListItem(); root.setCenter(item); - if (skinnable.getBackupWorld().getWorldName() != null) - item.setTitle(parseColorEscapes(skinnable.getBackupWorld().getWorldName())); + skinnable.getBackupWorld().getWorldName(); + item.setTitle(parseColorEscapes(skinnable.getBackupWorld().getWorldName())); item.setSubtitle(formatDateTime(skinnable.getBackupTime()) + (skinnable.count == 0 ? "" : " (" + skinnable.count + ")")); if (world.getGameVersion() != null) @@ -264,6 +283,11 @@ private static final class BackupInfoSkin extends SkinBase { FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager")); btnReveal.setOnAction(event -> skinnable.onReveal()); + JFXButton btnRestore = FXUtils.newToggleButton4(SVG.UPDATE); + right.getChildren().add(btnRestore); + FXUtils.installFastTooltip(btnRestore, i18n("world.restore.tooltip")); + btnRestore.setOnAction(event -> skinnable.onRestore()); + JFXButton btnDelete = FXUtils.newToggleButton4(SVG.DELETE_FOREVER); right.getChildren().add(btnDelete); FXUtils.installFastTooltip(btnDelete, i18n("world.backup.delete")); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java index 1e036cd1b5..cb55530349 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java @@ -130,11 +130,10 @@ private void updateControls() { }, newWorldPath -> { try { - Controllers.getWorldManagePage().setWorld(new World(newWorldPath), worldManagePage.getProfile(), worldManagePage.getInstanceId()); + Controllers.getWorldManagePage().setWorldAndRefresh(new World(newWorldPath), worldManagePage.getProfile(), worldManagePage.getInstanceId()); } catch (IOException e) { worldManagePage.closePageForLoadingFail(); } - worldManagePage.refresh(); } ); }); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index 060d2fffac..760190c6d1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -98,6 +98,12 @@ public WorldManagePage setWorld(World world, Profile profile, String instanceId) return this; } + public WorldManagePage setWorldAndRefresh(World world, Profile profile, String instanceId) { + setWorld(world, profile, instanceId); + refresh(); + return this; + } + @Override public void refresh() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java new file mode 100644 index 0000000000..e1eff95c6f --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java @@ -0,0 +1,59 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.versions; + +import org.jackhuang.hmcl.game.World; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.io.Unzipper; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +public class WorldRestoreTask extends Task { + private final Path backupZipPath; + private final World world; + + public WorldRestoreTask(Path backupZipPath, World world) { + this.backupZipPath = backupZipPath; + this.world = world; + } + + @Override + public void execute() throws Exception { + Path worldPath = world.getFile(); + Path tempPath = FileUtils.tmpSaveFile(worldPath); + + // Used to check if the world format is correct and get the path name + World oldWorld = new World(backupZipPath); + Path oldWorldPath = oldWorld.getFile(); + + try { + new Unzipper(backupZipPath, tempPath).setSubDirectory(oldWorld.getFileName()).unzip(); + } catch (IOException e) { + FileUtils.deleteDirectory(tempPath); + throw e; + } + FileUtils.deleteDirectory(worldPath); + Files.move(tempPath, oldWorldPath, StandardCopyOption.ATOMIC_MOVE); + + setResult(oldWorldPath); + } +} diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 4790c52e5d..1e16a31a0f 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1230,6 +1230,13 @@ world.name=World Name world.name.enter=Enter the world name world.rename=Rename World world.rename.prompt=Please enter the new world name +world.restore=Restore Backup +world.restore.confirm=Are you sure you want to restore this backup?\nCurrent save progress will be overwritten and cannot be undone. +world.restore.failed=Failed to restore backup.\n%s +world.restore.format=Backup file format error or corrupted +world.restore.processing=Restoring backup... +world.restore.success=Backup restored successfully +world.restore.tooltip=Restore backup world.show_all=Show All profile=Game Directories diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index e45acea272..c468b56ff5 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -1024,6 +1024,13 @@ world.name=世界名稱 world.name.enter=輸入世界名稱 world.rename=重新命名世界 world.rename.prompt=請輸入新世界名稱 +world.restore=還原備份 +world.restore.confirm=確定要還原該備份嗎?\n目前存檔進度將被覆蓋並無法撤銷。 +world.restore.failed=還原備份失敗\n%s +world.restore.format=備份檔案格式錯誤或已損壞 +world.restore.processing=正在還原備份…… +world.restore.success=備份還原成功 +world.restore.tooltip=還原備份 world.show_all=全部顯示 profile=遊戲目錄 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index c26330c34b..77be68004c 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -1029,6 +1029,13 @@ world.name=世界名称 world.name.enter=输入世界名称 world.rename=重命名此世界 world.rename.prompt=请输入新世界名称 +world.restore=还原存档 +world.restore.confirm=确定要还原该备份吗?\n当前存档进度将被覆盖且无法撤销。 +world.restore.failed=还原存档失败\n%s +world.restore.format=备份文件格式错误或已损坏 +world.restore.processing=正在还原存档…… +world.restore.success=存档还原成功 +world.restore.tooltip=还原存档 world.show_all=显示全部 profile=游戏文件夹 From 0a3fd646f7605a577661201d399f8209b5eca1d5 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Wed, 25 Mar 2026 10:52:28 +0800 Subject: [PATCH 16/54] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=BF=98?= =?UTF-8?q?=E5=8E=9F=E4=B8=96=E7=95=8C=E4=BD=8D=E7=BD=AE=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java index e1eff95c6f..cba1fec026 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java @@ -41,16 +41,16 @@ public void execute() throws Exception { Path worldPath = world.getFile(); Path tempPath = FileUtils.tmpSaveFile(worldPath); - // Used to check if the world format is correct and get the path name + // Use to check if the world format is correct and get the path name World oldWorld = new World(backupZipPath); - Path oldWorldPath = oldWorld.getFile(); - + Path oldWorldPath = worldPath.resolveSibling(worldPath.getFileName()); try { new Unzipper(backupZipPath, tempPath).setSubDirectory(oldWorld.getFileName()).unzip(); } catch (IOException e) { - FileUtils.deleteDirectory(tempPath); + FileUtils.deleteDirectoryQuietly(tempPath); throw e; } + world.getWorldLock().releaseLock(); FileUtils.deleteDirectory(worldPath); Files.move(tempPath, oldWorldPath, StandardCopyOption.ATOMIC_MOVE); From a3f5e2e21309ddd177fcf104a8522eecbb415583 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Wed, 25 Mar 2026 11:08:19 +0800 Subject: [PATCH 17/54] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=BF=98?= =?UTF-8?q?=E5=8E=9F=E4=B8=96=E7=95=8C=E4=BD=8D=E7=BD=AE=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java index cba1fec026..26629d52c8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java @@ -43,7 +43,7 @@ public void execute() throws Exception { // Use to check if the world format is correct and get the path name World oldWorld = new World(backupZipPath); - Path oldWorldPath = worldPath.resolveSibling(worldPath.getFileName()); + Path oldWorldPath = worldPath.resolveSibling(oldWorld.getFileName()); try { new Unzipper(backupZipPath, tempPath).setSubDirectory(oldWorld.getFileName()).unzip(); } catch (IOException e) { From 5a128b62fc2f6dd730f9c359fde9de1ecdc9fc96 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Wed, 25 Mar 2026 11:59:55 +0800 Subject: [PATCH 18/54] feat: update --- .../jackhuang/hmcl/ui/versions/WorldRestoreTask.java | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java index 26629d52c8..f8d34e7b3a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java @@ -41,19 +41,18 @@ public void execute() throws Exception { Path worldPath = world.getFile(); Path tempPath = FileUtils.tmpSaveFile(worldPath); - // Use to check if the world format is correct and get the path name - World oldWorld = new World(backupZipPath); - Path oldWorldPath = worldPath.resolveSibling(oldWorld.getFileName()); + // Check if the world format is correct + new World(backupZipPath); try { - new Unzipper(backupZipPath, tempPath).setSubDirectory(oldWorld.getFileName()).unzip(); + new Unzipper(backupZipPath, tempPath).setSubDirectory(world.getFileName()).unzip(); } catch (IOException e) { FileUtils.deleteDirectoryQuietly(tempPath); throw e; } world.getWorldLock().releaseLock(); FileUtils.deleteDirectory(worldPath); - Files.move(tempPath, oldWorldPath, StandardCopyOption.ATOMIC_MOVE); + Files.move(tempPath, worldPath, StandardCopyOption.ATOMIC_MOVE); - setResult(oldWorldPath); + setResult(worldPath); } } From 5aed9d7b736362b9ffef3b861b46e4a153c76dc8 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Wed, 25 Mar 2026 12:56:34 +0800 Subject: [PATCH 19/54] =?UTF-8?q?feat:=20=E5=A4=87=E4=BB=BD=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=BB=A5=E5=80=92=E5=BA=8F=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/WorldBackupsPage.java | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java index 709b8882e8..2378c12330 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java @@ -107,7 +107,7 @@ public void refresh() { } }); - result.sort(Comparator.naturalOrder()); + result.sort(Comparator.reverseOrder()); return result; } } else { @@ -150,7 +150,7 @@ void createBackup() { }).whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { WorldBackupsPage.this.getItems().add(result.getValue()); - WorldBackupsPage.this.getItems().sort(Comparator.naturalOrder()); + WorldBackupsPage.this.getItems().sort(Comparator.reverseOrder()); Controllers.dialog(i18n("world.backup.create.success", result.getKey()), null, MessageDialogPane.MessageType.INFO); } else if (exception instanceof WorldLockedException) { Controllers.dialog(i18n("world.locked.failed"), null, MessageDialogPane.MessageType.WARNING); @@ -215,22 +215,20 @@ void onDelete() { } void onRestore() { - Controllers.confirm(i18n("world.restore.confirm"), i18n("world.restore"), () -> { - Controllers.taskDialog( - new WorldRestoreTask(file, world).setName(i18n("world.restore.processing")) - .whenComplete(Schedulers.javafx(), (result, exception) -> { - if (exception == null) { - Controllers.getWorldManagePage().setWorldAndRefresh(new World(result), worldManagePage.getProfile(), worldManagePage.getInstanceId()); - Controllers.dialog(i18n("world.restore.success"), null, MessageDialogPane.MessageType.INFO); - } else if (exception instanceof WorldLockedException) { - Controllers.dialog(i18n("world.locked.failed"), null, MessageDialogPane.MessageType.WARNING); - } else { - LOG.warning("Failed to restore backup", exception); - Controllers.dialog(i18n("world.restore.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING); - } - }), - i18n("world.restore"), null); - }, null); + Controllers.taskDialog( + new WorldRestoreTask(file, world).setName(i18n("world.restore.processing")) + .whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + Controllers.getWorldManagePage().setWorldAndRefresh(new World(result), worldManagePage.getProfile(), worldManagePage.getInstanceId()); + Controllers.dialog(i18n("world.restore.success"), null, MessageDialogPane.MessageType.INFO); + } else if (exception instanceof WorldLockedException) { + Controllers.dialog(i18n("world.locked.failed"), null, MessageDialogPane.MessageType.WARNING); + } else { + LOG.warning("Failed to restore backup", exception); + Controllers.dialog(i18n("world.restore.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING); + } + }), + i18n("world.restore"), null); } @Override @@ -286,7 +284,7 @@ private static final class BackupInfoSkin extends SkinBase { JFXButton btnRestore = FXUtils.newToggleButton4(SVG.UPDATE); right.getChildren().add(btnRestore); FXUtils.installFastTooltip(btnRestore, i18n("world.restore.tooltip")); - btnRestore.setOnAction(event -> skinnable.onRestore()); + btnRestore.setOnAction(event -> Controllers.confirm(i18n("world.restore.confirm"), i18n("world.restore"), skinnable::onRestore, null)); JFXButton btnDelete = FXUtils.newToggleButton4(SVG.DELETE_FOREVER); right.getChildren().add(btnDelete); From 3e8a253ed21c96eb83390563f9e3549155b96678 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Wed, 25 Mar 2026 15:25:57 +0800 Subject: [PATCH 20/54] =?UTF-8?q?feat:=20=E6=95=B0=E6=8D=AE=E5=8C=85?= =?UTF-8?q?=E4=B8=8D=E6=94=AF=E6=8C=81=E6=96=87=E6=9C=AC=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java | 2 +- HMCL/src/main/resources/assets/lang/I18N.properties | 1 + HMCL/src/main/resources/assets/lang/I18N_zh.properties | 1 + HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java index 79ade298e6..ccff285cd2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java @@ -83,7 +83,7 @@ public void refresh() { setFailedReason(null); world = worldManagePage.getWorld(); if (!world.supportsDatapacks()) { - setFailedReason("此版本不支持数据包"); + setFailedReason(i18n("datapack.not_support.info")); setLoading(false); return; } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 1e16a31a0f..8dc40e6aa9 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1138,6 +1138,7 @@ datapack.add=Add datapack.add.title=Choose datapack archive you want to add datapack.reload.toast=Minecraft is running, please use the /reload command to reload the data pack datapack.title=World [%s] - Datapacks +datapack.not_support.info=This version does not support datapacks web.failed=Failed to load page web.open_in_browser=Do you want to open this address in a browser:\n%s diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index c468b56ff5..a991496614 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -932,6 +932,7 @@ datapack.add=新增資料包 datapack.add.title=選取要新增的資料包壓縮檔 datapack.reload.toast=Minecraft 正在執行,請使用 /reload 指令重新載入資料包 datapack.title=世界 [%s] - 資料包 +datapack.not_support.info=此版本不支援資料包 web.failed=載入頁面失敗 web.open_in_browser=是否要在瀏覽器中開啟此連結:\n%s diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 77be68004c..da164def98 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -937,6 +937,7 @@ datapack.add=添加数据包 datapack.add.title=选择要添加的数据包压缩包 datapack.reload.toast=Minecraft 正在运行,请使用 /reload 命令重新加载数据包 datapack.title=世界 [%s] - 数据包 +datapack.not_support.info=此版本不支持数据包 web.failed=加载页面失败 web.open_in_browser=是否要在浏览器中打开此链接:\n%s From 792727b023248a67c6266ea50ee15eb689e98482 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Wed, 25 Mar 2026 15:39:38 +0800 Subject: [PATCH 21/54] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=B8=96?= =?UTF-8?q?=E7=95=8C=E8=A7=A3=E6=9E=90=E9=94=99=E8=AF=AF=E5=90=8E=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E7=A6=81=E7=94=A8=E7=9A=84=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java | 1 + 1 file changed, 1 insertion(+) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java index cb55530349..154bef3cea 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java @@ -461,6 +461,7 @@ private void setRightTextField(LinePane linePane, int perfWidth, Tag tag) { } else if (tag instanceof FloatTag floatTag) { bindTagAndTextField(floatTag, textField); } else { + textField.disableProperty().unbind(); textField.setDisable(true); } } From 87e1970a50391bcab0aa5397769d0451810f1d3c Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Wed, 25 Mar 2026 17:28:34 +0800 Subject: [PATCH 22/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/WorldRestoreTask.java | 5 ++-- .../java/org/jackhuang/hmcl/game/World.java | 24 +++++++++---------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java index f8d34e7b3a..2513a0cc1a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java @@ -45,13 +45,12 @@ public void execute() throws Exception { new World(backupZipPath); try { new Unzipper(backupZipPath, tempPath).setSubDirectory(world.getFileName()).unzip(); + world.delete(); + Files.move(tempPath, worldPath, StandardCopyOption.ATOMIC_MOVE); } catch (IOException e) { FileUtils.deleteDirectoryQuietly(tempPath); throw e; } - world.getWorldLock().releaseLock(); - FileUtils.deleteDirectory(worldPath); - Files.move(tempPath, worldPath, StandardCopyOption.ATOMIC_MOVE); setResult(worldPath); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index f323e43802..3a36c6edad 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -311,7 +311,7 @@ public Path rename(String newName) throws IOException { throw new IOException("Not a valid world directory"); if (getWorldLock().getLockState() == WorldLock.LockState.LOCKED_BY_OTHER) { - throw new IOException("World is locked by other process"); + throw new IOException("The world " + getFile() + " has been locked"); } // Change the name recorded in level.dat @@ -387,10 +387,9 @@ public void export(Path zip, String worldName) throws IOException { } public void delete() throws IOException { - if (getWorldLock().getLockState() == WorldLock.LockState.LOCKED_BY_OTHER) { - throw new WorldLockedException("The world " + getFile() + " has been locked"); - } else if (getWorldLock().getLockState() == WorldLock.LockState.LOCKED_BY_SELF) { - getWorldLock().releaseLock(); + switch (getWorldLock().getLockState()) { + case LOCKED_BY_OTHER -> throw new WorldLockedException("The world " + getFile() + " has been locked"); + case LOCKED_BY_SELF -> getWorldLock().releaseLock(); } FileUtils.forceDelete(file); } @@ -410,11 +409,11 @@ public void copy(String newName) throws IOException { newPath = file.resolveSibling(count == 0 ? safeName : safeName + " (" + count + ")"); if (!Files.exists(newPath)) { FileUtils.copyDirectory(file, newPath, path -> !path.contains("session.lock")); - World newWorld = new World(newPath); - newWorld.setWorldName(newName); + new World(newPath).setWorldName(newName); break; } } + throw new IOException("Too many attempts"); } public void writeWorldData() throws IOException { @@ -497,12 +496,12 @@ public synchronized boolean lock() { case LOCKED_BY_SELF -> true; case UNLOCKED -> { try { - acquireInternal(); + acquireLock(); + yield true; } catch (WorldLockedException e) { LOG.warning("Failed to acquire world lock for " + file, e); yield false; } - yield true; } }; } @@ -513,10 +512,11 @@ public void lockStrict() throws WorldLockedException { } } - public void acquireInternal() throws WorldLockedException { + public void acquireLock() throws WorldLockedException { FileChannel channel = null; try { channel = FileChannel.open(sessionLockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE); + //noinspection ResultOfMethodCallIgnored channel.write(ByteBuffer.wrap("\u2603".getBytes(StandardCharsets.UTF_8))); channel.force(true); FileLock fileLock = channel.tryLock(); @@ -535,9 +535,9 @@ public void acquireInternal() throws WorldLockedException { private boolean isLockedExternally() { try (FileChannel fileChannel = FileChannel.open(sessionLockFile, StandardOpenOption.WRITE)) { return fileChannel.tryLock() == null; - } catch (AccessDeniedException | OverlappingFileLockException accessDeniedException) { + } catch (AccessDeniedException accessDeniedException) { return true; - } catch (NoSuchFileException noSuchFileException) { + } catch (OverlappingFileLockException | NoSuchFileException overlappingFileLockException) { return false; } catch (IOException e) { LOG.warning("Failed to open the lock file " + sessionLockFile, e); From db5704ef4df48c4fd3dd6e480906dedfe956bdfa Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Wed, 25 Mar 2026 19:08:40 +0800 Subject: [PATCH 23/54] =?UTF-8?q?feat:=20=E5=88=86=E7=A6=BB=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9=E5=8C=85=E4=B8=96=E7=95=8C=E5=88=B0ArchiveWorld?= =?UTF-8?q?=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/ArchiveWorld.java | 158 ++++++++++++++++++ .../hmcl/ui/versions/WorldBackupsPage.java | 22 +-- .../hmcl/ui/versions/WorldListPage.java | 3 +- .../hmcl/ui/versions/WorldRestoreTask.java | 2 +- .../java/org/jackhuang/hmcl/game/World.java | 93 +---------- 5 files changed, 178 insertions(+), 100 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ArchiveWorld.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ArchiveWorld.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ArchiveWorld.java new file mode 100644 index 0000000000..61e2046b23 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ArchiveWorld.java @@ -0,0 +1,158 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.versions; + +import javafx.scene.image.Image; +import org.glavo.nbt.io.NBTCodec; +import org.glavo.nbt.tag.CompoundTag; +import org.glavo.nbt.tag.LongTag; +import org.glavo.nbt.tag.StringTag; +import org.glavo.nbt.tag.TagType; +import org.jackhuang.hmcl.game.World; +import org.jackhuang.hmcl.util.io.CompressingUtils; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.io.Unzipper; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.*; +import java.util.List; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// @author mineDiamond +public final class ArchiveWorld { + private final Path file; + private final String fileName; + private final boolean hasSubDir; + private String worldName; + private @Nullable GameVersionNumber gameVersion; + private @Nullable Image icon; + + public ArchiveWorld(Path file) throws IOException { + if (Files.isRegularFile(file)) { + this.file = file; + + try (FileSystem fs = CompressingUtils.readonly(this.file).setAutoDetectEncoding(true).build()) { + Path root; + if (Files.isRegularFile(fs.getPath("/level.dat"))) { + root = fs.getPath("/"); + hasSubDir = false; + fileName = FileUtils.getName(this.file); + } else { + List files = Files.list(fs.getPath("/")).toList(); + if (files.size() != 1 || !Files.isDirectory(files.get(0))) { + throw new IOException("Not a valid world zip file"); + } + + root = files.get(0); + hasSubDir = true; + fileName = FileUtils.getName(root); + } + + Path levelDat = root.resolve("level.dat"); + if (!Files.exists(levelDat)) { //version 20w14infinite + levelDat = root.resolve("special_level.dat"); + } + if (!Files.exists(levelDat)) { + throw new IOException("Not a valid world zip file since level.dat or special_level.dat cannot be found."); + } + checkAndLoadLevelData(levelDat); + + Path iconFile = root.resolve("icon.png"); + if (Files.isRegularFile(iconFile)) { + try (InputStream inputStream = Files.newInputStream(iconFile)) { + icon = new Image(inputStream, 64, 64, true, false); + if (icon.isError()) + throw icon.getException(); + } catch (Exception e) { + LOG.warning("Failed to load world icon", e); + } + } + } + } else { + throw new IOException("Path " + file + " cannot be recognized as a archive Minecraft world"); + } + } + + private void checkAndLoadLevelData(Path levelDatPath) throws IOException { + CompoundTag levelData = NBTCodec.of().readTag(levelDatPath, TagType.COMPOUND); + if (!(levelData.get("Data") instanceof CompoundTag data)) + throw new IOException("level.dat missing Data"); + + if (data.get("LevelName") instanceof StringTag levelNameTag) { + this.worldName = levelNameTag.getValue(); + } else { + throw new IOException("level.dat missing LevelName"); + } + + if (data.get("Version") instanceof CompoundTag versionTag && + versionTag.get("Name") instanceof StringTag nameTag) { + this.gameVersion = GameVersionNumber.asGameVersion(nameTag.getValue()); + } + + if (!(data.get("LastPlayed") instanceof LongTag)) + throw new IOException("level.dat missing LastPlayed"); + } + + public Path getFile() { + return file; + } + + public String getFileName() { + return fileName; + } + + public boolean hasSubDir() { + return hasSubDir; + } + + public String getWorldName() { + return worldName; + } + + public @Nullable GameVersionNumber getGameVersion() { + return gameVersion; + } + + public @Nullable Image getIcon() { + return icon; + } + + public void install(Path savesDir, String name) throws IOException { + Path worldDir; + try { + worldDir = savesDir.resolve(name); + } catch (InvalidPathException e) { + throw new IOException(e); + } + + if (Files.isDirectory(worldDir)) { + throw new FileAlreadyExistsException("World already exists"); + } + + if (hasSubDir) { + new Unzipper(file, worldDir).setSubDirectory("/" + fileName + "/").unzip(); + } else { + new Unzipper(file, worldDir).unzip(); + } + new World(worldDir).rename(name); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java index 2378c12330..2d3990884e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java @@ -43,7 +43,8 @@ import org.jackhuang.hmcl.util.i18n.I18n; import org.jetbrains.annotations.NotNull; -import java.nio.file.*; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -99,7 +100,7 @@ public void refresh() { count = Integer.parseInt(matcher.group("count")); } - result.add(new BackupInfo(path, new World(path), time, count)); + result.add(new BackupInfo(path, new ArchiveWorld(path), time, count)); } } catch (Throwable e) { LOG.warning("Failed to load backup file " + path, e); @@ -146,7 +147,7 @@ void createBackup() { count = Integer.parseInt(matcher.group("count")); } - return Pair.pair(path, new BackupInfo(path, new World(path), time, count)); + return Pair.pair(path, new BackupInfo(path, new ArchiveWorld(path), time, count)); }).whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { WorldBackupsPage.this.getItems().add(result.getValue()); @@ -181,18 +182,18 @@ protected List initializeToolbar(WorldBackupsPage skinnable) { public final class BackupInfo extends Control implements Comparable { private final Path file; - private final World backupWorld; + private final ArchiveWorld backupWorld; private final LocalDateTime backupTime; private final int count; - public BackupInfo(Path file, World backupWorld, LocalDateTime backupTime, int count) { + public BackupInfo(Path file, ArchiveWorld backupWorld, LocalDateTime backupTime, int count) { this.file = file; this.backupWorld = backupWorld; this.backupTime = backupTime; this.count = count; } - public World getBackupWorld() { + public ArchiveWorld getBackupWorld() { return backupWorld; } @@ -243,7 +244,7 @@ private static final class BackupInfoSkin extends SkinBase { BackupInfoSkin(BackupInfo skinnable) { super(skinnable); - World world = skinnable.getBackupWorld(); + ArchiveWorld backupWorld = skinnable.getBackupWorld(); BorderPane root = new BorderPane(); root.getStyleClass().add("md-list-cell"); @@ -256,19 +257,18 @@ private static final class BackupInfoSkin extends SkinBase { var imageView = new ImageContainer(32); left.getChildren().add(imageView); - imageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon()); + imageView.setImage(backupWorld.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : backupWorld.getIcon()); } { TwoLineListItem item = new TwoLineListItem(); root.setCenter(item); - skinnable.getBackupWorld().getWorldName(); item.setTitle(parseColorEscapes(skinnable.getBackupWorld().getWorldName())); item.setSubtitle(formatDateTime(skinnable.getBackupTime()) + (skinnable.count == 0 ? "" : " (" + skinnable.count + ")")); - if (world.getGameVersion() != null) - item.addTag(I18n.getDisplayVersion(world.getGameVersion())); + if (backupWorld.getGameVersion() != null) + item.addTag(I18n.getDisplayVersion(backupWorld.getGameVersion())); } { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java index 12b922ab82..a253617f60 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java @@ -54,6 +54,7 @@ import java.util.Arrays; import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition; import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes; @@ -154,7 +155,7 @@ public void download() { private void installWorld(Path zipFile) { // Only accept one world file because user is required to confirm the new world name // Or too many input dialogs are popped. - Task.supplyAsync(() -> new World(zipFile)) + Task.supplyAsync(() -> new ArchiveWorld(zipFile)) .whenComplete(Schedulers.javafx(), world -> { Controllers.prompt(i18n("world.name.enter"), (name, handler) -> { Task.runAsync(() -> world.install(savesDir, name)) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java index 2513a0cc1a..ed71d941ff 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java @@ -42,7 +42,7 @@ public void execute() throws Exception { Path tempPath = FileUtils.tmpSaveFile(worldPath); // Check if the world format is correct - new World(backupZipPath); + new ArchiveWorld(backupZipPath); try { new Unzipper(backupZipPath, tempPath).setSubDirectory(world.getFileName()).unzip(); world.delete(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 3a36c6edad..0538a9190d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -20,7 +20,9 @@ import javafx.scene.image.Image; import org.glavo.nbt.io.NBTCodec; import org.glavo.nbt.tag.*; -import org.jackhuang.hmcl.util.io.*; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.io.IOUtils; +import org.jackhuang.hmcl.util.io.Zipper; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.Nullable; @@ -83,44 +85,9 @@ public World(Path file) throws IOException { LOG.warning("Failed to load world icon", e); } } - } else if (Files.isRegularFile(file)) - try (FileSystem fs = CompressingUtils.readonly(this.file).setAutoDetectEncoding(true).build()) { - Path root; - if (Files.isRegularFile(fs.getPath("/level.dat"))) { - root = fs.getPath("/"); - fileName = FileUtils.getName(this.file); - } else { - List files = Files.list(fs.getPath("/")).toList(); - if (files.size() != 1 || !Files.isDirectory(files.get(0))) { - throw new IOException("Not a valid world zip file"); - } - - root = files.get(0); - fileName = FileUtils.getName(root); - } - - Path levelDat = root.resolve("level.dat"); - if (!Files.exists(levelDat)) { //version 20w14infinite - levelDat = root.resolve("special_level.dat"); - } - if (!Files.exists(levelDat)) { - throw new IOException("Not a valid world zip file since level.dat or special_level.dat cannot be found."); - } - loadAndCheckLevelData(levelDat); - - Path iconFile = root.resolve("icon.png"); - if (Files.isRegularFile(iconFile)) { - try (InputStream inputStream = Files.newInputStream(iconFile)) { - icon = new Image(inputStream, 64, 64, true, false); - if (icon.isError()) - throw icon.getException(); - } catch (Exception e) { - LOG.warning("Failed to load world icon", e); - } - } - } - else + } else { throw new IOException("Path " + file + " cannot be recognized as a Minecraft world"); + } } public WorldLock getWorldLock() { @@ -307,9 +274,6 @@ public void reloadWorldData() throws IOException { // The renameWorld method do not modify the `file` field. // A new World object needs to be created to obtain the renamed world. public Path rename(String newName) throws IOException { - if (!Files.isDirectory(file)) - throw new IOException("Not a valid world directory"); - if (getWorldLock().getLockState() == WorldLock.LockState.LOCKED_BY_OTHER) { throw new IOException("The world " + getFile() + " has been locked"); } @@ -333,48 +297,7 @@ public Path rename(String newName) throws IOException { throw new IOException("Too many attempts"); } - public void install(Path savesDir, String name) throws IOException { - Path worldDir; - try { - worldDir = savesDir.resolve(name); - } catch (InvalidPathException e) { - throw new IOException(e); - } - - if (Files.isDirectory(worldDir)) { - throw new FileAlreadyExistsException("World already exists"); - } - - if (Files.isRegularFile(file)) { - try (FileSystem fs = CompressingUtils.readonly(file).setAutoDetectEncoding(true).build()) { - Path levelDatPath = fs.getPath("/level.dat"); - if (Files.isRegularFile(levelDatPath)) { - fileName = FileUtils.getName(file); - - new Unzipper(file, worldDir).unzip(); - } else { - try (Stream stream = Files.list(fs.getPath("/"))) { - List subDirs = stream.toList(); - if (subDirs.size() != 1) { - throw new IOException("World zip malformed"); - } - String subDirectoryName = FileUtils.getName(subDirs.get(0)); - new Unzipper(file, worldDir) - .setSubDirectory("/" + subDirectoryName + "/") - .unzip(); - } - } - - } - new World(worldDir).rename(name); - } else if (Files.isDirectory(file)) { - FileUtils.copyDirectory(file, worldDir); - } - } - public void export(Path zip, String worldName) throws IOException { - if (!Files.isDirectory(file)) - throw new IOException(); if (getWorldLock().getLockState() == WorldLock.LockState.LOCKED_BY_OTHER) { throw new WorldLockedException("The world " + getFile() + " has been locked"); } @@ -391,14 +314,10 @@ public void delete() throws IOException { case LOCKED_BY_OTHER -> throw new WorldLockedException("The world " + getFile() + " has been locked"); case LOCKED_BY_SELF -> getWorldLock().releaseLock(); } - FileUtils.forceDelete(file); + FileUtils.deleteDirectory(file); } public void copy(String newName) throws IOException { - if (!Files.isDirectory(file)) { - throw new IOException("Not a valid world directory"); - } - if (getWorldLock().getLockState() == WorldLock.LockState.LOCKED_BY_OTHER) { throw new WorldLockedException("The world " + getFile() + " has been locked"); } From 77e606c60b34b94c66c3255398de9fed8e779354 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Wed, 25 Mar 2026 19:53:22 +0800 Subject: [PATCH 24/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=A4=87=E4=BB=BD=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/WorldListPage.java | 1 - .../hmcl/ui/versions/WorldRestoreTask.java | 29 +++++++++++++++++-- .../resources/assets/lang/I18N.properties | 2 +- .../resources/assets/lang/I18N_zh.properties | 2 +- .../assets/lang/I18N_zh_CN.properties | 2 +- 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java index a253617f60..c6cca42fbb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java @@ -54,7 +54,6 @@ import java.util.Arrays; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition; import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java index ed71d941ff..3bc3c5885e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java @@ -39,19 +39,42 @@ public WorldRestoreTask(Path backupZipPath, World world) { @Override public void execute() throws Exception { Path worldPath = world.getFile(); - Path tempPath = FileUtils.tmpSaveFile(worldPath); + Path tempPath = worldPath.toAbsolutePath().resolveSibling("." + worldPath.getFileName().toString() + ".tmp"); + Path tempPath2 = worldPath.toAbsolutePath().resolveSibling("." + worldPath.getFileName().toString() + ".tmp2"); // Check if the world format is correct new ArchiveWorld(backupZipPath); try { new Unzipper(backupZipPath, tempPath).setSubDirectory(world.getFileName()).unzip(); - world.delete(); - Files.move(tempPath, worldPath, StandardCopyOption.ATOMIC_MOVE); } catch (IOException e) { FileUtils.deleteDirectoryQuietly(tempPath); throw e; } + try { + world.getWorldLock().releaseLock(); + } catch (IOException e) { + FileUtils.deleteDirectoryQuietly(tempPath); + world.getWorldLock().acquireLock(); + throw e; + } + + try { + Files.move(worldPath, tempPath2, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + FileUtils.deleteDirectoryQuietly(tempPath); + throw e; + } + + try { + Files.move(tempPath, worldPath, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + Files.move(tempPath2, worldPath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + throw e; + } + + FileUtils.deleteDirectoryQuietly(tempPath2); + setResult(worldPath); } } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 8dc40e6aa9..a881a5229e 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1232,7 +1232,7 @@ world.name.enter=Enter the world name world.rename=Rename World world.rename.prompt=Please enter the new world name world.restore=Restore Backup -world.restore.confirm=Are you sure you want to restore this backup?\nCurrent save progress will be overwritten and cannot be undone. +world.restore.confirm=Are you sure you want to restore this backup?\nCurrent save progress will be overwritten. This action cannot be undone! world.restore.failed=Failed to restore backup.\n%s world.restore.format=Backup file format error or corrupted world.restore.processing=Restoring backup... diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index a991496614..b4677b3e0e 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -1026,7 +1026,7 @@ world.name.enter=輸入世界名稱 world.rename=重新命名世界 world.rename.prompt=請輸入新世界名稱 world.restore=還原備份 -world.restore.confirm=確定要還原該備份嗎?\n目前存檔進度將被覆蓋並無法撤銷。 +world.restore.confirm=確定要還原該備份嗎?\n目前存檔進度將被覆蓋,此操作無法復原! world.restore.failed=還原備份失敗\n%s world.restore.format=備份檔案格式錯誤或已損壞 world.restore.processing=正在還原備份…… diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index da164def98..ba5ded17fb 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -1031,7 +1031,7 @@ world.name.enter=输入世界名称 world.rename=重命名此世界 world.rename.prompt=请输入新世界名称 world.restore=还原存档 -world.restore.confirm=确定要还原该备份吗?\n当前存档进度将被覆盖且无法撤销。 +world.restore.confirm=确定要还原该备份吗?\n当前存档进度将被覆盖,此操作无法撤销! world.restore.failed=还原存档失败\n%s world.restore.format=备份文件格式错误或已损坏 world.restore.processing=正在还原存档…… From 7f407581c1b0ffa2dfcf29d8b332189a65a5f96e Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Wed, 25 Mar 2026 20:11:35 +0800 Subject: [PATCH 25/54] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E4=B8=96=E7=95=8C=E9=80=BB=E8=BE=91=E7=9A=84=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 0538a9190d..cc1a7d15bd 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -329,7 +329,7 @@ public void copy(String newName) throws IOException { if (!Files.exists(newPath)) { FileUtils.copyDirectory(file, newPath, path -> !path.contains("session.lock")); new World(newPath).setWorldName(newName); - break; + return; } } throw new IOException("Too many attempts"); From 239eec0ac012a917931260fb1b127b409c74ee5f Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Wed, 25 Mar 2026 22:09:02 +0800 Subject: [PATCH 26/54] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=8D?= =?UTF-8?q?=E5=88=B6/=E9=87=8D=E5=91=BD=E5=90=8D=E4=B8=96=E7=95=8C?= =?UTF-8?q?=E6=97=B6=E5=8F=AF=E8=83=BD=E7=9A=84=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/WorldManageUIUtils.java | 50 ++++++++++--------- .../resources/assets/lang/I18N.properties | 2 + .../resources/assets/lang/I18N_zh.properties | 2 + .../assets/lang/I18N_zh_CN.properties | 2 + 4 files changed, 33 insertions(+), 23 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java index c089a0cb94..d2f8e40d88 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java @@ -67,8 +67,12 @@ public static void export(World world) { } public static void copyWorld(World world, Runnable runnable) { - Controllers.dialog(new InputDialogPane(i18n("world.duplicate.prompt"), world.getWorldName(), (result, handler) -> { - Task.runAsync(Schedulers.io(), () -> world.copy(result)).thenAcceptAsync(Schedulers.javafx(), (Void) -> Controllers.showToast(i18n("world.duplicate.success.toast"))).thenAcceptAsync(Schedulers.javafx(), (Void) -> { + Controllers.dialog(new InputDialogPane(i18n("world.duplicate.prompt"), world.getWorldName(), (newWorldName, handler) -> { + if (StringUtils.isBlank(newWorldName)) { + newWorldName = i18n("world.name.default"); + } + String finalNewWorldName = newWorldName; + Task.runAsync(Schedulers.io(), () -> world.copy(finalNewWorldName)).thenAcceptAsync(Schedulers.javafx(), (Void) -> Controllers.showToast(i18n("world.duplicate.success.toast"))).thenAcceptAsync(Schedulers.javafx(), (Void) -> { if (runnable != null) { runnable.run(); } @@ -91,30 +95,30 @@ public static void renameWorld(World world, Consumer notRenameFolderCons Controllers.prompt(new PromptDialogPane.Builder(i18n("world.rename.prompt"), (res, handler) -> { String newWorldName = ((PromptDialogPane.Builder.StringQuestion) res.get(0)).getValue(); boolean renameFolder = ((PromptDialogPane.Builder.BooleanQuestion) res.get(1)).getValue(); - if (StringUtils.isNotBlank(newWorldName)) { - if (newWorldName.equals(world.getWorldName())) { - handler.resolve(); - return; - } - try { - if (renameFolder) { - if (renameFolderConsumer != null) { - renameFolderConsumer.accept(world.rename(newWorldName)); - } - } else { - world.setWorldName(newWorldName); - if (notRenameFolderConsumer != null) { - notRenameFolderConsumer.accept(newWorldName); - } + if (StringUtils.isBlank(newWorldName)) { + newWorldName = i18n("world.name.default"); + } + if (newWorldName.equals(world.getWorldName())) { + handler.resolve(); + return; + } + + try { + if (renameFolder) { + if (renameFolderConsumer != null) { + renameFolderConsumer.accept(world.rename(newWorldName)); + } + } else { + world.setWorldName(newWorldName); + if (notRenameFolderConsumer != null) { + notRenameFolderConsumer.accept(newWorldName); } - handler.resolve(); - } catch (IOException e) { - LOG.warning("Failed to set world name", e); - handler.reject(i18n("world.duplicate.failed")); } - } else { - handler.reject(i18n("world.duplicate.failed")); + handler.resolve(); + } catch (IOException e) { + LOG.warning("Failed to set world name", e); + handler.reject(i18n("world.rename.failed")); } }) .addQuestion(new PromptDialogPane.Builder.StringQuestion(null, world.getWorldName())) diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index a881a5229e..12fad755b8 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1229,7 +1229,9 @@ world.manage.button=World Management world.manage.title=World - %s world.name=World Name world.name.enter=Enter the world name +world.name.default=New World world.rename=Rename World +world.rename.failed=Failed to rename the world world.rename.prompt=Please enter the new world name world.restore=Restore Backup world.restore.confirm=Are you sure you want to restore this backup?\nCurrent save progress will be overwritten. This action cannot be undone! diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index b4677b3e0e..6d63c2c409 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -1023,7 +1023,9 @@ world.manage.button=世界管理 world.manage.title=世界管理 - %s world.name=世界名稱 world.name.enter=輸入世界名稱 +world.name.default=新的世界 world.rename=重新命名世界 +world.rename.failed=重新命名世界失敗 world.rename.prompt=請輸入新世界名稱 world.restore=還原備份 world.restore.confirm=確定要還原該備份嗎?\n目前存檔進度將被覆蓋,此操作無法復原! diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index ba5ded17fb..9bbc095d03 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -1028,7 +1028,9 @@ world.manage.button=世界管理 world.manage.title=世界管理 - %s world.name=世界名称 world.name.enter=输入世界名称 +world.name.default=新的世界 world.rename=重命名此世界 +world.rename.failed=重命名世界失败 world.rename.prompt=请输入新世界名称 world.restore=还原存档 world.restore.confirm=确定要还原该备份吗?\n当前存档进度将被覆盖,此操作无法撤销! From b3631f07ecf7fbe2c5a907112df9cb14a2023809 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Wed, 25 Mar 2026 22:18:42 +0800 Subject: [PATCH 27/54] =?UTF-8?q?fix:=20=E4=B8=96=E7=95=8C=E9=94=81?= =?UTF-8?q?=E9=94=99=E8=AF=AF/=E6=96=87=E4=BB=B6=E5=90=8D=E4=BF=AE?= =?UTF-8?q?=E5=BB=BA=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/org/jackhuang/hmcl/game/World.java | 6 ++---- .../java/org/jackhuang/hmcl/util/io/FileUtils.java | 12 ++++++------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index cc1a7d15bd..4350c706a9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -58,10 +58,11 @@ public final class World { private CompoundTag playerData; // Use for both reading/modification and writing back to the file private Path playerDataPath; - private WorldLock lock; + private final WorldLock lock; public World(Path file) throws IOException { this.file = file; + this.lock = new WorldLock(); if (Files.isDirectory(file)) { fileName = FileUtils.getName(this.file); @@ -91,9 +92,6 @@ public World(Path file) throws IOException { } public WorldLock getWorldLock() { - if (lock == null) { - lock = new WorldLock(); - } return lock; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java index 1582a28a88..80e0b08dac 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java @@ -214,19 +214,19 @@ public static String getSafeWorldFolderName(String name) { return "New Name"; } - // 1. Replace invalid characters with underscores + // Replace invalid characters with underscores // Note: The handling of `.` here is to align with Minecraft's processing logic. String sanitized = name.replaceAll("[\\x00-\\x1f\\\\/:*?\"<>|.]", "_"); - // 2. Handle Windows reserved keywords + // Ensure the name does not start or end with a space + sanitized = sanitized.strip(); + + // Handle Windows reserved keywords if (INVALID_WINDOWS_RESOURCE_BASE_NAMES.contains(sanitized.toLowerCase(Locale.ROOT))) { sanitized = "_" + sanitized + "_"; } - // 3. Ensure the name does not start or end with a space - sanitized = sanitized.strip(); - - // 4. Provide a default value if the sanitized string is empty + // Provide a default value if the sanitized string is empty if (sanitized.isEmpty()) { return "New Name"; } From 91a5c7d6c38b963c5dada45d3847c101bb5ce410 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Wed, 25 Mar 2026 22:31:16 +0800 Subject: [PATCH 28/54] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java | 2 +- HMCL/src/main/resources/assets/lang/I18N.properties | 1 + HMCL/src/main/resources/assets/lang/I18N_zh.properties | 1 + HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties | 1 + HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java | 2 +- 5 files changed, 5 insertions(+), 2 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java index d2f8e40d88..8486bc7f77 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java @@ -122,6 +122,6 @@ public static void renameWorld(World world, Consumer notRenameFolderCons } }) .addQuestion(new PromptDialogPane.Builder.StringQuestion(null, world.getWorldName())) - .addQuestion(new PromptDialogPane.Builder.BooleanQuestion("重命名世界文件夹", false))); + .addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("world.rename.rename_folder"), false))); } } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 12fad755b8..3cadfe502e 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1233,6 +1233,7 @@ world.name.default=New World world.rename=Rename World world.rename.failed=Failed to rename the world world.rename.prompt=Please enter the new world name +world.rename.rename_folder=Rename world folder world.restore=Restore Backup world.restore.confirm=Are you sure you want to restore this backup?\nCurrent save progress will be overwritten. This action cannot be undone! world.restore.failed=Failed to restore backup.\n%s diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 6d63c2c409..16446b0031 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -1027,6 +1027,7 @@ world.name.default=新的世界 world.rename=重新命名世界 world.rename.failed=重新命名世界失敗 world.rename.prompt=請輸入新世界名稱 +world.rename.rename_folder=重命名世界資料夾 world.restore=還原備份 world.restore.confirm=確定要還原該備份嗎?\n目前存檔進度將被覆蓋,此操作無法復原! world.restore.failed=還原備份失敗\n%s diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 9bbc095d03..7be936ea7c 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -1032,6 +1032,7 @@ world.name.default=新的世界 world.rename=重命名此世界 world.rename.failed=重命名世界失败 world.rename.prompt=请输入新世界名称 +world.rename.rename_folder=重命名世界文件夹 world.restore=还原存档 world.restore.confirm=确定要还原该备份吗?\n当前存档进度将被覆盖,此操作无法撤销! world.restore.failed=还原存档失败\n%s diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 4350c706a9..161772af04 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -273,7 +273,7 @@ public void reloadWorldData() throws IOException { // A new World object needs to be created to obtain the renamed world. public Path rename(String newName) throws IOException { if (getWorldLock().getLockState() == WorldLock.LockState.LOCKED_BY_OTHER) { - throw new IOException("The world " + getFile() + " has been locked"); + throw new WorldLockedException("The world " + getFile() + " has been locked"); } // Change the name recorded in level.dat From e441fef4df02973fa5cb4f3140c4311b279b331d Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Wed, 25 Mar 2026 22:54:03 +0800 Subject: [PATCH 29/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/org/jackhuang/hmcl/game/World.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 161772af04..79baf7b611 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -44,12 +44,12 @@ public final class World { private final Path file; - private String fileName; + private final String fileName; private Image icon; private CompoundTag levelData; private CompoundTag dataTag; - private Path levelDataPath; + private final Path levelDataPath; private CompoundTag worldGenSettingsDataBackingTag; // Use for writing back to the file private CompoundTag normalizedWorldGenSettingsData; // Use for reading/modification @@ -295,15 +295,14 @@ public Path rename(String newName) throws IOException { throw new IOException("Too many attempts"); } - public void export(Path zip, String worldName) throws IOException { + public void export(Path zipPath, String worldName) throws IOException { if (getWorldLock().getLockState() == WorldLock.LockState.LOCKED_BY_OTHER) { throw new WorldLockedException("The world " + getFile() + " has been locked"); } - try (WorldLock.Suspension ignored = getWorldLock().suspend()) { - try (Zipper zipper = new Zipper(zip)) { - zipper.putDirectory(file, worldName); - } + try (WorldLock.Suspension ignored = getWorldLock().suspend(); + Zipper zipper = new Zipper(zipPath)) { + zipper.putDirectory(file, worldName); } } From 50d7b1e2ee4b87e9d83cf24f71f012d7906aa055 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Thu, 26 Mar 2026 10:58:28 +0800 Subject: [PATCH 30/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96WorldLock?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/game/World.java | 50 ++++++++----------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 79baf7b611..6137272f65 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -380,7 +380,7 @@ public static List getWorlds(Path savesDir) { return List.of(); } - public class WorldLock implements AutoCloseable { + public class WorldLock { private FileChannel sessionLockChannel; private final Path sessionLockFile; @@ -396,7 +396,7 @@ public WorldLock() { } public synchronized LockState getLockState() { - if (sessionLockChannel != null && sessionLockChannel.isOpen()) { + if (isLockedInternally()) { return LockState.LOCKED_BY_SELF; } else if (isLockedExternally()) { return LockState.LOCKED_BY_OTHER; @@ -406,25 +406,20 @@ public synchronized LockState getLockState() { } public synchronized boolean lock() { - LockState lockState = getLockState(); - return switch (lockState) { - case LOCKED_BY_OTHER -> false; - case LOCKED_BY_SELF -> true; - case UNLOCKED -> { - try { - acquireLock(); - yield true; - } catch (WorldLockedException e) { - LOG.warning("Failed to acquire world lock for " + file, e); - yield false; - } - } - }; + try { + lockStrict(); + return true; + } catch (WorldLockedException e) { + return false; + } } public void lockStrict() throws WorldLockedException { - if (!lock()) { - throw new WorldLockedException("Failed to lock world " + World.this.getFile()); + switch (getLockState()) { + case LOCKED_BY_SELF -> { + } + case LOCKED_BY_OTHER -> throw new WorldLockedException("World is locked by others"); + case UNLOCKED -> acquireLock(); } } @@ -448,6 +443,10 @@ public void acquireLock() throws WorldLockedException { } } + private boolean isLockedInternally() { + return sessionLockChannel != null && sessionLockChannel.isOpen(); + } + private boolean isLockedExternally() { try (FileChannel fileChannel = FileChannel.open(sessionLockFile, StandardOpenOption.WRITE)) { return fileChannel.tryLock() == null; @@ -456,7 +455,7 @@ private boolean isLockedExternally() { } catch (OverlappingFileLockException | NoSuchFileException overlappingFileLockException) { return false; } catch (IOException e) { - LOG.warning("Failed to open the lock file " + sessionLockFile, e); + LOG.warning("Unexpected I/O error checking world lock: " + sessionLockFile, e); return false; } } @@ -468,11 +467,6 @@ public synchronized void releaseLock() throws IOException { } } - @Override - public void close() throws IOException { - releaseLock(); - } - public Guard guard() throws WorldLockedException { return new Guard(); } @@ -486,7 +480,7 @@ public final class Guard implements AutoCloseable { private Guard() throws WorldLockedException { synchronized (WorldLock.this) { - this.wasAlreadyLocked = (getLockState() == LockState.LOCKED_BY_SELF); + this.wasAlreadyLocked = isLockedInternally(); if (!wasAlreadyLocked) { lockStrict(); } @@ -512,7 +506,7 @@ public final class Suspension implements AutoCloseable { private Suspension() throws IOException { synchronized (WorldLock.this) { - this.hadLock = (getLockState() == LockState.LOCKED_BY_SELF); + this.hadLock = isLockedInternally(); if (hadLock) { releaseLock(); } @@ -524,8 +518,8 @@ public void close() { synchronized (WorldLock.this) { if (hadLock) { try { - lock(); - } catch (Exception e) { + lockStrict(); + } catch (WorldLockedException e) { LOG.warning("Failed to resume lock after suspension", e); } } From f7ee94129a41f27146239e1625e8951323178753 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Thu, 26 Mar 2026 12:11:04 +0800 Subject: [PATCH 31/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=87=8D?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E4=B8=96=E7=95=8C=E5=BC=B9=E7=AA=97=EF=BC=8C?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E5=BC=82=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/construct/PromptDialogPane.java | 23 ++++++------ .../hmcl/ui/versions/WorldManageUIUtils.java | 35 ++++++++++--------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java index 25c700e668..09f94304e3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java @@ -59,12 +59,11 @@ public PromptDialogPane(Builder builder) { List bindings = new ArrayList<>(); int rowIndex = 0; for (Builder.Question question : builder.questions) { - if (question instanceof Builder.StringQuestion) { - Builder.StringQuestion stringQuestion = (Builder.StringQuestion) question; + if (question instanceof Builder.StringQuestion stringQuestion) { JFXTextField textField = new JFXTextField(); textField.textProperty().addListener((a, b, newValue) -> stringQuestion.value = textField.getText()); textField.setText(stringQuestion.value); - textField.setValidators(((Builder.StringQuestion) question).validators.toArray(new ValidatorBase[0])); + textField.setValidators(stringQuestion.validators.toArray(new ValidatorBase[0])); if (stringQuestion.promptText != null) { textField.setPromptText(stringQuestion.promptText); } @@ -73,35 +72,35 @@ public PromptDialogPane(Builder builder) { if (StringUtils.isNotBlank(question.question.get())) { body.addRow(rowIndex++, new Label(question.question.get()), textField); } else { - GridPane.setColumnSpan(textField, 1); + GridPane.setColumnSpan(textField, 2); body.addRow(rowIndex++, textField); } GridPane.setMargin(textField, new Insets(0, 0, 20, 0)); - } else if (question instanceof Builder.BooleanQuestion) { + } else if (question instanceof Builder.BooleanQuestion booleanQuestion) { HBox hBox = new HBox(); - GridPane.setColumnSpan(hBox, 1); + GridPane.setColumnSpan(hBox, 2); JFXCheckBox checkBox = new JFXCheckBox(); hBox.getChildren().setAll(checkBox); HBox.setMargin(checkBox, new Insets(0, 0, 0, -10)); - checkBox.setSelected(((Builder.BooleanQuestion) question).value); + checkBox.setSelected(booleanQuestion.value); checkBox.selectedProperty().addListener((a, b, newValue) -> ((Builder.BooleanQuestion) question).value = newValue); checkBox.setText(question.question.get()); body.addRow(rowIndex++, hBox); - } else if (question instanceof Builder.CandidatesQuestion) { + } else if (question instanceof Builder.CandidatesQuestion candidatesQuestion) { JFXComboBox comboBox = new JFXComboBox<>(); - comboBox.getItems().setAll(((Builder.CandidatesQuestion) question).candidates); + comboBox.getItems().setAll(candidatesQuestion.candidates); comboBox.getSelectionModel().selectedIndexProperty().addListener((a, b, newValue) -> - ((Builder.CandidatesQuestion) question).value = newValue.intValue()); + candidatesQuestion.value = newValue.intValue()); comboBox.getSelectionModel().select(0); if (StringUtils.isNotBlank(question.question.get())) { body.addRow(rowIndex++, new Label(question.question.get()), comboBox); } else { - GridPane.setColumnSpan(comboBox, 1); + GridPane.setColumnSpan(comboBox, 2); body.addRow(rowIndex++, comboBox); } } else if (question instanceof Builder.HintQuestion) { HintPane pane = new HintPane(); - GridPane.setColumnSpan(pane, 1); + GridPane.setColumnSpan(pane, 2); pane.textProperty().bind(question.question); body.addRow(rowIndex++, pane); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java index 8486bc7f77..43b9a113f9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java @@ -30,7 +30,6 @@ import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.FileUtils; -import java.io.IOException; import java.nio.file.Path; import java.util.function.Consumer; @@ -94,32 +93,34 @@ public static void renameWorld(World world, Runnable runnable) { public static void renameWorld(World world, Consumer notRenameFolderConsumer, Consumer renameFolderConsumer) { Controllers.prompt(new PromptDialogPane.Builder(i18n("world.rename.prompt"), (res, handler) -> { String newWorldName = ((PromptDialogPane.Builder.StringQuestion) res.get(0)).getValue(); + String finalNewWorldName = StringUtils.isBlank(newWorldName) ? i18n("world.name.default") : newWorldName; boolean renameFolder = ((PromptDialogPane.Builder.BooleanQuestion) res.get(1)).getValue(); - if (StringUtils.isBlank(newWorldName)) { - newWorldName = i18n("world.name.default"); - } - if (newWorldName.equals(world.getWorldName())) { + if (finalNewWorldName.equals(world.getWorldName())) { handler.resolve(); return; } - try { + Task.supplyAsync(Schedulers.io(), () -> { if (renameFolder) { - if (renameFolderConsumer != null) { - renameFolderConsumer.accept(world.rename(newWorldName)); - } + return world.rename(finalNewWorldName); + } else { + world.setWorldName(finalNewWorldName); + return null; + } + }).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception != null) { + LOG.warning("Failed to set world name", exception); + handler.reject(i18n("world.rename.failed")); } else { - world.setWorldName(newWorldName); - if (notRenameFolderConsumer != null) { - notRenameFolderConsumer.accept(newWorldName); + if (renameFolder && renameFolderConsumer != null) { + renameFolderConsumer.accept(result); + } else if (!renameFolder && notRenameFolderConsumer != null) { + notRenameFolderConsumer.accept(finalNewWorldName); } + handler.resolve(); } - handler.resolve(); - } catch (IOException e) { - LOG.warning("Failed to set world name", e); - handler.reject(i18n("world.rename.failed")); - } + }).start(); }) .addQuestion(new PromptDialogPane.Builder.StringQuestion(null, world.getWorldName())) .addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("world.rename.rename_folder"), false))); From 91cc515b3ed3583d599b3e94258cdfa50bced2c4 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Thu, 26 Mar 2026 15:02:07 +0800 Subject: [PATCH 32/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=A4=87?= =?UTF-8?q?=E4=BB=BD=E8=BF=98=E5=8E=9F=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/WorldRestoreTask.java | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java index 3bc3c5885e..d0ccc1fd63 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java @@ -53,23 +53,20 @@ public void execute() throws Exception { try { world.getWorldLock().releaseLock(); + Files.move(worldPath, tempPath2, StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { FileUtils.deleteDirectoryQuietly(tempPath); + FileUtils.deleteDirectoryQuietly(tempPath2); world.getWorldLock().acquireLock(); throw e; } try { - Files.move(worldPath, tempPath2, StandardCopyOption.ATOMIC_MOVE); + Files.move(tempPath, worldPath, StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { + Files.move(tempPath2, worldPath, StandardCopyOption.REPLACE_EXISTING); FileUtils.deleteDirectoryQuietly(tempPath); - throw e; - } - - try { - Files.move(tempPath, worldPath, StandardCopyOption.ATOMIC_MOVE); - } catch (IOException e) { - Files.move(tempPath2, worldPath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + world.getWorldLock().acquireLock(); throw e; } From 06e252566d498d9b8a8e8956c5f508b54fcdf03b Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Thu, 26 Mar 2026 15:57:22 +0800 Subject: [PATCH 33/54] =?UTF-8?q?feat:=20=E7=8E=B0=E5=9C=A8=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=AE=89=E8=A3=85=E6=96=87=E4=BB=B6=E5=A4=B9=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E7=9A=84=E4=B8=96=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ArchiveWorld.java => ImportableWorld.java} | 77 ++++++++++++------- .../hmcl/ui/versions/WorldBackupsPage.java | 12 +-- .../hmcl/ui/versions/WorldListPage.java | 17 ++-- .../hmcl/ui/versions/WorldRestoreTask.java | 2 +- .../resources/assets/lang/I18N.properties | 1 - .../resources/assets/lang/I18N_ar.properties | 1 - .../resources/assets/lang/I18N_es.properties | 1 - .../resources/assets/lang/I18N_ja.properties | 1 - .../resources/assets/lang/I18N_ru.properties | 1 - .../resources/assets/lang/I18N_uk.properties | 1 - .../resources/assets/lang/I18N_zh.properties | 1 - .../assets/lang/I18N_zh_CN.properties | 1 - 12 files changed, 62 insertions(+), 54 deletions(-) rename HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/{ArchiveWorld.java => ImportableWorld.java} (66%) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ArchiveWorld.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java similarity index 66% rename from HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ArchiveWorld.java rename to HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java index 61e2046b23..c94c3a0fd7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ArchiveWorld.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java @@ -38,24 +38,26 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; /// @author mineDiamond -public final class ArchiveWorld { - private final Path file; +public final class ImportableWorld { + private final Path sourcePath; private final String fileName; - private final boolean hasSubDir; + private final boolean isArchive; + private final boolean hasTopLevelDirectory; private String worldName; private @Nullable GameVersionNumber gameVersion; private @Nullable Image icon; - public ArchiveWorld(Path file) throws IOException { - if (Files.isRegularFile(file)) { - this.file = file; + public ImportableWorld(Path sourcePath) throws IOException { + if (Files.isRegularFile(sourcePath)) { + this.sourcePath = sourcePath; + this.isArchive = true; - try (FileSystem fs = CompressingUtils.readonly(this.file).setAutoDetectEncoding(true).build()) { + try (FileSystem fs = CompressingUtils.readonly(this.sourcePath).setAutoDetectEncoding(true).build()) { Path root; if (Files.isRegularFile(fs.getPath("/level.dat"))) { root = fs.getPath("/"); - hasSubDir = false; - fileName = FileUtils.getName(this.file); + hasTopLevelDirectory = false; + fileName = FileUtils.getName(this.sourcePath); } else { List files = Files.list(fs.getPath("/")).toList(); if (files.size() != 1 || !Files.isDirectory(files.get(0))) { @@ -63,7 +65,7 @@ public ArchiveWorld(Path file) throws IOException { } root = files.get(0); - hasSubDir = true; + hasTopLevelDirectory = true; fileName = FileUtils.getName(root); } @@ -87,8 +89,22 @@ public ArchiveWorld(Path file) throws IOException { } } } + } else if (Files.isDirectory(sourcePath)) { + this.sourcePath = sourcePath; + fileName = FileUtils.getName(this.sourcePath); + this.isArchive = false; + this.hasTopLevelDirectory = false; + + Path levelDatPath = this.sourcePath.resolve("level.dat"); + if (!Files.exists(levelDatPath)) { // version 20w14infinite + levelDatPath = this.sourcePath.resolve("special_level.dat"); + } + if (!Files.exists(levelDatPath)) { + throw new IOException("Not a valid world directory since level.dat or special_level.dat cannot be found."); + } + checkAndLoadLevelData(levelDatPath); } else { - throw new IOException("Path " + file + " cannot be recognized as a archive Minecraft world"); + throw new IOException("Path " + sourcePath + " cannot be recognized as a archive Minecraft world"); } } @@ -112,8 +128,8 @@ private void checkAndLoadLevelData(Path levelDatPath) throws IOException { throw new IOException("level.dat missing LastPlayed"); } - public Path getFile() { - return file; + public Path getSourcePath() { + return sourcePath; } public String getFileName() { @@ -121,7 +137,7 @@ public String getFileName() { } public boolean hasSubDir() { - return hasSubDir; + return hasTopLevelDirectory; } public String getWorldName() { @@ -137,22 +153,25 @@ public String getWorldName() { } public void install(Path savesDir, String name) throws IOException { - Path worldDir; - try { - worldDir = savesDir.resolve(name); - } catch (InvalidPathException e) { - throw new IOException(e); - } - - if (Files.isDirectory(worldDir)) { - throw new FileAlreadyExistsException("World already exists"); - } + String safeName = FileUtils.getSafeWorldFolderName(name); - if (hasSubDir) { - new Unzipper(file, worldDir).setSubDirectory("/" + fileName + "/").unzip(); - } else { - new Unzipper(file, worldDir).unzip(); + Path worldDir; + for (int count = 0; count < 256; count++) { + worldDir = savesDir.resolve(count == 0 ? safeName : safeName + " (" + count + ")"); + if (!Files.exists(worldDir)) { + if (isArchive) { + if (hasTopLevelDirectory) { + new Unzipper(sourcePath, worldDir).setSubDirectory("/" + fileName + "/").unzip(); + } else { + new Unzipper(sourcePath, worldDir).unzip(); + } + } else { + FileUtils.copyDirectory(sourcePath, worldDir, path -> !path.contains("session.lock")); + } + new World(worldDir).setWorldName(name); + return; + } } - new World(worldDir).rename(name); + throw new IOException("Too many attempts"); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java index 2d3990884e..09ca20f2c3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java @@ -100,7 +100,7 @@ public void refresh() { count = Integer.parseInt(matcher.group("count")); } - result.add(new BackupInfo(path, new ArchiveWorld(path), time, count)); + result.add(new BackupInfo(path, new ImportableWorld(path), time, count)); } } catch (Throwable e) { LOG.warning("Failed to load backup file " + path, e); @@ -147,7 +147,7 @@ void createBackup() { count = Integer.parseInt(matcher.group("count")); } - return Pair.pair(path, new BackupInfo(path, new ArchiveWorld(path), time, count)); + return Pair.pair(path, new BackupInfo(path, new ImportableWorld(path), time, count)); }).whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { WorldBackupsPage.this.getItems().add(result.getValue()); @@ -182,18 +182,18 @@ protected List initializeToolbar(WorldBackupsPage skinnable) { public final class BackupInfo extends Control implements Comparable { private final Path file; - private final ArchiveWorld backupWorld; + private final ImportableWorld backupWorld; private final LocalDateTime backupTime; private final int count; - public BackupInfo(Path file, ArchiveWorld backupWorld, LocalDateTime backupTime, int count) { + public BackupInfo(Path file, ImportableWorld backupWorld, LocalDateTime backupTime, int count) { this.file = file; this.backupWorld = backupWorld; this.backupTime = backupTime; this.count = count; } - public ArchiveWorld getBackupWorld() { + public ImportableWorld getBackupWorld() { return backupWorld; } @@ -244,7 +244,7 @@ private static final class BackupInfoSkin extends SkinBase { BackupInfoSkin(BackupInfo skinnable) { super(skinnable); - ArchiveWorld backupWorld = skinnable.getBackupWorld(); + ImportableWorld backupWorld = skinnable.getBackupWorld(); BorderPane root = new BorderPane(); root.getStyleClass().add("md-list-cell"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java index c6cca42fbb..c660e41b49 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java @@ -46,8 +46,7 @@ import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; -import java.io.IOException; -import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.time.Instant; @@ -73,8 +72,8 @@ public final class WorldListPage extends ListPageBase implements VersionP private int refreshCount = 0; public WorldListPage() { - FXUtils.applyDragListener(this, it -> "zip".equals(FileUtils.getExtension(it)), modpacks -> { - installWorld(modpacks.get(0)); + FXUtils.applyDragListener(this, it -> "zip".equals(FileUtils.getExtension(it)) || Files.isDirectory(it), worlds -> { + installWorld(worlds.get(0)); }); showAll.addListener(e -> updateWorldList()); @@ -151,10 +150,10 @@ public void download() { Controllers.navigate(Controllers.getDownloadPage()); } - private void installWorld(Path zipFile) { + private void installWorld(Path worldPath) { // Only accept one world file because user is required to confirm the new world name // Or too many input dialogs are popped. - Task.supplyAsync(() -> new ArchiveWorld(zipFile)) + Task.supplyAsync(() -> new ImportableWorld(worldPath)) .whenComplete(Schedulers.javafx(), world -> { Controllers.prompt(i18n("world.name.enter"), (name, handler) -> { Task.runAsync(() -> world.install(savesDir, name)) @@ -162,16 +161,14 @@ private void installWorld(Path zipFile) { handler.resolve(); refresh(); }, e -> { - if (e instanceof FileAlreadyExistsException) - handler.reject(i18n("world.add.failed", i18n("world.add.already_exists"))); - else if (e instanceof IOException && e.getCause() instanceof InvalidPathException) + if (e instanceof InvalidPathException) handler.reject(i18n("world.add.failed", i18n("install.new_game.malformed"))); else handler.reject(i18n("world.add.failed", e.getClass().getName() + ": " + e.getLocalizedMessage())); }).start(); }, world.getWorldName(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid)); }, e -> { - LOG.warning("Unable to parse world file " + zipFile, e); + LOG.warning("Unable to parse world file " + worldPath, e); Controllers.dialog(i18n("world.add.invalid")); }).start(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java index d0ccc1fd63..b2afb21815 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java @@ -43,7 +43,7 @@ public void execute() throws Exception { Path tempPath2 = worldPath.toAbsolutePath().resolveSibling("." + worldPath.getFileName().toString() + ".tmp2"); // Check if the world format is correct - new ArchiveWorld(backupZipPath); + new ImportableWorld(backupZipPath); try { new Unzipper(backupZipPath, tempPath).setSubDirectory(world.getFileName()).unzip(); } catch (IOException e) { diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 3cadfe502e..857f311980 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1146,7 +1146,6 @@ web.view_in_browser=View all in browser world=Worlds world.add=Add -world.add.already_exists=This world already exists. world.add.failed=Failed to add this world: %s world.add.invalid=Failed to parse the world. world.add.title=Choose world archive you want to add diff --git a/HMCL/src/main/resources/assets/lang/I18N_ar.properties b/HMCL/src/main/resources/assets/lang/I18N_ar.properties index e82d6d880c..110a11267a 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ar.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ar.properties @@ -1139,7 +1139,6 @@ web.view_in_browser=عرض الكل في المتصفح world=العوالم world.add=إضافة -world.add.already_exists=هذا العالم موجود مسبقاً. world.add.failed=فشل إضافة هذا العالم: %s world.add.invalid=فشل تحليل العالم. world.add.title=اختر أرشيف العالم الذي تريد إضافته diff --git a/HMCL/src/main/resources/assets/lang/I18N_es.properties b/HMCL/src/main/resources/assets/lang/I18N_es.properties index 2d47c4b7c6..ff09e7a825 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_es.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_es.properties @@ -1080,7 +1080,6 @@ web.open_in_browser=Desea abrir esta dirección en un navegador:\n%s web.view_in_browser=Ver en navegador world=Mundos -world.add.already_exists=Este mundo ya existe. world.add.title=Elija el archivo de mundo que desea importar world.add.failed=No se ha podido importar este mundo: %s world.add.invalid=No se ha podido analizar el mundo. diff --git a/HMCL/src/main/resources/assets/lang/I18N_ja.properties b/HMCL/src/main/resources/assets/lang/I18N_ja.properties index 9bf676a382..779238383a 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ja.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ja.properties @@ -687,7 +687,6 @@ datapack=Datapacks datapack.title=World %s -データパック world=マップ -world.add.already_exists=このマップはすでに存在しています。 world.add.title=インポートするzipファイルを選択してください world.add.failed=このマップをインポートできません:%s world.add.invalid=無効なワールドzipファイル diff --git a/HMCL/src/main/resources/assets/lang/I18N_ru.properties b/HMCL/src/main/resources/assets/lang/I18N_ru.properties index 41b5944cfc..c6ec5f2d57 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ru.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ru.properties @@ -1073,7 +1073,6 @@ web.open_in_browser=Хотите ли вы открыть этот адрес в web.view_in_browser=Смотреть в браузере world=Миры -world.add.already_exists=Мир уже существует. world.add.title=Выберите архив мира, который хотите импортировать world.add.failed=Не удалось импортировать этот мир\: %s world.add.invalid=Не удалось разобрать мир. diff --git a/HMCL/src/main/resources/assets/lang/I18N_uk.properties b/HMCL/src/main/resources/assets/lang/I18N_uk.properties index 15626f00e7..9a3b2ade64 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_uk.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_uk.properties @@ -1020,7 +1020,6 @@ web.open_in_browser=Бажаєте відкрити цю адресу в бра web.view_in_browser=Переглянути все в браузері world=Світи -world.add.already_exists=Цей світ вже існує. world.add.title=Виберіть архів світу, який ви хочете імпортувати world.add.failed=Не вдалося імпортувати цей світ: %s world.add.invalid=Не вдалося розібрати світ. diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 16446b0031..53f761a1a7 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -940,7 +940,6 @@ web.view_in_browser=在瀏覽器中查看完整日誌 world=世界 world.add=新增世界 -world.add.already_exists=此世界已經存在 world.add.failed=無法新增此世界: %s world.add.invalid=無法識別的世界壓縮檔 world.add.title=選取要新增的世界壓縮檔 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 7be936ea7c..8b4e2d6008 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -945,7 +945,6 @@ web.view_in_browser=在浏览器中查看完整日志 world=世界 world.add=添加世界 -world.add.already_exists=此世界已经存在 world.add.failed=无法添加此世界:%s world.add.invalid=无法识别该世界压缩包 world.add.title=选择要添加的世界压缩包 From 32227a5413c635c04f360fd0a7f73bbe021d6cfb Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Thu, 26 Mar 2026 16:16:09 +0800 Subject: [PATCH 34/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/ui/versions/WorldListPage.java | 6 ++++-- .../src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java index c660e41b49..5079a9e456 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java @@ -42,6 +42,7 @@ import org.jackhuang.hmcl.ui.*; import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.util.ChunkBaseApp; +import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; @@ -156,7 +157,8 @@ private void installWorld(Path worldPath) { Task.supplyAsync(() -> new ImportableWorld(worldPath)) .whenComplete(Schedulers.javafx(), world -> { Controllers.prompt(i18n("world.name.enter"), (name, handler) -> { - Task.runAsync(() -> world.install(savesDir, name)) + String finalName = StringUtils.isBlank(name) ? i18n("world.name.default") : name; + Task.runAsync(() -> world.install(savesDir, finalName)) .whenComplete(Schedulers.javafx(), () -> { handler.resolve(); refresh(); @@ -166,7 +168,7 @@ private void installWorld(Path worldPath) { else handler.reject(i18n("world.add.failed", e.getClass().getName() + ": " + e.getLocalizedMessage())); }).start(); - }, world.getWorldName(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid)); + }, world.getWorldName()); }, e -> { LOG.warning("Unable to parse world file " + worldPath, e); Controllers.dialog(i18n("world.add.invalid")); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java index 80e0b08dac..17086d9b41 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java @@ -211,7 +211,7 @@ public static boolean isNameValid(OperatingSystem os, String name) { public static String getSafeWorldFolderName(String name) { if (StringUtils.isBlank(name)) { - return "New Name"; + return "New World"; } // Replace invalid characters with underscores @@ -228,7 +228,7 @@ public static String getSafeWorldFolderName(String name) { // Provide a default value if the sanitized string is empty if (sanitized.isEmpty()) { - return "New Name"; + return "New World"; } return sanitized; From a95c861a6e7e7531df4416d20041b65c297ed6be Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Thu, 26 Mar 2026 20:11:28 +0800 Subject: [PATCH 35/54] =?UTF-8?q?feat:=20=E6=8B=86=E5=88=86=E6=96=B9?= =?UTF-8?q?=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/ImportableWorld.java | 34 ++++++++----------- .../java/org/jackhuang/hmcl/game/World.java | 29 ++++------------ .../org/jackhuang/hmcl/util/io/FileUtils.java | 31 +++++++++++++++++ 3 files changed, 52 insertions(+), 42 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java index c94c3a0fd7..1c9cf0f5ab 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java @@ -32,7 +32,9 @@ import java.io.IOException; import java.io.InputStream; -import java.nio.file.*; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -136,7 +138,7 @@ public String getFileName() { return fileName; } - public boolean hasSubDir() { + public boolean hasTopLevelDirectory() { return hasTopLevelDirectory; } @@ -153,25 +155,17 @@ public String getWorldName() { } public void install(Path savesDir, String name) throws IOException { - String safeName = FileUtils.getSafeWorldFolderName(name); - - Path worldDir; - for (int count = 0; count < 256; count++) { - worldDir = savesDir.resolve(count == 0 ? safeName : safeName + " (" + count + ")"); - if (!Files.exists(worldDir)) { - if (isArchive) { - if (hasTopLevelDirectory) { - new Unzipper(sourcePath, worldDir).setSubDirectory("/" + fileName + "/").unzip(); - } else { - new Unzipper(sourcePath, worldDir).unzip(); - } - } else { - FileUtils.copyDirectory(sourcePath, worldDir, path -> !path.contains("session.lock")); - } - new World(worldDir).setWorldName(name); - return; + Path targetPath = FileUtils.getNonConflictingDirectory(savesDir, FileUtils.getSafeWorldFolderName(name)); + + if (isArchive) { + if (hasTopLevelDirectory) { + new Unzipper(sourcePath, targetPath).setSubDirectory("/" + fileName + "/").unzip(); + } else { + new Unzipper(sourcePath, targetPath).unzip(); } + } else { + FileUtils.copyDirectory(sourcePath, targetPath, path -> !path.contains("session.lock")); } - throw new IOException("Too many attempts"); + new World(targetPath).setWorldName(name); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 6137272f65..1e93da26d9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -281,18 +281,11 @@ public Path rename(String newName) throws IOException { writeLevelData(); // Then change the folder's name - String safeName = FileUtils.getSafeWorldFolderName(newName); - Path newPath; - for (int count = 0; count < 256; count++) { - newPath = file.resolveSibling(count == 0 ? safeName : safeName + " (" + count + ")"); - if (!Files.exists(newPath)) { - try (WorldLock.Suspension ignored = getWorldLock().suspend()) { - Files.move(file, newPath); - return newPath; - } - } + Path targetPath = FileUtils.getNonConflictingDirectory(file.getParent(), FileUtils.getSafeWorldFolderName(newName)); + try (WorldLock.Suspension ignored = getWorldLock().suspend()) { + Files.move(file, targetPath); + return targetPath; } - throw new IOException("Too many attempts"); } public void export(Path zipPath, String worldName) throws IOException { @@ -319,17 +312,9 @@ public void copy(String newName) throws IOException { throw new WorldLockedException("The world " + getFile() + " has been locked"); } - String safeName = FileUtils.getSafeWorldFolderName(newName); - Path newPath; - for (int count = 0; count < 256; count++) { - newPath = file.resolveSibling(count == 0 ? safeName : safeName + " (" + count + ")"); - if (!Files.exists(newPath)) { - FileUtils.copyDirectory(file, newPath, path -> !path.contains("session.lock")); - new World(newPath).setWorldName(newName); - return; - } - } - throw new IOException("Too many attempts"); + Path targetPath = FileUtils.getNonConflictingDirectory(file.getParent(), FileUtils.getSafeWorldFolderName(newName)); + FileUtils.copyDirectory(file, targetPath, path -> !path.contains("session.lock")); + new World(targetPath).setWorldName(newName); } public void writeWorldData() throws IOException { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java index 17086d9b41..1ca3539943 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java @@ -209,6 +209,37 @@ public static boolean isNameValid(OperatingSystem os, String name) { return true; } + public static Path getNonConflictingDirectory(@NotNull Path parent, @NotNull String name) throws IOException { + for (int count = 0; count < 256; count++) { + String suffix = (count == 0) ? "" : " (" + count + ")"; + Path targetPath = parent.resolve(name + suffix); + + if (!Files.exists(targetPath)) { + return targetPath; + } + } + throw new IOException("Too many directory name collisions in " + parent); + } + + public static Path getNonConflictingFilePath(@NotNull Path path, @NotNull String name) throws IOException { + String baseName = getNameWithoutExtension(name); + String extension = getExtension(name); + String suffix = extension.isEmpty() ? "" : "." + extension; + + for (int count = 0; count < 256; count++) { + String fileName = (count == 0) + ? name + : String.format("%s (%d)%s", baseName, count, suffix); + + Path targetPath = path.resolve(fileName); + + if (!Files.exists(targetPath)) { + return targetPath; + } + } + throw new IOException("Too many file name collisions in " + path); + } + public static String getSafeWorldFolderName(String name) { if (StringUtils.isBlank(name)) { return "New World"; From f947be560608ea44855c5e6c5bf27b267a766d95 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Thu, 26 Mar 2026 21:09:40 +0800 Subject: [PATCH 36/54] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0WorldDataSectio?= =?UTF-8?q?n=E6=9D=A5=E5=AD=98=E5=82=A8=E4=B8=96=E7=95=8Cnbt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/game/World.java | 141 +++++++----------- 1 file changed, 57 insertions(+), 84 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 1e93da26d9..fe8b76752c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -47,16 +47,9 @@ public final class World { private final String fileName; private Image icon; - private CompoundTag levelData; - private CompoundTag dataTag; - private final Path levelDataPath; - - private CompoundTag worldGenSettingsDataBackingTag; // Use for writing back to the file - private CompoundTag normalizedWorldGenSettingsData; // Use for reading/modification - private Path worldGenSettingsDataPath; - - private CompoundTag playerData; // Use for both reading/modification and writing back to the file - private Path playerDataPath; + private WorldDataSection levelDataTag; + private WorldDataSection worldGenSettingsTag; + private WorldDataSection playerTag; private final WorldLock lock; @@ -73,8 +66,7 @@ public World(Path file) throws IOException { if (!Files.exists(levelDatPath)) { throw new IOException("Not a valid world directory since level.dat or special_level.dat cannot be found."); } - this.levelDataPath = levelDatPath; - loadAndCheckWorldData(); + loadAndCheckWorldData(levelDatPath); Path iconFile = this.file.resolve("icon.png"); if (Files.isRegularFile(iconFile)) { @@ -104,38 +96,37 @@ public String getFileName() { } public String getWorldName() { - if (levelData.get("Data") instanceof CompoundTag data - && data.get("LevelName") instanceof StringTag levelNameTag) + if (getDataTag().get("LevelName") instanceof StringTag levelNameTag) return levelNameTag.get(); else return ""; } public void setWorldName(String worldName) throws IOException { - if (levelData.get("Data") instanceof CompoundTag data && data.get("LevelName") instanceof StringTag levelNameTag) { + if (getDataTag().get("LevelName") instanceof StringTag levelNameTag) { levelNameTag.setValue(worldName); - writeLevelData(); + levelDataTag.write(); } } public CompoundTag getLevelData() { - return levelData; + return levelDataTag.nbtBackingTag(); } public CompoundTag getDataTag() { - return dataTag; + return levelDataTag.normalizedNbtTag; } public @Nullable CompoundTag getNormalizedWorldGenSettingsData() { - return normalizedWorldGenSettingsData; + return worldGenSettingsTag.normalizedNbtTag; } public @Nullable CompoundTag getPlayerData() { - return playerData; + return playerTag.normalizedNbtTag; } public long getLastPlayed() { - if (dataTag.get("LastPlayed") instanceof LongTag lastPlayedTag) { + if (getDataTag().get("LastPlayed") instanceof LongTag lastPlayedTag) { return lastPlayedTag.get(); } else { return 0L; @@ -143,8 +134,7 @@ public long getLastPlayed() { } public @Nullable GameVersionNumber getGameVersion() { - if (levelData.get("Data") instanceof CompoundTag data && - data.get("Version") instanceof CompoundTag versionTag && + if (getDataTag().get("Version") instanceof CompoundTag versionTag && versionTag.get("Name") instanceof StringTag nameTag) { return GameVersionNumber.asGameVersion(nameTag.getValue()); } @@ -153,12 +143,12 @@ public long getLastPlayed() { public @Nullable Long getSeed() { // Valid after 1.16(20w20a) - if (normalizedWorldGenSettingsData != null - && normalizedWorldGenSettingsData.get("seed") instanceof LongTag seedTag) { + if (getNormalizedWorldGenSettingsData() != null + && getNormalizedWorldGenSettingsData().get("seed") instanceof LongTag seedTag) { return seedTag.getValue(); } // Valid before 1.16(20w20a) - if (dataTag.get("RandomSeed") instanceof LongTag seedTag) { + if (getDataTag().get("RandomSeed") instanceof LongTag seedTag) { return seedTag.getValue(); } return null; @@ -166,12 +156,12 @@ public long getLastPlayed() { public boolean isLargeBiomes() { // Valid before 1.16(20w20a) - if (dataTag.get("generatorName") instanceof StringTag generatorNameTag) { + if (getDataTag().get("generatorName") instanceof StringTag generatorNameTag) { return "largeBiomes".equals(generatorNameTag.getValue()); } // Unified handling of logic after version 1.16 - else if (normalizedWorldGenSettingsData != null - && normalizedWorldGenSettingsData.get("dimensions") instanceof CompoundTag dimensionsTag) { + else if (getNormalizedWorldGenSettingsData() != null + && getNormalizedWorldGenSettingsData().get("dimensions") instanceof CompoundTag dimensionsTag) { if (dimensionsTag.get("minecraft:overworld") instanceof CompoundTag overworldTag && overworldTag.get("generator") instanceof CompoundTag generatorTag) { // Valid between 1.16(20w20a) and 1.18(21w37a) @@ -204,13 +194,13 @@ public static boolean supportsQuickPlay(GameVersionNumber gameVersionNumber) { return gameVersionNumber != null && gameVersionNumber.isAtLeast("1.20", "23w14a"); } - private void loadAndCheckWorldData() throws IOException { + private void loadAndCheckWorldData(Path levelDataPath) throws IOException { loadAndCheckLevelData(levelDataPath); loadOtherData(); } - private void loadAndCheckLevelData(Path levelDat) throws IOException { - this.levelData = NBTCodec.of().readTag(levelDat, TagType.COMPOUND); + private void loadAndCheckLevelData(Path levelDatPath) throws IOException { + CompoundTag levelData = NBTCodec.of().readTag(levelDatPath, TagType.COMPOUND); if (!(levelData.get("Data") instanceof CompoundTag data)) throw new IOException("level.dat missing Data"); @@ -219,54 +209,43 @@ private void loadAndCheckLevelData(Path levelDat) throws IOException { if (!(data.get("LastPlayed") instanceof LongTag)) throw new IOException("level.dat missing LastPlayed"); - this.dataTag = data; + this.levelDataTag = new WorldDataSection(levelDatPath, levelData, data); } private void loadOtherData() throws IOException { - if (!(levelData.get("Data") instanceof CompoundTag data)) return; Path worldGenSettingsDatPath = file.resolve("data/minecraft/world_gen_settings.dat"); - if (data.get("WorldGenSettings") instanceof CompoundTag worldGenSettingsTag) { - setWorldGenSettingsData(null, worldGenSettingsTag, worldGenSettingsTag); + if (getDataTag().get("WorldGenSettings") instanceof CompoundTag worldGenSettingsTag) { + this.worldGenSettingsTag = new WorldDataSection(null, worldGenSettingsTag, worldGenSettingsTag); } else if (Files.isRegularFile(worldGenSettingsDatPath)) { CompoundTag raw = NBTCodec.of().readTag(worldGenSettingsDatPath, TagType.COMPOUND); if (raw.get("data") instanceof CompoundTag compoundTag) { - setWorldGenSettingsData(worldGenSettingsDatPath, raw, compoundTag); + this.worldGenSettingsTag = new WorldDataSection(worldGenSettingsDatPath, raw, compoundTag); } else { - setWorldGenSettingsData(null, null, null); + this.worldGenSettingsTag = new WorldDataSection(null, null, null); } } else { - setWorldGenSettingsData(null, null, null); + this.worldGenSettingsTag = new WorldDataSection(null, null, null); } - if (data.get("Player") instanceof CompoundTag playerTag) { - setPlayerData(null, playerTag); - } else if (data.get("singleplayer_uuid") instanceof IntArrayTag uuidTag && uuidTag.isUUID()) { + if (getDataTag().get("Player") instanceof CompoundTag playerTag) { + this.playerTag = new WorldDataSection(null, playerTag, playerTag); + } else if (getDataTag().get("singleplayer_uuid") instanceof IntArrayTag uuidTag && uuidTag.isUUID()) { String playerUUID = uuidTag.getUUID().toString(); Path playerDatPath = file.resolve("players/data/" + playerUUID + ".dat"); if (Files.exists(playerDatPath)) { - setPlayerData(playerDatPath, NBTCodec.of().readTag(playerDatPath, TagType.COMPOUND)); + CompoundTag playerTag = NBTCodec.of().readTag(playerDatPath, TagType.COMPOUND); + this.playerTag = new WorldDataSection(playerDatPath, playerTag, playerTag); } else { - setPlayerData(null, null); + this.playerTag = new WorldDataSection(null, null, null); } } else { - setPlayerData(null, null); + this.playerTag = new WorldDataSection(null, null, null); } } - private void setWorldGenSettingsData(Path worldGenSettingsDataPath, CompoundTag worldGenSettingsDataBackingTag, CompoundTag unifiedWorldGenSettingsData) { - this.worldGenSettingsDataPath = worldGenSettingsDataPath; - this.worldGenSettingsDataBackingTag = worldGenSettingsDataBackingTag; - this.normalizedWorldGenSettingsData = unifiedWorldGenSettingsData; - } - - private void setPlayerData(Path playerDataPath, CompoundTag playerData) { - this.playerDataPath = playerDataPath; - this.playerData = playerData; - } - public void reloadWorldData() throws IOException { - loadAndCheckWorldData(); + loadAndCheckWorldData(levelDataTag.nbtPath()); } // The renameWorld method do not modify the `file` field. @@ -277,8 +256,8 @@ public Path rename(String newName) throws IOException { } // Change the name recorded in level.dat - dataTag.setString("LevelName", newName); - writeLevelData(); + getDataTag().setString("LevelName", newName); + levelDataTag.write(); // Then change the folder's name Path targetPath = FileUtils.getNonConflictingDirectory(file.getParent(), FileUtils.getSafeWorldFolderName(newName)); @@ -318,34 +297,13 @@ public void copy(String newName) throws IOException { } public void writeWorldData() throws IOException { - if (!Files.isDirectory(file)) throw new IOException("Not a valid world directory"); - - writeLevelData(); - - if (worldGenSettingsDataPath != null && worldGenSettingsDataBackingTag != null) { - writeTag(worldGenSettingsDataBackingTag, worldGenSettingsDataPath); - } - - if (playerDataPath != null && playerData != null) { - writeTag(playerData, playerDataPath); - } - } - - public void writeLevelData() throws IOException { - writeTag(levelData, levelDataPath); - } - - private void writeTag(CompoundTag nbt, Path path) throws IOException { - if (!Files.isDirectory(file)) throw new IOException("Not a valid world directory"); - FileUtils.saveSafely(path, os -> { - try (OutputStream gos = new GZIPOutputStream(os)) { - NBTCodec.of().writeTag(gos, nbt); - } - }); + levelDataTag.write(); + worldGenSettingsTag.write(); + playerTag.write(); } public static List getWorlds(Path savesDir) { - if (Files.exists(savesDir)) { + if (Files.isDirectory(savesDir)) { try (Stream stream = Files.list(savesDir)) { return stream .filter(Files::isDirectory) @@ -512,4 +470,19 @@ public void close() { } } } + + record WorldDataSection(Path nbtPath, + CompoundTag nbtBackingTag, // Use for writing back to the file + CompoundTag normalizedNbtTag // Use for reading/modification + ) { + public void write() throws IOException { + if (nbtPath != null) { + FileUtils.saveSafely(nbtPath, os -> { + try (OutputStream gos = new GZIPOutputStream(os)) { + NBTCodec.of().writeTag(gos, nbtBackingTag); + } + }); + } + } + } } From 3a69c01bda20190a7ea298f6ef6e2db9dc602572 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Thu, 26 Mar 2026 22:00:50 +0800 Subject: [PATCH 37/54] =?UTF-8?q?feat:=20=E9=A2=84=E9=98=B2=E6=80=A7?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0main=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ackListPage.java => DataPackListPage.java} | 52 +++++++++---------- ...ageSkin.java => DataPackListPageSkin.java} | 32 ++++++------ .../hmcl/ui/versions/WorldManagePage.java | 10 ++-- .../resources/assets/lang/I18N.properties | 14 ++--- .../java/org/jackhuang/hmcl/game/World.java | 2 +- .../hmcl/mod/{Datapack.java => DataPack.java} | 48 ++++++++--------- 6 files changed, 79 insertions(+), 79 deletions(-) rename HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/{DatapackListPage.java => DataPackListPage.java} (74%) rename HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/{DatapackListPageSkin.java => DataPackListPageSkin.java} (94%) rename HMCLCore/src/main/java/org/jackhuang/hmcl/mod/{Datapack.java => DataPack.java} (89%) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPage.java similarity index 74% rename from HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java rename to HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPage.java index ccff285cd2..9de20c9399 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPage.java @@ -22,7 +22,7 @@ import javafx.scene.control.Skin; import javafx.stage.FileChooser; import org.jackhuang.hmcl.game.World; -import org.jackhuang.hmcl.mod.Datapack; +import org.jackhuang.hmcl.mod.DataPack; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.Controllers; @@ -44,37 +44,37 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public final class DatapackListPage extends ListPageBase implements WorldManagePage.WorldRefreshable { +public final class DataPackListPage extends ListPageBase implements WorldManagePage.WorldRefreshable { private final WorldManagePage worldManagePage; private World world; - private Datapack datapack; + private DataPack dataPack; - public DatapackListPage(WorldManagePage worldManagePage) { + public DataPackListPage(WorldManagePage worldManagePage) { this.worldManagePage = worldManagePage; FXUtils.applyDragListener(this, it -> Objects.equals("zip", FileUtils.getExtension(it)), - this::installMultiDatapack, this::refresh); + this::installMultiDataPack, this::refresh); refresh(); } - private void installMultiDatapack(List datapackPath) { - datapackPath.forEach(this::installSingleDatapack); + private void installMultiDataPack(List dataPackPath) { + dataPackPath.forEach(this::installSingleDataPack); if (readOnlyProperty().get()) { Controllers.showToast(i18n("datapack.reload.toast")); } } - private void installSingleDatapack(Path datapack) { + private void installSingleDataPack(Path dataPack) { try { - this.datapack.installPack(datapack, world.getGameVersion()); + this.dataPack.installPack(dataPack, world.getGameVersion()); } catch (IOException | IllegalArgumentException e) { - LOG.warning("Unable to parse datapack file " + datapack, e); + LOG.warning("Unable to parse datapack file " + dataPack, e); } } @Override protected Skin createDefaultSkin() { - return new DatapackListPageSkin(this); + return new DataPackListPageSkin(this); } @Override @@ -82,14 +82,14 @@ public void refresh() { setLoading(true); setFailedReason(null); world = worldManagePage.getWorld(); - if (!world.supportsDatapacks()) { + if (!world.supportsDataPacks()) { setFailedReason(i18n("datapack.not_support.info")); setLoading(false); return; } - datapack = new Datapack(world.getFile().resolve("datapacks")); - setItems(MappedObservableList.create(datapack.getPacks(), DatapackListPageSkin.DatapackInfoObject::new)); - Task.runAsync(datapack::loadFromDir) + dataPack = new DataPack(world.getFile().resolve("datapacks")); + setItems(MappedObservableList.create(dataPack.getPacks(), DataPackListPageSkin.DataPackInfoObject::new)); + Task.runAsync(dataPack::loadFromDir) .withRunAsync(Schedulers.javafx(), () -> setLoading(false)) .start(); } @@ -105,18 +105,18 @@ public void add() { List res = FileUtils.toPaths(chooser.showOpenMultipleDialog(Controllers.getStage())); if (res != null) { - installMultiDatapack(res); + installMultiDataPack(res); } - datapack.loadFromDir(); + dataPack.loadFromDir(); } - void removeSelected(ObservableList selectedItems) { + void removeSelected(ObservableList selectedItems) { selectedItems.stream() - .map(DatapackListPageSkin.DatapackInfoObject::getPackInfo) + .map(DataPackListPageSkin.DataPackInfoObject::getPackInfo) .forEach(pack -> { try { - datapack.deletePack(pack); + dataPack.deletePack(pack); } catch (IOException e) { // Fail to remove mods if the game is running or the datapack is absent. LOG.warning("Failed to delete datapack \"" + pack.getId() + "\"", e); @@ -124,23 +124,23 @@ void removeSelected(ObservableList sele }); } - void enableSelected(ObservableList selectedItems) { + void enableSelected(ObservableList selectedItems) { selectedItems.stream() - .map(DatapackListPageSkin.DatapackInfoObject::getPackInfo) + .map(DataPackListPageSkin.DataPackInfoObject::getPackInfo) .forEach(pack -> pack.setActive(true)); } - void disableSelected(ObservableList selectedItems) { + void disableSelected(ObservableList selectedItems) { selectedItems.stream() - .map(DatapackListPageSkin.DatapackInfoObject::getPackInfo) + .map(DataPackListPageSkin.DataPackInfoObject::getPackInfo) .forEach(pack -> pack.setActive(false)); } void openDataPackFolder() { - FXUtils.openFolder(datapack.getPath()); + FXUtils.openFolder(dataPack.getPath()); } - @NotNull Predicate updateSearchPredicate(String queryString) { + @NotNull Predicate updateSearchPredicate(String queryString) { if (queryString.isBlank()) { return dataPack -> true; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPageSkin.java similarity index 94% rename from HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java rename to HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPageSkin.java index 03b5d9d2df..c9af20969c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPageSkin.java @@ -42,7 +42,7 @@ import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; import javafx.util.Duration; -import org.jackhuang.hmcl.mod.Datapack; +import org.jackhuang.hmcl.mod.DataPack; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; @@ -67,7 +67,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -final class DatapackListPageSkin extends SkinBase { +final class DataPackListPageSkin extends SkinBase { private final TransitionPane toolbarPane; private final HBox searchBar; @@ -75,8 +75,8 @@ final class DatapackListPageSkin extends SkinBase { private final HBox selectingToolbar; InvalidationListener updateBarByStateWeakListener; - private final JFXListView listView; - private final FilteredList filteredList; + private final JFXListView listView; + private final FilteredList filteredList; private final BooleanProperty isSearching = new SimpleBooleanProperty(false); private final BooleanProperty isSelecting = new SimpleBooleanProperty(false); @@ -85,7 +85,7 @@ final class DatapackListPageSkin extends SkinBase { private static final AtomicInteger lastShiftClickIndex = new AtomicInteger(-1); final Consumer toggleSelect; - DatapackListPageSkin(DatapackListPage skinnable) { + DataPackListPageSkin(DataPackListPage skinnable) { super(skinnable); StackPane pane = new StackPane(); @@ -183,7 +183,7 @@ final class DatapackListPageSkin extends SkinBase { center.loadingProperty().bind(skinnable.loadingProperty()); center.failedReasonProperty().bind(skinnable.failedReasonProperty()); - listView.setCellFactory(x -> new DatapackInfoListCell(listView, getSkinnable().readOnlyProperty())); + listView.setCellFactory(x -> new DataPackInfoListCell(listView, getSkinnable().readOnlyProperty())); listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); this.listView.setItems(filteredList); @@ -218,13 +218,13 @@ private void changeToolbar(HBox newToolbar) { } } - static class DatapackInfoObject extends RecursiveTreeObject { + static class DataPackInfoObject extends RecursiveTreeObject { private final BooleanProperty activeProperty; - private final Datapack.Pack packInfo; + private final DataPack.Pack packInfo; private SoftReference> iconCache; - DatapackInfoObject(Datapack.Pack packInfo) { + DataPackInfoObject(DataPack.Pack packInfo) { this.packInfo = packInfo; this.activeProperty = packInfo.activeProperty(); } @@ -237,7 +237,7 @@ String getSubtitle() { return packInfo.getDescription().toString(); } - Datapack.Pack getPackInfo() { + DataPack.Pack getPackInfo() { return packInfo; } @@ -272,7 +272,7 @@ Image loadIcon() { } } - public void loadIcon(ImageContainer imageContainer, @Nullable WeakReference> current) { + public void loadIcon(ImageContainer imageContainer, @Nullable WeakReference> current) { SoftReference> iconCache = this.iconCache; CompletableFuture imageFuture; if (iconCache != null && (imageFuture = iconCache.get()) != null) { @@ -288,7 +288,7 @@ public void loadIcon(ImageContainer imageContainer, @Nullable WeakReference { if (current != null) { - ObjectProperty infoObjectProperty = current.get(); + ObjectProperty infoObjectProperty = current.get(); if (infoObjectProperty == null || infoObjectProperty.get() != this) { // The current ListCell has already switched to another object return; @@ -300,13 +300,13 @@ public void loadIcon(ImageContainer imageContainer, @Nullable WeakReference { + private final class DataPackInfoListCell extends MDListCell { final JFXCheckBox checkBox = new JFXCheckBox(); ImageContainer imageContainer = new ImageContainer(32); final TwoLineListItem content = new TwoLineListItem(); BooleanProperty booleanProperty; - DatapackInfoListCell(JFXListView listView, BooleanProperty isReadOnlyProperty) { + DataPackInfoListCell(JFXListView listView, BooleanProperty isReadOnlyProperty) { super(listView); HBox container = new HBox(8); @@ -328,7 +328,7 @@ private final class DatapackInfoListCell extends MDListCell } @Override - protected void updateControl(DatapackInfoObject dataItem, boolean empty) { + protected void updateControl(DataPackInfoObject dataItem, boolean empty) { if (empty) return; content.setTitle(dataItem.getTitle()); content.setSubtitle(dataItem.getSubtitle()); @@ -340,7 +340,7 @@ protected void updateControl(DatapackInfoObject dataItem, boolean empty) { } } - public void handleSelect(DatapackInfoListCell cell, MouseEvent mouseEvent) { + public void handleSelect(DataPackInfoListCell cell, MouseEvent mouseEvent) { if (cell.isEmpty()) { mouseEvent.consume(); return; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index 760190c6d1..9ca9d60fda 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -66,12 +66,12 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco private final TabHeader header = new TabHeader(transitionPane); private final TabHeader.Tab worldInfoTab = new TabHeader.Tab<>("worldInfoPage"); private final TabHeader.Tab worldBackupsTab = new TabHeader.Tab<>("worldBackupsPage"); - private final TabHeader.Tab datapackTab = new TabHeader.Tab<>("datapackListPage"); + private final TabHeader.Tab dataPackTab = new TabHeader.Tab<>("dataPackListPage"); public WorldManagePage() { worldInfoTab.setNodeSupplier(() -> new WorldInfoPage(this)); worldBackupsTab.setNodeSupplier(() -> new WorldBackupsPage(this)); - datapackTab.setNodeSupplier(() -> new DatapackListPage(this)); + dataPackTab.setNodeSupplier(() -> new DataPackListPage(this)); this.addEventHandler(Navigator.NavigationEvent.EXITED, this::onExited); this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); @@ -94,7 +94,7 @@ public WorldManagePage setWorld(World world, Profile profile, String instanceId) Optional gameVersion = profile.getRepository().getGameVersion(instanceId); currentWorldSupportQuickPlay.set(World.supportsQuickPlay(GameVersionNumber.asGameVersion(gameVersion))); - currentWorldSupportDataPack.set(world.supportsDatapacks()); + currentWorldSupportDataPack.set(world.supportsDataPacks()); return this; } @@ -240,8 +240,8 @@ private AdvancedListBox getTabBar() { tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldInfoTab, i18n("world.info"), SVG.INFO, SVG.INFO_FILL) .addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL); - getSkinnable().header.getTabs().add(getSkinnable().datapackTab); - tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().datapackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL); + getSkinnable().header.getTabs().add(getSkinnable().dataPackTab); + tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().dataPackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL); } return tabBar; diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 857f311980..d604eb163e 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -397,7 +397,7 @@ extension.ps1=Windows PowerShell Script extension.sh=Shell Script extension.command=macOS Shell Script -extension.datapack=Datapack Archive +extension.datapack=Data Pack Archive extension.mod=Mod File extension.modloader.installer=Mod Loader Installer extension.resourcepack=Resource Pack Archive @@ -702,7 +702,7 @@ game.version=Game Instance help=Help help.doc=Hello Minecraft! Launcher Documentation -help.detail=For datapack and modpack makers. +help.detail=For data pack and modpack makers. input.email=The username must be an email address. input.number=The input must be numbers. @@ -990,7 +990,7 @@ modrinth.category.colored-lighting=Colored Lighting modrinth.category.combat=Combat modrinth.category.core-shaders=Core Shaders modrinth.category.cursed=Cursed -modrinth.category.datapack=Datapack +modrinth.category.datapack=Data Pack modrinth.category.decoration=Decoration modrinth.category.economy=Economy modrinth.category.entities=Entities @@ -1133,11 +1133,11 @@ nbt.open.failed=Failed to open file nbt.save.failed=Failed to save file nbt.title=View File - %s -datapack=Datapacks +datapack=Data Packs datapack.add=Add -datapack.add.title=Choose datapack archive you want to add -datapack.reload.toast=Minecraft is running, please use the /reload command to reload the data pack -datapack.title=World [%s] - Datapacks +datapack.add.title=Choose data pack archive you want to add +datapack.reload.toast=Minecraft is running. Use the /reload command to reload data packs +datapack.title=World [%s] - Data Packs datapack.not_support.info=This version does not support datapacks web.failed=Failed to load page diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index fe8b76752c..c23241a6f7 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -182,7 +182,7 @@ public Image getIcon() { return icon; } - public boolean supportsDatapacks() { + public boolean supportsDataPacks() { return getGameVersion() != null && getGameVersion().isAtLeast("1.13", "17w43a"); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/Datapack.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/DataPack.java similarity index 89% rename from HMCLCore/src/main/java/org/jackhuang/hmcl/mod/Datapack.java rename to HMCLCore/src/main/java/org/jackhuang/hmcl/mod/DataPack.java index 3ac488582d..a1ad0a4d1c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/Datapack.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/DataPack.java @@ -39,14 +39,14 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public class Datapack { +public class DataPack { private static final String DISABLED_EXT = "disabled"; private static final String ZIP_EXT = "zip"; private final Path path; private final ObservableList packs = FXCollections.observableArrayList(); - public Datapack(Path path) { + public DataPack(Path path) { this.path = path; } @@ -58,14 +58,14 @@ public ObservableList getPacks() { return packs; } - public static void installPack(Path sourceDatapackPath, Path targetDatapackDirectory, GameVersionNumber gameVersionNumber) throws IOException { + public static void installPack(Path sourceDataPackPath, Path targetDataPackDirectory, GameVersionNumber gameVersionNumber) throws IOException { boolean containsMultiplePacks; Set packs = new HashSet<>(); - try (FileSystem fs = CompressingUtils.readonly(sourceDatapackPath).setAutoDetectEncoding(true).build()) { - Path datapacks = fs.getPath("datapacks"); + try (FileSystem fs = CompressingUtils.readonly(sourceDataPackPath).setAutoDetectEncoding(true).build()) { + Path dataPacks = fs.getPath("datapacks"); Path mcmeta = fs.getPath("pack.mcmeta"); - if (Files.exists(datapacks)) { + if (Files.exists(dataPacks)) { containsMultiplePacks = true; } else if (Files.exists(mcmeta)) { containsMultiplePacks = false; @@ -74,14 +74,14 @@ public static void installPack(Path sourceDatapackPath, Path targetDatapackDirec } if (containsMultiplePacks) { - try (Stream s = Files.list(datapacks)) { + try (Stream s = Files.list(dataPacks)) { packs = s.map(FileUtils::getNameWithoutExtension).collect(Collectors.toSet()); } } else { - packs.add(FileUtils.getNameWithoutExtension(sourceDatapackPath)); + packs.add(FileUtils.getNameWithoutExtension(sourceDataPackPath)); } - try (DirectoryStream stream = Files.newDirectoryStream(targetDatapackDirectory)) { + try (DirectoryStream stream = Files.newDirectoryStream(targetDataPackDirectory)) { for (Path dir : stream) { String packName = FileUtils.getName(dir); if (FileUtils.getExtension(dir).equals(DISABLED_EXT)) { @@ -100,9 +100,9 @@ public static void installPack(Path sourceDatapackPath, Path targetDatapackDirec } if (!containsMultiplePacks) { - FileUtils.copyFile(sourceDatapackPath, targetDatapackDirectory.resolve(FileUtils.getName(sourceDatapackPath))); + FileUtils.copyFile(sourceDataPackPath, targetDataPackDirectory.resolve(FileUtils.getName(sourceDataPackPath))); } else { - new Unzipper(sourceDatapackPath, targetDatapackDirectory) + new Unzipper(sourceDataPackPath, targetDataPackDirectory) .setReplaceExistentFile(true) .setSubDirectory("/datapacks/") .unzip(); @@ -113,14 +113,14 @@ public static void installPack(Path sourceDatapackPath, Path targetDatapackDirec && gameVersionNumber.compareTo("26.1-snapshot-6") >= 0; if (useNewResourcePath) { - Files.createDirectories(targetDatapackDirectory.getParent().resolve("resourcepacks")); - targetResourceZipPath = targetDatapackDirectory.getParent().resolve("resourcepacks/resources.zip"); + Files.createDirectories(targetDataPackDirectory.getParent().resolve("resourcepacks")); + targetResourceZipPath = targetDataPackDirectory.getParent().resolve("resourcepacks/resources.zip"); } else { - targetResourceZipPath = targetDatapackDirectory.getParent().resolve("resources.zip"); + targetResourceZipPath = targetDataPackDirectory.getParent().resolve("resources.zip"); } try (FileSystem outputResourcesZipFS = CompressingUtils.createWritableZipFileSystem(targetResourceZipPath); - FileSystem inputPackZipFS = CompressingUtils.createReadOnlyZipFileSystem(sourceDatapackPath)) { + FileSystem inputPackZipFS = CompressingUtils.createReadOnlyZipFileSystem(sourceDataPackPath)) { Path resourcesZip = inputPackZipFS.getPath("resources.zip"); if (Files.isRegularFile(resourcesZip)) { Path tempResourcesFile = Files.createTempFile("hmcl", ".zip"); @@ -229,14 +229,14 @@ private Optional loadSinglePackFromZipFile(Path path) { } } - private Optional parsePack(Path datapackPath, boolean isDirectory, String name, Path mcmetaPath) { + private Optional parsePack(Path dataPackPath, boolean isDirectory, String name, Path mcmetaPath) { try { PackMcMeta mcMeta = JsonUtils.fromNonNullJson(Files.readString(mcmetaPath), PackMcMeta.class); - return Optional.of(new Pack(datapackPath, isDirectory, name, mcMeta.pack().description(), this)); + return Optional.of(new Pack(dataPackPath, isDirectory, name, mcMeta.pack().description(), this)); } catch (JsonParseException e) { - LOG.warning("Invalid pack.mcmeta format in " + datapackPath, e); + LOG.warning("Invalid pack.mcmeta format in " + dataPackPath, e); } catch (IOException e) { - LOG.warning("IO error reading " + datapackPath, e); + LOG.warning("IO error reading " + dataPackPath, e); } return Optional.empty(); } @@ -248,14 +248,14 @@ public static class Pack { private final BooleanProperty activeProperty; private final String id; private final LocalModFile.Description description; - private final Datapack parentDatapack; + private final DataPack parentDataPack; - public Pack(Path path, boolean isDirectory, String id, LocalModFile.Description description, Datapack parentDatapack) { + public Pack(Path path, boolean isDirectory, String id, LocalModFile.Description description, DataPack parentDataPack) { this.path = path; this.isDirectory = isDirectory; this.id = id; this.description = description; - this.parentDatapack = parentDatapack; + this.parentDataPack = parentDataPack; this.statusFile = initializeStatusFile(path, isDirectory); this.activeProperty = initializeActiveProperty(); @@ -313,8 +313,8 @@ public LocalModFile.Description getDescription() { return description; } - public Datapack getParentDatapack() { - return parentDatapack; + public DataPack getParentDataPack() { + return parentDataPack; } public BooleanProperty activeProperty() { From a131be1f4c6d436b91210d0809d1d90c48ab4abc Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Thu, 26 Mar 2026 22:13:02 +0800 Subject: [PATCH 38/54] =?UTF-8?q?fix:=20=E6=84=8F=E5=A4=96=E5=9C=B0?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HMCL/src/main/resources/assets/lang/I18N.properties | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index d604eb163e..9ea3c20645 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1393,8 +1393,9 @@ settings.advanced.renderer.zink.desc=Vulkan (Best performance, poor compatibilit settings.advanced.server_ip=Server Address settings.advanced.server_ip.prompt=Automatically join after launching the game settings.advanced.unsupported_system_options=Settings not applicable to the current system -settings.advanced.use_native_glfw=[Linux/FreeBSD Only] Use System GLFW -settings.advanced.use_native_openal=[Linux/FreeBSD Only] Use System OpenAL +settings.advanced.use_native_glfw=Use System GLFW +settings.advanced.use_native_openal=Use System OpenAL +settings.advanced.linux_freebsd_only=Linux/FreeBSD Only settings.advanced.workaround=Workaround settings.advanced.workaround.warning=Workaround options are intended only for advanced users. Tweaking with these options may crash the game. Unless you know what you are doing, please do not edit these options. settings.advanced.wrapper_launcher=Wrapper Command From 8f10b6c177e5742c9d9f381c69c2bf5b0cf43653 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Thu, 26 Mar 2026 22:34:45 +0800 Subject: [PATCH 39/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jackhuang/hmcl/ui/versions/WorldManagePage.java | 10 +++++----- HMCL/src/main/resources/assets/lang/I18N.properties | 2 +- .../src/main/java/org/jackhuang/hmcl/mod/DataPack.java | 3 +++ 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index 9ca9d60fda..63d9fbf7d6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -95,6 +95,8 @@ public WorldManagePage setWorld(World world, Profile profile, String instanceId) Optional gameVersion = profile.getRepository().getGameVersion(instanceId); currentWorldSupportQuickPlay.set(World.supportsQuickPlay(GameVersionNumber.asGameVersion(gameVersion))); currentWorldSupportDataPack.set(world.supportsDataPacks()); + + header.select(worldInfoTab); return this; } @@ -234,14 +236,12 @@ private BorderPane getSidebar() { private AdvancedListBox getTabBar() { AdvancedListBox tabBar = new AdvancedListBox(); { - getSkinnable().header.getTabs().addAll(getSkinnable().worldInfoTab, getSkinnable().worldBackupsTab); + getSkinnable().header.getTabs().addAll(getSkinnable().worldInfoTab, getSkinnable().worldBackupsTab, getSkinnable().dataPackTab); getSkinnable().header.select(getSkinnable().worldInfoTab); tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldInfoTab, i18n("world.info"), SVG.INFO, SVG.INFO_FILL) - .addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL); - - getSkinnable().header.getTabs().add(getSkinnable().dataPackTab); - tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().dataPackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL); + .addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL) + .addNavigationDrawerTab(getSkinnable().header, getSkinnable().dataPackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL); } return tabBar; diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 9ea3c20645..483b94d9f3 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1167,7 +1167,7 @@ world.duplicate.failed.empty_name=Name cannot be empty world.duplicate.failed.invalid_name=Name contains invalid characters world.duplicate.failed=Failed to duplicate the world world.duplicate.success.toast=Successfully duplicated the world -world.datapack=Datapacks +world.datapack=Data Packs world.datetime=Last played on %s world.delete=Delete the World world.delete.failed=Failed to delete world.\n%s diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/DataPack.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/DataPack.java index a1ad0a4d1c..3c52e442e3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/DataPack.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/DataPack.java @@ -176,6 +176,9 @@ public void loadFromDir() { private void loadFromDir(Path dir) throws IOException { List discoveredPacks; + if (!Files.exists(dir)) { + Files.createDirectories(dir); + } try (Stream stream = Files.list(dir)) { discoveredPacks = stream .parallel() From 3b6e9f518048302ea420dd6543ab4df8442357e1 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Fri, 27 Mar 2026 14:34:35 +0800 Subject: [PATCH 40/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/WorldManagePage.java | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index 63d9fbf7d6..4225972681 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -57,6 +57,8 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco private final BooleanProperty currentWorldSupportQuickPlay = new SimpleBooleanProperty(false); private final BooleanProperty currentWorldSupportDataPack = new SimpleBooleanProperty(false); + private final BooleanProperty currentWorldSupportChunkBase = new SimpleBooleanProperty(false); + private final BooleanProperty currentWorldSupportEndCity = new SimpleBooleanProperty(false); private final ObjectProperty state = new SimpleObjectProperty<>(); private final BooleanProperty refreshable = new SimpleBooleanProperty(true); @@ -73,6 +75,8 @@ public WorldManagePage() { worldBackupsTab.setNodeSupplier(() -> new WorldBackupsPage(this)); dataPackTab.setNodeSupplier(() -> new DataPackListPage(this)); + header.getTabs().addAll(worldInfoTab, worldBackupsTab, dataPackTab); + this.addEventHandler(Navigator.NavigationEvent.EXITED, this::onExited); this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); } @@ -95,8 +99,10 @@ public WorldManagePage setWorld(World world, Profile profile, String instanceId) Optional gameVersion = profile.getRepository().getGameVersion(instanceId); currentWorldSupportQuickPlay.set(World.supportsQuickPlay(GameVersionNumber.asGameVersion(gameVersion))); currentWorldSupportDataPack.set(world.supportsDataPacks()); + currentWorldSupportChunkBase.set(ChunkBaseApp.isSupported(world)); + currentWorldSupportEndCity.set(ChunkBaseApp.supportEndCity(world)); - header.select(worldInfoTab); + header.select(worldInfoTab, false); return this; } @@ -222,10 +228,9 @@ protected Skin(WorldManagePage control) { private BorderPane getSidebar() { BorderPane sidebar = new BorderPane(); - { - FXUtils.setLimitWidth(sidebar, 200); - VBox.setVgrow(sidebar, Priority.ALWAYS); - } + + FXUtils.setLimitWidth(sidebar, 200); + VBox.setVgrow(sidebar, Priority.ALWAYS); sidebar.setTop(getTabBar()); sidebar.setBottom(getToolBar()); @@ -235,14 +240,10 @@ private BorderPane getSidebar() { private AdvancedListBox getTabBar() { AdvancedListBox tabBar = new AdvancedListBox(); - { - getSkinnable().header.getTabs().addAll(getSkinnable().worldInfoTab, getSkinnable().worldBackupsTab, getSkinnable().dataPackTab); - getSkinnable().header.select(getSkinnable().worldInfoTab); - tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldInfoTab, i18n("world.info"), SVG.INFO, SVG.INFO_FILL) - .addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL) - .addNavigationDrawerTab(getSkinnable().header, getSkinnable().dataPackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL); - } + tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldInfoTab, i18n("world.info"), SVG.INFO, SVG.INFO_FILL) + .addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL) + .addNavigationDrawerTab(getSkinnable().header, getSkinnable().dataPackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL); return tabBar; } @@ -256,26 +257,26 @@ private AdvancedListBox getToolBar() { advancedListItem.visibleProperty().bind(getSkinnable().currentWorldSupportQuickPlay); }); - if (ChunkBaseApp.isSupported(getSkinnable().world)) { + { PopupMenu chunkBasePopupMenu = new PopupMenu(); JFXPopup chunkBasePopup = new JFXPopup(chunkBasePopupMenu); + IconedMenuItem endCityItem = new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), () -> ChunkBaseApp.openEndCityFinder(getSkinnable().world), chunkBasePopup); + endCityItem.visibleProperty().bind(getSkinnable().currentWorldSupportEndCity); + endCityItem.managedProperty().bind(getSkinnable().currentWorldSupportEndCity); + chunkBasePopupMenu.getContent().addAll( new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(getSkinnable().world), chunkBasePopup), new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(getSkinnable().world), chunkBasePopup), - new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(getSkinnable().world), chunkBasePopup) + new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(getSkinnable().world), chunkBasePopup), + endCityItem ); - if (ChunkBaseApp.supportEndCity(getSkinnable().world)) { - chunkBasePopupMenu.getContent().add( - new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), () -> ChunkBaseApp.openEndCityFinder(getSkinnable().world), chunkBasePopup)); - } - - toolbar.addNavigationDrawerItem(i18n("world.chunkbase"), SVG.EXPLORE, null, chunkBaseMenuItem -> - chunkBaseMenuItem.setOnAction(e -> - chunkBasePopup.show(chunkBaseMenuItem, - JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, - chunkBaseMenuItem.getWidth(), 0))); + toolbar.addNavigationDrawerItem(i18n("world.chunkbase"), SVG.EXPLORE, null, chunkBaseMenuItem -> { + chunkBaseMenuItem.setOnAction(e -> chunkBasePopup.show(chunkBaseMenuItem, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, chunkBaseMenuItem.getWidth(), 0)); + chunkBaseMenuItem.visibleProperty().bind(getSkinnable().currentWorldSupportChunkBase); + chunkBaseMenuItem.managedProperty().bind(getSkinnable().currentWorldSupportChunkBase); + }); } toolbar.addNavigationDrawerItem(i18n("settings.game.exploration"), SVG.FOLDER_OPEN, () -> FXUtils.openFolder(getSkinnable().world.getFile())); From e9bbd0cf2c9b23c72c4ee08becb52cf9a14996bf Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Fri, 27 Mar 2026 14:38:08 +0800 Subject: [PATCH 41/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/WorldManagePage.java | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index 4225972681..528f89fc94 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -239,13 +239,10 @@ private BorderPane getSidebar() { } private AdvancedListBox getTabBar() { - AdvancedListBox tabBar = new AdvancedListBox(); - - tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldInfoTab, i18n("world.info"), SVG.INFO, SVG.INFO_FILL) + return new AdvancedListBox() + .addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldInfoTab, i18n("world.info"), SVG.INFO, SVG.INFO_FILL) .addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL) .addNavigationDrawerTab(getSkinnable().header, getSkinnable().dataPackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL); - - return tabBar; } private AdvancedListBox getToolBar() { @@ -273,7 +270,8 @@ private AdvancedListBox getToolBar() { ); toolbar.addNavigationDrawerItem(i18n("world.chunkbase"), SVG.EXPLORE, null, chunkBaseMenuItem -> { - chunkBaseMenuItem.setOnAction(e -> chunkBasePopup.show(chunkBaseMenuItem, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, chunkBaseMenuItem.getWidth(), 0)); + chunkBaseMenuItem.setOnAction(e -> + chunkBasePopup.show(chunkBaseMenuItem, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, chunkBaseMenuItem.getWidth(), 0)); chunkBaseMenuItem.visibleProperty().bind(getSkinnable().currentWorldSupportChunkBase); chunkBaseMenuItem.managedProperty().bind(getSkinnable().currentWorldSupportChunkBase); }); @@ -306,12 +304,10 @@ private AdvancedListBox getToolBar() { new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> WorldManageUIUtils.copyWorld(getSkinnable().world, null), managePopup) ); - toolbar.addNavigationDrawerItem(i18n("settings.game.management"), SVG.MENU, null, managePopupMenuItem -> - { + toolbar.addNavigationDrawerItem(i18n("settings.game.management"), SVG.MENU, null, managePopupMenuItem -> { managePopupMenuItem.setOnAction(e -> managePopup.show(managePopupMenuItem, - JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, - managePopupMenuItem.getWidth(), 0)); + JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, managePopupMenuItem.getWidth(), 0)); managePopupMenuItem.disableProperty().bind(getSkinnable().readOnlyProperty()); }); } From b2629f4b61898b2482e993947ebb839506662cb4 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Fri, 27 Mar 2026 21:32:42 +0800 Subject: [PATCH 42/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/ui/versions/WorldManagePage.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index 528f89fc94..fe2bc462e7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -40,6 +40,7 @@ import java.io.IOException; import java.nio.file.Path; +import java.util.Objects; import java.util.Optional; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -114,10 +115,7 @@ public WorldManagePage setWorldAndRefresh(World world, Profile profile, String i @Override public void refresh() { - - if (world == null) { - throw new IllegalStateException("World is not initialized"); - } + Objects.requireNonNull(world, "World is not initialized"); updateSessionLockChannel(); try { From 547e7d888bc4eb4557d6ab73a9a06f74b5db8712 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Sat, 28 Mar 2026 00:00:22 +0800 Subject: [PATCH 43/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/DataPackListPage.java | 4 ++-- .../hmcl/ui/versions/DataPackListPageSkin.java | 3 ++- .../hmcl/ui/versions/WorldBackupsPage.java | 13 +++++++++---- .../jackhuang/hmcl/ui/versions/WorldInfoPage.java | 4 ++-- .../jackhuang/hmcl/ui/versions/WorldManagePage.java | 2 +- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPage.java index 9de20c9399..5ab398592b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPage.java @@ -17,7 +17,7 @@ */ package org.jackhuang.hmcl.ui.versions; -import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.collections.ObservableList; import javafx.scene.control.Skin; import javafx.stage.FileChooser; @@ -94,7 +94,7 @@ public void refresh() { .start(); } - public BooleanProperty readOnlyProperty() { + public ReadOnlyBooleanProperty readOnlyProperty() { return worldManagePage.readOnlyProperty(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPageSkin.java index c9af20969c..cfd27fe3a7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPageSkin.java @@ -27,6 +27,7 @@ import javafx.beans.InvalidationListener; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.collections.transformation.FilteredList; import javafx.geometry.Insets; @@ -306,7 +307,7 @@ private final class DataPackInfoListCell extends MDListCell final TwoLineListItem content = new TwoLineListItem(); BooleanProperty booleanProperty; - DataPackInfoListCell(JFXListView listView, BooleanProperty isReadOnlyProperty) { + DataPackInfoListCell(JFXListView listView, ReadOnlyBooleanProperty isReadOnlyProperty) { super(listView); HBox container = new HBox(8); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java index 09ca20f2c3..bcd907f8f0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java @@ -19,6 +19,8 @@ import com.jfoenix.controls.JFXButton; import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanWrapper; import javafx.collections.FXCollections; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -100,7 +102,7 @@ public void refresh() { count = Integer.parseInt(matcher.group("count")); } - result.add(new BackupInfo(path, new ImportableWorld(path), time, count)); + result.add(new BackupInfo(path, new ImportableWorld(path), time, count, readOnlyProperty())); } } catch (Throwable e) { LOG.warning("Failed to load backup file " + path, e); @@ -124,7 +126,7 @@ public void refresh() { }).start(); } - public BooleanProperty readOnlyProperty() { + public ReadOnlyBooleanProperty readOnlyProperty() { return worldManagePage.readOnlyProperty(); } @@ -147,7 +149,7 @@ void createBackup() { count = Integer.parseInt(matcher.group("count")); } - return Pair.pair(path, new BackupInfo(path, new ImportableWorld(path), time, count)); + return Pair.pair(path, new BackupInfo(path, new ImportableWorld(path), time, count, readOnlyProperty())); }).whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { WorldBackupsPage.this.getItems().add(result.getValue()); @@ -185,12 +187,14 @@ public final class BackupInfo extends Control implements Comparable private final ImportableWorld backupWorld; private final LocalDateTime backupTime; private final int count; + private final ReadOnlyBooleanProperty readOnly; - public BackupInfo(Path file, ImportableWorld backupWorld, LocalDateTime backupTime, int count) { + public BackupInfo(Path file, ImportableWorld backupWorld, LocalDateTime backupTime, int count, ReadOnlyBooleanProperty readOnly) { this.file = file; this.backupWorld = backupWorld; this.backupTime = backupTime; this.count = count; + this.readOnly = readOnly; } public ImportableWorld getBackupWorld() { @@ -284,6 +288,7 @@ private static final class BackupInfoSkin extends SkinBase { JFXButton btnRestore = FXUtils.newToggleButton4(SVG.UPDATE); right.getChildren().add(btnRestore); FXUtils.installFastTooltip(btnRestore, i18n("world.restore.tooltip")); + btnRestore.disableProperty().bind(getSkinnable().readOnly); btnRestore.setOnAction(event -> Controllers.confirm(i18n("world.restore.confirm"), i18n("world.restore"), skinnable::onRestore, null)); JFXButton btnDelete = FXUtils.newToggleButton4(SVG.DELETE_FOREVER); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java index 154bef3cea..d0e7ac08a4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java @@ -19,7 +19,7 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXTextField; -import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; @@ -72,7 +72,7 @@ public WorldInfoPage(WorldManagePage worldManagePage) { refresh(); } - private BooleanProperty readOnlyProperty() { + private ReadOnlyBooleanProperty readOnlyProperty() { return worldManagePage.readOnlyProperty(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index fe2bc462e7..434fb40db6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -206,7 +206,7 @@ public boolean isReadOnly() { return readOnly.get(); } - public BooleanProperty readOnlyProperty() { + public ReadOnlyBooleanProperty readOnlyProperty() { return readOnly; } From 7fbcd46a3d560cf46e9c09f43d8200b458427398 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Sat, 28 Mar 2026 10:45:44 +0800 Subject: [PATCH 44/54] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=B8=80?= =?UTF-8?q?=E4=BA=9B=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/WorldBackupsPage.java | 2 -- .../resources/assets/lang/I18N.properties | 3 +-- .../resources/assets/lang/I18N_zh.properties | 5 ++--- .../assets/lang/I18N_zh_CN.properties | 13 ++++++------ .../java/org/jackhuang/hmcl/game/World.java | 20 ++++++++----------- 5 files changed, 17 insertions(+), 26 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java index bcd907f8f0..3eff47f617 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java @@ -18,9 +18,7 @@ package org.jackhuang.hmcl.ui.versions; import com.jfoenix.controls.JFXButton; -import javafx.beans.property.BooleanProperty; import javafx.beans.property.ReadOnlyBooleanProperty; -import javafx.beans.property.ReadOnlyBooleanWrapper; import javafx.collections.FXCollections; import javafx.geometry.Insets; import javafx.geometry.Pos; diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 483b94d9f3..16562423df 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1234,9 +1234,8 @@ world.rename.failed=Failed to rename the world world.rename.prompt=Please enter the new world name world.rename.rename_folder=Rename world folder world.restore=Restore Backup -world.restore.confirm=Are you sure you want to restore this backup?\nCurrent save progress will be overwritten. This action cannot be undone! +world.restore.confirm=Are you sure you want to restore this backup?\nCurrent world progress will be overwritten. This action cannot be undone! world.restore.failed=Failed to restore backup.\n%s -world.restore.format=Backup file format error or corrupted world.restore.processing=Restoring backup... world.restore.success=Backup restored successfully world.restore.tooltip=Restore backup diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 3eedf28b87..0ddfbb9451 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -1026,11 +1026,10 @@ world.name.default=新的世界 world.rename=重新命名世界 world.rename.failed=重新命名世界失敗 world.rename.prompt=請輸入新世界名稱 -world.rename.rename_folder=重命名世界資料夾 +world.rename.rename_folder=重新命名世界資料夾 world.restore=還原備份 -world.restore.confirm=確定要還原該備份嗎?\n目前存檔進度將被覆蓋,此操作無法復原! +world.restore.confirm=確定要還原該備份嗎?\n目前世界進度將被覆蓋,此操作無法復原! world.restore.failed=還原備份失敗\n%s -world.restore.format=備份檔案格式錯誤或已損壞 world.restore.processing=正在還原備份…… world.restore.success=備份還原成功 world.restore.tooltip=還原備份 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 4b65aa2a32..a23a6a8513 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -1032,13 +1032,12 @@ world.rename=重命名此世界 world.rename.failed=重命名世界失败 world.rename.prompt=请输入新世界名称 world.rename.rename_folder=重命名世界文件夹 -world.restore=还原存档 -world.restore.confirm=确定要还原该备份吗?\n当前存档进度将被覆盖,此操作无法撤销! -world.restore.failed=还原存档失败\n%s -world.restore.format=备份文件格式错误或已损坏 -world.restore.processing=正在还原存档…… -world.restore.success=存档还原成功 -world.restore.tooltip=还原存档 +world.restore=还原备份 +world.restore.confirm=确定要还原该备份吗?\n当前世界进度将被覆盖,此操作无法撤销! +world.restore.failed=还原备份失败\n%s +world.restore.processing=正在还原备份…… +world.restore.success=备份还原成功 +world.restore.tooltip=还原备份 world.show_all=显示全部 profile=游戏文件夹 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index c23241a6f7..758bed3bd4 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -103,10 +103,8 @@ public String getWorldName() { } public void setWorldName(String worldName) throws IOException { - if (getDataTag().get("LevelName") instanceof StringTag levelNameTag) { - levelNameTag.setValue(worldName); - levelDataTag.write(); - } + getDataTag().setString("LevelName", worldName); + levelDataTag.write(); } public CompoundTag getLevelData() { @@ -251,20 +249,18 @@ public void reloadWorldData() throws IOException { // The renameWorld method do not modify the `file` field. // A new World object needs to be created to obtain the renamed world. public Path rename(String newName) throws IOException { - if (getWorldLock().getLockState() == WorldLock.LockState.LOCKED_BY_OTHER) { - throw new WorldLockedException("The world " + getFile() + " has been locked"); + switch (getWorldLock().getLockState()) { + case LOCKED_BY_OTHER -> throw new WorldLockedException("The world " + getFile() + " has been locked"); + case LOCKED_BY_SELF -> getWorldLock().releaseLock(); } // Change the name recorded in level.dat - getDataTag().setString("LevelName", newName); - levelDataTag.write(); + setWorldName(newName); // Then change the folder's name Path targetPath = FileUtils.getNonConflictingDirectory(file.getParent(), FileUtils.getSafeWorldFolderName(newName)); - try (WorldLock.Suspension ignored = getWorldLock().suspend()) { - Files.move(file, targetPath); - return targetPath; - } + Files.move(file, targetPath); + return targetPath; } public void export(Path zipPath, String worldName) throws IOException { From e41f114f6bf73187741c2205b09165cb7b16bb2a Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Sat, 28 Mar 2026 11:01:48 +0800 Subject: [PATCH 45/54] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=B8=80?= =?UTF-8?q?=E4=BA=9B=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/WorldBackupsPage.java | 16 ++++++++++------ .../hmcl/ui/versions/WorldManagePage.java | 1 + .../hmcl/ui/versions/WorldRestoreTask.java | 4 ++-- .../main/java/org/jackhuang/hmcl/game/World.java | 2 +- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java index 3eff47f617..6b5a63d513 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java @@ -34,15 +34,13 @@ import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.*; -import org.jackhuang.hmcl.ui.construct.ImageContainer; -import org.jackhuang.hmcl.ui.construct.MessageDialogPane; -import org.jackhuang.hmcl.ui.construct.RipplerContainer; -import org.jackhuang.hmcl.ui.construct.TwoLineListItem; +import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.i18n.I18n; import org.jetbrains.annotations.NotNull; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDateTime; @@ -222,8 +220,14 @@ void onRestore() { new WorldRestoreTask(file, world).setName(i18n("world.restore.processing")) .whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { - Controllers.getWorldManagePage().setWorldAndRefresh(new World(result), worldManagePage.getProfile(), worldManagePage.getInstanceId()); - Controllers.dialog(i18n("world.restore.success"), null, MessageDialogPane.MessageType.INFO); + try { + Controllers.getWorldManagePage().setWorldAndRefresh(new World(result), worldManagePage.getProfile(), worldManagePage.getInstanceId()); + Controllers.dialog(i18n("world.restore.success"), null, MessageDialogPane.MessageType.INFO); + } catch (IOException e) { + // Under normal circumstances, this should not happen. + fireEvent(new PageCloseEvent()); + Controllers.dialog(i18n("world.restore.failed", StringUtils.getStackTrace(e)), null, MessageDialogPane.MessageType.WARNING); + } } else if (exception instanceof WorldLockedException) { Controllers.dialog(i18n("world.locked.failed"), null, MessageDialogPane.MessageType.WARNING); } else { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index 434fb40db6..182a480f90 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -103,6 +103,7 @@ public WorldManagePage setWorld(World world, Profile profile, String instanceId) currentWorldSupportChunkBase.set(ChunkBaseApp.isSupported(world)); currentWorldSupportEndCity.set(ChunkBaseApp.supportEndCity(world)); + setTitle(world.getWorldName()); header.select(worldInfoTab, false); return this; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java index b2afb21815..ade443bc1b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java @@ -57,7 +57,7 @@ public void execute() throws Exception { } catch (IOException e) { FileUtils.deleteDirectoryQuietly(tempPath); FileUtils.deleteDirectoryQuietly(tempPath2); - world.getWorldLock().acquireLock(); + world.getWorldLock().lock(); throw e; } @@ -66,7 +66,7 @@ public void execute() throws Exception { } catch (IOException e) { Files.move(tempPath2, worldPath, StandardCopyOption.REPLACE_EXISTING); FileUtils.deleteDirectoryQuietly(tempPath); - world.getWorldLock().acquireLock(); + world.getWorldLock().lock(); throw e; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 758bed3bd4..0471a3cb0e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -362,7 +362,7 @@ public void lockStrict() throws WorldLockedException { } } - public void acquireLock() throws WorldLockedException { + private void acquireLock() throws WorldLockedException { FileChannel channel = null; try { channel = FileChannel.open(sessionLockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE); From 0b473bb48e729bd4f5671148908f742d5edac2ae Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Sun, 29 Mar 2026 12:19:36 +0800 Subject: [PATCH 46/54] =?UTF-8?q?fix:=20=E6=9A=82=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/ImportableWorld.java | 50 +++++-------------- .../hmcl/ui/versions/WorldRestoreTask.java | 4 +- .../java/org/jackhuang/hmcl/game/World.java | 49 ++++++++++-------- 3 files changed, 44 insertions(+), 59 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java index 1c9cf0f5ab..10fbae467e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java @@ -31,13 +31,11 @@ import org.jetbrains.annotations.Nullable; import java.io.IOException; -import java.io.InputStream; import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; - -import static org.jackhuang.hmcl.util.logging.Logger.LOG; +import java.util.stream.Stream; /// @author mineDiamond public final class ImportableWorld { @@ -61,35 +59,20 @@ public ImportableWorld(Path sourcePath) throws IOException { hasTopLevelDirectory = false; fileName = FileUtils.getName(this.sourcePath); } else { - List files = Files.list(fs.getPath("/")).toList(); - if (files.size() != 1 || !Files.isDirectory(files.get(0))) { - throw new IOException("Not a valid world zip file"); + try (Stream stream = Files.list(fs.getPath("/"))) { + List files = stream.toList(); + if (files.size() != 1 || !Files.isDirectory(files.get(0))) { + throw new IOException("Not a valid world zip file"); + } + + root = files.get(0); + hasTopLevelDirectory = true; + fileName = FileUtils.getName(root); } - - root = files.get(0); - hasTopLevelDirectory = true; - fileName = FileUtils.getName(root); } - Path levelDat = root.resolve("level.dat"); - if (!Files.exists(levelDat)) { //version 20w14infinite - levelDat = root.resolve("special_level.dat"); - } - if (!Files.exists(levelDat)) { - throw new IOException("Not a valid world zip file since level.dat or special_level.dat cannot be found."); - } - checkAndLoadLevelData(levelDat); - - Path iconFile = root.resolve("icon.png"); - if (Files.isRegularFile(iconFile)) { - try (InputStream inputStream = Files.newInputStream(iconFile)) { - icon = new Image(inputStream, 64, 64, true, false); - if (icon.isError()) - throw icon.getException(); - } catch (Exception e) { - LOG.warning("Failed to load world icon", e); - } - } + checkAndLoadLevelData(World.findLevelDatPath(root)); + this.icon = World.loadIcon(root); } } else if (Files.isDirectory(sourcePath)) { this.sourcePath = sourcePath; @@ -97,14 +80,7 @@ public ImportableWorld(Path sourcePath) throws IOException { this.isArchive = false; this.hasTopLevelDirectory = false; - Path levelDatPath = this.sourcePath.resolve("level.dat"); - if (!Files.exists(levelDatPath)) { // version 20w14infinite - levelDatPath = this.sourcePath.resolve("special_level.dat"); - } - if (!Files.exists(levelDatPath)) { - throw new IOException("Not a valid world directory since level.dat or special_level.dat cannot be found."); - } - checkAndLoadLevelData(levelDatPath); + checkAndLoadLevelData(World.findLevelDatPath(this.sourcePath)); } else { throw new IOException("Path " + sourcePath + " cannot be recognized as a archive Minecraft world"); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java index ade443bc1b..1fafbbf352 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java @@ -43,9 +43,9 @@ public void execute() throws Exception { Path tempPath2 = worldPath.toAbsolutePath().resolveSibling("." + worldPath.getFileName().toString() + ".tmp2"); // Check if the world format is correct - new ImportableWorld(backupZipPath); + ImportableWorld importableWorld = new ImportableWorld(backupZipPath); try { - new Unzipper(backupZipPath, tempPath).setSubDirectory(world.getFileName()).unzip(); + new Unzipper(backupZipPath, tempPath).setSubDirectory(importableWorld.getFileName()).unzip(); } catch (IOException e) { FileUtils.deleteDirectoryQuietly(tempPath); throw e; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 0471a3cb0e..fa53556ead 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -45,7 +45,7 @@ public final class World { private final Path file; private final String fileName; - private Image icon; + private final Image icon; private WorldDataSection levelDataTag; private WorldDataSection worldGenSettingsTag; @@ -59,30 +59,39 @@ public World(Path file) throws IOException { if (Files.isDirectory(file)) { fileName = FileUtils.getName(this.file); - Path levelDatPath = this.file.resolve("level.dat"); - if (!Files.exists(levelDatPath)) { // version 20w14infinite - levelDatPath = this.file.resolve("special_level.dat"); - } - if (!Files.exists(levelDatPath)) { - throw new IOException("Not a valid world directory since level.dat or special_level.dat cannot be found."); - } - loadAndCheckWorldData(levelDatPath); - - Path iconFile = this.file.resolve("icon.png"); - if (Files.isRegularFile(iconFile)) { - try (InputStream inputStream = Files.newInputStream(iconFile)) { - icon = new Image(inputStream, 64, 64, true, false); - if (icon.isError()) - throw icon.getException(); - } catch (Exception e) { - LOG.warning("Failed to load world icon", e); - } - } + loadAndCheckWorldData(findLevelDatPath(this.file)); + icon = loadIcon(this.file); } else { throw new IOException("Path " + file + " cannot be recognized as a Minecraft world"); } } + public static Path findLevelDatPath(Path root) throws IOException { + Path levelDat = root.resolve("level.dat"); + if (!Files.exists(levelDat)) { //version 20w14infinite + levelDat = root.resolve("special_level.dat"); + } + if (!Files.exists(levelDat)) { + throw new IOException("Not a valid world zip file since level.dat or special_level.dat cannot be found."); + } + return levelDat; + } + + public static Image loadIcon(Path root) { + Path iconFile = root.resolve("icon.png"); + if (Files.isRegularFile(iconFile)) { + try (InputStream inputStream = Files.newInputStream(iconFile)) { + Image icon = new Image(inputStream, 64, 64, true, false); + if (icon.isError()) + throw icon.getException(); + return icon; + } catch (Exception e) { + LOG.warning("Failed to load world icon", e); + } + } + return null; + } + public WorldLock getWorldLock() { return lock; } From fc09d0a8e62e1ae32bdfacd5839e02dd950d8f6e Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Thu, 9 Apr 2026 10:16:12 +0800 Subject: [PATCH 47/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=87=8D?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java index 43b9a113f9..97e68e6bb0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java @@ -96,7 +96,7 @@ public static void renameWorld(World world, Consumer notRenameFolderCons String finalNewWorldName = StringUtils.isBlank(newWorldName) ? i18n("world.name.default") : newWorldName; boolean renameFolder = ((PromptDialogPane.Builder.BooleanQuestion) res.get(1)).getValue(); - if (finalNewWorldName.equals(world.getWorldName())) { + if (finalNewWorldName.equals(world.getWorldName()) && !renameFolder) { handler.resolve(); return; } From deacfeb48d2a9dbf4499ea2f248c9c1d86849782 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Thu, 9 Apr 2026 10:42:16 +0800 Subject: [PATCH 48/54] =?UTF-8?q?fix:=20=E7=8E=B0=E5=9C=A8=E6=98=AF?= =?UTF-8?q?=E5=90=A6=E6=94=AF=E6=8C=81=E6=95=B0=E6=8D=AE=E5=8C=85=E4=B9=9F?= =?UTF-8?q?=E7=9C=8B=E6=B8=B8=E6=88=8F=E7=89=88=E6=9C=AC=E8=80=8C=E4=B8=8D?= =?UTF-8?q?=E6=98=AF=E5=AD=98=E6=A1=A3=E7=89=88=E6=9C=AC=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/ui/versions/DataPackListPage.java | 2 +- .../java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java | 4 ++-- HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPage.java index 5ab398592b..a66bc2548e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPage.java @@ -82,7 +82,7 @@ public void refresh() { setLoading(true); setFailedReason(null); world = worldManagePage.getWorld(); - if (!world.supportsDataPacks()) { + if (!worldManagePage.currentWorldSupportDataPack.get()) { setFailedReason(i18n("datapack.not_support.info")); setLoading(false); return; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index 182a480f90..a44e49557f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -57,7 +57,7 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco private String instanceId; private final BooleanProperty currentWorldSupportQuickPlay = new SimpleBooleanProperty(false); - private final BooleanProperty currentWorldSupportDataPack = new SimpleBooleanProperty(false); + public final BooleanProperty currentWorldSupportDataPack = new SimpleBooleanProperty(false); private final BooleanProperty currentWorldSupportChunkBase = new SimpleBooleanProperty(false); private final BooleanProperty currentWorldSupportEndCity = new SimpleBooleanProperty(false); @@ -99,7 +99,7 @@ public WorldManagePage setWorld(World world, Profile profile, String instanceId) Optional gameVersion = profile.getRepository().getGameVersion(instanceId); currentWorldSupportQuickPlay.set(World.supportsQuickPlay(GameVersionNumber.asGameVersion(gameVersion))); - currentWorldSupportDataPack.set(world.supportsDataPacks()); + currentWorldSupportDataPack.set(World.supportsDataPacks(GameVersionNumber.asGameVersion(gameVersion))); currentWorldSupportChunkBase.set(ChunkBaseApp.isSupported(world)); currentWorldSupportEndCity.set(ChunkBaseApp.supportEndCity(world)); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index fa53556ead..a9339d1ae5 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -193,6 +193,10 @@ public boolean supportsDataPacks() { return getGameVersion() != null && getGameVersion().isAtLeast("1.13", "17w43a"); } + public static boolean supportsDataPacks(GameVersionNumber gameVersionNumber) { + return gameVersionNumber != null && gameVersionNumber.isAtLeast("1.13", "17w43a"); + } + public boolean supportsQuickPlay() { return getGameVersion() != null && getGameVersion().isAtLeast("1.20", "23w14a"); } From dbe04addfe83852e111761d83dfaef7134e63e0a Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Thu, 9 Apr 2026 10:45:08 +0800 Subject: [PATCH 49/54] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java | 2 +- .../java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java index 10fbae467e..73312b2866 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java @@ -82,7 +82,7 @@ public ImportableWorld(Path sourcePath) throws IOException { checkAndLoadLevelData(World.findLevelDatPath(this.sourcePath)); } else { - throw new IOException("Path " + sourcePath + " cannot be recognized as a archive Minecraft world"); + throw new IOException("Path " + sourcePath + " cannot be recognized as an archive Minecraft world"); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index a44e49557f..a634c1da33 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -251,6 +251,7 @@ private AdvancedListBox getToolBar() { toolbar.addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, () -> getSkinnable().launch(), advancedListItem -> { advancedListItem.disableProperty().bind(getSkinnable().readOnlyProperty()); advancedListItem.visibleProperty().bind(getSkinnable().currentWorldSupportQuickPlay); + advancedListItem.managedProperty().bind(getSkinnable().currentWorldSupportQuickPlay); }); { From 866b485b2cee00ffda0f964bac525983e6cfc2e4 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Tue, 14 Apr 2026 20:21:19 +0800 Subject: [PATCH 50/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/WorldInfoPage.java | 8 ++-- .../java/org/jackhuang/hmcl/game/World.java | 37 +++++++++++-------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java index d0e7ac08a4..a6a671050a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java @@ -44,7 +44,6 @@ import org.jackhuang.hmcl.util.io.FileUtils; import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; import java.text.DecimalFormat; import java.time.Duration; @@ -81,7 +80,7 @@ public void refresh() { this.world = worldManagePage.getWorld(); setFailedReason(null); try { - this.dataTag = world.getDataTag(); + this.dataTag = world.getLevelDataTag(); this.playerData = world.getPlayerData(); updateControls(); } catch (Exception e) { @@ -675,7 +674,7 @@ private void changeWorldIcon() { private void saveWorldIcon(Path sourcePath, Image image, Path targetPath) { Image oldImage = iconImageView.getImage(); try { - FileUtils.copyFile(sourcePath, targetPath); + world.changeWorldIcon(sourcePath, targetPath); iconImageView.setImage(image); Controllers.showToast(i18n("world.icon.change.succeed.toast")); } catch (IOException e) { @@ -685,9 +684,8 @@ private void saveWorldIcon(Path sourcePath, Image image, Path targetPath) { } private void clearWorldIcon() { - Path output = world.getFile().resolve("icon.png"); try { - Files.deleteIfExists(output); + world.clearWorldIcon(); iconImageView.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_server.png")); } catch (IOException e) { LOG.warning("Failed to delete world icon ", e); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index a9339d1ae5..3762b25973 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -45,7 +45,7 @@ public final class World { private final Path file; private final String fileName; - private final Image icon; + private Image icon; private WorldDataSection levelDataTag; private WorldDataSection worldGenSettingsTag; @@ -105,22 +105,18 @@ public String getFileName() { } public String getWorldName() { - if (getDataTag().get("LevelName") instanceof StringTag levelNameTag) + if (getLevelDataTag().get("LevelName") instanceof StringTag levelNameTag) return levelNameTag.get(); else return ""; } public void setWorldName(String worldName) throws IOException { - getDataTag().setString("LevelName", worldName); + getLevelDataTag().setString("LevelName", worldName); levelDataTag.write(); } - public CompoundTag getLevelData() { - return levelDataTag.nbtBackingTag(); - } - - public CompoundTag getDataTag() { + public CompoundTag getLevelDataTag() { return levelDataTag.normalizedNbtTag; } @@ -133,7 +129,7 @@ public CompoundTag getDataTag() { } public long getLastPlayed() { - if (getDataTag().get("LastPlayed") instanceof LongTag lastPlayedTag) { + if (getLevelDataTag().get("LastPlayed") instanceof LongTag lastPlayedTag) { return lastPlayedTag.get(); } else { return 0L; @@ -141,7 +137,7 @@ public long getLastPlayed() { } public @Nullable GameVersionNumber getGameVersion() { - if (getDataTag().get("Version") instanceof CompoundTag versionTag && + if (getLevelDataTag().get("Version") instanceof CompoundTag versionTag && versionTag.get("Name") instanceof StringTag nameTag) { return GameVersionNumber.asGameVersion(nameTag.getValue()); } @@ -155,7 +151,7 @@ && getNormalizedWorldGenSettingsData().get("seed") instanceof LongTag seedTag) { return seedTag.getValue(); } // Valid before 1.16(20w20a) - if (getDataTag().get("RandomSeed") instanceof LongTag seedTag) { + if (getLevelDataTag().get("RandomSeed") instanceof LongTag seedTag) { return seedTag.getValue(); } return null; @@ -163,7 +159,7 @@ && getNormalizedWorldGenSettingsData().get("seed") instanceof LongTag seedTag) { public boolean isLargeBiomes() { // Valid before 1.16(20w20a) - if (getDataTag().get("generatorName") instanceof StringTag generatorNameTag) { + if (getLevelDataTag().get("generatorName") instanceof StringTag generatorNameTag) { return "largeBiomes".equals(generatorNameTag.getValue()); } // Unified handling of logic after version 1.16 @@ -189,6 +185,17 @@ public Image getIcon() { return icon; } + public void changeWorldIcon(Path sourcePath, Path targetPath) throws IOException { + FileUtils.copyFile(sourcePath, targetPath); + icon = loadIcon(file); + } + + public void clearWorldIcon() throws IOException { + Path output = file.resolve("icon.png"); + Files.deleteIfExists(output); + icon = null; + } + public boolean supportsDataPacks() { return getGameVersion() != null && getGameVersion().isAtLeast("1.13", "17w43a"); } @@ -226,7 +233,7 @@ private void loadAndCheckLevelData(Path levelDatPath) throws IOException { private void loadOtherData() throws IOException { Path worldGenSettingsDatPath = file.resolve("data/minecraft/world_gen_settings.dat"); - if (getDataTag().get("WorldGenSettings") instanceof CompoundTag worldGenSettingsTag) { + if (getLevelDataTag().get("WorldGenSettings") instanceof CompoundTag worldGenSettingsTag) { this.worldGenSettingsTag = new WorldDataSection(null, worldGenSettingsTag, worldGenSettingsTag); } else if (Files.isRegularFile(worldGenSettingsDatPath)) { CompoundTag raw = NBTCodec.of().readTag(worldGenSettingsDatPath, TagType.COMPOUND); @@ -239,9 +246,9 @@ private void loadOtherData() throws IOException { this.worldGenSettingsTag = new WorldDataSection(null, null, null); } - if (getDataTag().get("Player") instanceof CompoundTag playerTag) { + if (getLevelDataTag().get("Player") instanceof CompoundTag playerTag) { this.playerTag = new WorldDataSection(null, playerTag, playerTag); - } else if (getDataTag().get("singleplayer_uuid") instanceof IntArrayTag uuidTag && uuidTag.isUUID()) { + } else if (getLevelDataTag().get("singleplayer_uuid") instanceof IntArrayTag uuidTag && uuidTag.isUUID()) { String playerUUID = uuidTag.getUUID().toString(); Path playerDatPath = file.resolve("players/data/" + playerUUID + ".dat"); if (Files.exists(playerDatPath)) { From 047608a2e2390f3eda12c95d2d337e9ee3da80e6 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Tue, 14 Apr 2026 20:27:02 +0800 Subject: [PATCH 51/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java | 8 -------- 1 file changed, 8 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 3762b25973..bf152389bd 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -196,18 +196,10 @@ public void clearWorldIcon() throws IOException { icon = null; } - public boolean supportsDataPacks() { - return getGameVersion() != null && getGameVersion().isAtLeast("1.13", "17w43a"); - } - public static boolean supportsDataPacks(GameVersionNumber gameVersionNumber) { return gameVersionNumber != null && gameVersionNumber.isAtLeast("1.13", "17w43a"); } - public boolean supportsQuickPlay() { - return getGameVersion() != null && getGameVersion().isAtLeast("1.20", "23w14a"); - } - public static boolean supportsQuickPlay(GameVersionNumber gameVersionNumber) { return gameVersionNumber != null && gameVersionNumber.isAtLeast("1.20", "23w14a"); } From ed4ca2de0adc5dd41cd473cd0d26c9d6c44c0bb9 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Tue, 14 Apr 2026 20:46:33 +0800 Subject: [PATCH 52/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/util/io/FileUtils.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java index 1ca3539943..e0d5ab3a47 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java @@ -241,6 +241,10 @@ public static Path getNonConflictingFilePath(@NotNull Path path, @NotNull String } public static String getSafeWorldFolderName(String name) { + return getSafeWorldFolderName(OperatingSystem.CURRENT_OS, name); + } + + public static String getSafeWorldFolderName(OperatingSystem os, String name) { if (StringUtils.isBlank(name)) { return "New World"; } @@ -253,8 +257,10 @@ public static String getSafeWorldFolderName(String name) { sanitized = sanitized.strip(); // Handle Windows reserved keywords - if (INVALID_WINDOWS_RESOURCE_BASE_NAMES.contains(sanitized.toLowerCase(Locale.ROOT))) { - sanitized = "_" + sanitized + "_"; + if (os == OperatingSystem.WINDOWS) { // Windows only + if (INVALID_WINDOWS_RESOURCE_BASE_NAMES.contains(sanitized.toLowerCase(Locale.ROOT))) { + sanitized = "_" + sanitized + "_"; + } } // Provide a default value if the sanitized string is empty From dcffe355e344d3928ffc2c1bd7649b21ee3fc604 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Wed, 15 Apr 2026 17:32:45 +0800 Subject: [PATCH 53/54] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/ImportableWorld.java | 92 ++++++++----------- .../hmcl/ui/versions/WorldBackupsPage.java | 12 +-- .../hmcl/ui/versions/WorldListPage.java | 4 +- .../hmcl/ui/versions/WorldRestoreTask.java | 4 +- 4 files changed, 47 insertions(+), 65 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java index 73312b2866..8f0911067e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java @@ -38,26 +38,25 @@ import java.util.stream.Stream; /// @author mineDiamond -public final class ImportableWorld { - private final Path sourcePath; - private final String fileName; - private final boolean isArchive; - private final boolean hasTopLevelDirectory; - private String worldName; - private @Nullable GameVersionNumber gameVersion; - private @Nullable Image icon; +record ImportableWorld(Path sourcePath, String fileName, boolean isArchive, boolean hasTopLevelDirectory, String worldName, @Nullable GameVersionNumber gameVersion, @Nullable Image icon) { - public ImportableWorld(Path sourcePath) throws IOException { - if (Files.isRegularFile(sourcePath)) { - this.sourcePath = sourcePath; - this.isArchive = true; + public static ImportableWorld fromPath(Path sourcePath) throws IOException { + + String fileName; + boolean isArchive; + boolean hasTopLevelDirectory; + String worldName; + GameVersionNumber gameVersion; + Image icon = null; - try (FileSystem fs = CompressingUtils.readonly(this.sourcePath).setAutoDetectEncoding(true).build()) { + if (Files.isRegularFile(sourcePath)) { + isArchive = true; + try (FileSystem fs = CompressingUtils.readonly(sourcePath).setAutoDetectEncoding(true).build()) { Path root; if (Files.isRegularFile(fs.getPath("/level.dat"))) { root = fs.getPath("/"); hasTopLevelDirectory = false; - fileName = FileUtils.getName(this.sourcePath); + fileName = FileUtils.getName(sourcePath); } else { try (Stream stream = Files.list(fs.getPath("/"))) { List files = stream.toList(); @@ -71,63 +70,46 @@ public ImportableWorld(Path sourcePath) throws IOException { } } - checkAndLoadLevelData(World.findLevelDatPath(root)); - this.icon = World.loadIcon(root); + CompoundTag dataTag = loadLevelData(World.findLevelDatPath(root)); + worldName = getLevelName(dataTag); + gameVersion = getGameVersion(dataTag); + icon = World.loadIcon(root); } - } else if (Files.isDirectory(sourcePath)) { - this.sourcePath = sourcePath; - fileName = FileUtils.getName(this.sourcePath); - this.isArchive = false; - this.hasTopLevelDirectory = false; - - checkAndLoadLevelData(World.findLevelDatPath(this.sourcePath)); } else { - throw new IOException("Path " + sourcePath + " cannot be recognized as an archive Minecraft world"); + fileName = FileUtils.getName(sourcePath); + isArchive = false; + hasTopLevelDirectory = false; + CompoundTag dataTag = loadLevelData(World.findLevelDatPath(sourcePath)); + worldName = getLevelName(dataTag); + gameVersion = getGameVersion(dataTag); } + return new ImportableWorld(sourcePath, fileName, isArchive, hasTopLevelDirectory, worldName, gameVersion, icon); } - private void checkAndLoadLevelData(Path levelDatPath) throws IOException { + private static CompoundTag loadLevelData(Path levelDatPath) throws IOException { CompoundTag levelData = NBTCodec.of().readTag(levelDatPath, TagType.COMPOUND); if (!(levelData.get("Data") instanceof CompoundTag data)) throw new IOException("level.dat missing Data"); - if (data.get("LevelName") instanceof StringTag levelNameTag) { - this.worldName = levelNameTag.getValue(); - } else { - throw new IOException("level.dat missing LevelName"); - } - - if (data.get("Version") instanceof CompoundTag versionTag && - versionTag.get("Name") instanceof StringTag nameTag) { - this.gameVersion = GameVersionNumber.asGameVersion(nameTag.getValue()); - } - if (!(data.get("LastPlayed") instanceof LongTag)) throw new IOException("level.dat missing LastPlayed"); - } - - public Path getSourcePath() { - return sourcePath; - } - - public String getFileName() { - return fileName; - } - - public boolean hasTopLevelDirectory() { - return hasTopLevelDirectory; - } - public String getWorldName() { - return worldName; + return data; } - public @Nullable GameVersionNumber getGameVersion() { - return gameVersion; + private static String getLevelName(CompoundTag data) throws IOException { + if (data.get("LevelName") instanceof StringTag levelNameTag) { + return levelNameTag.getValue(); + } + throw new IOException("level.dat missing LevelName"); } - public @Nullable Image getIcon() { - return icon; + private static GameVersionNumber getGameVersion(CompoundTag data) throws IOException { + if (data.get("Version") instanceof CompoundTag versionTag && + versionTag.get("Name") instanceof StringTag nameTag) { + return GameVersionNumber.asGameVersion(nameTag.getValue()); + } + return null; } public void install(Path savesDir, String name) throws IOException { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java index 6b5a63d513..802d1032cf 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java @@ -98,7 +98,7 @@ public void refresh() { count = Integer.parseInt(matcher.group("count")); } - result.add(new BackupInfo(path, new ImportableWorld(path), time, count, readOnlyProperty())); + result.add(new BackupInfo(path, ImportableWorld.fromPath(path), time, count, readOnlyProperty())); } } catch (Throwable e) { LOG.warning("Failed to load backup file " + path, e); @@ -145,7 +145,7 @@ void createBackup() { count = Integer.parseInt(matcher.group("count")); } - return Pair.pair(path, new BackupInfo(path, new ImportableWorld(path), time, count, readOnlyProperty())); + return Pair.pair(path, new BackupInfo(path, ImportableWorld.fromPath(path), time, count, readOnlyProperty())); }).whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { WorldBackupsPage.this.getItems().add(result.getValue()); @@ -263,18 +263,18 @@ private static final class BackupInfoSkin extends SkinBase { var imageView = new ImageContainer(32); left.getChildren().add(imageView); - imageView.setImage(backupWorld.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : backupWorld.getIcon()); + imageView.setImage(backupWorld.icon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : backupWorld.icon()); } { TwoLineListItem item = new TwoLineListItem(); root.setCenter(item); - item.setTitle(parseColorEscapes(skinnable.getBackupWorld().getWorldName())); + item.setTitle(parseColorEscapes(skinnable.getBackupWorld().worldName())); item.setSubtitle(formatDateTime(skinnable.getBackupTime()) + (skinnable.count == 0 ? "" : " (" + skinnable.count + ")")); - if (backupWorld.getGameVersion() != null) - item.addTag(I18n.getDisplayVersion(backupWorld.getGameVersion())); + if (backupWorld.gameVersion() != null) + item.addTag(I18n.getDisplayVersion(backupWorld.gameVersion())); } { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java index 5079a9e456..23999b09a2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java @@ -154,7 +154,7 @@ public void download() { private void installWorld(Path worldPath) { // Only accept one world file because user is required to confirm the new world name // Or too many input dialogs are popped. - Task.supplyAsync(() -> new ImportableWorld(worldPath)) + Task.supplyAsync(() -> ImportableWorld.fromPath(worldPath)) .whenComplete(Schedulers.javafx(), world -> { Controllers.prompt(i18n("world.name.enter"), (name, handler) -> { String finalName = StringUtils.isBlank(name) ? i18n("world.name.default") : name; @@ -168,7 +168,7 @@ private void installWorld(Path worldPath) { else handler.reject(i18n("world.add.failed", e.getClass().getName() + ": " + e.getLocalizedMessage())); }).start(); - }, world.getWorldName()); + }, world.worldName()); }, e -> { LOG.warning("Unable to parse world file " + worldPath, e); Controllers.dialog(i18n("world.add.invalid")); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java index 1fafbbf352..b0bb56631d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java @@ -43,9 +43,9 @@ public void execute() throws Exception { Path tempPath2 = worldPath.toAbsolutePath().resolveSibling("." + worldPath.getFileName().toString() + ".tmp2"); // Check if the world format is correct - ImportableWorld importableWorld = new ImportableWorld(backupZipPath); + ImportableWorld importableWorld = ImportableWorld.fromPath(backupZipPath); try { - new Unzipper(backupZipPath, tempPath).setSubDirectory(importableWorld.getFileName()).unzip(); + new Unzipper(backupZipPath, tempPath).setSubDirectory(importableWorld.fileName()).unzip(); } catch (IOException e) { FileUtils.deleteDirectoryQuietly(tempPath); throw e; From 6a0d7b1883e0a9fb15a75860a3ad3222ac40476b Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Wed, 15 Apr 2026 21:16:52 +0800 Subject: [PATCH 54/54] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java | 3 ++- HMCL/src/main/resources/assets/lang/I18N.properties | 1 + HMCL/src/main/resources/assets/lang/I18N_zh.properties | 1 + HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java index 97e68e6bb0..3683bbf16b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java @@ -123,6 +123,7 @@ public static void renameWorld(World world, Consumer notRenameFolderCons }).start(); }) .addQuestion(new PromptDialogPane.Builder.StringQuestion(null, world.getWorldName())) - .addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("world.rename.rename_folder"), false))); + .addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("world.rename.rename_folder"), false)) + .addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("world.rename.rename_folder.hint")))); } } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index cc83f91b0e..805db4847a 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1241,6 +1241,7 @@ world.rename=Rename World world.rename.failed=Failed to rename the world world.rename.prompt=Please enter the new world name world.rename.rename_folder=Rename world folder +world.rename.rename_folder.hint=Only changing the display name is safe. However, renaming the folder may cause backup software, map mods, etc. to lose their connection with the original data. Please proceed with caution. world.restore=Restore Backup world.restore.confirm=Are you sure you want to restore this backup?\nCurrent world progress will be overwritten. This action cannot be undone! world.restore.failed=Failed to restore backup.\n%s diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index d807ed8bbf..a44c48e374 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -1035,6 +1035,7 @@ world.rename=重新命名世界 world.rename.failed=重新命名世界失敗 world.rename.prompt=請輸入新世界名稱 world.rename.rename_folder=重新命名世界資料夾 +world.rename.rename_folder.hint=僅修改顯示名稱是安全的。但重新命名資料夾可能導致備份軟體、地圖 Mod 等無法關聯原有數據,請謹慎操作。 world.restore=還原備份 world.restore.confirm=確定要還原該備份嗎?\n目前世界進度將被覆蓋,此操作無法復原! world.restore.failed=還原備份失敗\n%s diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 96a4cba99a..79b5cb1587 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -1040,6 +1040,7 @@ world.rename=重命名此世界 world.rename.failed=重命名世界失败 world.rename.prompt=请输入新世界名称 world.rename.rename_folder=重命名世界文件夹 +world.rename.rename_folder.hint=仅修改显示名称是安全的。但重命名文件夹可能导致备份软件、地图 Mod 等无法关联原有数据,请谨慎操作。 world.restore=还原备份 world.restore.confirm=确定要还原该备份吗?\n当前世界进度将被覆盖,此操作无法撤销! world.restore.failed=还原备份失败\n%s