Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,13 @@ public BooleanProperty disableAutoShowUpdateDialogProperty() {
return disableAutoShowUpdateDialog;
}

@SerializedName("autoDownloadUpdate")
private final BooleanProperty autoDownloadUpdate = new SimpleBooleanProperty(false);

public BooleanProperty autoDownloadUpdateProperty() {
return autoDownloadUpdate;
}

/// Whether April Fools features are disabled.
@SerializedName("disableAprilFools")
private final BooleanProperty disableAprilFools = new SimpleBooleanProperty(false);
Expand Down
30 changes: 23 additions & 7 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import com.jfoenix.controls.JFXButton;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.StringProperty;
import javafx.css.PseudoClass;
Expand Down Expand Up @@ -86,7 +87,6 @@ public SettingsPage() {
{
ObjectProperty<UpdateChannel> updateChannel;
{

JFXButton updateButton = FXUtils.newToggleButton4(SVG.UPDATE, 20);
updateButton.setOnAction(e -> onUpdate());
updateButton.setPadding(Insets.EMPTY);
Expand Down Expand Up @@ -139,17 +139,13 @@ protected int getTrailingTextIndex() {
updatePaneList.getContent().add(updatePane);
}

BooleanProperty preview;
{
LineToggleButton previewPane = new LineToggleButton();
previewPane.setTitle(i18n("update.preview"));
previewPane.setSubtitle(i18n("update.preview.subtitle"));
previewPane.selectedProperty().bindBidirectional(settings().acceptPreviewUpdateProperty());

InvalidationListener checkUpdateListener = e -> {
UpdateChecker.requestCheckUpdate(updateChannel.get(), previewPane.isSelected());
};
updateChannel.addListener(checkUpdateListener);
previewPane.selectedProperty().addListener(checkUpdateListener);
preview = previewPane.selectedProperty();

updatePaneList.getContent().add(previewPane);
}
Expand All @@ -162,6 +158,26 @@ protected int getTrailingTextIndex() {
updatePaneList.getContent().add(disableAutoShowUpdateDialogPane);
}

BooleanProperty autoDownloadUpdate;
{
LineToggleButton autoDownloadUpdatePane = new LineToggleButton();
autoDownloadUpdatePane.setTitle(i18n("update.auto_download"));
autoDownloadUpdatePane.setSubtitle(i18n("update.auto_download.subtitle"));
autoDownloadUpdatePane.selectedProperty().bindBidirectional(settings().autoDownloadUpdateProperty());
autoDownloadUpdate = autoDownloadUpdatePane.selectedProperty();

updatePaneList.getContent().add(autoDownloadUpdatePane);
}

{
InvalidationListener checkUpdateListener = e -> {
UpdateChecker.requestCheckUpdate(updateChannel.get(), preview.get(), autoDownloadUpdate.get());
};
updateChannel.addListener(checkUpdateListener);
preview.addListener(checkUpdateListener);
autoDownloadUpdate.addListener(checkUpdateListener);
}

rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("update")), updatePaneList);
}

Expand Down
32 changes: 32 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/upgrade/RemoteVersion.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,24 @@
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.task.FileDownloadTask.IntegrityCheck;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

import static org.jackhuang.hmcl.util.logging.Logger.LOG;

