From 78db75d3778e7c635194e0f26f8d9fdc9cb603cc Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Mon, 23 Mar 2026 23:20:56 +0800 Subject: [PATCH 01/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] 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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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/45] =?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);