feat: 优化数据包管理功能 (#4672)

close #4661

Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
This commit is contained in:
mineDiamond
2025-11-13 16:43:17 +08:00
committed by GitHub
parent 80b2242e4d
commit 17bc003698
4 changed files with 513 additions and 188 deletions

View File

@@ -33,13 +33,17 @@ import org.jackhuang.hmcl.util.io.Unzipper;
import java.io.IOException;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class Datapack {
private boolean isMultiple;
private static final String DISABLED_EXT = "disabled";
private static final String ZIP_EXT = "zip";
private final Path path;
private final ObservableList<Pack> info = FXCollections.observableArrayList();
private final ObservableList<Pack> packs = FXCollections.observableArrayList();
public Datapack(Path path) {
this.path = path;
@@ -49,96 +53,104 @@ public class Datapack {
return path;
}
public ObservableList<Pack> getInfo() {
return info;
public ObservableList<Pack> getPacks() {
return packs;
}
public void installTo(Path worldPath) throws IOException {
Path datapacks = worldPath.resolve("datapacks");
public static void installPack(Path sourceDatapackPath, Path targetDatapackDirectory) throws IOException {
boolean containsMultiplePacks;
Set<String> packs = new HashSet<>();
for (Pack pack : info) packs.add(pack.getId());
if (Files.isDirectory(datapacks)) {
try (DirectoryStream<Path> directoryStream = Files.newDirectoryStream(datapacks)) {
for (Path datapack : directoryStream) {
if (Files.isDirectory(datapack) && packs.contains(FileUtils.getName(datapack)))
FileUtils.deleteDirectory(datapack);
else if (Files.isRegularFile(datapack) && packs.contains(FileUtils.getNameWithoutExtension(datapack)))
Files.delete(datapack);
}
}
}
if (isMultiple) {
new Unzipper(path, worldPath)
.setReplaceExistentFile(true)
.setFilter(new Unzipper.FileFilter() {
@Override
public boolean accept(Path destPath, boolean isDirectory, Path zipEntry, String entryPath) {
// We will merge resources.zip instead of replacement.
return !entryPath.equals("resources.zip");
}
})
.unzip();
try (FileSystem dest = CompressingUtils.createWritableZipFileSystem(worldPath.resolve("resources.zip"));
FileSystem zip = CompressingUtils.createReadOnlyZipFileSystem(path)) {
Path resourcesZip = zip.getPath("resources.zip");
if (Files.isRegularFile(resourcesZip)) {
Path temp = Files.createTempFile("hmcl", ".zip");
Files.copy(resourcesZip, temp, StandardCopyOption.REPLACE_EXISTING);
try (FileSystem resources = CompressingUtils.createReadOnlyZipFileSystem(temp)) {
FileUtils.copyDirectory(resources.getPath("/"), dest.getPath("/"));
}
}
Path packMcMeta = dest.getPath("pack.mcmeta");
Files.write(packMcMeta, Arrays.asList("{",
"\t\"pack\": {",
"\t\t\"pack_format\": 4,",
"\t\t\"description\": \"Modified by HMCL.\"",
"\t}",
"}"), StandardOpenOption.CREATE);
Path packPng = dest.getPath("pack.png");
if (Files.isRegularFile(packPng))
Files.delete(packPng);
}
} else {
FileUtils.copyFile(path, datapacks.resolve(FileUtils.getName(path)));
}
}
public void deletePack(Pack pack) throws IOException {
Path subPath = pack.file;
if (Files.isDirectory(subPath))
FileUtils.deleteDirectory(subPath);
else if (Files.isRegularFile(subPath))
Files.delete(subPath);
Platform.runLater(() -> info.removeIf(p -> p.getId().equals(pack.getId())));
}
public void loadFromZip() throws IOException {
try (FileSystem fs = CompressingUtils.readonly(path).setAutoDetectEncoding(true).build()) {
Path datapacks = fs.getPath("/datapacks/");
try (FileSystem fs = CompressingUtils.readonly(sourceDatapackPath).setAutoDetectEncoding(true).build()) {
Path datapacks = fs.getPath("datapacks");
Path mcmeta = fs.getPath("pack.mcmeta");
if (Files.exists(datapacks)) { // multiple datapacks
isMultiple = true;
loadFromDir(datapacks);
} else if (Files.exists(mcmeta)) { // single datapack
isMultiple = false;
try {
PackMcMeta pack = JsonUtils.fromNonNullJson(Files.readString(mcmeta), PackMcMeta.class);
Platform.runLater(() -> info.add(new Pack(path, FileUtils.getNameWithoutExtension(path), pack.getPackInfo().getDescription(), this)));
} catch (Exception e) {
LOG.warning("Failed to read datapack " + path, e);
}
if (Files.exists(datapacks)) {
containsMultiplePacks = true;
} else if (Files.exists(mcmeta)) {
containsMultiplePacks = false;
} else {
throw new IOException("Malformed datapack zip");
}
if (containsMultiplePacks) {
try (Stream<Path> s = Files.list(datapacks)) {
packs = s.map(FileUtils::getNameWithoutExtension).collect(Collectors.toSet());
}
} else {
packs.add(FileUtils.getNameWithoutExtension(sourceDatapackPath));
}
try (DirectoryStream<Path> stream = Files.newDirectoryStream(targetDatapackDirectory)) {
for (Path dir : stream) {
String packName = FileUtils.getName(dir);
if (FileUtils.getExtension(dir).equals(DISABLED_EXT)) {
packName = StringUtils.removeSuffix(packName, "." + DISABLED_EXT);
}
packName = FileUtils.getNameWithoutExtension(packName);
if (packs.contains(packName)) {
if (Files.isDirectory(dir)) {
FileUtils.deleteDirectory(dir);
} else if (Files.isRegularFile(dir)) {
Files.delete(dir);
}
}
}
}
}
if (!containsMultiplePacks) {
FileUtils.copyFile(sourceDatapackPath, targetDatapackDirectory.resolve(FileUtils.getName(sourceDatapackPath)));
} else {
new Unzipper(sourceDatapackPath, targetDatapackDirectory)
.setReplaceExistentFile(true)
.setSubDirectory("/datapacks/")
.unzip();
try (FileSystem outputResourcesZipFS = CompressingUtils.createWritableZipFileSystem(targetDatapackDirectory.getParent().resolve("resources.zip"));
FileSystem inputPackZipFS = CompressingUtils.createReadOnlyZipFileSystem(sourceDatapackPath)) {
Path resourcesZip = inputPackZipFS.getPath("resources.zip");
if (Files.isRegularFile(resourcesZip)) {
Path tempResourcesFile = Files.createTempFile("hmcl", ".zip");
try {
Files.copy(resourcesZip, tempResourcesFile, StandardCopyOption.REPLACE_EXISTING);
try (FileSystem resources = CompressingUtils.createReadOnlyZipFileSystem(tempResourcesFile)) {
FileUtils.copyDirectory(resources.getPath("/"), outputResourcesZipFS.getPath("/"));
}
} finally {
Files.deleteIfExists(tempResourcesFile);
}
}
Path packMcMeta = outputResourcesZipFS.getPath("pack.mcmeta");
String metaContent = """
{
"pack": {
"pack_format": 4,
"description": "Modified by HMCL."
}
}
""";
Files.writeString(packMcMeta, metaContent, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
Path packPng = outputResourcesZipFS.getPath("pack.png");
if (Files.isRegularFile(packPng))
Files.delete(packPng);
}
}
}
public void installPack(Path sourcePackPath) throws IOException {
installPack(sourcePackPath, path);
loadFromDir();
}
public void deletePack(Pack packToDelete) throws IOException {
Path pathToDelete = packToDelete.path;
if (Files.isDirectory(pathToDelete))
FileUtils.deleteDirectory(pathToDelete);
else if (Files.isRegularFile(pathToDelete))
Files.delete(pathToDelete);
Platform.runLater(() -> packs.removeIf(p -> p.getId().equals(packToDelete.getId())));
}
public void loadFromDir() {
@@ -150,86 +162,134 @@ public class Datapack {
}
private void loadFromDir(Path dir) throws IOException {
List<Pack> info = new ArrayList<>();
List<Pack> discoveredPacks;
try (Stream<Path> stream = Files.list(dir)) {
discoveredPacks = stream
.parallel()
.map(this::loadSinglePackFromPath)
.flatMap(Optional::stream)
.sorted(Comparator.comparing(Pack::getId, String.CASE_INSENSITIVE_ORDER))
.collect(Collectors.toList());
}
Platform.runLater(() -> this.packs.setAll(discoveredPacks));
}
if (Files.isDirectory(dir)) {
try (DirectoryStream<Path> directoryStream = Files.newDirectoryStream(dir)) {
for (Path subDir : directoryStream) {
if (Files.isDirectory(subDir)) {
Path mcmeta = subDir.resolve("pack.mcmeta");
Path mcmetaDisabled = subDir.resolve("pack.mcmeta.disabled");
private Optional<Pack> loadSinglePackFromPath(Path path) {
if (Files.isDirectory(path)) {
return loadSinglePackFromDirectory(path);
} else if (Files.isRegularFile(path)) {
return loadSinglePackFromZipFile(path);
}
return Optional.empty();
}
if (!Files.exists(mcmeta) && !Files.exists(mcmetaDisabled))
continue;
private Optional<Pack> loadSinglePackFromDirectory(Path path) {
Path mcmeta = path.resolve("pack.mcmeta");
Path mcmetaDisabled = path.resolve("pack.mcmeta.disabled");
boolean enabled = Files.exists(mcmeta);
try {
PackMcMeta pack = enabled ? JsonUtils.fromNonNullJson(Files.readString(mcmeta), PackMcMeta.class)
: JsonUtils.fromNonNullJson(Files.readString(mcmetaDisabled), PackMcMeta.class);
info.add(new Pack(enabled ? mcmeta : mcmetaDisabled, FileUtils.getName(subDir), pack.getPackInfo().getDescription(), this));
} catch (IOException | JsonParseException e) {
LOG.warning("Failed to read datapack " + subDir, e);
}
} else if (Files.isRegularFile(subDir)) {
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(subDir)) {
Path mcmeta = fs.getPath("pack.mcmeta");
if (!Files.exists(mcmeta))
continue;
String name = FileUtils.getName(subDir);
if (name.endsWith(".disabled")) {
name = name.substring(0, name.length() - ".disabled".length());
}
if (!name.endsWith(".zip"))
continue;
name = StringUtils.substringBeforeLast(name, ".zip");
PackMcMeta pack = JsonUtils.fromNonNullJson(Files.readString(mcmeta), PackMcMeta.class);
info.add(new Pack(subDir, name, pack.getPackInfo().getDescription(), this));
} catch (IOException | JsonParseException e) {
LOG.warning("Failed to read datapack " + subDir, e);
}
}
}
}
if (!Files.exists(mcmeta) && !Files.exists(mcmetaDisabled)) {
return Optional.empty();
}
Platform.runLater(() -> this.info.setAll(info));
Path targetPath = Files.exists(mcmeta) ? mcmeta : mcmetaDisabled;
return parsePack(path, true, FileUtils.getNameWithoutExtension(path), targetPath);
}
private Optional<Pack> loadSinglePackFromZipFile(Path path) {
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(path)) {
Path mcmeta = fs.getPath("pack.mcmeta");
if (!Files.exists(mcmeta)) {
return Optional.empty();
}
String packName = FileUtils.getName(path);
if (FileUtils.getExtension(path).equals(DISABLED_EXT)) {
packName = FileUtils.getNameWithoutExtension(packName);
}
packName = FileUtils.getNameWithoutExtension(packName);
return parsePack(path, false, packName, mcmeta);
} catch (IOException e) {
LOG.warning("IO error reading " + path, e);
return Optional.empty();
}
}
private Optional<Pack> parsePack(Path datapackPath, boolean isDirectory, String name, Path mcmetaPath) {
try {
PackMcMeta mcMeta = JsonUtils.fromNonNullJson(Files.readString(mcmetaPath), PackMcMeta.class);
return Optional.of(new Pack(datapackPath, isDirectory, name, mcMeta.getPackInfo().getDescription(), this));
} catch (JsonParseException e) {
LOG.warning("Invalid pack.mcmeta format in " + datapackPath, e);
} catch (IOException e) {
LOG.warning("IO error reading " + datapackPath, e);
}
return Optional.empty();
}
public static class Pack {
private Path file;
private final BooleanProperty active;
private Path path;
private final boolean isDirectory;
private Path statusFile;
private final BooleanProperty activeProperty;
private final String id;
private final LocalModFile.Description description;
private final Datapack datapack;
private final Datapack parentDatapack;
public Pack(Path file, String id, LocalModFile.Description description, Datapack datapack) {
this.file = file;
public Pack(Path path, boolean isDirectory, String id, LocalModFile.Description description, Datapack parentDatapack) {
this.path = path;
this.isDirectory = isDirectory;
this.id = id;
this.description = description;
this.datapack = datapack;
this.parentDatapack = parentDatapack;
active = new SimpleBooleanProperty(this, "active", !DISABLED_EXT.equals(FileUtils.getExtension(file))) {
@Override
protected void invalidated() {
Path f = Pack.this.file.toAbsolutePath(), newF;
if (DISABLED_EXT.equals(FileUtils.getExtension(f)))
newF = f.getParent().resolve(FileUtils.getNameWithoutExtension(f));
else
newF = f.getParent().resolve(FileUtils.getName(f) + "." + DISABLED_EXT);
this.statusFile = initializeStatusFile(path, isDirectory);
this.activeProperty = initializeActiveProperty();
}
try {
Files.move(f, newF);
Pack.this.file = newF;
} catch (IOException e) {
// Mod file is occupied.
LOG.warning("Unable to rename file " + f + " to " + newF);
}
private Path initializeStatusFile(Path path, boolean isDirectory) {
if (isDirectory) {
Path mcmeta = path.resolve("pack.mcmeta");
return Files.exists(mcmeta) ? mcmeta : path.resolve("pack.mcmeta.disabled");
}
return path;
}
private BooleanProperty initializeActiveProperty() {
BooleanProperty property = new SimpleBooleanProperty(this, "active", !FileUtils.getExtension(this.statusFile).equals(DISABLED_EXT));
property.addListener((obs, wasActive, isNowActive) -> {
if (wasActive != isNowActive) {
handleFileRename(isNowActive);
}
};
});
return property;
}
private void handleFileRename(boolean isNowActive) {
Path newStatusFile = calculateNewStatusFilePath(isNowActive);
if (statusFile.equals(newStatusFile)) {
return;
}
try {
Files.move(this.statusFile, newStatusFile);
this.statusFile = newStatusFile;
if (!this.isDirectory) {
this.path = newStatusFile;
}
} catch (IOException e) {
LOG.warning("Unable to rename file from " + this.statusFile + " to " + newStatusFile, e);
}
}
private Path calculateNewStatusFilePath(boolean isActive) {
boolean isFileDisabled = DISABLED_EXT.equals(FileUtils.getExtension(this.statusFile));
if (isActive && isFileDisabled) {
return this.statusFile.getParent().resolve(FileUtils.getNameWithoutExtension(this.statusFile));
} else if (!isActive && !isFileDisabled) {
return this.statusFile.getParent().resolve(FileUtils.getName(this.statusFile) + "." + DISABLED_EXT);
}
return this.statusFile;
}
public String getId() {
@@ -240,23 +300,29 @@ public class Datapack {
return description;
}
public Datapack getDatapack() {
return datapack;
public Datapack getParentDatapack() {
return parentDatapack;
}
public BooleanProperty activeProperty() {
return active;
return activeProperty;
}
public boolean isActive() {
return active.get();
return activeProperty.get();
}
public void setActive(boolean active) {
this.active.set(active);
this.activeProperty.set(active);
}
public Path getPath() {
return path;
}
public boolean isDirectory() {
return isDirectory;
}
}
private static final String DISABLED_EXT = "disabled";
}