public record RemoteVersion(UpdateChannel channel, String version, String url, Type type, IntegrityCheck integrityCheck,
boolean preview, boolean force) {

public static final Map<RemoteVersion, Path> downloadCache = new ConcurrentHashMap<>();

public static RemoteVersion fetch(UpdateChannel channel, boolean preview, String url) throws IOException {
try {
JsonObject response = JsonUtils.fromNonNullJson(NetworkUtils.doGet(url), JsonObject.class);
Expand All @@ -53,6 +62,29 @@ public static RemoteVersion fetch(UpdateChannel channel, boolean preview, String
return "[" + version + " from " + url + "]";
}

public void tryDownload() {
Path downloaded = downloadCache.get(this);
if (downloaded != null && FileUtils.verifyHash(downloaded, integrityCheck().algorithm(), integrityCheck().checksum())) return;

try {
downloaded = Files.createTempFile("hmcl-update-", ".jar");
} catch (IOException e) {
LOG.warning("Failed to create temp file", e);
return;
}

var executor = new HMCLDownloadTask(this, downloaded).executor();
if (executor.test()) {
downloadCache.put(this, downloaded);
} else {
LOG.warning("Failed to download update for " + this, executor.getException());
try {
Files.deleteIfExists(downloaded);
} catch (IOException ignored) {
}
}
Comment thread
ToobLac marked this conversation as resolved.
}
Comment on lines +65 to +86

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.

medium

在后台自动下载更新时,tryDownload() 可能会被并发调用(例如,用户在短时间内多次手动触发更新检查,或者多个模块同时请求检查)。

由于 tryDownload() 中的“检查缓存 -> 创建临时文件 -> 开始下载”操作不是原子的,可能会导致多个线程同时为同一个 RemoteVersion 创建临时文件并重复下载,造成网络资源和磁盘空间的浪费。

建议引入一个并发安全的集合(例如 ConcurrentHashMap.newKeySet())来记录当前正在下载的 RemoteVersion,从而避免重复的并发下载。

    private static final java.util.Set<RemoteVersion> downloadingVersions = ConcurrentHashMap.newKeySet();

    public void tryDownload() {
        Path downloaded = downloadCache.get(this);
        if (downloaded != null && FileUtils.verifyHash(downloaded, integrityCheck().algorithm(), integrityCheck().checksum())) return;

        if (!downloadingVersions.add(this)) return;
        try {
            try {
                downloaded = Files.createTempFile("hmcl-update-", ".jar");
            } catch (IOException e) {
                LOG.warning("Failed to create temp file", e);
                return;
            }

            var executor = new HMCLDownloadTask(this, downloaded).executor();
            if (executor.test()) {
                downloadCache.put(this, downloaded);
            } else {
                LOG.warning("Failed to download update for " + this, executor.getException());
                try {
                    Files.deleteIfExists(downloaded);
                } catch (IOException ignored) {
                }
            }
        } finally {
            downloadingVersions.remove(this);
        }
    }


public enum Type {
JAR
}
Expand Down
74 changes: 46 additions & 28 deletions HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateChecker.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.*;
import javafx.beans.value.ObservableBooleanValue;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
Expand All @@ -38,25 +37,14 @@ private UpdateChecker() {
}

private static final ObjectProperty<RemoteVersion> latestVersion = new SimpleObjectProperty<>();
private static final BooleanBinding outdated = Bindings.createBooleanBinding(
() -> {
RemoteVersion latest = latestVersion.get();
if (latest == null || isDevelopmentVersion(Metadata.VERSION)) {
return false;
} else if (latest.force()
|| Metadata.isNightly()
|| latest.channel() == UpdateChannel.NIGHTLY
|| latest.channel() != UpdateChannel.getChannel()) {
return !latest.version().equals(Metadata.VERSION);
} else {
return VersionNumber.compare(Metadata.VERSION, latest.version()) < 0;
}
},
latestVersion);
private static final ReadOnlyBooleanWrapper checkingUpdate = new ReadOnlyBooleanWrapper(false);
private static final ReadOnlyBooleanWrapper outdated = new ReadOnlyBooleanWrapper(false);
private static final IntegerProperty runningThreads = new SimpleIntegerProperty(0);
private static final BooleanBinding checkingUpdate = Bindings.createBooleanBinding(() -> runningThreads.get() > 0, runningThreads);

private static UpdateTarget desiredTarget = null;

public static void init() {
requestCheckUpdate(UpdateChannel.getChannel(), settings().acceptPreviewUpdateProperty().get());
requestCheckUpdate(UpdateChannel.getChannel(), settings().acceptPreviewUpdateProperty().get(), settings().autoDownloadUpdateProperty().get());
}

public static RemoteVersion getLatestVersion() {
Expand All @@ -71,16 +59,16 @@ public static boolean isOutdated() {
return outdated.get();
}

public static ObservableBooleanValue outdatedProperty() {
public static ReadOnlyBooleanProperty outdatedProperty() {
return outdated;
}

public static boolean isCheckingUpdate() {
return checkingUpdate.get();
}

public static ReadOnlyBooleanProperty checkingUpdateProperty() {
return checkingUpdate.getReadOnlyProperty();
public static BooleanBinding checkingUpdateProperty() {
return checkingUpdate;
}

private static RemoteVersion checkUpdate(UpdateChannel channel, boolean preview) throws IOException {
Expand All @@ -101,29 +89,59 @@ private static boolean isDevelopmentVersion(String version) {
version.contains("SNAPSHOT"); // eg. 3.5.SNAPSHOT
}

public static void requestCheckUpdate(UpdateChannel channel, boolean preview) {
private static boolean checkOutdated(RemoteVersion latest) {
if (latest == null || isDevelopmentVersion(Metadata.VERSION)) {
return false;
} else if (latest.force()
|| Metadata.isNightly()
|| latest.channel() == UpdateChannel.NIGHTLY
|| latest.channel() != UpdateChannel.getChannel()) {
return !latest.version().equals(Metadata.VERSION);
} else {
return VersionNumber.compare(Metadata.VERSION, latest.version()) < 0;
}
}

public static void requestCheckUpdate(UpdateChannel channel, boolean preview, boolean download) {
Comment thread
ToobLac marked this conversation as resolved.
requestCheckUpdate(new UpdateTarget(channel, preview, download));
}

private static void requestCheckUpdate(UpdateTarget target) {
Platform.runLater(() -> {
if (isCheckingUpdate())
if (isCheckingUpdate() && target.concealedBy(desiredTarget))
return;
checkingUpdate.set(true);
runningThreads.set(runningThreads.get() + 1);
desiredTarget = target;

thread(() -> {
RemoteVersion result = null;
boolean b = false;
try {
result = checkUpdate(channel, preview);
LOG.info("Latest version (" + channel + ", preview=" + preview + ") is " + result);
result = checkUpdate(target.channel(), target.preview());
b = checkOutdated(result);
LOG.info("Latest version (" + target.channel() + ", preview=" + target.preview() + ") is " + result);
if (target.download() && b) result.tryDownload();
} catch (Throwable e) {
LOG.warning("Failed to check for update", e);
}
boolean isOutdated = b;

RemoteVersion finalResult = result;
Platform.runLater(() -> {
if (finalResult != null) {
if (finalResult != null && target.concealedBy(desiredTarget)) {

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.

medium

此处使用 target.concealedBy(desiredTarget) 来判断是否更新 UI 上的版本信息过于严格。

如果前一个请求 target1 启用了下载(download=true),而最新的请求 target2 未启用下载(download=false),那么当 target1 的线程执行完毕时,target1.concealedBy(target2) 将返回 false。这会导致 target1 成功获取到的最新版本信息被丢弃,无法更新到 UI 上。

实际上,只要 channelpreview 相同,获取到的最新版本信息就是完全一致且有效的。建议在 UpdateTarget 中增加一个 isSameSource 方法,仅比较 channelpreview,并在此处使用它。

Suggested change
if (finalResult != null && target.concealedBy(desiredTarget)) {
if (finalResult != null && target.isSameSource(desiredTarget)) {

latestVersion.set(finalResult);
outdated.set(isOutdated);
}
checkingUpdate.set(false);
runningThreads.set(runningThreads.get() - 1);
});
}, "Update Checker", true);
});
}

private record UpdateTarget(UpdateChannel channel, boolean preview, boolean download) {
public boolean concealedBy(UpdateTarget other) {
if (other == null) return false;
return other.channel == channel && other.preview == preview && (!download || other.download);
}
}
Comment on lines +141 to +146

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.

medium

建议在 UpdateTarget 中增加 isSameSource 方法,用于在不考虑 download 属性的情况下,判断两个更新目标是否指向相同的更新通道和预览设置。这可以避免在更新 UI 版本信息时,因 download 属性不同而错误地丢弃有效的更新结果。

    private record UpdateTarget(UpdateChannel channel, boolean preview, boolean download) {
        public boolean concealedBy(UpdateTarget other) {
            if (other == null) return false;
            return other.channel == channel && other.preview == preview && (!download || other.download);
        }

        public boolean isSameSource(UpdateTarget other) {
            if (other == null) return false;
            return other.channel == channel && other.preview == preview;
        }
    }

}
35 changes: 20 additions & 15 deletions HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.SwingUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;

Expand Down Expand Up @@ -106,22 +107,26 @@ public static void updateFrom(RemoteVersion version) {
}

Controllers.dialog(new UpgradeDialog(version, () -> {
Path downloaded;
try {
downloaded = Files.createTempFile("hmcl-update-", ".jar");
} catch (IOException e) {
LOG.warning("Failed to create temp file", e);
return;
}

Task<?> task = new HMCLDownloadTask(version, downloaded);
Path downloaded = RemoteVersion.downloadCache.get(version);
TaskExecutor executor;
if (downloaded != null && FileUtils.verifyHash(downloaded, version.integrityCheck().algorithm(), version.integrityCheck().checksum())) {
executor = Task.completed(null).executor();
} else {
try {
downloaded = Files.createTempFile("hmcl-update-", ".jar");
} catch (IOException e) {
LOG.warning("Failed to create temp file", e);
return;
}

TaskExecutor executor = task.executor();
Controllers.taskDialog(executor, i18n("message.downloading"), TaskCancellationAction.NORMAL);
Task<?> task = new HMCLDownloadTask(version, downloaded);
executor = task.executor();
Controllers.taskDialog(executor, i18n("message.downloading"), TaskCancellationAction.NORMAL);
}
final Path finalDownloaded = downloaded;
thread(() -> {
boolean success = executor.test();

if (success) {
if (executor.test()) {
RemoteVersion.downloadCache.put(version, finalDownloaded);
try {
if (!IntegrityChecker.isSelfVerified() && !IntegrityChecker.DISABLE_SELF_INTEGRITY_CHECK) {
throw new IOException("Current JAR is not verified");
Expand Down Expand Up @@ -150,7 +155,7 @@ public static void updateFrom(RemoteVersion version) {
// Ignore
}

requestUpdate(downloaded, getCurrentLocation());
requestUpdate(finalDownloaded, getCurrentLocation());
EntryPoint.exit(0);
} catch (IOException e) {
LOG.warning("Failed to update to " + version, e);
Expand Down
2 changes: 2 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N.properties
Original file line number Diff line number Diff line change
Expand Up @@ -1699,6 +1699,8 @@ unofficial.hint=You are using an unofficial build of HMCL. We cannot guarantee i

update=Update
update.accept=Update
update.auto_download=Auto download update
update.auto_download.subtitle=Automatically download launcher updates and notify when ready to install.
update.changelog=Changelog
update.channel.dev=Beta
update.channel.dev.hint=You are currently using a Beta channel build of the launcher. While it may include some extra features, it is also sometimes less stable than the Stable channel builds.\n\
Expand Down
2 changes: 2 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N_zh.properties
Original file line number Diff line number Diff line change
Expand Up @@ -1490,6 +1490,8 @@ unofficial.hint=你正在使用非官方構建的 HMCL。我們無法保證其

update=啟動器更新
update.accept=更新
update.auto_download=自動下載更新
update.auto_download.subtitle=自動在背景下載更新,下載完成後顯示通知
update.changelog=更新日誌
update.channel.dev=開發版
update.channel.dev.hint=你正在使用 HMCL 開發版。開發版包含一些未在穩定版中包含的測試性功能,僅用於體驗新功能。開發版功能未受充分驗證,使用起來可能不穩定!\n\
Expand Down
2 changes: 2 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties
Original file line number Diff line number Diff line change
Expand Up @@ -1495,6 +1495,8 @@ unofficial.hint=你正在使用非官方构建的 HMCL。我们无法保证其

update=启动器更新
update.accept=更新
update.auto_download=自动下载更新
update.auto_download.subtitle=自动在后台下载更新,下载完成后显示通知
update.changelog=更新日志
update.channel.dev=开发版
update.channel.dev.hint=你正在使用 HMCL 开发版。开发版包含一些未在稳定版中包含的测试性功能,仅用于体验新功能。开发版功能未受充分验证,使用起来可能不稳定!<a href="https://hmcl.huangyuhui.net/download">下载稳定版</a>\n\
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,7 @@ protected Path getFile(String algorithm, String hash) {
protected boolean fileExists(String algorithm, String hash) {
if (!DigestUtils.isSha1Digest(hash)) return false;
Path file = getFile(algorithm, hash);
if (Files.exists(file)) {
try {
return DigestUtils.digestToString(algorithm, file).equalsIgnoreCase(hash);
} catch (IOException e) {
return false;
}
} else {
return false;
}
return FileUtils.verifyHash(file, algorithm, hash);
}

private void checkHash(String hash) throws IOException {
Expand Down
14 changes: 14 additions & 0 deletions HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import org.glavo.chardet.DetectedCharset;
import org.glavo.chardet.UniversalDetector;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.function.ExceptionalConsumer;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
Expand Down Expand Up @@ -573,4 +574,17 @@ public static EnumSet<PosixFilePermission> parsePosixFilePermission(int unixMode

return permissions;
}

public static boolean verifyHash(Path file, String algorithm, String hash) {
if (Files.isRegularFile(file)) {
try {
return DigestUtils.digestToString(algorithm, file).equalsIgnoreCase(hash);
} catch (IOException e) {
LOG.warning("Failed to verify hash for file " + file, e);
return false;
}
} else {
return false;
}
}
Comment on lines +578 to +589

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.

medium

为了提高工具类方法的健壮性,建议在 verifyHash 方法中添加对 filealgorithmhash 的防御性空检查(Null Checks),避免在传入 null 时抛出 NullPointerException

    public static boolean verifyHash(Path file, String algorithm, String hash) {
        if (file == null || algorithm == null || hash == null) {
            return false;
        }
        if (Files.isRegularFile(file)) {
            try {
                return DigestUtils.digestToString(algorithm, file).equalsIgnoreCase(hash);
            } catch (IOException e) {
                LOG.warning("Failed to verify hash for file " + file, e);
                return false;
            }
        } else {
            return false;
        }
    }

}