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/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/DataPackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DataPackListPage.java index 8f619a6ee6..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; @@ -45,15 +45,12 @@ 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; 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); @@ -62,7 +59,7 @@ public DataPackListPage(WorldManagePage worldManagePage) { private void installMultiDataPack(List dataPackPath) { dataPackPath.forEach(this::installSingleDataPack); - if (readOnly.get()) { + if (readOnlyProperty().get()) { Controllers.showToast(i18n("datapack.reload.toast")); } } @@ -80,13 +77,27 @@ protected Skin createDefaultSkin() { return new DataPackListPageSkin(this); } + @Override public void refresh() { setLoading(true); + setFailedReason(null); + world = worldManagePage.getWorld(); + 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) .withRunAsync(Schedulers.javafx(), () -> setLoading(false)) .start(); } + public ReadOnlyBooleanProperty 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 342170f86d..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; @@ -95,7 +96,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(); @@ -119,9 +120,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, @@ -163,6 +164,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,8 +182,9 @@ 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().readOnly)); + listView.setCellFactory(x -> new DataPackInfoListCell(listView, getSkinnable().readOnlyProperty())); listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); this.listView.setItems(filteredList); @@ -304,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/ImportableWorld.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java new file mode 100644 index 0000000000..1c9cf0f5ab --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ImportableWorld.java @@ -0,0 +1,171 @@ +/* + * 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.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// @author mineDiamond +public final class ImportableWorld { + private final Path sourcePath; + private final String fileName; + private final boolean isArchive; + private final boolean hasTopLevelDirectory; + private String worldName; + private @Nullable GameVersionNumber gameVersion; + private @Nullable Image icon; + + public ImportableWorld(Path sourcePath) throws IOException { + if (Files.isRegularFile(sourcePath)) { + this.sourcePath = sourcePath; + this.isArchive = true; + + try (FileSystem fs = CompressingUtils.readonly(this.sourcePath).setAutoDetectEncoding(true).build()) { + Path root; + if (Files.isRegularFile(fs.getPath("/level.dat"))) { + root = fs.getPath("/"); + hasTopLevelDirectory = false; + fileName = FileUtils.getName(this.sourcePath); + } else { + List files = Files.list(fs.getPath("/")).toList(); + if (files.size() != 1 || !Files.isDirectory(files.get(0))) { + throw new IOException("Not a valid world zip file"); + } + + root = files.get(0); + hasTopLevelDirectory = true; + fileName = FileUtils.getName(root); + } + + Path levelDat = root.resolve("level.dat"); + if (!Files.exists(levelDat)) { //version 20w14infinite + levelDat = root.resolve("special_level.dat"); + } + if (!Files.exists(levelDat)) { + throw new IOException("Not a valid world zip file since level.dat or special_level.dat cannot be found."); + } + checkAndLoadLevelData(levelDat); + + Path iconFile = root.resolve("icon.png"); + if (Files.isRegularFile(iconFile)) { + try (InputStream inputStream = Files.newInputStream(iconFile)) { + icon = new Image(inputStream, 64, 64, true, false); + if (icon.isError()) + throw icon.getException(); + } catch (Exception e) { + LOG.warning("Failed to load world icon", e); + } + } + } + } 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 " + sourcePath + " 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 getSourcePath() { + return sourcePath; + } + + public String getFileName() { + return fileName; + } + + public boolean hasTopLevelDirectory() { + return hasTopLevelDirectory; + } + + 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 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")); + } + new World(targetPath).setWorldName(name); + } +} 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..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 @@ -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,17 +36,15 @@ 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) { + try (World.WorldLock.Guard guard = world.getWorldLock().guard()) { Files.createDirectories(backupsDir); String time = LocalDateTime.now().format(WorldBackupsPage.TIME_FORMATTER); String baseName = time + "_" + world.getFileName(); 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..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 @@ -18,7 +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.collections.FXCollections; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -34,16 +34,15 @@ 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.nio.file.*; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -65,22 +64,23 @@ 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 BooleanProperty readOnly; - private final Pattern backupFileNamePattern; + private final WorldManagePage worldManagePage; + private World world; + private Path backupsDir; + private Pattern backupFileNamePattern; public WorldBackupsPage(WorldManagePage worldManagePage) { - this.world = worldManagePage.getWorld(); - this.backupsDir = worldManagePage.getBackupsDir(); - 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"); + this.worldManagePage = worldManagePage; 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)) { @@ -98,7 +98,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 ImportableWorld(path), time, count, readOnlyProperty())); } } catch (Throwable e) { LOG.warning("Failed to load backup file " + path, e); @@ -106,7 +106,7 @@ public void refresh() { } }); - result.sort(Comparator.naturalOrder()); + result.sort(Comparator.reverseOrder()); return result; } } else { @@ -122,13 +122,17 @@ public void refresh() { }).start(); } + public ReadOnlyBooleanProperty readOnlyProperty() { + return worldManagePage.readOnlyProperty(); + } + @Override protected Skin createDefaultSkin() { return new WorldBackupsPageSkin(); } 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); @@ -141,11 +145,11 @@ 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 ImportableWorld(path), time, count, readOnlyProperty())); }).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); @@ -165,7 +169,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), @@ -176,18 +180,20 @@ protected List initializeToolbar(WorldBackupsPage skinnable) { public final class BackupInfo extends Control implements Comparable { private final Path file; - private final World backupWorld; + private final ImportableWorld backupWorld; private final LocalDateTime backupTime; private final int count; + private final ReadOnlyBooleanProperty readOnly; - public BackupInfo(Path file, World 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 World getBackupWorld() { + public ImportableWorld getBackupWorld() { return backupWorld; } @@ -209,6 +215,29 @@ void onDelete() { Task.runAsync(() -> Files.delete(file)).start(); } + void onRestore() { + Controllers.taskDialog( + new WorldRestoreTask(file, world).setName(i18n("world.restore.processing")) + .whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + 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 { + 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 public int compareTo(@NotNull WorldBackupsPage.BackupInfo that) { int c = this.backupTime.compareTo(that.backupTime); @@ -221,7 +250,7 @@ private static final class BackupInfoSkin extends SkinBase { BackupInfoSkin(BackupInfo skinnable) { super(skinnable); - World world = skinnable.getBackupWorld(); + ImportableWorld backupWorld = skinnable.getBackupWorld(); BorderPane root = new BorderPane(); root.getStyleClass().add("md-list-cell"); @@ -234,19 +263,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); - if (skinnable.getBackupWorld().getWorldName() != null) - item.setTitle(parseColorEscapes(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())); } { @@ -259,6 +287,12 @@ 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.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); 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 16c633fbc1..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,8 +19,8 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXTextField; +import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.property.SimpleStringProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.geometry.Pos; @@ -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; @@ -62,21 +61,36 @@ */ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.WorldRefreshable { private final WorldManagePage worldManagePage; - private boolean isReadOnly; - private final World world; - private CompoundTag levelData; + private World world; + private CompoundTag dataTag; private CompoundTag playerData; private final ImageContainer iconImageView = new ImageContainer(32); public WorldInfoPage(WorldManagePage worldManagePage) { this.worldManagePage = worldManagePage; - this.world = worldManagePage.getWorld(); refresh(); } + private ReadOnlyBooleanProperty 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(); @@ -96,25 +110,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.disableProperty().bind(readOnlyProperty()); + editIconButton.setOnAction(event -> { + WorldManageUIUtils.renameWorld(world, + newWorldName -> { + worldNameLabel.setText(newWorldName); + worldManagePage.setTitle(newWorldName); + }, + newWorldPath -> { + try { + Controllers.getWorldManagePage().setWorldAndRefresh(new World(newWorldPath), worldManagePage.getProfile(), worldManagePage.getInstanceId()); + } catch (IOException e) { + worldManagePage.closePageForLoadingFail(); + } + } + ); }); } else { - worldNameField.setDisable(true); + editIconButton.setDisable(true); } } @@ -135,7 +159,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"), @@ -145,7 +169,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")); } @@ -234,7 +258,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); } @@ -242,7 +266,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); @@ -262,7 +286,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; @@ -296,7 +320,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); @@ -361,7 +385,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 @@ -437,12 +461,13 @@ 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); } } private void setRightTextField(LinePane linePane, JFXTextField textField, int perfWidth) { - textField.setDisable(isReadOnly); + textField.disableProperty().bind(readOnlyProperty()); textField.setPrefWidth(perfWidth); linePane.setRight(textField); } @@ -520,20 +545,6 @@ private void saveWorldData() { } } - @Override - public void refresh() { - setFailedReason(null); - try { - this.isReadOnly = worldManagePage.isReadOnly(); - 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/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java index b5f0f355ad..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,12 +42,12 @@ 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; -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 +73,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()); @@ -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(); @@ -151,33 +151,33 @@ 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 World(zipFile)) + 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(); }, 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)); + }, world.getWorldName()); }, 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(); } 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) { @@ -192,6 +192,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()); } @@ -331,7 +335,7 @@ protected void updateItem(World world, boolean empty) { if (world.getGameVersion() != null) content.addTag(I18n.getDisplayVersion(world.getGameVersion())); - if (world.isLocked()) { + if (world.getWorldLock().getLockState() == World.WorldLock.LockState.LOCKED_BY_OTHER) { content.addTag(i18n("world.locked")); btnLaunch.setDisable(true); } else { @@ -347,7 +351,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().getLockState() == World.WorldLock.LockState.LOCKED_BY_OTHER; PopupMenu popupMenu = new PopupMenu(); JFXPopup popup = new JFXPopup(popupMenu); @@ -388,10 +392,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(SVG.EDIT, i18n("world.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 6b4f2efb80..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 @@ -39,8 +39,8 @@ import org.jetbrains.annotations.NotNull; import java.io.IOException; -import java.nio.channels.FileChannel; import java.nio.file.Path; +import java.util.Objects; import java.util.Optional; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -51,15 +51,17 @@ */ 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 FileChannel sessionLockChannel; + private World world; + private Path backupsDir; + private Profile profile; + private String instanceId; - private final ObjectProperty state; - private boolean isFirstNavigation = true; + 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); private final BooleanProperty readOnly = new SimpleBooleanProperty(false); @@ -69,29 +71,12 @@ 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) { - this.world = world; - this.backupsDir = profile.getRepository().getBackupsDirectory(instanceId); - this.profile = profile; - this.instanceId = 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()); - } - + public WorldManagePage() { 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)); - - Optional gameVersion = profile.getRepository().getGameVersion(instanceId); - supportQuickPlay = World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion)); + header.getTabs().addAll(worldInfoTab, worldBackupsTab, dataPackTab); this.addEventHandler(Navigator.NavigationEvent.EXITED, this::onExited); this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); @@ -102,8 +87,37 @@ public WorldManagePage(World world, Profile profile, String instanceId) { return new Skin(this); } + public WorldManagePage setWorld(World world, Profile profile, String instanceId) { + closeSessionLockChannel(); + + this.world = world; + this.backupsDir = profile.getRepository().getBackupsDirectory(instanceId); + this.profile = profile; + this.instanceId = instanceId; + + updateSessionLockChannel(); + + 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)); + + setTitle(world.getWorldName()); + header.select(worldInfoTab, false); + return this; + } + + public WorldManagePage setWorldAndRefresh(World world, Profile profile, String instanceId) { + setWorld(world, profile, instanceId); + refresh(); + return this; + } + @Override public void refresh() { + Objects.requireNonNull(world, "World is not initialized"); + updateSessionLockChannel(); try { world.reloadWorldData(); @@ -113,6 +127,8 @@ public void refresh() { return; } + setTitle(world.getWorldName()); + for (var tab : header.getTabs()) { if (tab.getNode() instanceof WorldRefreshable r) { r.refresh(); @@ -120,7 +136,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); @@ -128,28 +144,33 @@ private 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() { + if (world != null) { + try { + world.getWorldLock().releaseLock(); + } catch (IOException e) { + LOG.warning("Can not close session lock channel of world: " + this.world.getFile(), e); + } } } private void onNavigated(Navigator.NavigationEvent event) { - if (isFirstNavigation) - isFirstNavigation = false; - else - refresh(); + refresh(); } public void onExited(Navigator.NavigationEvent event) { - try { - WorldManageUIUtils.closeSessionLockChannel(world, sessionLockChannel); - } catch (IOException ignored) { - } + closeSessionLockChannel(); } public void launch() { - fireEvent(new PageCloseEvent()); + closeSessionLockChannel(); + readOnly.set(true); Versions.launchAndEnterWorld(profile, instanceId, world.getFileName()); } @@ -162,8 +183,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() { @@ -174,11 +195,19 @@ public Path getBackupsDir() { return backupsDir; } + public Profile getProfile() { + return profile; + } + + public String getInstanceId() { + return instanceId; + } + public boolean isReadOnly() { return readOnly.get(); } - public BooleanProperty readOnlyProperty() { + public ReadOnlyBooleanProperty readOnlyProperty() { return readOnly; } @@ -198,10 +227,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()); @@ -210,51 +238,42 @@ private BorderPane getSidebar() { } private AdvancedListBox getTabBar() { - AdvancedListBox tabBar = new AdvancedListBox(); - { - getSkinnable().header.getTabs().addAll(getSkinnable().worldInfoTab, getSkinnable().worldBackupsTab); - 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); - - if (getSkinnable().world.supportDataPacks()) { - getSkinnable().header.getTabs().add(getSkinnable().dataPackTab); - tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().dataPackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL); - } - } - - return tabBar; + 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); } 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(); 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())); @@ -263,26 +282,31 @@ 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, 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) ); - 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()); }); } 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..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 @@ -25,14 +25,13 @@ 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; -import java.io.IOException; -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; @@ -42,33 +41,18 @@ 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) -> { - 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(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) { - 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")); @@ -78,75 +62,67 @@ 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))); } 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; - } + 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(); + } + }).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) { + renameWorld(world, newWorldName -> runnable.run(), newWorldPath -> runnable.run()); } - 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 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 (finalNewWorldName.equals(world.getWorldName())) { + handler.resolve(); + return; } - } - } - 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; - } + Task.supplyAsync(Schedulers.io(), () -> { + if (renameFolder) { + 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 { + if (renameFolder && renameFolderConsumer != null) { + renameFolderConsumer.accept(result); + } else if (!renameFolder && notRenameFolderConsumer != null) { + notRenameFolderConsumer.accept(finalNewWorldName); + } + handler.resolve(); + } + }).start(); + }) + .addQuestion(new PromptDialogPane.Builder.StringQuestion(null, world.getWorldName())) + .addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("world.rename.rename_folder"), false))); } - } 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..ade443bc1b --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java @@ -0,0 +1,77 @@ +/* + * 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 = worldPath.toAbsolutePath().resolveSibling("." + worldPath.getFileName().toString() + ".tmp"); + Path tempPath2 = worldPath.toAbsolutePath().resolveSibling("." + worldPath.getFileName().toString() + ".tmp2"); + + // Check if the world format is correct + new ImportableWorld(backupZipPath); + try { + new Unzipper(backupZipPath, tempPath).setSubDirectory(world.getFileName()).unzip(); + } catch (IOException e) { + FileUtils.deleteDirectoryQuietly(tempPath); + throw e; + } + + try { + world.getWorldLock().releaseLock(); + Files.move(worldPath, tempPath2, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + FileUtils.deleteDirectoryQuietly(tempPath); + FileUtils.deleteDirectoryQuietly(tempPath2); + world.getWorldLock().lock(); + throw e; + } + + try { + Files.move(tempPath, worldPath, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + Files.move(tempPath2, worldPath, StandardCopyOption.REPLACE_EXISTING); + FileUtils.deleteDirectoryQuietly(tempPath); + world.getWorldLock().lock(); + 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 af0f9b13af..16562423df 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 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 web.open_in_browser=Do you want to open this address in a browser:\n%s @@ -1145,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 @@ -1228,6 +1228,17 @@ 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.rename.rename_folder=Rename world folder +world.restore=Restore Backup +world.restore.confirm=Are you sure you want to restore this backup?\nCurrent world progress will be overwritten. This action cannot be undone! +world.restore.failed=Failed to restore backup.\n%s +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_ar.properties b/HMCL/src/main/resources/assets/lang/I18N_ar.properties index 03efd9406b..43020f0110 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 03a1c106e4..64bdcf65e2 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 ea3acffc25..83f20d39c0 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 ce5d610f63..34b6b2601f 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 59470e36b3..dad401495f 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 f0a791bf75..0ddfbb9451 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 @@ -939,7 +940,6 @@ web.view_in_browser=在瀏覽器中查看完整日誌 world=世界 world.add=新增世界 -world.add.already_exists=此世界已經存在 world.add.failed=無法新增此世界: %s world.add.invalid=無法識別的世界壓縮檔 world.add.title=選取要新增的世界壓縮檔 @@ -1022,6 +1022,17 @@ world.manage.button=世界管理 world.manage.title=世界管理 - %s world.name=世界名稱 world.name.enter=輸入世界名稱 +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 +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 378253ed63..a23a6a8513 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 @@ -944,7 +945,6 @@ web.view_in_browser=在浏览器中查看完整日志 world=世界 world.add=添加世界 -world.add.already_exists=此世界已经存在 world.add.failed=无法添加此世界:%s world.add.invalid=无法识别该世界压缩包 world.add.title=选择要添加的世界压缩包 @@ -1027,6 +1027,17 @@ world.manage.button=世界管理 world.manage.title=世界管理 - %s world.name=世界名称 world.name.enter=输入世界名称 +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 +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 eb5b99c247..0471a3cb0e 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; @@ -42,22 +44,18 @@ 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 WorldDataSection levelDataTag; + private WorldDataSection worldGenSettingsTag; + private WorldDataSection playerTag; - 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 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); @@ -68,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)) { @@ -81,44 +78,13 @@ 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() { + return lock; } public Path getFile() { @@ -130,38 +96,35 @@ 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) { - levelNameTag.setValue(worldName); - writeLevelData(); - } + getDataTag().setString("LevelName", worldName); + levelDataTag.write(); } - public Path getSessionLockFile() { - return file.resolve("session.lock"); + public CompoundTag getLevelData() { + return levelDataTag.nbtBackingTag(); } - public CompoundTag getLevelData() { - return levelData; + public CompoundTag getDataTag() { + 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; @@ -169,8 +132,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()); } @@ -179,12 +141,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; @@ -192,12 +154,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) @@ -218,29 +180,25 @@ public Image getIcon() { return icon; } - 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"); } - 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"); @@ -249,218 +207,278 @@ 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 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 { - if (!Files.isDirectory(file)) - throw new IOException("Not a valid world directory"); + // 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 { + 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 - dataTag.setString("LevelName", newName); - writeLevelData(); + setWorldName(newName); + + // Then change the folder's name + Path targetPath = FileUtils.getNonConflictingDirectory(file.getParent(), FileUtils.getSafeWorldFolderName(newName)); + Files.move(file, targetPath); + return targetPath; + } + + 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"); + } - // then change the folder's name - Files.move(file, file.resolveSibling(newName)); + try (WorldLock.Suspension ignored = getWorldLock().suspend(); + Zipper zipper = new Zipper(zipPath)) { + zipper.putDirectory(file, worldName); + } } - public void install(Path savesDir, String name) throws IOException { - Path worldDir; - try { - worldDir = savesDir.resolve(name); - } catch (InvalidPathException e) { - throw new IOException(e); + public void delete() throws IOException { + switch (getWorldLock().getLockState()) { + case LOCKED_BY_OTHER -> throw new WorldLockedException("The world " + getFile() + " has been locked"); + case LOCKED_BY_SELF -> getWorldLock().releaseLock(); } + FileUtils.deleteDirectory(file); + } - if (Files.isDirectory(worldDir)) { - throw new FileAlreadyExistsException("World already exists"); + public void copy(String newName) throws IOException { + if (getWorldLock().getLockState() == WorldLock.LockState.LOCKED_BY_OTHER) { + throw new WorldLockedException("The world " + getFile() + " has been locked"); } - 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); + Path targetPath = FileUtils.getNonConflictingDirectory(file.getParent(), FileUtils.getSafeWorldFolderName(newName)); + FileUtils.copyDirectory(file, targetPath, path -> !path.contains("session.lock")); + new World(targetPath).setWorldName(newName); + } - 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(); - } - } + public void writeWorldData() throws IOException { + levelDataTag.write(); + worldGenSettingsTag.write(); + playerTag.write(); + } + public static List getWorlds(Path savesDir) { + if (Files.isDirectory(savesDir)) { + try (Stream stream = Files.list(savesDir)) { + return stream + .filter(Files::isDirectory) + .flatMap(world -> { + try { + return Stream.of(new World(world.toAbsolutePath().normalize())); + } catch (IOException e) { + LOG.warning("Failed to read world " + world, e); + return Stream.empty(); + } + }) + .toList(); + } catch (IOException e) { + LOG.warning("Failed to read saves", e); } - new World(worldDir).rename(name); - } else if (Files.isDirectory(file)) { - FileUtils.copyDirectory(file, worldDir); } + return List.of(); } - public void export(Path zip, String worldName) throws IOException { - if (!Files.isDirectory(file)) - throw new IOException(); + public class WorldLock { + private FileChannel sessionLockChannel; + private final Path sessionLockFile; - try (Zipper zipper = new Zipper(zip)) { - zipper.putDirectory(file, worldName); + public enum LockState { + LOCKED_BY_OTHER, + LOCKED_BY_SELF, + UNLOCKED; } - } - public void delete() throws IOException { - if (isLocked()) { - throw new WorldLockedException("The world " + getFile() + " has been locked"); + public WorldLock() { + this.sessionLockFile = file.resolve("session.lock"); + this.sessionLockChannel = null; } - FileUtils.forceDelete(file); - } - public void copy(String newName) throws IOException { - if (!Files.isDirectory(file)) { - throw new IOException("Not a valid world directory"); + public synchronized LockState getLockState() { + if (isLockedInternally()) { + return LockState.LOCKED_BY_SELF; + } else if (isLockedExternally()) { + return LockState.LOCKED_BY_OTHER; + } else { + return LockState.UNLOCKED; + } } - if (isLocked()) { - throw new WorldLockedException("The world " + getFile() + " has been locked"); + public synchronized boolean lock() { + try { + lockStrict(); + return true; + } catch (WorldLockedException e) { + return false; + } } - Path newPath = file.resolveSibling(newName); - FileUtils.copyDirectory(file, newPath, path -> !path.contains("session.lock")); - World newWorld = new World(newPath); - newWorld.rename(newName); - } + public void lockStrict() throws WorldLockedException { + switch (getLockState()) { + case LOCKED_BY_SELF -> { + } + case LOCKED_BY_OTHER -> throw new WorldLockedException("World is locked by others"); + case UNLOCKED -> acquireLock(); + } + } - 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 { + private 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(); + if (fileLock != null) { + this.sessionLockChannel = channel; + } else { + IOUtils.closeQuietly(channel); + throw new WorldLockedException("The world " + getFile() + " has been locked"); + } + } catch (IOException e) { IOUtils.closeQuietly(channel); - throw new WorldLockedException("The world " + getFile() + " has been locked"); + throw new WorldLockedException(e); } - } catch (IOException e) { - IOUtils.closeQuietly(channel); - throw new WorldLockedException(e); } - } - public void writeWorldData() throws IOException { - if (!Files.isDirectory(file)) throw new IOException("Not a valid world directory"); + private boolean isLockedInternally() { + return sessionLockChannel != null && sessionLockChannel.isOpen(); + } - writeLevelData(); + private boolean isLockedExternally() { + try (FileChannel fileChannel = FileChannel.open(sessionLockFile, StandardOpenOption.WRITE)) { + return fileChannel.tryLock() == null; + } catch (AccessDeniedException accessDeniedException) { + return true; + } catch (OverlappingFileLockException | NoSuchFileException overlappingFileLockException) { + return false; + } catch (IOException e) { + LOG.warning("Unexpected I/O error checking world lock: " + sessionLockFile, e); + return false; + } + } - if (worldGenSettingsDataPath != null && worldGenSettingsDataBackingTag != null) { - writeTag(worldGenSettingsDataBackingTag, worldGenSettingsDataPath); + public synchronized void releaseLock() throws IOException { + if (sessionLockChannel != null) { + sessionLockChannel.close(); + sessionLockChannel = null; + } } - if (playerDataPath != null && playerData != null) { - writeTag(playerData, playerDataPath); + public Guard guard() throws WorldLockedException { + return new Guard(); } - } - public void writeLevelData() throws IOException { - writeTag(levelData, levelDataPath); - } + public Suspension suspend() throws IOException { + return new Suspension(); + } - 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); + public final class Guard implements AutoCloseable { + private final boolean wasAlreadyLocked; + + private Guard() throws WorldLockedException { + synchronized (WorldLock.this) { + this.wasAlreadyLocked = isLockedInternally(); + 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 final class Suspension implements AutoCloseable { + private final boolean hadLock; + + private Suspension() throws IOException { + synchronized (WorldLock.this) { + this.hadLock = isLockedInternally(); + if (hadLock) { + releaseLock(); + } + } } - }); - } - 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; + @Override + public void close() { + synchronized (WorldLock.this) { + if (hadLock) { + try { + lockStrict(); + } catch (WorldLockedException e) { + LOG.warning("Failed to resume lock after suspension", e); + } + } + } + } } } - public static List getWorlds(Path savesDir) { - if (Files.exists(savesDir)) { - try (Stream stream = Files.list(savesDir)) { - return stream - .filter(Files::isDirectory) - .flatMap(world -> { - try { - return Stream.of(new World(world.toAbsolutePath().normalize())); - } catch (IOException e) { - LOG.warning("Failed to read world " + world, e); - return Stream.empty(); - } - }) - .toList(); - } catch (IOException e) { - LOG.warning("Failed to read saves", e); + 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); + } + }); } } - return List.of(); } } 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()); } 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() 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..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,62 @@ 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"; + } + + // Replace invalid characters with underscores + // Note: The handling of `.` here is to align with Minecraft's processing logic. + String sanitized = name.replaceAll("[\\x00-\\x1f\\\\/:*?\"<>|.]", "_"); + + // 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 + "_"; + } + + // Provide a default value if the sanitized string is empty + if (sanitized.isEmpty()) { + return "New World"; + } + + 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 {