Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,19 @@
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.Lang;

import org.jackhuang.hmcl.util.PortablePath;
import org.jackhuang.hmcl.util.function.ExceptionalConsumer;
import org.jackhuang.hmcl.util.function.ExceptionalRunnable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;

import java.io.Closeable;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.Charset;
Expand All @@ -60,6 +63,7 @@
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;

import static org.jackhuang.hmcl.util.Lang.mapOf;
Expand Down Expand Up @@ -124,6 +128,65 @@ public static Modpack readModpackManifest(Path file, Charset charset) throws Uns
throw new UnsupportedModpackException(file.toString());
}

/// Owns the launcher wrapper file system and the embedded modpack path within it.
///
/// The owner is safe to close repeatedly and from competing terminal paths.
public static final class LauncherWrapper implements Closeable {
/// Embedded modpack path backed by the wrapper file system.
private final Path innerPath;

/// Wrapper file system, atomically cleared by the first close operation.
private final AtomicReference<@Nullable FileSystem> wrapperFsRef;

/// Creates an owner for an embedded modpack path and its backing file system.
///
/// @param innerPath embedded modpack path
/// @param wrapperFs backing wrapper file system
private LauncherWrapper(Path innerPath, FileSystem wrapperFs) {
this.innerPath = innerPath;
this.wrapperFsRef = new AtomicReference<>(wrapperFs);
}

/// Returns the embedded modpack path.
///
/// The path is valid only until this owner is closed.
public Path innerPath() {
return innerPath;
}

/// Closes the backing wrapper file system if it is still open.

public void close() throws IOException {
FileSystem wrapperFs = wrapperFsRef.getAndSet(null);
if (wrapperFs != null) {
wrapperFs.close();
}
}
}

/// 检测 [file] 是否为 HMCL 启动器包装 ZIP(其内部嵌入了实际的整合包 `modpack.zip` 或 `modpack.mrpack`)
/// 返回一个包含内部条目路径和包装文件系统的 [LauncherWrapper],
/// 如果 [file] 不是包装 ZIP,则返回 `null`
@Nullable
public static LauncherWrapper unwrapIfLauncherWrapper(Path file, Charset charset) {
FileSystem outerFs = null;
try {
outerFs = CompressingUtils.createReadOnlyZipFileSystem(file, charset);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

为什么要用 ZipFileSystem?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

可以在代码改动少且不解压临时文件的情况下实现这个功能

getPath 直接返回 Path,与现有代码兼容

for (String innerName : new String[]{"modpack.zip", "modpack.mrpack"}) {
Path entryPath = outerFs.getPath("/" + innerName);
if (Files.isRegularFile(entryPath)) {
LauncherWrapper result = new LauncherWrapper(entryPath, outerFs);
outerFs = null;
return result;
}
}
} catch (IOException ignored) {
} finally {
IOUtils.closeQuietly(outerFs);
}
return null;
}

