enhance(addon): addon更新功能优化 (#6531)

Fixes #6940 Fixes #5865 Fixes #3841 Fixes #3705
This commit is contained in:
ToobLac authored and GitHub committed 2026-10-11 21:11:06 +08:00
1 parent 0597282463
commit 6f406cede8
29 files changed
+549 -288

No files matched your search

@@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <[email protected]> and contributors
* Copyright (C) 2026 huangyuhui <[email protected]> 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
@@ -15,47 +15,55 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.instances;
package org.jackhuang.hmcl.addon.update;
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<T extends LocalAddonFile> extends Task<List<LocalAddonFile.AddonUpdate>> {
private final DownloadProvider downloadProvider;
private final List<Task<LocalAddonFile.AddonUpdate>> dependents;
public class AddonCheckUpdatesTask extends Task<AddonUpdates> {
private final List<Task<Pair<AddonUpdate, @Nullable AddonUpdate>>> dependents;
public AddonCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersion, Collection<T> addons) {
this.downloadProvider = downloadProvider;
public AddonCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersion, Collection<? extends LocalAddonFile> 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 +93,12 @@ public class AddonCheckUpdatesTask<T extends LocalAddonFile> extends Task<List<L
@Override
public void execute() throws Exception {
setResult(dependents.stream()
.map(Task::getResult)
.filter(Objects::nonNull).toList());
List<AddonUpdate> 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 AddonUpdates(commonUpdates, releaseUpdates));
}
}
@@ -0,0 +1,123 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <[email protected]> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.addon.update;
import org.jackhuang.hmcl.addon.LocalAddonFile;
import org.jackhuang.hmcl.addon.LocalAddonManager;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.StringUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class AddonUpdateTask extends Task<Void> {
private final Collection<Task<?>> dependents = new ArrayList<>();
private final List<LocalAddonFile> failedAddons = new ArrayList<>();
public AddonUpdateTask(DownloadProvider downloadProvider, Path addonDirectory, List<AddonUpdate> addons) {
setStage("addon.check_update.confirm");
getProperties().put("total", addons.size());
for (AddonUpdate addon : addons) {
LocalAddonFile local = addon.localAddonFile();
RemoteAddon.Version remote = addon.targetVersion();
boolean isDisabled = local.isDisabled();
String fileName = remote.file().filename();
if (isDisabled)
fileName = StringUtils.addSuffix(fileName, LocalAddonManager.DISABLED_EXTENSION);
String newFileName = fileName;
Path targetFile = addonDirectory.resolve(newFileName);
dependents.add(Task
.supplyAsync(Schedulers.io(), () -> Files.createTempFile("hmcl-addon-update-", ".tmp"))
.thenComposeAsync(tempFile ->
new FileDownloadTask(downloadProvider.getDownloadCandidates(remote.file().url()), tempFile)
.setName(remote.name())
.thenRunAsync(Schedulers.javafx(), () -> local.setOld(true))
.thenRunAsync(Schedulers.io(), () -> Files.move(tempFile, targetFile))
.whenComplete(Schedulers.javafx(), exception -> {
try {
Files.deleteIfExists(tempFile);
} catch (Exception e) {
LOG.warning("Failed to delete temp file " + tempFile, e);
}
if (exception != null) {
// restore state if failed
local.setOld(false);
if (isDisabled)
local.markDisabled();
} else {
local.onUpdated(newFileName);
if (!local.keepOldFiles()) {
try {
local.delete();
} catch (IOException e) {
LOG.warning("Failed to delete outdated addon: " + local.getFile(), e);
}
}
}
})
).whenComplete(Schedulers.javafx(), exception -> {
if (exception != null) {
LOG.warning("Failed to update addon", exception);
failedAddons.add(local);
}
}).withCounter("addon.check_update.confirm"));
}
}
public List<LocalAddonFile> getFailedAddons() {
return failedAddons;
}
@Override
public Collection<Task<?>> getDependents() {
return dependents;
}
@Override
public boolean doPreExecute() {
return true;
}
@Override
public void preExecute() {
notifyPropertiesChanged();
}
@Override
public boolean isRelyingOnDependents() {
return false;
}
@Override
public void execute() throws Exception {
if (!isDependentsSucceeded())
throw getException();
}
}
@@ -80,6 +80,7 @@ import org.jackhuang.hmcl.util.ResourceNotFoundError;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
import org.jackhuang.hmcl.util.javafx.Subscription;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jetbrains.annotations.Nullable;
@@ -823,7 +824,7 @@ public final class FXUtils {
property.addListener(binding);
}
public static void bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) {
public static List<Subscription> bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) {
int itemCount = children.length;
int childSelectedCount = 0;
for (BooleanProperty child : children) {
@@ -833,49 +834,46 @@ public final class FXUtils {
allEnabled.set(childSelectedCount == itemCount);
class Listener implements InvalidationListener {
class AllListener {
private int childSelectedCount;
private boolean updating = false;
private final List<Subscription> 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(Subscription.subscribe(allEnabled, () -> {
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(Subscription.subscribe(child, () -> {
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) {
@@ -31,30 +31,31 @@ import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.addon.update.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.addon.update.AddonUpdateTask;
import org.jackhuang.hmcl.addon.update.AddonUpdates;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
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;
import org.jackhuang.hmcl.util.javafx.Subscription;
import java.io.IOException;
import java.nio.file.Path;
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 +63,17 @@ import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane implements DecoratorPage {
public class AddonUpdatesPage extends BorderPane implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("addon.check_update")));
private final LocalAddonManager<F> localAddonManager;
private final DownloadProvider downloadProvider;
private final Path localAddonDirectory;
private final ObservableList<AddonUpdateObject> objects;
@SuppressWarnings("unchecked")
public AddonUpdatesPage(LocalAddonManager<F> localAddonManager, List<LocalAddonFile.AddonUpdate> updates) {
this.localAddonManager = localAddonManager;
public AddonUpdatesPage(DownloadProvider downloadProvider, Path localAddonDirectory, AddonUpdates updates) {
this.downloadProvider = downloadProvider;
this.localAddonDirectory = localAddonDirectory;
getStyleClass().add("gray-background");
@@ -96,6 +99,9 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
targetVersionColumn.setPrefWidth(180);
setupCellValueFactory(targetVersionColumn, AddonUpdateObject::targetVersionProperty);
TableColumn<AddonUpdateObject, String> targetVersionTypeColumn = new TableColumn<>(i18n("addon.version_type"));
setupCellValueFactory(targetVersionTypeColumn, AddonUpdateObject::targetVersionTypeProperty);
TableColumn<AddonUpdateObject, String> sourceColumn = new TableColumn<>(i18n("addon.check_update.source"));
setupCellValueFactory(sourceColumn, AddonUpdateObject::sourceProperty);
@@ -115,12 +121,26 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
changelogColumn.setCellValueFactory(__ -> 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();
BooleanProperty showPreview = new SimpleBooleanProperty();
{
List<Subscription> subscriptions = new ArrayList<>();
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<AddonUpdateObject> 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 +149,9 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
actions.setPadding(new Insets(8));
actions.setAlignment(Pos.CENTER_RIGHT);
JFXCheckBox showPreviewCheck = new JFXCheckBox(i18n("addon.check_update.preview"));
showPreviewCheck.selectedProperty().bindBidirectional(showPreview);
JFXButton exportListButton = FXUtils.newRaisedButton(i18n("button.export"));
exportListButton.setOnAction(e -> exportList());
@@ -140,7 +163,7 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
onEscPressed(this, cancelButton::fire);
onEscPressed(table, cancelButton::fire);
actions.getChildren().setAll(exportListButton, nextButton, cancelButton);
actions.getChildren().setAll(showPreviewCheck, exportListButton, nextButton, cancelButton);
setBottom(actions);
}
@@ -150,7 +173,8 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
private void updateFiles() {
AddonUpdateTask task = new AddonUpdateTask(
localAddonManager.getDirectory(),
downloadProvider,
localAddonDirectory,
objects.stream()
.filter(AddonUpdateObject::isEnabled)
.map(AddonUpdateObject::getData)
@@ -210,31 +234,30 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
}
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 +269,26 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
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 +334,10 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
private void loadChangelog(AddonUpdateObject object, SpinnerPane spinnerPane, ScrollPane scrollPane) {
spinnerPane.setLoading(true);
RemoteAddonRepository repo = object.data.source().getRepository();
RemoteAddonRepository repo = object.data.targetVersion().source().getRepository();
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 +345,7 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
}).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 +356,9 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
}
private void loadVersionPageUrl(AddonUpdateObject object, JFXHyperlink button) {
Task.supplyAsync(() -> {
RemoteAddonRepository repo = object.data.source().getRepository();
return repo == null ? null : repo.getVersionPageUrl(object.data.targetVersion());
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
Task.supplyAsync(() ->
object.data.targetVersion().source().getRepository().getVersionPageUrl(object.data.targetVersion())
).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null && StringUtils.isNotBlank(result)) {
button.setExternalLink(result);
button.setDisable(false);
@@ -380,84 +368,4 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
}).start();
}
}
public static class AddonUpdateTask extends Task<Void> {
private final Collection<Task<?>> dependents;
private final List<LocalAddonFile> failedAddons = new ArrayList<>();
AddonUpdateTask(Path addonDirectory, List<LocalAddonFile.AddonUpdate> addons) {
setStage("addon.check_update.confirm");
getProperties().put("total", addons.size());
this.dependents = new ArrayList<>();
for (LocalAddonFile.AddonUpdate addon : addons) {
LocalAddonFile local = addon.localAddonFile();
RemoteAddon.Version remote = addon.targetVersion();
boolean isDisabled = local.isDisabled();
String originalFileName = local.getFile().getFileName().toString();
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 -> {
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);
}
}
})
.withCounter("addon.check_update.confirm"));
}
}
public List<LocalAddonFile> getFailedAddons() {
return failedAddons;
}
@Override
public Collection<Task<?>> getDependents() {
return dependents;
}
@Override
public boolean doPreExecute() {
return true;
}
@Override
public void preExecute() {
notifyPropertiesChanged();
}
@Override
public boolean isRelyingOnDependents() {
return false;
}
@Override
public void execute() throws Exception {
if (!isDependentsSucceeded())
throw getException();
}
}
}
@@ -45,6 +45,7 @@ import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.addon.update.AddonCheckUpdatesTask;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.addon.mod.LocalModFile;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
@@ -251,19 +252,22 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
Runnable action = () -> Controllers.taskDialog(Task
.composeAsync(() -> {
GameVersionNumber version = gameInstance.getVersion();
return version != GameVersionNumber.unknown()
? new AddonCheckUpdatesTask<>(
DownloadProviders.getDownloadProvider(), version.toString(), mods)
: null;
if (version != GameVersionNumber.unknown()) {
return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), version.toString(), 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(DownloadProviders.getDownloadProvider(), modManager.getDirectory(), result));
}
})
.withStagesHints("update.checking"),
@@ -44,6 +44,7 @@ import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackFile;
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager;
import org.jackhuang.hmcl.addon.update.AddonCheckUpdatesTask;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.SettingsManager;
@@ -261,18 +262,20 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
Runnable action = () -> Controllers.taskDialog(Task
.composeAsync(() -> {
GameVersionNumber version = gameInstance.getVersion();
return version != GameVersionNumber.unknown()
? new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(),
version.toString(), resourcePacks)
: null;
if (version != GameVersionNumber.unknown()) {
return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), version.toString(), resourcePacks);
} 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 != 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(DownloadProviders.getDownloadProvider(), resourcePackManager.getDirectory(), result));
}
})
.withStagesHints("update.checking"),
@@ -65,8 +65,8 @@ public class DownloadSettingsPage extends StackPane {
getChildren().setAll(scrollPane);
{
var downloadSource = new ComponentList();
downloadSource.getStyleClass().add("card-non-transparent");
var gameContent = new ComponentList();
gameContent.getStyleClass().add("card-non-transparent");
{
Function<DownloadSource, String> converter = source -> switch (source) {
case DEFAULT -> i18n("settings.launcher.download_source.auto");
@@ -102,10 +102,10 @@ public class DownloadSettingsPage extends StackPane {
defaultAddonSourcePane.setItems("modrinth", "curseforge");
defaultAddonSourcePane.valueProperty().bindBidirectional(settings().defaultAddonSourceProperty());
downloadSource.getContent().setAll(versionListSourcePane, downloadSourcePane, defaultAddonSourcePane);
gameContent.getContent().setAll(versionListSourcePane, downloadSourcePane, defaultAddonSourcePane);
}
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.download_source")), downloadSource);
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.game_content")), gameContent);
}
{
@@ -175,6 +175,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
@@ -192,6 +193,7 @@ addon.sort.last_updated=Last Updated
addon.sort.popularity=Popularity
addon.sort.relevancy=Relevancy
addon.sort.total_downloads=Total Downloads
addon.version_type=Type
archive.author=Author(s)
archive.date=Publish Date
@@ -1565,6 +1567,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
@@ -176,6 +176,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
@@ -193,6 +194,7 @@ addon.sort.last_updated=آخر تحديث
addon.sort.popularity=الشعبية
# addon.sort.relevancy=
addon.sort.total_downloads=إجمالي التنزيلات
# addon.version_type=
archive.author=المؤلف/المؤلفون
archive.date=تاريخ النشر
@@ -1562,6 +1564,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=تصدير سجلات المشغّل
@@ -174,6 +174,7 @@ addon.check_update.empty=Alle Dateien sind auf dem neuesten Stand
addon.check_update.failed_check=Fehler beim Suchen nach Aktualisierungen.
addon.check_update.failed_download=Fehler beim Herunterladen einiger Dateien.
addon.check_update.file=Datei
# addon.check_update.preview=
addon.check_update.source=Quelle
addon.check_update.target_version=Zielversion
addon.curseforge=CurseForge
@@ -191,6 +192,7 @@ addon.sort.last_updated=Zuletzt aktualisiert
addon.sort.popularity=Beliebtheit
# addon.sort.relevancy=
addon.sort.total_downloads=Downloads insgesamt
# addon.version_type=
archive.author=Autor(en)
archive.date=Veröffentlichungsdatum
@@ -1563,6 +1565,7 @@ settings.launcher.font.anti_aliasing.auto=Auto
settings.launcher.font.anti_aliasing.gray=Graustufen
settings.launcher.font.anti_aliasing.lcd=Sub-Pixel
settings.launcher.fonts=Schriftarten
# settings.launcher.game_content=
settings.launcher.general=Allgemein
settings.launcher.language=Sprache
settings.launcher.launcher_log.export=Launcher-Protokolle exportieren
@@ -174,6 +174,7 @@ addon.check_update.empty=Todos los mods están actualizados
addon.check_update.failed_check=No se ha podido comprobar si hay actualizaciones.
addon.check_update.failed_download=No se han podido descargar algunos de los archivos.
addon.check_update.file=Archivo
# addon.check_update.preview=
addon.check_update.source=Fuente
addon.check_update.target_version=Versión de destino
addon.curseforge=CurseForge
@@ -191,6 +192,7 @@ addon.sort.last_updated=Última actualización
addon.sort.popularity=Popularidad
# addon.sort.relevancy=
addon.sort.total_downloads=Descargas totales
# addon.version_type=
archive.author=Autor(es)
archive.date=Fecha de publicación
@@ -1554,6 +1556,7 @@ settings.launcher.font.anti_aliasing.auto=Automático
settings.launcher.font.anti_aliasing.gray=Escala de grises
settings.launcher.font.anti_aliasing.lcd=Subpíxel
# settings.launcher.fonts=
# settings.launcher.game_content=
settings.launcher.general=General
settings.launcher.language=Idioma
settings.launcher.launcher_log.export=Exportar registros del launcher
@@ -163,6 +163,7 @@ addon.check_update.current_version=Current
addon.check_update.failed_check=更新のチェックに失敗しました
addon.check_update.failed_download=一部のファイルのダウンロードに失敗しました
addon.check_update.file=ファイル
# addon.check_update.preview=
addon.check_update.source=Source
addon.check_update.target_version=Target
addon.curseforge=CurseForge
@@ -180,6 +181,7 @@ addon.sort.last_updated=最終更新
addon.sort.popularity=人気
# addon.sort.relevancy=
addon.sort.total_downloads=合計ダウンロード数
# addon.version_type=
archive.author=作成者
archive.date=公開日
@@ -1358,6 +1360,7 @@ settings.launcher.font=フォント
# 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=ランチャーログのエクスポート
@@ -174,6 +174,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
@@ -191,6 +192,7 @@ addon.sort.last_updated=近日迭更
addon.sort.popularity=幾熱
# addon.sort.relevancy=
addon.sort.total_downloads=引數
# addon.version_type=
archive.author=作者
archive.date=廣佈之期
@@ -1370,6 +1372,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=錄出啟者之誌
@@ -170,6 +170,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
@@ -187,6 +188,7 @@ addon.sort.last_updated=Обновлён
addon.sort.popularity=Популярность
addon.sort.relevancy=Релевантность
addon.sort.total_downloads=Скачиваний
# addon.version_type=
archive.author=Автор(ы)
archive.date=Дата публикации
@@ -1494,6 +1496,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=Экспорт логов лаунчера
@@ -176,6 +176,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
@@ -193,6 +194,7 @@ addon.sort.last_updated=Останнє оновлення
addon.sort.popularity=Популярність
# addon.sort.relevancy=
addon.sort.total_downloads=Всього завантажень
# addon.version_type=
archive.author=Автор(и)
archive.date=Дата публікації
@@ -1517,6 +1519,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=Експортувати журнали лаунчера
@@ -175,6 +175,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
@@ -192,6 +193,7 @@ addon.sort.last_updated=最近更新
addon.sort.popularity=热度
addon.sort.relevancy=相关度
addon.sort.total_downloads=下载量
addon.version_type=版本类型
archive.author=作者
archive.date=发布日期
@@ -1377,6 +1379,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=导出启动器日志
@@ -173,6 +173,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
@@ -190,6 +191,7 @@ addon.sort.last_updated=最近更新
addon.sort.popularity=熱度
addon.sort.relevancy=相關度
addon.sort.total_downloads=下載量
addon.version_type=版本類型
archive.author=作者
archive.date=發布日期
@@ -1371,6 +1373,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=匯出啟動器日誌
@@ -17,7 +17,10 @@
*/
package org.jackhuang.hmcl.addon;
import org.jackhuang.hmcl.addon.update.AddonUpdate;
import org.jackhuang.hmcl.addon.update.AddonUpdateConditions;
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;
@@ -26,8 +29,10 @@ import org.jetbrains.annotations.Unmodifiable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
/// Sub-classes should implement `Comparable`
@@ -53,20 +58,43 @@ public abstract class LocalAddonFile {
public abstract void delete() throws IOException;
/// @return the update target version restrictions for this addon file, or null if not upgradable.
@Nullable
public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) throws IOException {
protected AddonUpdateConditions getTargetVersionRestrictions() {
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<AddonUpdate, @Nullable AddonUpdate> checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) throws IOException {
var restrictions = getTargetVersionRestrictions();
if (restrictions == null) return null;
RemoteAddonRepository repo = source.getRepository();
Optional<RemoteAddon.Version> currentVersion = repo.getRemoteVersionByLocalFile(getFile());
if (currentVersion.isEmpty()) return null;
var current = currentVersion.orElseThrow();
var stream = repo.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()));
for (var p : restrictions.restrictions())
stream = stream.filter(p);
List<RemoteAddon.Version> 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) {
}
@NotNullByDefault
@@ -21,6 +21,7 @@ import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
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;
@@ -143,6 +144,7 @@ public record RemoteAddon(String slug, String author, String title, String descr
private final RemoteAddonRepository repo;
@NotNull
public RemoteAddonRepository getRepository() {
return repo;
}
@@ -167,11 +169,7 @@ public record RemoteAddon(String slug, String author, String title, String descr
Stream<Version> 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<Dependency> dependencies,
List<String> gameVersions, List<AddonLoader> loaders) {
}
@@ -19,17 +19,17 @@ package org.jackhuang.hmcl.addon.mod;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import org.jackhuang.hmcl.addon.AddonLoader;
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.addon.update.AddonUpdateConditions;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -195,19 +195,8 @@ public final class LocalModFile extends LocalAddonFile implements Comparable<Loc
}
@Override
public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) throws IOException {
RemoteAddonRepository repository = source.getRepository();
if (repository == null) return null;
Optional<RemoteAddon.Version> currentVersion = repository.getRemoteVersionByLocalFile(file);
if (currentVersion.isEmpty()) return null;
List<RemoteAddon.Version> remoteVersions = repository.getRemoteVersionsById(downloadProvider, currentVersion.get().projectId())
.filter(version -> version.gameVersions().contains(gameVersion))
.filter(version -> version.loaders().stream().anyMatch(it -> it.type() == 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 AddonUpdateConditions getTargetVersionRestrictions() {
return new AddonUpdateConditions(v -> v.loaders().stream().map(AddonLoader::type).collect(Collectors.toSet()).contains(getModLoaderType()));
}
@Override
@@ -554,7 +554,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
int releaseType, int fileStatus, List<LatestFileHash> hashes, Instant fileDate,
int fileLength, int downloadCount, String downloadUrl, List<String> gameVersions,
List<Dependency> dependencies, int alternateFileId, boolean isServerPack,
long fileFingerprint) implements RemoteAddon.IVersion {
long fileFingerprint) {
@Override
public String downloadUrl() {
@@ -566,11 +566,6 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
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;
@@ -596,7 +591,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
}
return new RemoteAddon.Version(
this,
RemoteAddon.Source.CURSEFORGE,
Integer.toString(id()),
Integer.toString(modId()),
displayName(),
@@ -443,7 +443,7 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
@SerializedName("author_id") String authorId,
@SerializedName("date_published") Instant datePublished, int downloads,
@SerializedName("changelog_url") String changelogUrl,
List<ProjectVersionFile> files) implements RemoteAddon.IVersion {
List<ProjectVersionFile> files) {
private static final Map<String, RemoteAddon.@Nullable DependencyType> DEPENDENCY_TYPE = mapOf(
pair("required", RemoteAddon.DependencyType.REQUIRED),
pair("optional", RemoteAddon.DependencyType.OPTIONAL),
@@ -451,29 +451,20 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
pair("incompatible", RemoteAddon.DependencyType.INCOMPATIBLE)
);
@Override
public RemoteAddon.Source getSource() {
return RemoteAddon.Source.MODRINTH;
}
public Optional<RemoteAddon.Version> 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,
@@ -72,5 +72,4 @@ final class ResourcePackFolder extends ResourcePackFile {
public void delete() throws IOException {
FileUtils.deleteDirectory(file);
}
}
@@ -509,6 +509,39 @@ public final class ResourcePackManager extends LocalAddonManager<ResourcePackFil
}
}
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<String, String> options = loadOptions();
List<String> optPacks = new ArrayList<>(deserializePackList(options.get("resourcePacks")));
List<String> 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<Pair<ResourcePackFile, Boolean>> arePacksEnabled(Stream<ResourcePackFile> resourcePacks) {
lock.lock();
try {
@@ -18,10 +18,8 @@
package org.jackhuang.hmcl.addon.resourcepack;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.meta.PackMcMeta;
import org.jackhuang.hmcl.addon.update.AddonUpdateConditions;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jetbrains.annotations.Nullable;
@@ -29,13 +27,11 @@ import java.io.ByteArrayInputStream;
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 final PackMcMeta.PackInfo info;
public ResourcePackZipFile(ResourcePackManager manager, Path path) throws IOException {
@@ -85,18 +81,14 @@ final class ResourcePackZipFile extends ResourcePackFile {
}
@Override
public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) throws IOException {
RemoteAddonRepository repository = source.getRepository();
if (repository == null) return null;
Optional<RemoteAddon.Version> currentVersion = repository.getRemoteVersionByLocalFile(file);
if (currentVersion.isEmpty()) return null;
List<RemoteAddon.Version> 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 AddonUpdateConditions getTargetVersionRestrictions() {
return new AddonUpdateConditions();
}
@Override
public void onUpdated(String newFileNameWithExt) {
super.onUpdated(newFileNameWithExt);
manager.rename(getFileNameWithExtension(), newFileNameWithExt);
}
}
@@ -0,0 +1,30 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.addon.update;
import org.jackhuang.hmcl.addon.LocalAddonFile;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jetbrains.annotations.NotNullByDefault;
@NotNullByDefault
public record AddonUpdate(
LocalAddonFile localAddonFile,
RemoteAddon.Version currentVersion,
RemoteAddon.Version targetVersion
) {
}
@@ -0,0 +1,39 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.addon.update;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Unmodifiable;
import java.util.List;
import java.util.function.Predicate;
///
/// @param restrictions conditions that the remote version must satisfy to become a valid target
@NotNullByDefault
public record AddonUpdateConditions(@Unmodifiable List<Predicate<RemoteAddon.Version>> restrictions) {
public AddonUpdateConditions {
restrictions = List.copyOf(restrictions);
}
@SafeVarargs
public AddonUpdateConditions(Predicate<RemoteAddon.Version>... restrictions) {
this(List.of(restrictions));
}
}
@@ -0,0 +1,32 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.addon.update;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Unmodifiable;
import java.util.List;
@NotNullByDefault
public record AddonUpdates(@Unmodifiable List<AddonUpdate> commonUpdates, @Unmodifiable List<AddonUpdate> releaseUpdates) {
public AddonUpdates {
commonUpdates = List.copyOf(commonUpdates);
releaseUpdates = List.copyOf(releaseUpdates);
}
}
@@ -0,0 +1,57 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.javafx;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import java.util.List;
import java.util.Objects;
/// Alternate for JavaFX version below 21
@FunctionalInterface
public interface Subscription {
Subscription EMPTY = () -> {};
static Subscription subscribe(Observable observable, Runnable invalidationSubscriber) {
Objects.requireNonNull(invalidationSubscriber, "invalidationSubscriber cannot be null");
InvalidationListener listener = obs -> invalidationSubscriber.run();
observable.addListener(listener);
return () -> observable.removeListener(listener);
}
static Subscription combine(Subscription... subscriptions) {
List<Subscription> list = List.of(subscriptions);
return () -> list.forEach(Subscription::unsubscribe);
}
void unsubscribe();
default Subscription and(Subscription other) {
Objects.requireNonNull(other, "other cannot be null");
return () -> {
unsubscribe();
other.unsubscribe();
};
}
}