diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java index f2802244981..71caaaf61cd 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java @@ -541,6 +541,15 @@ public StringProperty defaultAddonSourceProperty() { return defaultAddonSource; } + /// Whether to update addons to preview versions + @SerializedName("defaultUpdateAddonsToPreview") + private final BooleanProperty defaultUpdateAddonsToPreview = new SimpleBooleanProperty(true); + + /// Returns whether to update addons to preview versions property by default. + public BooleanProperty defaultUpdateAddonsToPreviewProperty() { + return defaultUpdateAddonsToPreview; + } + /// Whether proxy authentication is enabled. @SerializedName("hasProxyAuth") private final BooleanProperty hasProxyAuth = new SimpleBooleanProperty(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java index 6d5b1eeb45e..2d5ea1c9327 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java @@ -64,6 +64,7 @@ import javafx.util.Callback; import javafx.util.Duration; import javafx.util.StringConverter; +import javafx.util.Subscription; import org.glavo.url.WebURL; import org.jackhuang.hmcl.setting.StyleSheets; import org.jackhuang.hmcl.task.CacheFileTask; @@ -1072,7 +1073,7 @@ public static void unbindWindowsSize(JFXComboBox comboBox, IntegerProper heightProperty.removeListener(binding); } - public static void bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) { + public static List bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) { int itemCount = children.length; int childSelectedCount = 0; for (BooleanProperty child : children) { @@ -1082,49 +1083,46 @@ public static void bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... allEnabled.set(childSelectedCount == itemCount); - class Listener implements InvalidationListener { + class AllListener { private int childSelectedCount; private boolean updating = false; + private final List subscriptions = new ArrayList<>(children.length + 1); - public Listener(int childSelectedCount) { + public AllListener(int childSelectedCount) { this.childSelectedCount = childSelectedCount; } - @Override - public void invalidated(Observable observable) { - if (updating) - return; - - updating = true; - try { - boolean value = ((BooleanProperty) observable).get(); - - if (observable == allEnabled) { + { + subscriptions.add(allEnabled.subscribe(() -> { + if (updating) return; + updating = true; + try { + boolean value = allEnabled.get(); for (BooleanProperty child : children) { - child.setValue(value); + child.set(value); } childSelectedCount = value ? itemCount : 0; - } else { - if (value) - childSelectedCount++; - else - childSelectedCount--; - - allEnabled.set(childSelectedCount == itemCount); + } finally { + updating = false; } - } finally { - updating = false; + })); + for (var child : children) { + subscriptions.add(child.subscribe(() -> { + if (updating) return; + updating = true; + try { + if (child.get()) childSelectedCount++; + else childSelectedCount--; + allEnabled.set(childSelectedCount == itemCount); + } finally { + updating = false; + } + })); } } } - InvalidationListener listener = new Listener(childSelectedCount); - - WeakInvalidationListener weakListener = new WeakInvalidationListener(listener); - allEnabled.addListener(listener); - for (BooleanProperty child : children) { - child.addListener(weakListener); - } + return new AllListener(childSelectedCount).subscriptions; } public static void setIcon(Stage stage) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonCheckUpdatesTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonCheckUpdatesTask.java index 531bbaa94fb..8feb885b104 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonCheckUpdatesTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonCheckUpdatesTask.java @@ -17,45 +17,54 @@ */ package org.jackhuang.hmcl.ui.instances; +import org.jackhuang.hmcl.addon.AddonUpdate; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.addon.LocalAddonFile; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.Pair; +import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Objects; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public class AddonCheckUpdatesTask extends Task> { - private final DownloadProvider downloadProvider; - private final List> dependents; +public class AddonCheckUpdatesTask extends Task { + private final List>> dependents; - public AddonCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersion, Collection addons) { - this.downloadProvider = downloadProvider; + public AddonCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersion, Collection addons) { dependents = addons.stream().map(addon -> Task.supplyAsync(Schedulers.io(), () -> { - LocalAddonFile.AddonUpdate candidate = null; + AddonUpdate candidate = null; + AddonUpdate candidateRelease = null; for (RemoteAddon.Source source : RemoteAddon.Source.values()) { - LocalAddonFile.AddonUpdate update = null; + AddonUpdate update = null; + AddonUpdate updateRelease = null; try { - update = addon.checkUpdates(downloadProvider, gameVersion, source); + var u = addon.checkUpdates(downloadProvider, gameVersion, source); + if (u != null) { + update = u.key(); + updateRelease = u.value(); + } } catch (IOException e) { LOG.warning(String.format("Cannot check update for addon %s.", addon.getFileName()), e); } - if (update == null) { - continue; - } - - if (candidate == null || candidate.targetVersion().datePublished().isBefore(update.targetVersion().datePublished())) { + if (update == null) continue; + if (candidate == null || candidate.targetVersion().datePublished().isBefore(update.targetVersion().datePublished())) candidate = update; - } + + if (updateRelease == null) continue; + if (candidateRelease == null || candidateRelease.targetVersion().datePublished().isBefore(updateRelease.targetVersion().datePublished())) + candidateRelease = updateRelease; } - return candidate; + if (candidate == null) return null; // If there's no candidate for all channels, then no candidate for release channel + return Pair.pair(candidate, candidateRelease); }).setName(addon.getFileName()).setSignificance(TaskSignificance.MAJOR).withCounter("update.checking") ).toList(); @@ -85,8 +94,15 @@ public boolean isRelyingOnDependents() { @Override public void execute() throws Exception { - setResult(dependents.stream() - .map(Task::getResult) - .filter(Objects::nonNull).toList()); + List commonUpdates = new ArrayList<>(), releaseUpdates = new ArrayList<>(); + dependents.stream().map(Task::getResult).filter(Objects::nonNull) + .forEachOrdered(pair -> { + commonUpdates.add(pair.key()); + if (pair.value() != null) releaseUpdates.add(pair.value()); + }); + setResult(new Result(List.copyOf(commonUpdates), List.copyOf(releaseUpdates))); + } + + public record Result(List commonUpdates, List releaseUpdates) { } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonUpdatesPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonUpdatesPage.java index 1b21d70e638..82548d0404f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonUpdatesPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonUpdatesPage.java @@ -31,11 +31,14 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; +import javafx.util.Subscription; +import org.jackhuang.hmcl.addon.AddonUpdate; import org.jackhuang.hmcl.addon.LocalAddonFile; import org.jackhuang.hmcl.addon.LocalAddonManager; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.addon.RemoteAddonRepository; import org.jackhuang.hmcl.setting.DownloadProviders; +import org.jackhuang.hmcl.setting.SettingsManager; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -43,6 +46,7 @@ import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; +import org.jackhuang.hmcl.util.Lazy; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.TaskCancellationAction; import org.jackhuang.hmcl.util.io.CSVTable; @@ -52,9 +56,7 @@ import java.nio.file.Paths; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; +import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; @@ -62,15 +64,15 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public class AddonUpdatesPage extends BorderPane implements DecoratorPage { +public class AddonUpdatesPage extends BorderPane implements DecoratorPage { private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("addon.check_update"))); - private final LocalAddonManager localAddonManager; + private final Path localAddonDirectory; private final ObservableList objects; @SuppressWarnings("unchecked") - public AddonUpdatesPage(LocalAddonManager localAddonManager, List updates) { - this.localAddonManager = localAddonManager; + public AddonUpdatesPage(Path localAddonDirectory, AddonCheckUpdatesTask.Result updates) { + this.localAddonDirectory = localAddonDirectory; getStyleClass().add("gray-background"); @@ -96,6 +98,9 @@ public AddonUpdatesPage(LocalAddonManager localAddonManager, List targetVersionTypeColumn = new TableColumn<>(i18n("addon.version_type")); + setupCellValueFactory(targetVersionTypeColumn, AddonUpdateObject::targetVersionTypeProperty); + TableColumn sourceColumn = new TableColumn<>(i18n("addon.check_update.source")); setupCellValueFactory(sourceColumn, AddonUpdateObject::sourceProperty); @@ -115,12 +120,23 @@ public AddonUpdatesPage(LocalAddonManager localAddonManager, List new SimpleStringProperty(i18n("button.view"))); } - objects = FXCollections.observableList(updates.stream().map(AddonUpdateObject::new).collect(Collectors.toList())); - FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(o -> o.enabled).toArray(BooleanProperty[]::new)); + var common = new Lazy<>(() -> updates.commonUpdates().stream().map(AddonUpdateObject::new).toList()); + var release = new Lazy<>(() -> updates.releaseUpdates().stream().map(AddonUpdateObject::new).toList()); + + objects = FXCollections.observableArrayList(); + List subscriptions = new ArrayList<>(); + BooleanProperty showPreview = new SimpleBooleanProperty(SettingsManager.settings().defaultUpdateAddonsToPreviewProperty().get()); + FXUtils.onChangeAndOperate(showPreview, preview -> { + if (preview) objects.setAll(common.get()); + else objects.setAll(release.get()); + subscriptions.forEach(Subscription::unsubscribe); + subscriptions.clear(); + subscriptions.addAll(FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(AddonUpdateObject::enabledProperty).toArray(BooleanProperty[]::new))); + }); TableView table = new TableView<>(objects); table.setEditable(true); - table.getColumns().setAll(enabledColumn, fileNameColumn, currentVersionColumn, targetVersionColumn, sourceColumn, changelogColumn); + table.getColumns().setAll(enabledColumn, fileNameColumn, currentVersionColumn, targetVersionColumn, targetVersionTypeColumn, sourceColumn, changelogColumn); setMargin(table, new Insets(10, 10, 5, 10)); setCenter(table); @@ -129,6 +145,9 @@ public AddonUpdatesPage(LocalAddonManager localAddonManager, List exportList()); @@ -140,7 +159,7 @@ public AddonUpdatesPage(LocalAddonManager localAddonManager, List void setupCellValueFactory(TableColumn column, private void updateFiles() { AddonUpdateTask task = new AddonUpdateTask( - localAddonManager.getDirectory(), + localAddonDirectory, objects.stream() .filter(AddonUpdateObject::isEnabled) .map(AddonUpdateObject::getData) @@ -210,31 +229,30 @@ public ReadOnlyObjectWrapper stateProperty() { } private static final class AddonUpdateObject { - final LocalAddonFile.AddonUpdate data; + final AddonUpdate data; final BooleanProperty enabled = new SimpleBooleanProperty(); final StringProperty fileName = new SimpleStringProperty(); final StringProperty currentVersion = new SimpleStringProperty(); final StringProperty targetVersion = new SimpleStringProperty(); + final StringProperty targetVersionType = new SimpleStringProperty(); final StringProperty source = new SimpleStringProperty(); String changelog = null; - public AddonUpdateObject(LocalAddonFile.AddonUpdate data) { + public AddonUpdateObject(AddonUpdate data) { this.data = data; enabled.set(!data.localAddonFile().isDisabled()); fileName.set(data.localAddonFile().getFileName()); currentVersion.set(data.currentVersion().version()); targetVersion.set(data.targetVersion().version()); - switch (data.currentVersion().self().getSource()) { - case CURSEFORGE: - source.set(i18n("addon.curseforge")); - break; - case MODRINTH: - source.set(i18n("addon.modrinth")); - } + targetVersionType.set(data.targetVersion().versionType().name()); + source.set(switch (data.currentVersion().source()) { + case CURSEFORGE -> i18n("addon.curseforge"); + case MODRINTH -> i18n("addon.modrinth"); + }); } - public LocalAddonFile.AddonUpdate getData() { + public AddonUpdate getData() { return data; } @@ -246,57 +264,26 @@ public BooleanProperty enabledProperty() { return enabled; } - public void setEnabled(boolean enabled) { - this.enabled.set(enabled); - } - - public String getFileName() { - return fileName.get(); - } - public StringProperty fileNameProperty() { return fileName; } - public void setFileName(String fileName) { - this.fileName.set(fileName); - } - - public String getCurrentVersion() { - return currentVersion.get(); - } - public StringProperty currentVersionProperty() { return currentVersion; } - public void setCurrentVersion(String currentVersion) { - this.currentVersion.set(currentVersion); - } - - public String getTargetVersion() { - return targetVersion.get(); - } - public StringProperty targetVersionProperty() { return targetVersion; } - public void setTargetVersion(String targetVersion) { - this.targetVersion.set(targetVersion); - } - - public String getSource() { - return source.get(); + public StringProperty targetVersionTypeProperty() { + return targetVersionType; } public StringProperty sourceProperty() { return source; } - public void setSource(String source) { - this.source.set(source); - } } private static final class AddonChangelog extends JFXDialogLayout { @@ -342,13 +329,10 @@ public AddonChangelog(AddonUpdateObject object) { private void loadChangelog(AddonUpdateObject object, SpinnerPane spinnerPane, ScrollPane scrollPane) { spinnerPane.setLoading(true); - RemoteAddonRepository repo = object.data.source().getRepoForType(object.data.repoType()); + RemoteAddonRepository repo = object.data.targetVersion().source().getCommonRepo(); Task.supplyAsync(() -> { - if (object.changelog != null) { - return object.changelog; - } + if (object.changelog != null) return object.changelog; RemoteAddon.Version version = object.data.targetVersion(); - if (repo == null) return null; return StringUtils.convertToHtml( repo.getAddonChangelog(DownloadProviders.getDownloadProvider(), version.projectId(), version.versionId()), "238222".equals(object.data.targetVersion().projectId()) @@ -356,7 +340,7 @@ private void loadChangelog(AddonUpdateObject object, SpinnerPane spinnerPane, Sc }).whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { object.changelog = StringUtils.isNotBlank(result) ? result : i18n("addon.changelog.empty"); - scrollPane.setContent(FXUtils.renderAddonChangelog(object.changelog, repo == null ? "" : repo.getBaseUrl())); + scrollPane.setContent(FXUtils.renderAddonChangelog(object.changelog, repo.getBaseUrl())); FXUtils.smoothScrolling(scrollPane); spinnerPane.setFailedReason(null); } else { @@ -367,10 +351,9 @@ private void loadChangelog(AddonUpdateObject object, SpinnerPane spinnerPane, Sc } private void loadVersionPageUrl(AddonUpdateObject object, JFXHyperlink button) { - Task.supplyAsync(() -> { - RemoteAddonRepository repo = object.data.source().getRepoForType(object.data.repoType()); - return repo == null ? null : repo.getVersionPageUrl(object.data.targetVersion()); - }).whenComplete(Schedulers.javafx(), (result, exception) -> { + Task.supplyAsync(() -> + object.data.targetVersion().source().getCommonRepo().getVersionPageUrl(object.data.targetVersion()) + ).whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null && StringUtils.isNotBlank(result)) { button.setOnAction(__ -> Controllers.openUriInBrowser(result)); button.setDisable(false); @@ -385,44 +368,39 @@ public static class AddonUpdateTask extends Task { private final Collection> dependents; private final List failedAddons = new ArrayList<>(); - AddonUpdateTask(Path addonDirectory, List addons) { + AddonUpdateTask(Path addonDirectory, List addons) { setStage("addon.check_update.confirm"); getProperties().put("total", addons.size()); this.dependents = new ArrayList<>(); - for (LocalAddonFile.AddonUpdate addon : addons) { + for (AddonUpdate addon : addons) { LocalAddonFile local = addon.localAddonFile(); RemoteAddon.Version remote = addon.targetVersion(); boolean isDisabled = local.isDisabled(); - String originalFileName = local.getFile().getFileName().toString(); + String fileName = remote.file().filename(); + if (isDisabled) + fileName = StringUtils.addSuffix(fileName, LocalAddonManager.DISABLED_EXTENSION); + String newFileName = fileName; dependents.add(Task .runAsync(Schedulers.javafx(), () -> local.setOld(true)) - .thenComposeAsync(() -> { - String fileName = addon.useRemoteFileName() ? remote.file().filename() : originalFileName; - if (isDisabled) - fileName = StringUtils.addSuffix(fileName, LocalAddonManager.DISABLED_EXTENSION); - - var task = new FileDownloadTask( - remote.file().url(), - addonDirectory.resolve(fileName) - ); - - task.setName(remote.name()); - return task; - }) - .whenComplete(Schedulers.javafx(), exception -> { + .thenComposeAsync(() -> + new FileDownloadTask(remote.file().url(), addonDirectory.resolve(newFileName)).setName(remote.name()) + ).whenComplete(Schedulers.javafx(), exception -> { if (exception != null) { // restore state if failed local.setOld(false); if (isDisabled) local.markDisabled(); failedAddons.add(local); - } else if (!local.keepOldFiles()) { - try { - local.delete(); - } catch (IOException e) { - LOG.warning("Failed to delete outdated addon: " + local.getFile(), e); + } else { + local.onUpdated(newFileName); + if (!local.keepOldFiles()) { + try { + local.delete(); + } catch (IOException e) { + LOG.warning("Failed to delete outdated addon: " + local.getFile(), e); + } } } }) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java index cc11d1d7125..c2d26f73ffa 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java @@ -247,16 +247,22 @@ public void checkUpdates(Collection mods) { Runnable action = () -> Controllers.taskDialog(Task .composeAsync(() -> { Optional gameVersion = repository.getGameVersion(instanceId); - return gameVersion.map(g -> new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(), g, mods)).orElse(null); + if (gameVersion.isPresent()) { + return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), gameVersion.get(), mods); + } else { + LOG.warning("Failed to check for updates, due to unable to get instance game version"); + return null; + } }) .whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception instanceof CancellationException) return; if (exception != null || result == null) { Controllers.dialog(i18n("addon.check_update.failed_check"), i18n("message.failed"), MessageDialogPane.MessageType.ERROR); - } else if (result.isEmpty()) { + if (exception != null) LOG.warning("Failed to check for updates", exception); + } else if (result.commonUpdates().isEmpty()) { Controllers.dialog(i18n("addon.check_update.empty")); } else { - Controllers.navigateForward(new AddonUpdatesPage<>(modManager, result)); + Controllers.navigateForward(new AddonUpdatesPage(modManager.getDirectory(), result)); } }) .withStagesHints("update.checking"), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java index f98fa0fc46e..eb7ec2cb9f0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java @@ -235,15 +235,15 @@ public void checkUpdates(Collection resourcePacks) { Runnable action = () -> Controllers.taskDialog(Task .composeAsync(() -> { Optional gameVersion = repository.getGameVersion(instanceId); - return gameVersion.map(g -> new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(), g, resourcePacks)).orElse(null); + return gameVersion.map(g -> new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), g, resourcePacks)).orElse(null); }) .whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception != null || result == null) { Controllers.dialog(i18n("addon.check_update.failed_check"), i18n("message.failed"), MessageDialogPane.MessageType.ERROR); - } else if (result.isEmpty()) { + } else if (result.commonUpdates().isEmpty()) { Controllers.dialog(i18n("addon.check_update.empty")); } else { - Controllers.navigateForward(new AddonUpdatesPage<>(resourcePackManager, result)); + Controllers.navigateForward(new AddonUpdatesPage(resourcePackManager.getDirectory(), result)); } }) .withStagesHints("update.checking"), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/DownloadSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/DownloadSettingsPage.java index a7a9936b057..072f9b232d5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/DownloadSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/DownloadSettingsPage.java @@ -65,8 +65,8 @@ public DownloadSettingsPage() { getChildren().setAll(scrollPane); { - var downloadSource = new ComponentList(); - downloadSource.getStyleClass().add("card-non-transparent"); + var gameContent = new ComponentList(); + gameContent.getStyleClass().add("card-non-transparent"); { Function converter = source -> switch (source) { case DEFAULT -> i18n("settings.launcher.download_source.auto"); @@ -102,10 +102,14 @@ public DownloadSettingsPage() { defaultAddonSourcePane.setItems("modrinth", "curseforge"); defaultAddonSourcePane.valueProperty().bindBidirectional(settings().defaultAddonSourceProperty()); - downloadSource.getContent().setAll(versionListSourcePane, downloadSourcePane, defaultAddonSourcePane); + var updateAddonsToPreviewButton = new LineToggleButton(); + updateAddonsToPreviewButton.setTitle(i18n("settings.launcher.default_update_addons_to_preview")); + updateAddonsToPreviewButton.selectedProperty().bindBidirectional(settings().defaultUpdateAddonsToPreviewProperty()); + + gameContent.getContent().setAll(versionListSourcePane, downloadSourcePane, defaultAddonSourcePane, updateAddonsToPreviewButton); } - content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.download_source")), downloadSource); + content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.game_content")), gameContent); } { diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index ca60b1b0a01..784b39de4a5 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -188,6 +188,7 @@ addon.check_update.empty=All files are up-to-date addon.check_update.failed_check=Failed to check for updates. addon.check_update.failed_download=Failed to download some files. addon.check_update.file=File +addon.check_update.preview=Show preview versions addon.check_update.source=Source addon.check_update.target_version=Target Version addon.curseforge=CurseForge @@ -202,6 +203,7 @@ addon.download.recommend=Recommended Version for Minecraft %1s addon.download.title.release=Minecraft %s addon.download.title.snapshot=Minecraft %s (Snapshots) addon.modrinth=Modrinth +addon.version_type=Type archive.author=Author(s) archive.date=Publish Date @@ -1626,6 +1628,7 @@ settings.launcher.font.anti_aliasing.auto=Auto settings.launcher.font.anti_aliasing.gray=Grayscale settings.launcher.font.anti_aliasing.lcd=Sub-pixel settings.launcher.fonts=Fonts +settings.launcher.game_content=Game Content settings.launcher.general=General settings.launcher.language=Language settings.launcher.launcher_log.export=Export Launcher Logs @@ -1672,6 +1675,7 @@ settings.launcher.theme_color_type.default=Default settings.launcher.theme_color_type.system=Follow System settings.launcher.title_transparent=Transparent Titlebar settings.launcher.turn_off_animations=Disable Animation +settings.launcher.default_update_addons_to_preview=Update Addons to Alpha/Beta Versions by Default settings.launcher.version_list_source=Version List settings.launcher.window_transparent=Transparent Window settings.launcher.background.settings.opacity=Opacity diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 5eeafafcb3b..4abad7d372b 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -186,6 +186,7 @@ addon.check_update.empty=沒有需要更新的檔案 addon.check_update.failed_check=檢查更新失敗 addon.check_update.failed_download=部分檔案下載失敗 addon.check_update.file=檔案 +addon.check_update.preview=顯示 Alpha/Beta 版本 addon.check_update.source=來源 addon.check_update.target_version=目標版本 addon.curseforge=CurseForge @@ -200,6 +201,7 @@ addon.download.recommend=推薦版本 - Minecraft %1s addon.download.title.release=Minecraft %s addon.download.title.snapshot=Minecraft %s (快照) addon.modrinth=Modrinth +addon.version_type=版本類型 archive.author=作者 archive.date=發布日期 @@ -1427,6 +1429,7 @@ settings.launcher.font.anti_aliasing.auto=自動 settings.launcher.font.anti_aliasing.gray=灰階 settings.launcher.font.anti_aliasing.lcd=子像素 settings.launcher.fonts=字體 +settings.launcher.game_content=遊戲內容 settings.launcher.general=一般 settings.launcher.language=語言 settings.launcher.launcher_log.export=匯出啟動器日誌 @@ -1473,6 +1476,7 @@ settings.launcher.theme_color_type.default=預設 settings.launcher.theme_color_type.system=跟隨系統 settings.launcher.title_transparent=標題欄透明 settings.launcher.turn_off_animations=關閉動畫 +settings.launcher.default_update_addons_to_preview=預設更新附加內容到 Alpha/Beta 版本 settings.launcher.version_list_source=版本清單來源 settings.launcher.window_transparent=視窗透明 settings.launcher.background.settings.opacity=不透明度 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 6fe08134e54..608c719c551 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -188,6 +188,7 @@ addon.check_update.empty=没有需要更新的文件 addon.check_update.failed_check=检查更新失败 addon.check_update.failed_download=部分文件下载失败 addon.check_update.file=文件 +addon.check_update.preview=显示测试版本 addon.check_update.source=来源 addon.check_update.target_version=目标版本 addon.curseforge=CurseForge @@ -202,6 +203,7 @@ addon.download.recommend=推荐版本 - Minecraft %1s addon.download.title.release=Minecraft %s addon.download.title.snapshot=Minecraft %s (快照) addon.modrinth=Modrinth +addon.version_type=版本类型 archive.author=作者 archive.date=发布日期 @@ -1427,6 +1429,7 @@ settings.launcher.font.anti_aliasing.auto=自动 settings.launcher.font.anti_aliasing.gray=灰度 settings.launcher.font.anti_aliasing.lcd=子像素 settings.launcher.fonts=字体 +settings.launcher.game_content=游戏内容 settings.launcher.general=通用 settings.launcher.language=语言 settings.launcher.launcher_log.export=导出启动器日志 @@ -1473,6 +1476,7 @@ settings.launcher.theme_color_type.default=默认 settings.launcher.theme_color_type.system=跟随系统 settings.launcher.title_transparent=标题栏透明 settings.launcher.turn_off_animations=关闭动画 +settings.launcher.default_update_addons_to_preview=默认更新附加内容到测试版本 settings.launcher.version_list_source=版本列表源 settings.launcher.window_transparent=窗口透明 settings.launcher.background.settings.opacity=不透明度 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/AddonUpdate.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/AddonUpdate.java new file mode 100644 index 00000000000..66de27bd8bf --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/AddonUpdate.java @@ -0,0 +1,37 @@ +/* + * 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.addon; + +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.function.Predicate; + +public record AddonUpdate( + LocalAddonFile localAddonFile, + RemoteAddon.Version currentVersion, + RemoteAddon.Version targetVersion +) { + + @SuppressWarnings("RedundantRecordConstructor") + public record UpdateConditions(RemoteAddon.Type type, @Nullable List> predicates) { + public UpdateConditions { + } + } + +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonFile.java index 0537e4a4501..28307d6fbf9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonFile.java @@ -18,16 +18,14 @@ package org.jackhuang.hmcl.addon; import org.jackhuang.hmcl.download.DownloadProvider; +import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.FileUtils; -import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; +import java.util.*; import java.util.stream.Collectors; /// Sub-classes should implement `Comparable` @@ -54,19 +52,43 @@ public boolean isDisabled() { public abstract void delete() throws IOException; @Nullable - public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) throws IOException { + protected AddonUpdate.UpdateConditions getUpdateConditions() { return null; } - @NotNullByDefault - public record AddonUpdate( - RemoteAddon.Source source, - RemoteAddon.Type repoType, - LocalAddonFile localAddonFile, - RemoteAddon.Version currentVersion, - RemoteAddon.Version targetVersion, - boolean useRemoteFileName - ) { + /// @return A pair of addon update info, the first for all versions and the second for release versions only (might be null). + @Nullable + public Pair checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) throws IOException { + var conditions = getUpdateConditions(); + if (conditions == null) return null; + + RemoteAddonRepository repository = source.getRepoForType(conditions.type()); + if (repository == null) return null; + Optional currentVersion = repository.getRemoteVersionByLocalFile(getFile()); + if (currentVersion.isEmpty()) return null; + var current = currentVersion.orElseThrow(); + + var stream = repository.getRemoteVersionsById(downloadProvider, currentVersion.get().projectId()) + .filter(version -> version.gameVersions().contains(gameVersion)); + if (current.gameVersions().contains(gameVersion)) // Otherwise it means we are upgrading from another game version + stream = stream.filter(version -> version.datePublished().isAfter(current.datePublished())); + if (conditions.predicates() != null) + for (var p : conditions.predicates()) + stream = stream.filter(p); + + List remoteVersions = stream.sorted(Comparator.comparing(RemoteAddon.Version::datePublished).reversed()).toList(); + if (remoteVersions.isEmpty()) return null; + var release = remoteVersions.stream() + .filter(v -> v.versionType() == RemoteAddon.VersionType.Release) + .findFirst().orElse(null); + + return Pair.pair( + new AddonUpdate(this, current, remoteVersions.get(0)), // All channels + release != null ? new AddonUpdate(this, current, release) : null // Release channel + ); + } + + public void onUpdated(String newFileNameWithExt) { } public static class Description { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddon.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddon.java index 41acefb921c..980c3156521 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddon.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddon.java @@ -22,6 +22,7 @@ import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.task.FileDownloadTask; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.IOException; @@ -180,6 +181,7 @@ public RemoteAddonRepository getRepoForType(Type type) { }; } + @NotNull public RemoteAddonRepository getCommonRepo() { return commonRepo; } @@ -218,11 +220,7 @@ public interface IAddon { Stream loadVersions(RemoteAddonRepository repo, DownloadProvider downloadProvider) throws IOException; } - public interface IVersion { - Source getSource(); - } - - public record Version(IVersion self, String versionId, String projectId, String name, String version, + public record Version(RemoteAddon.Source source, String versionId, String projectId, String name, String version, Instant datePublished, VersionType versionType, File file, List dependencies, List gameVersions, List loaders) { } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/LocalModFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/LocalModFile.java index 32984fa3baa..2d666b9364b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/LocalModFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/LocalModFile.java @@ -19,11 +19,10 @@ import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; +import org.jackhuang.hmcl.addon.AddonUpdate; import org.jackhuang.hmcl.addon.LocalAddonFile; import org.jackhuang.hmcl.addon.LocalAddonManager; import org.jackhuang.hmcl.addon.RemoteAddon; -import org.jackhuang.hmcl.addon.RemoteAddonRepository; -import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.util.io.FileUtils; import java.io.IOException; @@ -195,19 +194,8 @@ public void delete() throws IOException { } @Override - public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) throws IOException { - RemoteAddonRepository repository = source.getRepoForType(RemoteAddon.Type.MOD); - if (repository == null) return null; - Optional currentVersion = repository.getRemoteVersionByLocalFile(file); - if (currentVersion.isEmpty()) return null; - List remoteVersions = repository.getRemoteVersionsById(downloadProvider, currentVersion.get().projectId()) - .filter(version -> version.gameVersions().contains(gameVersion)) - .filter(version -> version.loaders().contains(getModLoaderType())) - .filter(version -> version.datePublished().compareTo(currentVersion.get().datePublished()) > 0) - .sorted(Comparator.comparing(RemoteAddon.Version::datePublished).reversed()) - .toList(); - if (remoteVersions.isEmpty()) return null; - return new AddonUpdate(source, RemoteAddon.Type.MOD, this, currentVersion.get(), remoteVersions.get(0), true); + protected AddonUpdate.UpdateConditions getUpdateConditions() { + return new AddonUpdate.UpdateConditions(RemoteAddon.Type.MOD, List.of(v -> v.loaders().contains(getModLoaderType()))); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java index e33849960bf..71fb4324ba0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java @@ -516,7 +516,7 @@ public record LatestFile(int id, int gameId, int modId, boolean isAvailable, Str int releaseType, int fileStatus, List hashes, Instant fileDate, int fileLength, int downloadCount, String downloadUrl, List gameVersions, List dependencies, int alternateFileId, boolean isServerPack, - long fileFingerprint) implements RemoteAddon.IVersion { + long fileFingerprint) { @Override public String downloadUrl() { @@ -528,11 +528,6 @@ public String downloadUrl() { return downloadUrl; } - @Override - public RemoteAddon.Source getSource() { - return RemoteAddon.Source.CURSEFORGE; - } - public RemoteAddon.Version toVersion() { RemoteAddon.VersionType versionType = switch (releaseType()) { case 1 -> RemoteAddon.VersionType.Release; @@ -542,7 +537,7 @@ public RemoteAddon.Version toVersion() { }; return new RemoteAddon.Version( - this, + RemoteAddon.Source.CURSEFORGE, Integer.toString(id()), Integer.toString(modId()), displayName(), diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/ModrinthRemoteAddonRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/ModrinthRemoteAddonRepository.java index b8de101b0e3..2aa07ce62a3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/ModrinthRemoteAddonRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/ModrinthRemoteAddonRepository.java @@ -430,7 +430,7 @@ public record ProjectVersion(String name, @SerializedName("version_number") Stri @SerializedName("author_id") String authorId, @SerializedName("date_published") Instant datePublished, int downloads, @SerializedName("changelog_url") String changelogUrl, - List files) implements RemoteAddon.IVersion { + List files) { private static final Map DEPENDENCY_TYPE = mapOf( pair("required", RemoteAddon.DependencyType.REQUIRED), pair("optional", RemoteAddon.DependencyType.OPTIONAL), @@ -438,29 +438,20 @@ public record ProjectVersion(String name, @SerializedName("version_number") Stri pair("incompatible", RemoteAddon.DependencyType.INCOMPATIBLE) ); - @Override - public RemoteAddon.Source getSource() { - return RemoteAddon.Source.MODRINTH; - } - public Optional toVersion() { - RemoteAddon.VersionType type; - if ("release".equals(versionType)) { - type = RemoteAddon.VersionType.Release; - } else if ("beta".equals(versionType)) { - type = RemoteAddon.VersionType.Beta; - } else if ("alpha".equals(versionType)) { - type = RemoteAddon.VersionType.Alpha; - } else { - type = RemoteAddon.VersionType.Release; - } + RemoteAddon.VersionType type = switch (versionType) { + case "release" -> RemoteAddon.VersionType.Release; + case "beta" -> RemoteAddon.VersionType.Beta; + case "alpha" -> RemoteAddon.VersionType.Alpha; + default -> RemoteAddon.VersionType.Release; + }; if (files.isEmpty()) { return Optional.empty(); } return Optional.of(new RemoteAddon.Version( - this, + RemoteAddon.Source.MODRINTH, id, projectId, name, diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackFolder.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackFolder.java index 0d33b958e04..e8187a2be5a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackFolder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackFolder.java @@ -75,5 +75,4 @@ public PackMcMeta getMeta() { public void delete() throws IOException { FileUtils.deleteDirectory(file); } - } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackManager.java index aee0e34e31e..9a73f5f68cc 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackManager.java @@ -507,6 +507,39 @@ public boolean isEnabled(ResourcePackFile resourcePack) { } } + public boolean rename(String oldName, String newName) { + if (newName.equals(oldName)) return false; + + String oldNameNewFormat = "file/" + oldName; + String newNameNewFormat = "file/" + newName; + boolean modified = false; + + lock.lock(); + try { + Map options = loadOptions(); + List optPacks = new ArrayList<>(deserializePackList(options.get("resourcePacks"))); + List optIncompatiblePacks = new ArrayList<>(deserializePackList(options.get("incompatibleResourcePacks"))); + for (var l : List.of(optPacks, optIncompatiblePacks)) { + var it = l.listIterator(); + while (it.hasNext()) { + var next = it.next(); + if (oldName.equals(next) || oldNameNewFormat.equals(next)) { + it.set(supportsNewOptionsFormat ? newNameNewFormat : newName); + modified = true; + } + } + } + if (modified) { + options.put("resourcePacks", serializePackList(optPacks)); + options.put("incompatibleResourcePacks", serializePackList(optIncompatiblePacks)); + saveOptions(options); + } + return modified; + } finally { + lock.unlock(); + } + } + public Stream> arePacksEnabled(Stream resourcePacks) { lock.lock(); try { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackZipFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackZipFile.java index 87aea908cc9..d75af09ca86 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackZipFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackZipFile.java @@ -18,9 +18,8 @@ package org.jackhuang.hmcl.addon.resourcepack; import javafx.scene.image.Image; -import org.jackhuang.hmcl.download.DownloadProvider; +import org.jackhuang.hmcl.addon.AddonUpdate; import org.jackhuang.hmcl.addon.RemoteAddon; -import org.jackhuang.hmcl.addon.RemoteAddonRepository; import org.jackhuang.hmcl.addon.meta.PackMcMeta; import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jetbrains.annotations.Nullable; @@ -29,13 +28,13 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Comparator; -import java.util.List; -import java.util.Optional; import static org.jackhuang.hmcl.util.logging.Logger.LOG; final class ResourcePackZipFile extends ResourcePackFile { + + private static final AddonUpdate.UpdateConditions UPDATE_CONDITIONS = new AddonUpdate.UpdateConditions(RemoteAddon.Type.RESOURCE_PACK, null); + private final PackMcMeta meta; private final @Nullable Image icon; @@ -90,18 +89,14 @@ public void delete() throws IOException { } @Override - public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) throws IOException { - RemoteAddonRepository repository = source.getRepoForType(RemoteAddon.Type.RESOURCE_PACK); - if (repository == null) return null; - Optional currentVersion = repository.getRemoteVersionByLocalFile(file); - if (currentVersion.isEmpty()) return null; - List remoteVersions = repository.getRemoteVersionsById(downloadProvider, currentVersion.get().projectId()) - .filter(version -> version.gameVersions().contains(gameVersion)) - .filter(version -> version.datePublished().compareTo(currentVersion.get().datePublished()) > 0) - .sorted(Comparator.comparing(RemoteAddon.Version::datePublished).reversed()) - .toList(); - if (remoteVersions.isEmpty()) return null; - return new AddonUpdate(source, RemoteAddon.Type.RESOURCE_PACK, this, currentVersion.get(), remoteVersions.get(0), false); + protected AddonUpdate.UpdateConditions getUpdateConditions() { + return UPDATE_CONDITIONS; + } + + @Override + public void onUpdated(String newFileNameWithExt) { + super.onUpdated(newFileNameWithExt); + manager.rename(getFileNameWithExtension(), newFileNameWithExt); } }