public static Path findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, FileSystem fs) throws IOException, UnsupportedModpackException {
Path root = fs.getPath("/");
if (isMinecraftDirectory(root)) return root;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.ManuallyCreatedModpackException;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.game.ModpackHelper.LauncherWrapper;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Profiles;
Expand All @@ -40,9 +41,14 @@
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jetbrains.annotations.Nullable;

import java.nio.charset.Charset;
import java.nio.file.FileSystems;
import java.nio.file.Path;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;

import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
Expand All @@ -53,6 +59,10 @@ public final class LocalModpackPage extends ModpackPage {
private Modpack manifest = null;
private Charset charset;

private final AtomicReference<LauncherWrapper> wrapperRef = new AtomicReference<>();

private volatile boolean cleanedUp;

public LocalModpackPage(WizardController controller) {
super(controller);

Expand Down Expand Up @@ -91,11 +101,12 @@ public LocalModpackPage(WizardController controller) {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("modpack.choose"));
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip"));
selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
if (selectedFile == null) {
Path chosenFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
if (chosenFile == null) {
controller.onEnd();
return;
}
selectedFile = chosenFile;

controller.getSettings().put(MODPACK_FILE, selectedFile);
}
Expand All @@ -104,10 +115,32 @@ public LocalModpackPage(WizardController controller) {
Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(selectedFile))
.thenApplyAsync(encoding -> {
charset = encoding;
manifest = ModpackHelper.readModpackManifest(selectedFile, encoding);
Path actualFile = selectedFile;
if (selectedFile.getFileSystem() == FileSystems.getDefault()) {
LauncherWrapper wrapper = ModpackHelper.unwrapIfLauncherWrapper(selectedFile, encoding);
if (wrapper != null) {
actualFile = wrapper.innerPath();
wrapperRef.set(wrapper);
}
}
Comment on lines +119 to +125

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

配合 ModpackHelper.unwrapIfLauncherWrapper 改为返回临时文件 Path 的方案,这里不需要再处理复杂的 MODPACK_WRAPPER_FS,而是改为记录并管理临时文件 MODPACK_TEMP_FILE 的生命周期。这可以极大简化逻辑并避免文件系统泄漏。

                    if (selectedFile.getFileSystem() == FileSystems.getDefault()) {
                        Path unwrapped = ModpackHelper.unwrapIfLauncherWrapper(selectedFile, encoding);
                        if (unwrapped != null) {
                            actualFile = unwrapped;
                            controller.getSettings().put(MODPACK_FILE, unwrapped);
                            Path oldTemp = controller.getSettings().put(MODPACK_TEMP_FILE, unwrapped);
                            if (oldTemp != null) {
                                try {
                                    java.nio.file.Files.deleteIfExists(oldTemp);
                                } catch (IOException ignored) {
                                }
                            }
                        } else {
                            Path oldTemp = controller.getSettings().remove(MODPACK_TEMP_FILE);
                            if (oldTemp != null) {
                                try {
                                    java.nio.file.Files.deleteIfExists(oldTemp);
                                } catch (IOException ignored) {
                                }
                            }
                        }
                    }

manifest = ModpackHelper.readModpackManifest(actualFile, encoding);
return manifest;
})
.whenComplete(Schedulers.javafx(), (manifest, exception) -> {
Comment on lines 116 to 129

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

在锠包包含启刨器的整合包包含启刨器的恐怕在快递切页或取消的情况下,帽步任务 Task.supplyAsync 可能会在页面已被清理(cleanup 已被调用)后才执行完毟。这会导致两个严重问题:

  1. 资源泄露:新创建的 wrapper.getValue()(即 FileSystem)会被放入 controller.getSettings() 中,但由于 cleanup 已经执行完毟,该文件系统将永远不会被关闭。
  2. 状态污染:已销毁页面的帽步任务会向共享的 controller.getSettings() 写入数据,从而覆盖新页面的设置。

解决方案
LocalModpackPage 中引入一个 private volatile boolean cleanedUp = false; 字段。在 cleanup 方法中将其置为 true。在帽步任务的 thenApplyAsyncwhenComplete 中检查该标志,如果已清理,将立即关闭新创建的 FileSystem 并退出,避免修改共享设置。

                .thenApplyAsync(encoding -> {
                    charset = encoding;
                    Path actualFile = selectedFile;
                    if (selectedFile.getFileSystem() == FileSystems.getDefault()) {
                        var wrapper = ModpackHelper.unwrapIfLauncherWrapper(selectedFile, encoding);
                        if (wrapper != null) {
                            if (cleanedUp) {
                                try {
                                    wrapper.getValue().close();
                                } catch (IOException ignored) {
                                }
                                return null;
                            }
                            actualFile = wrapper.getKey();
                            controller.getSettings().put(MODPACK_FILE, wrapper.getKey());
                            FileSystem oldFs = controller.getSettings().put(MODPACK_WRAPPER_FS, wrapper.getValue());
                            if (oldFs != null) {
                                try {
                                    oldFs.close();
                                } catch (IOException ignored) {
                                    // Ignore close errors for wrapper filesystem
                                }
                            }
                        } else {
                            FileSystem oldFs = controller.getSettings().remove(MODPACK_WRAPPER_FS);
                            if (oldFs != null) {
                                try { 
                                    oldFs.close();
                                } catch (IOException ignored) {
                                    // Ignore close errors for wrapper filesystem
                                }
                            }
                        }
                    }
                    manifest = ModpackHelper.readModpackManifest(actualFile, encoding);
                    return manifest;
                })
                .whenComplete(Schedulers.javafx(), (manifest, exception) -> {
                    if (cleanedUp) return;

LauncherWrapper wrapper = wrapperRef.getAndSet(null);
if (wrapper != null) {
if (exception != null || cleanedUp) {
IOUtils.closeQuietly(wrapper);
} else {
controller.getSettings().put(MODPACK_FILE, wrapper.innerPath());
controller.getSettings().put(MODPACK_WRAPPER, wrapper);
}
}

if (cleanedUp) {
return;
}

if (exception instanceof ManuallyCreatedModpackException) {
hideSpinner();
nameProperty.set(FileUtils.getName(selectedFile));
Expand All @@ -129,27 +162,36 @@ public LocalModpackPage(WizardController controller) {
Platform.runLater(controller::onEnd);
} else {
hideSpinner();
controller.getSettings().put(MODPACK_MANIFEST, manifest);
nameProperty.set(manifest.getName());
versionProperty.set(manifest.getVersion());
authorProperty.set(manifest.getAuthor());
Modpack parsedManifest = Objects.requireNonNull(manifest);
controller.getSettings().put(MODPACK_MANIFEST, parsedManifest);
nameProperty.set(parsedManifest.getName());
versionProperty.set(parsedManifest.getVersion());
authorProperty.set(parsedManifest.getAuthor());

if (name == null) {
// trim: https://github.com/HMCL-dev/HMCL/issues/962
txtModpackName.setText(manifest.getName().trim());
txtModpackName.setText(parsedManifest.getName().trim());
}

btnDescription.setVisible(StringUtils.isNotBlank(manifest.getDescription()));
btnDescription.setVisible(StringUtils.isNotBlank(parsedManifest.getDescription()));
}
}).start();
}

@Override
public void cleanup(SettingsMap settings) {
cleanedUp = true;
settings.remove(MODPACK_FILE);
IOUtils.closeQuietly(wrapperRef.getAndSet(null));
IOUtils.closeQuietly(settings.remove(MODPACK_WRAPPER));
}
Comment on lines 182 to 187

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

配合上述的 cleanedUp 机制,在 cleanup 方法中将 cleanedUp 标志置为 true,以确保在页面销毁后能够正确中止帽步任务并释放资源。请注意需要在 LocalModpackPage 类中声明 private volatile boolean cleanedUp = false; 成员变量。

    @Override
    public void cleanup(SettingsMap settings) {
        this.cleanedUp = true;
        settings.remove(MODPACK_FILE);
        FileSystem wrapperFs = settings.remove(MODPACK_WRAPPER_FS);
        if (wrapperFs != null) {
            try {
                wrapperFs.close();
            } catch (IOException ignored) {
                // Ignore close errors for wrapper filesystem
            }
        }
    }


protected void onInstall() {
Charset detectedCharset = charset;
if (detectedCharset == null) {
return;
}

String name = txtModpackName.getText();

// Check for non-ASCII characters.
Expand All @@ -160,15 +202,15 @@ protected void onInstall() {
MessageDialogPane.MessageType.QUESTION)
.yesOrNo(() -> {
controller.getSettings().put(MODPACK_NAME, name);
controller.getSettings().put(MODPACK_CHARSET, charset);
controller.getSettings().put(MODPACK_CHARSET, detectedCharset);
controller.onFinish();
}, () -> {
// The user selects Cancel and does nothing.
})
.build());
} else {
controller.getSettings().put(MODPACK_NAME, name);
controller.getSettings().put(MODPACK_CHARSET, charset);
controller.getSettings().put(MODPACK_CHARSET, detectedCharset);
controller.onFinish();
}
}
Expand All @@ -179,6 +221,9 @@ protected void onDescribe() {
}

public static final SettingsMap.Key<Path> MODPACK_FILE = new SettingsMap.Key<>("MODPACK_FILE");
public static final SettingsMap.Key<LauncherWrapper> MODPACK_WRAPPER =
new SettingsMap.Key<>("MODPACK_WRAPPER");

public static final SettingsMap.Key<String> MODPACK_NAME = new SettingsMap.Key<>("MODPACK_NAME");
public static final SettingsMap.Key<Modpack> MODPACK_MANIFEST = new SettingsMap.Key<>("MODPACK_MANIFEST");
public static final SettingsMap.Key<Charset> MODPACK_CHARSET = new SettingsMap.Key<>("MODPACK_CHARSET");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.IOUtils;

import java.io.FileNotFoundException;
import java.io.IOException;
Expand Down Expand Up @@ -94,6 +95,9 @@ private Task<?> finishModpackInstallingAsync(SettingsMap settings) {
boolean isManuallyCreated = settings.getOrDefault(LocalModpackPage.MODPACK_MANUALLY_CREATED, false);

if (isManuallyCreated) {
if (selected == null || name == null || charset == null) {
return null;
}
return ModpackHelper.getInstallManuallyCreatedModpackTask(profile, selected, name, charset);
}

Expand Down Expand Up @@ -145,7 +149,18 @@ public Object finish(SettingsMap settings) {
}
});

return finishModpackInstallingAsync(settings);
ModpackHelper.LauncherWrapper wrapper = settings.get(LocalModpackPage.MODPACK_WRAPPER);
try {
Task<?> task = finishModpackInstallingAsync(settings);
if (task != null && wrapper != null) {
ModpackHelper.LauncherWrapper ownedWrapper = wrapper;
wrapper = null;
task = task.whenComplete(Schedulers.defaultScheduler(), ignored -> IOUtils.closeQuietly(ownedWrapper));
}
return task;
} finally {
IOUtils.closeQuietly(wrapper);
}
}

private static Node createModpackInstallPage(WizardController controller) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import javafx.scene.Node;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jetbrains.annotations.Nullable;

import java.util.*;

Expand Down Expand Up @@ -55,19 +56,20 @@ public List<Node> getPages() {

@Override
public void onStart() {
Objects.requireNonNull(provider);
WizardProvider activeProvider = Objects.requireNonNull(provider);
stopped = false;

settings.clear();
provider.start(settings);
activeProvider.start(settings);

pages.clear();
Node page = navigatingTo(0);
pages.push(page);

if (stopped) { // navigatingTo may stop this wizard.
return;
}

pages.push(page);
if (page instanceof WizardPage)
((WizardPage) page).onNavigate(settings);

Expand All @@ -87,12 +89,11 @@ public void onNext(Node page) {
}

public void onNext(Node page, NavigationDirection direction) {
pages.push(page);

if (stopped) { // navigatingTo may stop this wizard.
return;
}

pages.push(page);
if (page instanceof WizardPage)
((WizardPage) page).onNavigate(settings);

Expand All @@ -107,7 +108,7 @@ public void onPrev(boolean cleanUp) {

public void onPrev(boolean cleanUp, NavigationDirection direction) {
if (!canPrev()) {
if (provider.cancelIfCannotGoBack()) {
if (Objects.requireNonNull(provider).cancelIfCannotGoBack()) {
onCancel();
return;
} else {
Expand All @@ -134,7 +135,8 @@ public boolean canPrev() {

@Override
public void onFinish() {
Object result = provider.finish(settings);
WizardProvider activeProvider = Objects.requireNonNull(provider);
@Nullable Object result = activeProvider.finish(settings);
if (result instanceof Summary) displayer.navigateTo(((Summary) result).getComponent(), NavigationDirection.NEXT);
else if (result instanceof Task<?>) displayer.handleTask(settings, ((Task<?>) result));
else if (result != null) throw new IllegalStateException("Unrecognized wizard result: " + result);
Expand All @@ -143,8 +145,17 @@ public void onFinish() {
@Override
public void onEnd() {
stopped = true;
while (!pages.isEmpty()) {
Node page = pages.pop();
if (page instanceof WizardPage wizardPage) {
try {
wizardPage.cleanup(settings);
} catch (RuntimeException e) {
LOG.warning("Failed to clean up wizard page " + page, e);
}
}
}
settings.clear();
pages.clear();
displayer.onEnd();
}

Expand All @@ -155,6 +166,6 @@ public void onCancel() {
}

protected Node navigatingTo(int step) {
return provider.createPage(this, step, settings);
return Objects.requireNonNull(provider).createPage(this, step, settings);
}
}