From 74d135fa046e12e7e9b9b701c310e2de80d569f0 Mon Sep 17 00:00:00 2001 From: ToobLac Date: Sat, 1 Aug 2026 13:37:39 +0800 Subject: [PATCH 01/11] update --- .../hmcl/ui/download/DownloadPage.java | 20 ++++----- .../hmcl/ui/instances/AddonFavoritesPage.java | 42 +++++++++++++++++++ .../resources/assets/lang/I18N.properties | 1 + .../resources/assets/lang/I18N_zh.properties | 1 + .../assets/lang/I18N_zh_CN.properties | 1 + 5 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonFavoritesPage.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java index e961c901bfd..7c11232ecd8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java @@ -37,16 +37,10 @@ import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.WeakListenerHolder; import org.jackhuang.hmcl.ui.animation.TransitionPane; -import org.jackhuang.hmcl.ui.construct.AdvancedListBox; -import org.jackhuang.hmcl.ui.construct.MessageDialogPane; -import org.jackhuang.hmcl.ui.construct.TabHeader; -import org.jackhuang.hmcl.ui.construct.Validator; +import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; -import org.jackhuang.hmcl.ui.instances.DownloadListPage; -import org.jackhuang.hmcl.ui.instances.HMCLLocalizedDownloadListPage; -import org.jackhuang.hmcl.ui.instances.GameInstancePage; -import org.jackhuang.hmcl.ui.instances.Instances; +import org.jackhuang.hmcl.ui.instances.*; import org.jackhuang.hmcl.ui.wizard.Navigation; import org.jackhuang.hmcl.ui.wizard.WizardController; import org.jackhuang.hmcl.ui.wizard.WizardProvider; @@ -84,6 +78,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage private final TabHeader.Tab resourcePackTab = new TabHeader.Tab<>("resourcePackTab"); private final TabHeader.Tab shaderTab = new TabHeader.Tab<>("shaderTab"); private final TabHeader.Tab worldTab = new TabHeader.Tab<>("worldTab"); + private final TabHeader.Tab favoritesPageTab = new TabHeader.Tab<>("favoritesTab"); private final TransitionPane transitionPane = new TransitionPane(); private final DownloadNavigator versionPageNavigator = new DownloadNavigator(); @@ -111,7 +106,8 @@ public DownloadPage(GameInstanceID uploadInstance) { resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack(FOR_RESOURCE_PACK, true))); shaderTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofShaderPack(FOR_SHADER, true))); worldTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(CurseForgeRemoteAddonRepository.WORLDS))); - tab = new TabHeader(transitionPane, newGameTab, modpackTab, modTab, resourcePackTab, shaderTab, worldTab); + favoritesPageTab.setNodeSupplier(loadVersionFor(AddonFavoritesPage::new)); + tab = new TabHeader(transitionPane, newGameTab, modpackTab, modTab, resourcePackTab, shaderTab, worldTab, favoritesPageTab); GameDirectoryManager.registerVersionsListener(this::loadVersions); @@ -125,7 +121,8 @@ public DownloadPage(GameInstanceID uploadInstance) { .addNavigationDrawerTab(tab, modTab, i18n("mods"), SVG.EXTENSION, SVG.EXTENSION_FILL) .addNavigationDrawerTab(tab, resourcePackTab, i18n("resourcepack"), SVG.TEXTURE) .addNavigationDrawerTab(tab, shaderTab, i18n("download.shader"), SVG.WB_SUNNY, SVG.WB_SUNNY_FILL) - .addNavigationDrawerTab(tab, worldTab, i18n("world"), SVG.PUBLIC); + .addNavigationDrawerTab(tab, worldTab, i18n("world"), SVG.PUBLIC) + .addNavigationDrawerTab(tab, favoritesPageTab, i18n("addon.favorites"), SVG.DEPLOYED_CODE, SVG.DEPLOYED_CODE_FILL); FXUtils.setLimitWidth(sideBar, 200); setLeft(sideBar); @@ -205,6 +202,9 @@ private void loadVersions(HMCLGameRepository repository) { if (worldTab.isInitialized()) { worldTab.getNode().loadInstance(repository, null); } + if (favoritesPageTab.isInitialized()) { + favoritesPageTab.getNode().loadInstance(repository, null); + } })); } }); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonFavoritesPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonFavoritesPage.java new file mode 100644 index 00000000000..9b98488ea86 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonFavoritesPage.java @@ -0,0 +1,42 @@ +/* + * 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.ui.instances; + +import javafx.beans.property.*; +import javafx.scene.control.Control; +import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.ui.decorator.DecoratorPage; +import org.jetbrains.annotations.Nullable; + +public class AddonFavoritesPage extends Control implements DecoratorPage, GameInstancePage.GameInstanceLoadable { + + protected final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); + private final BooleanProperty loading = new SimpleBooleanProperty(false); + private final ObjectProperty instanceReference = new SimpleObjectProperty<>(); + + @Override + public ReadOnlyObjectProperty stateProperty() { + return state.getReadOnlyProperty(); + } + + @Override + public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { + instanceReference.set(new HMCLGameRepository.InstanceReference(repository, instanceId)); + } +} diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index f3df2d455b2..065b80a2e38 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -199,6 +199,7 @@ addon.dependency.broken=Broken Dependencies (This addon existed before, but it d addon.download.title.release=Minecraft %s addon.download.title.snapshot=Minecraft %s (Snapshots) addon.modrinth=Modrinth +addon.favorites=Favorites archive.author=Author(s) archive.date=Publish Date diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index ae7e69e1d95..8a06823f4d5 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -197,6 +197,7 @@ addon.dependency.broken=損壞的相依內容 (該相依內容曾經存在於附 addon.download.title.release=Minecraft %s addon.download.title.snapshot=Minecraft %s (快照) addon.modrinth=Modrinth +addon.favorites=收藏夾 archive.author=作者 archive.date=發布日期 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 ca04c3366af..42d8122c0e5 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -199,6 +199,7 @@ addon.dependency.broken=损坏的前置内容 (该前置内容曾经在该附加 addon.download.title.release=Minecraft %s addon.download.title.snapshot=Minecraft %s (快照) addon.modrinth=Modrinth +addon.favorites=收藏夹 archive.author=作者 archive.date=发布日期 From b5748662d784e67d7fce6e05fa293c772a4e1b1e Mon Sep 17 00:00:00 2001 From: ToobLac Date: Sat, 1 Aug 2026 14:53:00 +0800 Subject: [PATCH 02/11] Update HMCL/src/main/resources/assets/lang/I18N_zh.properties Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com> --- HMCL/src/main/resources/assets/lang/I18N_zh.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 8a06823f4d5..c0d95fd7d33 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -197,7 +197,7 @@ addon.dependency.broken=損壞的相依內容 (該相依內容曾經存在於附 addon.download.title.release=Minecraft %s addon.download.title.snapshot=Minecraft %s (快照) addon.modrinth=Modrinth -addon.favorites=收藏夾 +addon.favorites=我的最愛 archive.author=作者 archive.date=發布日期 From bed8b92315f98135eff95debcfd553a977a0ee59 Mon Sep 17 00:00:00 2001 From: ToobLac Date: Sat, 1 Aug 2026 22:37:02 +0800 Subject: [PATCH 03/11] update --- .../org/jackhuang/hmcl/addon/Favorites.java | 152 ++++++++++++++++++ .../org/jackhuang/hmcl/addon/RemoteAddon.java | 13 +- .../CurseForgeRemoteAddonRepository.java | 4 +- .../ModrinthRemoteAddonRepository.java | 8 +- 4 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/addon/Favorites.java diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/Favorites.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/Favorites.java new file mode 100644 index 00000000000..b3c73f00b40 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/Favorites.java @@ -0,0 +1,152 @@ +/* + * 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 com.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; +import org.jackhuang.hmcl.download.DownloadProvider; +import org.jackhuang.hmcl.util.gson.JsonSerializable; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public final class Favorites { + + private static final TypeToken> typeToken = new TypeToken<>() { + }; + + private final Path file; // Any external changes to this file might be lost + private final LinkedHashSet items = new LinkedHashSet<>(); + private final List addons = new ArrayList<>(); + private final HashMap cache = new HashMap<>(); + private DownloadProvider lastProvider = null; + + private final ReentrantLock lock = new ReentrantLock(); + + public Favorites(Path favoritesFile) { + this.file = Objects.requireNonNull(favoritesFile); + } + + public Path getFile() { + return file; + } + + public void load() { + lock.lock(); + try { + items.clear(); + var set = JsonUtils.fromJsonFile(file, typeToken); + if (set != null) items.addAll(set); + } catch (IOException | JsonSyntaxException e) { + LOG.warning("Failed to load favorites file at " + file, e); + } finally { + lock.unlock(); + } + } + + public void save() { + lock.lock(); + try { + JsonUtils.writeToJsonFile(file, items); + } catch (IOException e) { + LOG.warning("Failed to save favorites file at " + file, e); + } finally { + lock.unlock(); + } + } + + public void add(RemoteAddon addon) { + lock.lock(); + try { + var item = Item.fromAddon(addon); + items.remove(item); + items.add(item); + } finally { + lock.unlock(); + } + } + + /// @return Whether the items list was modified. + public boolean remove(Collection addons) { + lock.lock(); + try { + return items.removeAll(addons.stream().map(Item::fromAddon).collect(Collectors.toSet())); + } finally { + lock.unlock(); + } + } + + public void resolveAll(DownloadProvider downloadProvider) { + lock.lock(); + try { + addons.clear(); + if (downloadProvider != lastProvider) { + cache.clear(); + lastProvider = downloadProvider; + } + LinkedHashMap resultReversed = new LinkedHashMap<>(items.size()); + for (var item : items.reversed()) { + RemoteAddon addon; + if (cache.containsKey(item)) { + addon = cache.get(item); + } else { + try { + addon = item.resolve(downloadProvider); + } catch (IOException e) { + LOG.warning("Failed to resolve favorite item: " + item, e); + continue; + } + } + if (!resultReversed.containsKey(addon)) { + resultReversed.put(addon, item); + cache.put(item, addon); + } + } + addons.addAll(resultReversed.reversed().keySet()); + if (items.retainAll(resultReversed.values())) { // Remove duplicate items + save(); + } + } finally { + lock.unlock(); + } + } + + @JsonSerializable + public record Item(@Nullable String modId, @Nullable RemoteAddon.Source source) { + + public static Item fromAddon(RemoteAddon addon) { + return new Item(addon.projectId(), addon.source()); + } + + public @NotNull RemoteAddon resolve(DownloadProvider downloadProvider) throws IOException { + if (modId == null || source == null) return RemoteAddon.BROKEN; + var repo = source.getRepoForType(RemoteAddonRepository.Type.MOD); //TODO use common repo + assert repo != null; + return repo.getModById(downloadProvider, modId); + } + + } +} 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 e4b700a5284..acd1597faa3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddon.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddon.java @@ -17,11 +17,13 @@ */ package org.jackhuang.hmcl.addon; +import com.google.gson.annotations.SerializedName; import org.jackhuang.hmcl.addon.mod.ModLoaderType; 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.jackhuang.hmcl.util.gson.JsonSerializable; import org.jetbrains.annotations.Nullable; import java.io.IOException; @@ -31,10 +33,10 @@ import java.util.Map; import java.util.stream.Stream; -public record RemoteAddon(String slug, String author, String title, String description, List categories, - String pageUrl, String iconUrl, IMod data, RemoteAddonRepository.Type repoType) { +public record RemoteAddon(String projectId, String slug, String author, String title, String description, List categories, + String pageUrl, String iconUrl, IMod data, RemoteAddonRepository.Type repoType, Source source) { - public static final RemoteAddon BROKEN = new RemoteAddon("", "", "RemoteAddon.BROKEN", "", Collections.emptyList(), "", "", new IMod() { + public static final RemoteAddon BROKEN = new RemoteAddon("", "", "", "RemoteAddon.BROKEN", "", Collections.emptyList(), "", "", new IMod() { @Override public List loadDependencies(RemoteAddonRepository modRepository, DownloadProvider downloadProvider) throws IOException { throw new IOException(); @@ -44,7 +46,7 @@ public List loadDependencies(RemoteAddonRepository modRepository, D public Stream loadVersions(RemoteAddonRepository modRepository, DownloadProvider downloadProvider) throws IOException { throw new IOException(); } - }, RemoteAddonRepository.Type.MOD); + }, RemoteAddonRepository.Type.MOD, null); public enum VersionType { Release, @@ -138,7 +140,9 @@ public int hashCode() { } } + @JsonSerializable public enum Source { + @SerializedName("curseforge") CURSEFORGE( CurseForgeRemoteAddonRepository.MODS, CurseForgeRemoteAddonRepository.RESOURCE_PACKS, @@ -147,6 +151,7 @@ public enum Source { CurseForgeRemoteAddonRepository.MODPACKS, CurseForgeRemoteAddonRepository.CUSTOMIZATIONS ), + @SerializedName("modrinth") MODRINTH( ModrinthRemoteAddonRepository.MODS, ModrinthRemoteAddonRepository.RESOURCE_PACKS, 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 3d12959d30f..072f359c8ee 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 @@ -389,6 +389,7 @@ else if (StringUtils.isNotBlank(logo.url())) } return new RemoteAddon( + Integer.toString(id), slug, "", name, @@ -397,7 +398,8 @@ else if (StringUtils.isNotBlank(logo.url())) links.websiteUrl, iconUrl, this, - type + type, + RemoteAddon.Source.CURSEFORGE ); } 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 497b6925374..bc655b933be 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 @@ -340,6 +340,7 @@ public RemoteAddon toMod() { default -> Type.MOD; }; return new RemoteAddon( + id, slug, "", title, @@ -348,7 +349,8 @@ public RemoteAddon toMod() { String.format("https://modrinth.com/%s/%s", projectType, id), iconUrl, this, - type + type, + RemoteAddon.Source.MODRINTH ); } } @@ -472,6 +474,7 @@ public RemoteAddon toMod() { default -> Type.MOD; }; return new RemoteAddon( + projectId, slug, author, title, @@ -480,7 +483,8 @@ public RemoteAddon toMod() { String.format("https://modrinth.com/%s/%s", projectType, projectId), iconUrl, this, - type + type, + RemoteAddon.Source.MODRINTH ); } } From 44aa57503be9a560a05ef3a84c5622ed749e1d1f Mon Sep 17 00:00:00 2001 From: ToobLac Date: Sat, 1 Aug 2026 22:51:44 +0800 Subject: [PATCH 04/11] update --- HMCLCore/src/main/java/org/jackhuang/hmcl/addon/Favorites.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/Favorites.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/Favorites.java index b3c73f00b40..e00dc87d471 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/Favorites.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/Favorites.java @@ -145,7 +145,7 @@ public static Item fromAddon(RemoteAddon addon) { if (modId == null || source == null) return RemoteAddon.BROKEN; var repo = source.getRepoForType(RemoteAddonRepository.Type.MOD); //TODO use common repo assert repo != null; - return repo.getModById(downloadProvider, modId); + return repo.getAddonById(downloadProvider, modId); } } From c141116cdc6d719499d300b0508f7d38819b7bf5 Mon Sep 17 00:00:00 2001 From: ToobLac Date: Sun, 2 Aug 2026 13:17:53 +0800 Subject: [PATCH 05/11] replace Java21 features --- .../src/main/java/org/jackhuang/hmcl/addon/Favorites.java | 5 +++-- HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/Favorites.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/Favorites.java index e00dc87d471..e641366ad50 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/Favorites.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/Favorites.java @@ -20,6 +20,7 @@ import com.google.gson.JsonSyntaxException; import com.google.gson.reflect.TypeToken; import org.jackhuang.hmcl.download.DownloadProvider; +import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jetbrains.annotations.NotNull; @@ -108,7 +109,7 @@ public void resolveAll(DownloadProvider downloadProvider) { lastProvider = downloadProvider; } LinkedHashMap resultReversed = new LinkedHashMap<>(items.size()); - for (var item : items.reversed()) { + for (var item : Lang.reversedCopyOf(items)) { RemoteAddon addon; if (cache.containsKey(item)) { addon = cache.get(item); @@ -125,7 +126,7 @@ public void resolveAll(DownloadProvider downloadProvider) { cache.put(item, addon); } } - addons.addAll(resultReversed.reversed().keySet()); + addons.addAll(Lang.reversedCopyOf(resultReversed.keySet())); if (items.retainAll(resultReversed.values())) { // Remove duplicate items save(); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java index 655acbad4da..9be93ff6aed 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java @@ -71,6 +71,12 @@ public static List immutableListOf(T... elements) { return Collections.unmodifiableList(Arrays.asList(elements)); } + public static Collection reversedCopyOf(Collection set) { + ArrayList list = new ArrayList<>(set); + Collections.reverse(list); + return new LinkedHashSet<>(list); + } + public static boolean test(ExceptionalRunnable r) { try { r.run(); From eb61a3b0199beeb8fcacad4af4a5c534d3ad1622 Mon Sep 17 00:00:00 2001 From: ToobLac Date: Sun, 2 Aug 2026 13:56:19 +0800 Subject: [PATCH 06/11] update --- HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java index 9be93ff6aed..e17a8a82126 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java @@ -71,10 +71,10 @@ public static List immutableListOf(T... elements) { return Collections.unmodifiableList(Arrays.asList(elements)); } - public static Collection reversedCopyOf(Collection set) { + public static List reversedCopyOf(Collection set) { ArrayList list = new ArrayList<>(set); Collections.reverse(list); - return new LinkedHashSet<>(list); + return list; } public static boolean test(ExceptionalRunnable r) { From 5add5ce31c1da0b61783f7a14ddfa12b3970d257 Mon Sep 17 00:00:00 2001 From: ToobLac Date: Sun, 2 Aug 2026 14:49:10 +0800 Subject: [PATCH 07/11] update --- .../{Favorites.java => FavoritesManager.java} | 131 ++++++++++++++---- 1 file changed, 103 insertions(+), 28 deletions(-) rename HMCLCore/src/main/java/org/jackhuang/hmcl/addon/{Favorites.java => FavoritesManager.java} (51%) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/Favorites.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/FavoritesManager.java similarity index 51% rename from HMCLCore/src/main/java/org/jackhuang/hmcl/addon/Favorites.java rename to HMCLCore/src/main/java/org/jackhuang/hmcl/addon/FavoritesManager.java index e641366ad50..a94f1fac135 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/Favorites.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/FavoritesManager.java @@ -21,10 +21,12 @@ import com.google.gson.reflect.TypeToken; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; import java.io.IOException; import java.nio.file.Path; @@ -34,20 +36,18 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public final class Favorites { +public final class FavoritesManager { - private static final TypeToken> typeToken = new TypeToken<>() { + private static final TypeToken>> typeToken = new TypeToken<>() { }; private final Path file; // Any external changes to this file might be lost - private final LinkedHashSet items = new LinkedHashSet<>(); - private final List addons = new ArrayList<>(); - private final HashMap cache = new HashMap<>(); - private DownloadProvider lastProvider = null; + + private final TreeMap favoritesMap = new TreeMap<>(String::compareToIgnoreCase); private final ReentrantLock lock = new ReentrantLock(); - public Favorites(Path favoritesFile) { + public FavoritesManager(Path favoritesFile) { this.file = Objects.requireNonNull(favoritesFile); } @@ -58,9 +58,10 @@ public Path getFile() { public void load() { lock.lock(); try { - items.clear(); - var set = JsonUtils.fromJsonFile(file, typeToken); - if (set != null) items.addAll(set); + favoritesMap.clear(); + var map = JsonUtils.fromJsonFile(file, typeToken); + if (map != null) + map.forEach((name, items) -> favoritesMap.put(name, new Favorites(this, name, items))); } catch (IOException | JsonSyntaxException e) { LOG.warning("Failed to load favorites file at " + file, e); } finally { @@ -71,7 +72,8 @@ public void load() { public void save() { lock.lock(); try { - JsonUtils.writeToJsonFile(file, items); + var pairs = favoritesMap.entrySet().stream().map(entry -> Pair.pair(entry.getKey(), entry.getValue().items)).toList(); + JsonUtils.writeToJsonFile(file, Lang.mapOf(pairs)); } catch (IOException e) { LOG.warning("Failed to save favorites file at " + file, e); } finally { @@ -79,37 +81,81 @@ public void save() { } } - public void add(RemoteAddon addon) { + public void resolveAll(DownloadProvider downloadProvider) { lock.lock(); try { - var item = Item.fromAddon(addon); - items.remove(item); - items.add(item); + boolean modified = false; + for (var fav : favoritesMap.values()) + modified |= fav.resolve0(downloadProvider); + if (modified) + save(); } finally { lock.unlock(); } } - /// @return Whether the items list was modified. - public boolean remove(Collection addons) { + @NotNull + public Favorites getOrCreate(String name) { lock.lock(); try { - return items.removeAll(addons.stream().map(Item::fromAddon).collect(Collectors.toSet())); + return favoritesMap.computeIfAbsent(name, n -> new Favorites(this, n, new LinkedHashSet<>())); } finally { lock.unlock(); } } - public void resolveAll(DownloadProvider downloadProvider) { - lock.lock(); - try { - addons.clear(); + public static class Favorites { + + private final FavoritesManager manager; + private final String name; + + private final LinkedHashSet items; + private transient final ArrayList resolvedAddons = new ArrayList<>(); + private transient final HashMap cache = new HashMap<>(); + private transient DownloadProvider lastProvider = null; + + private final ReentrantLock lock; + + private Favorites(FavoritesManager manager, String name, LinkedHashSet items) { + this.manager = manager; + this.lock = manager.lock; + this.name = name; + this.items = new LinkedHashSet<>(items); + } + + public String getName() { + return name; + } + + @Unmodifiable + public Set getItems() { + lock.lock(); + try { + return Set.copyOf(items); + } finally { + lock.unlock(); + } + } + + @Unmodifiable + public List getResolvedAddons() { + lock.lock(); + try { + return List.copyOf(resolvedAddons); + } finally { + lock.unlock(); + } + } + + /// @return Whether the item list was modified. + private boolean resolve0(DownloadProvider downloadProvider) { + resolvedAddons.clear(); if (downloadProvider != lastProvider) { cache.clear(); lastProvider = downloadProvider; } LinkedHashMap resultReversed = new LinkedHashMap<>(items.size()); - for (var item : Lang.reversedCopyOf(items)) { + for (var item : Lang.reversedCopyOf(items)) { // TODO migrate to SequencedSet::reversed after upgrading to JDK 21+ RemoteAddon addon; if (cache.containsKey(item)) { addon = cache.get(item); @@ -126,13 +172,42 @@ public void resolveAll(DownloadProvider downloadProvider) { cache.put(item, addon); } } - addons.addAll(Lang.reversedCopyOf(resultReversed.keySet())); - if (items.retainAll(resultReversed.values())) { // Remove duplicate items - save(); + resolvedAddons.addAll(Lang.reversedCopyOf(resultReversed.keySet())); + return items.retainAll(resultReversed.values()); // Remove duplicate items + } + + public void resolve(DownloadProvider downloadProvider) { + lock.lock(); + try { + if (resolve0(downloadProvider)) + manager.save(); + } finally { + lock.unlock(); + } + } + + public void add(RemoteAddon addon) { + lock.lock(); + try { + var item = Item.fromAddon(addon); + items.remove(item); + items.add(item); + manager.save(); + } finally { + lock.unlock(); } - } finally { - lock.unlock(); } + + public void remove(Collection addons) { + lock.lock(); + try { + if (items.removeAll(addons.stream().map(Item::fromAddon).collect(Collectors.toSet()))) + manager.save(); + } finally { + lock.unlock(); + } + } + } @JsonSerializable From 8fc477168e12ca96069ec9b33aa3d42db5c2522f Mon Sep 17 00:00:00 2001 From: ToobLac Date: Sun, 2 Aug 2026 14:53:27 +0800 Subject: [PATCH 08/11] update --- .../main/java/org/jackhuang/hmcl/addon/FavoritesManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/FavoritesManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/FavoritesManager.java index a94f1fac135..1e16cc346aa 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/FavoritesManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/FavoritesManager.java @@ -104,7 +104,7 @@ public Favorites getOrCreate(String name) { } } - public static class Favorites { + public static final class Favorites { private final FavoritesManager manager; private final String name; From 2569bfef4b730a92161eb7059222794962e983ee Mon Sep 17 00:00:00 2001 From: ToobLac Date: Sun, 2 Aug 2026 19:03:11 +0800 Subject: [PATCH 09/11] update --- .../hmcl/setting}/FavoritesManager.java | 65 +++++++++++++------ .../hmcl/ui/instances/AddonFavoritesPage.java | 30 +++++++++ .../java/org/jackhuang/hmcl/util/Lang.java | 6 -- 3 files changed, 74 insertions(+), 27 deletions(-) rename {HMCLCore/src/main/java/org/jackhuang/hmcl/addon => HMCL/src/main/java/org/jackhuang/hmcl/setting}/FavoritesManager.java (81%) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/FavoritesManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/FavoritesManager.java similarity index 81% rename from HMCLCore/src/main/java/org/jackhuang/hmcl/addon/FavoritesManager.java rename to HMCL/src/main/java/org/jackhuang/hmcl/setting/FavoritesManager.java index 1e16cc346aa..52e25ad08a5 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/FavoritesManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/FavoritesManager.java @@ -15,10 +15,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.jackhuang.hmcl.addon; +package org.jackhuang.hmcl.setting; import com.google.gson.JsonSyntaxException; import com.google.gson.reflect.TypeToken; +import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.addon.RemoteAddon; +import org.jackhuang.hmcl.addon.RemoteAddonRepository; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.Pair; @@ -41,13 +44,18 @@ public final class FavoritesManager { private static final TypeToken>> typeToken = new TypeToken<>() { }; - private final Path file; // Any external changes to this file might be lost + private static final FavoritesManager instance = new FavoritesManager(Metadata.HMCL_USER_HOME.resolve("config").resolve("user-addon-favorites.json")); - private final TreeMap favoritesMap = new TreeMap<>(String::compareToIgnoreCase); + public static FavoritesManager getInstance() { + return instance; + } + private final Path file; // Any external changes to this file while the application is running might be lost + private final TreeMap favoritesMap = new TreeMap<>(String::compareToIgnoreCase); private final ReentrantLock lock = new ReentrantLock(); + private boolean loaded; - public FavoritesManager(Path favoritesFile) { + private FavoritesManager(Path favoritesFile) { this.file = Objects.requireNonNull(favoritesFile); } @@ -55,13 +63,26 @@ public Path getFile() { return file; } + public void refresh() { + lock.lock(); + try { + loaded = false; + load(); + } finally { + lock.unlock(); + } + } + public void load() { + if (loaded) return; lock.lock(); try { + if (loaded) return; favoritesMap.clear(); var map = JsonUtils.fromJsonFile(file, typeToken); if (map != null) map.forEach((name, items) -> favoritesMap.put(name, new Favorites(this, name, items))); + loaded = true; } catch (IOException | JsonSyntaxException e) { LOG.warning("Failed to load favorites file at " + file, e); } finally { @@ -84,11 +105,18 @@ public void save() { public void resolveAll(DownloadProvider downloadProvider) { lock.lock(); try { - boolean modified = false; - for (var fav : favoritesMap.values()) - modified |= fav.resolve0(downloadProvider); - if (modified) - save(); + favoritesMap.values().forEach(fav -> fav.resolve0(downloadProvider)); + } finally { + lock.unlock(); + } + } + + @Unmodifiable + public List getFavorites() { + lock.lock(); + try { + if (!loaded) throw new IllegalStateException("Favorites not loaded"); + return List.copyOf(favoritesMap.values()); } finally { lock.unlock(); } @@ -147,15 +175,14 @@ public List getResolvedAddons() { } } - /// @return Whether the item list was modified. - private boolean resolve0(DownloadProvider downloadProvider) { + private void resolve0(DownloadProvider downloadProvider) { resolvedAddons.clear(); if (downloadProvider != lastProvider) { cache.clear(); lastProvider = downloadProvider; } - LinkedHashMap resultReversed = new LinkedHashMap<>(items.size()); - for (var item : Lang.reversedCopyOf(items)) { // TODO migrate to SequencedSet::reversed after upgrading to JDK 21+ + List result = new ArrayList<>(items.size()); + for (var item : items) { RemoteAddon addon; if (cache.containsKey(item)) { addon = cache.get(item); @@ -167,20 +194,16 @@ private boolean resolve0(DownloadProvider downloadProvider) { continue; } } - if (!resultReversed.containsKey(addon)) { - resultReversed.put(addon, item); - cache.put(item, addon); - } + cache.put(item, addon); + result.add(addon); } - resolvedAddons.addAll(Lang.reversedCopyOf(resultReversed.keySet())); - return items.retainAll(resultReversed.values()); // Remove duplicate items + resolvedAddons.addAll(result); } public void resolve(DownloadProvider downloadProvider) { lock.lock(); try { - if (resolve0(downloadProvider)) - manager.save(); + resolve0(downloadProvider); } finally { lock.unlock(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonFavoritesPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonFavoritesPage.java index 9b98488ea86..6b4539ff8ca 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonFavoritesPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonFavoritesPage.java @@ -18,17 +18,30 @@ package org.jackhuang.hmcl.ui.instances; import javafx.beans.property.*; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; import javafx.scene.control.Control; +import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.setting.FavoritesManager; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; import org.jetbrains.annotations.Nullable; public class AddonFavoritesPage extends Control implements DecoratorPage, GameInstancePage.GameInstanceLoadable { + private static final FavoritesManager manager = FavoritesManager.getInstance(); + protected final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); private final BooleanProperty loading = new SimpleBooleanProperty(false); private final ObjectProperty instanceReference = new SimpleObjectProperty<>(); + private final ObservableList instances = FXCollections.observableArrayList(); + private final ObjectProperty selectedInstance = new SimpleObjectProperty<>(); + + private final ListProperty items = new SimpleListProperty<>(this, "items", FXCollections.observableArrayList()); @Override public ReadOnlyObjectProperty stateProperty() { @@ -38,5 +51,22 @@ public ReadOnlyObjectProperty stateProperty() { @Override public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { instanceReference.set(new HMCLGameRepository.InstanceReference(repository, instanceId)); + instances.setAll(repository.getDisplayInstanceManifests() + .map(GameInstanceManifest::id) + .toList()); + selectedInstance.set(repository.getSelectedInstance()); + refresh(); + } + + public void refresh() { + setLoading(true); + Task.runAsync(Schedulers.io(), manager::load) + .thenRunAsync(Schedulers.javafx(), () -> { + items.setAll(manager.getFavorites()); + }).start(); + } + + public void setLoading(boolean loading) { + this.loading.set(loading); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java index e17a8a82126..655acbad4da 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java @@ -71,12 +71,6 @@ public static List immutableListOf(T... elements) { return Collections.unmodifiableList(Arrays.asList(elements)); } - public static List reversedCopyOf(Collection set) { - ArrayList list = new ArrayList<>(set); - Collections.reverse(list); - return list; - } - public static boolean test(ExceptionalRunnable r) { try { r.run(); From aa92349480fd7919177586f586c67823d0e9d2f3 Mon Sep 17 00:00:00 2001 From: ToobLac Date: Mon, 3 Aug 2026 10:52:17 +0800 Subject: [PATCH 10/11] update --- .../org/jackhuang/hmcl/setting/FavoritesManager.java | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/FavoritesManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/FavoritesManager.java index 52e25ad08a5..33ac1252e4a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/FavoritesManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/FavoritesManager.java @@ -21,7 +21,6 @@ import com.google.gson.reflect.TypeToken; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.addon.RemoteAddon; -import org.jackhuang.hmcl.addon.RemoteAddonRepository; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.Pair; @@ -32,6 +31,7 @@ import org.jetbrains.annotations.Unmodifiable; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.*; import java.util.concurrent.locks.ReentrantLock; @@ -79,7 +79,7 @@ public void load() { try { if (loaded) return; favoritesMap.clear(); - var map = JsonUtils.fromJsonFile(file, typeToken); + var map = Files.isRegularFile(file) ? JsonUtils.fromJsonFile(file, typeToken) : null; if (map != null) map.forEach((name, items) -> favoritesMap.put(name, new Favorites(this, name, items))); loaded = true; @@ -234,17 +234,15 @@ public void remove(Collection addons) { } @JsonSerializable - public record Item(@Nullable String modId, @Nullable RemoteAddon.Source source) { + public record Item(@Nullable String projectId, @Nullable RemoteAddon.Source source) { public static Item fromAddon(RemoteAddon addon) { return new Item(addon.projectId(), addon.source()); } public @NotNull RemoteAddon resolve(DownloadProvider downloadProvider) throws IOException { - if (modId == null || source == null) return RemoteAddon.BROKEN; - var repo = source.getRepoForType(RemoteAddonRepository.Type.MOD); //TODO use common repo - assert repo != null; - return repo.getAddonById(downloadProvider, modId); + if (projectId == null || source == null) return RemoteAddon.BROKEN; + return source.getCommonRepo().getAddonById(downloadProvider, projectId); } } From d384e6e7a2f4a35bf36e4994f1a2aba478e92ad8 Mon Sep 17 00:00:00 2001 From: ToobLac Date: Thu, 6 Aug 2026 11:42:19 +0800 Subject: [PATCH 11/11] update --- .../java/org/jackhuang/hmcl/ui/instances/AddonFavoritesPage.java | 1 - 1 file changed, 1 deletion(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonFavoritesPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonFavoritesPage.java index 6b4539ff8ca..fe8c6a31d65 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonFavoritesPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonFavoritesPage.java @@ -21,7 +21,6 @@ import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.scene.control.Control; -import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.HMCLGameRepository;