Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
74d135f
update
ToobLac Aug 1, 2026
b574866
Update HMCL/src/main/resources/assets/lang/I18N_zh.properties
ToobLac Aug 1, 2026
bed8b92
update
ToobLac Aug 1, 2026
f2440c4
Merge remote-tracking branch 'upstream/main' into favorites
ToobLac Aug 1, 2026
44aa575
update
ToobLac Aug 1, 2026
c141116
replace Java21 features
ToobLac Aug 2, 2026
eb61a3b
update
ToobLac Aug 2, 2026
5add5ce
update
ToobLac Aug 2, 2026
8fc4771
update
ToobLac Aug 2, 2026
2569bfe
update
ToobLac Aug 2, 2026
00870f1
Merge remote-tracking branch 'upstream/main' into favorites
ToobLac Aug 3, 2026
aa92349
update
ToobLac Aug 3, 2026
7cf32d7
Merge remote-tracking branch 'upstream/main' into favorites
ToobLac Aug 6, 2026
d384e6e
update
ToobLac Aug 6, 2026
27020b2
Merge remote-tracking branch 'upstream/main' into favorites
ToobLac Aug 18, 2026
0ec3444
Merge remote-tracking branch 'upstream/main' into favorites
ToobLac Aug 20, 2026
44d77ba
update
ToobLac Aug 23, 2026
6103190
Merge remote-tracking branch 'upstream/main' into favorites
ToobLac Aug 31, 2026
99d6e7b
frame
ToobLac Aug 31, 2026
9725d98
Merge remote-tracking branch 'upstream/main' into favorites
ToobLac Sep 15, 2026
182ee66
almost complete ui
ToobLac Sep 16, 2026
bcf31b1
info
ToobLac Sep 16, 2026
d7d91c9
Merge remote-tracking branch 'upstream/main' into favorites
ToobLac Sep 20, 2026
aa2a1ba
update
ToobLac Sep 20, 2026
803ab86
Merge remote-tracking branch 'upstream/main' into favorites
ToobLac Sep 20, 2026
00accd5
update
ToobLac Sep 20, 2026
f3b7751
a simple impl
ToobLac Sep 25, 2026
7c7b454
update
ToobLac Sep 25, 2026
7995f94
update
ToobLac Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
301 changes: 301 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/setting/FavoritesManager.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
/*
* 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.setting;

import com.google.gson.JsonSyntaxException;
import com.google.gson.annotations.SerializedName;
import com.google.gson.reflect.TypeToken;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.task.Schedulers;
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.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;

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

public final class FavoritesManager {

private static final TypeToken<TreeMap<String, LinkedHashSet<Item>>> typeToken = new TypeToken<>() {
};

private static final FavoritesManager instance = new FavoritesManager(Metadata.HMCL_USER_HOME.resolve("config").resolve("user-addon-favorites.json"));

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<String, Favorite> favoritesMap = new TreeMap<>(String::compareToIgnoreCase);
private final ReentrantLock lock = new ReentrantLock();
private boolean loaded;

private FavoritesManager(Path favoritesFile) {
this.file = Objects.requireNonNull(favoritesFile);
}

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 = Files.isRegularFile(file) ? JsonUtils.fromJsonFile(file, typeToken) : null;
if (map != null)
map.forEach((name, items) -> favoritesMap.put(name, new Favorite(this, name, items)));
favoritesMap.computeIfAbsent("", n -> new Favorite(this, n, new LinkedHashSet<>()));
loaded = true;
} catch (IOException | JsonSyntaxException e) {
LOG.warning("Failed to load favorites file at " + file, e);
} finally {
lock.unlock();
}
}

private void save0() {
lock.lock();
try {
if (!loaded) load();
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 {
lock.unlock();
}
}

public void save() {
CompletableFuture.runAsync(this::save0, Schedulers.io());
}

public void resolveAll(DownloadProvider downloadProvider) {
lock.lock();
try {
favoritesMap.values().forEach(fav -> fav.resolve0(downloadProvider));
} finally {
lock.unlock();
}
}

public @Unmodifiable List<Favorite> getFavorites() {
lock.lock();
try {
if (!loaded) throw new IllegalStateException("Favorites not loaded");
return List.copyOf(favoritesMap.values());
} finally {
lock.unlock();
}
}

public @NotNull FavoritesManager.Favorite getOrCreate(String name) {
lock.lock();
try {
return favoritesMap.computeIfAbsent(name, n -> new Favorite(this, n, new LinkedHashSet<>()));
} finally {
lock.unlock();
}
}

public @NotNull FavoritesManager.Favorite getDefault() {
return getOrCreate("");
}

public @Nullable FavoritesManager.Favorite get(String name) {
lock.lock();
try {
return favoritesMap.get(name);
} finally {
lock.unlock();
}
}

public boolean has(String name) {
return get(name) != null;
}

public boolean contains(RemoteAddon addon) {
lock.lock();
try {
return favoritesMap.values().stream().anyMatch(f -> f.contains(addon));
} finally {
lock.unlock();
}
}

public static final class Favorite {

private final FavoritesManager manager;
private final String name;

private final LinkedHashSet<Item> items;
private transient final LinkedHashMap<Item, RemoteAddon> resolvedAddons = new LinkedHashMap<>();
private transient DownloadProvider lastProvider = null;

private final ReentrantLock lock;

private Favorite(FavoritesManager manager, String name, LinkedHashSet<Item> items) {
this.manager = manager;
this.lock = manager.lock;
this.name = name;
this.items = items.stream()
.filter(item -> item.projectId() != null && item.source() != null)
.collect(Collectors.toCollection(LinkedHashSet::new));
}

public String getName() {
return name;
}

public boolean isDefault() {
return "".equals(name);
}

@Unmodifiable
public Set<Item> getItems() {
lock.lock();
try {
return Set.copyOf(items);
} finally {
lock.unlock();
}
}

public @Unmodifiable Map<Item, RemoteAddon> getResolvedAddons() {
lock.lock();
try {
return Collections.unmodifiableMap(new LinkedHashMap<>(resolvedAddons));
} finally {
lock.unlock();
}
}

private void resolve0(DownloadProvider downloadProvider) {
Map<Item, RemoteAddon> cache;
if (downloadProvider != lastProvider) {
cache = Map.of();
lastProvider = downloadProvider;
} else {
cache = Map.copyOf(resolvedAddons);
}
resolvedAddons.clear();
Map<Item, RemoteAddon> result = new LinkedHashMap<>(items.size());
for (var item : items) {
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);
result.put(item, null);
continue;
}
}
result.put(item, addon);
}
resolvedAddons.putAll(result);
}

public void resolve(DownloadProvider downloadProvider) {
lock.lock();
try {
resolve0(downloadProvider);
} finally {
lock.unlock();
}
}

public boolean contains(RemoteAddon addon) {
lock.lock();
try {
return items.contains(Item.fromAddon(addon));
} finally {
lock.unlock();
}
}

public void add(RemoteAddon addon) {
lock.lock();
try {
items.add(Item.fromAddon(addon));
manager.save();
} finally {
lock.unlock();
}
}

public boolean remove(Collection<Item> itemsToRemove) {
lock.lock();
try {
if (items.removeAll(itemsToRemove)) {
manager.save();
return true;
}
return false;
} finally {
lock.unlock();
}
}

public boolean removeAddons(Collection<RemoteAddon> addons) {
return remove(addons.stream().map(Item::fromAddon).collect(Collectors.toSet()));
}
}

@JsonSerializable
public record Item(@SerializedName("id") String projectId, @SerializedName("src") 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 (projectId == null || source == null) return RemoteAddon.BROKEN;
return source.getCommonRepo().getAddonById(downloadProvider, projectId);
}
}
}
2 changes: 2 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ public enum SVG {
SETTINGS_FILL("M9.25 22l-.4-3.2q-.325-.125-.6125-.3t-.5625-.375L4.7 19.375l-2.75-4.75 2.575-1.95Q4.5 12.5 4.5 12.3375v-.675q0-.1625.025-.3375L1.95 9.375 4.7 4.625l2.975 1.25q.275-.2.575-.375t.6-.3L9.25 2h5.5l.4 3.2q.325.125.6125.3t.5625.375L19.3 4.625l2.75 4.75-2.575 1.95q.025.175.025.3375v.675q0 .1625-.05.3375l2.575 1.95-2.75 4.75-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2H9.25Zm2.8-6.5q1.45 0 2.475-1.025T15.55 12 14.525 9.525 12.05 8.5q-1.475 0-2.4875 1.025T8.55 12q0 1.45 1.0125 2.475T12.05 15.5Z"), // Material Icons
STADIA_CONTROLLER("M4.725 20Q3.225 20 2.1625 18.925T1.05 16.325Q1.05 16.1 1.075 15.875T1.15 15.425L3.25 7.025Q3.6 5.675 4.675 4.8375T7.125 4H16.875Q18.25 4 19.325 4.8375T20.75 7.025L22.85 15.425Q22.9 15.65 22.9375 15.8875T22.975 16.35Q22.975 17.875 21.8875 18.9375T19.275 20Q18.225 20 17.325 19.45T15.975 17.95L15.275 16.5Q15.15 16.25 14.9 16.125T14.375 16H9.625Q9.35 16 9.1 16.125T8.725 16.5L8.025 17.95Q7.575 18.9 6.675 19.45T4.725 20ZM4.8 18Q5.275 18 5.6625 17.75T6.25 17.075L6.95 15.65Q7.325 14.875 8.05 14.4375T9.625 14H14.375Q15.225 14 15.95 14.45T17.075 15.65L17.775 17.075Q17.975 17.5 18.3625 17.75T19.225 18Q19.925 18 20.425 17.5375T20.95 16.375Q20.95 16.4 20.9 15.9L18.8 7.525Q18.625 6.85 18.1 6.425T16.875 6H7.125Q6.425 6 5.8875 6.425T5.2 7.525L3.1 15.9Q3.05 16.05 3.05 16.35 3.05 17.05 3.5625 17.525T4.8 18ZM13.5 11Q13.925 11 14.2125 10.7125T14.5 10Q14.5 9.575 14.2125 9.2875T13.5 9Q13.075 9 12.7875 9.2875T12.5 10Q12.5 10.425 12.7875 10.7125T13.5 11ZM15.5 9Q15.925 9 16.2125 8.7125T16.5 8Q16.5 7.575 16.2125 7.2875T15.5 7Q15.075 7 14.7875 7.2875T14.5 8Q14.5 8.425 14.7875 8.7125T15.5 9ZM15.5 13Q15.925 13 16.2125 12.7125T16.5 12Q16.5 11.575 16.2125 11.2875T15.5 11Q15.075 11 14.7875 11.2875T14.5 12Q14.5 12.425 14.7875 12.7125T15.5 13ZM17.5 11Q17.925 11 18.2125 10.7125T18.5 10Q18.5 9.575 18.2125 9.2875T17.5 9Q17.075 9 16.7875 9.2875T16.5 10Q16.5 10.425 16.7875 10.7125T17.5 11ZM8.5 12.5Q8.825 12.5 9.0375 12.2875T9.25 11.75V10.75H10.25Q10.575 10.75 10.7875 10.5375T11 10Q11 9.675 10.7875 9.4625T10.25 9.25H9.25V8.25Q9.25 7.925 9.0375 7.7125T8.5 7.5Q8.175 7.5 7.9625 7.7125T7.75 8.25V9.25H6.75Q6.425 9.25 6.2125 9.4625T6 10Q6 10.325 6.2125 10.5375T6.75 10.75H7.75V11.75Q7.75 12.075 7.9625 12.2875T8.5 12.5ZM12 12Z"),
STADIA_CONTROLLER_FILL("M4.725 20q-1.5 0-2.5625-1.075T1.05 16.325q0-.225.025-.45t.075-.45l2.1-8.4q.35-1.35 1.425-2.1875T7.125 4h9.75q1.375 0 2.45.8375T20.75 7.025l2.1 8.4q.05.225.0875.4625t.0375.4625q0 1.525-1.0875 2.5875T19.275 20q-1.05 0-1.95-.55t-1.35-1.5l-.7-1.45q-.125-.25-.375-.375T14.375 16H9.625q-.275 0-.525.125t-.375.375l-.7 1.45q-.45.95-1.35 1.5T4.725 20ZM13.5 11q.425 0 .7125-.2875T14.5 10t-.2875-.7125T13.5 9t-.7125.2875T12.5 10t.2875.7125T13.5 11Zm2-2q.425 0 .7125-.2875T16.5 8q0-.425-.2875-.7125T15.5 7q-.425 0-.7125.2875T14.5 8t.2875.7125T15.5 9Zm0 4q.425 0 .7125-.2875T16.5 12q0-.425-.2875-.7125T15.5 11q-.425 0-.7125.2875T14.5 12t.2875.7125T15.5 13Zm2-2q.425 0 .7125-.2875T18.5 10q0-.425-.2875-.7125T17.5 9q-.425 0-.7125.2875T16.5 10q0 .425.2875.7125T17.5 11Zm-9 1.5q.325 0 .5375-.2125T9.25 11.75v-1h1q.325 0 .5375-.2125T11 10t-.2125-.5375T10.25 9.25h-1v-1q0-.325-.2125-.5375T8.5 7.5q-.325 0-.5375.2125T7.75 8.25v1h-1q-.325 0-.5375.2125T6 10q0 .325.2125.5375T6.75 10.75h1v1q0 .325.2125.5375T8.5 12.5Z"),
STAR("m8.85 16.825 3.15 -1.9 3.15 1.925 -0.825 -3.6 2.775 -2.4 -3.65 -0.325 -1.45 -3.4 -1.45 3.375 -3.65 0.325 2.775 2.425 -0.825 3.575zM5.825 21l1.625 -7.025L2 9.25l7.2 -0.625 2.8 -6.625 2.8 6.625 7.2 0.625 -5.45 4.725 1.625 7.025 -6.175 -3.725 -6.175 3.725zm6.175 -8.75z"),
STAR_FILL("m5.825 21 1.625 -7.025L2 9.25l7.2 -0.625 2.8 -6.625 2.8 6.625 7.2 0.625 -5.45 4.725 1.625 7.025 -6.175 -3.725 -6.175 3.725z"),
STYLE("M3.975 19.8 3.125 19.45Q2.35 19.125 2.0875 18.325T2.175 16.75L3.975 12.85V19.8ZM7.975 22Q7.15 22 6.5625 21.4125T5.975 20V14L8.625 21.35Q8.7 21.525 8.775 21.6875T8.975 22H7.975ZM13.125 21.9Q12.325 22.2 11.575 21.825T10.525 20.65L6.075 8.45Q5.775 7.65 6.125 6.8875T7.275 5.85L14.825 3.1Q15.625 2.8 16.375 3.175T17.425 4.35L21.875 16.55Q22.175 17.35 21.825 18.1125T20.675 19.15L13.125 21.9ZM10.975 10Q11.4 10 11.6875 9.7125T11.975 9Q11.975 8.575 11.6875 8.2875T10.975 8Q10.55 8 10.2625 8.2875T9.975 9Q9.975 9.425 10.2625 9.7125T10.975 10ZM12.425 20 19.975 17.25 15.525 5 7.975 7.75 12.425 20ZM7.975 7.75 15.525 5 7.975 7.75Z"),
STYLE_FILL("M3.975 19.8l-.85-.35q-.775-.325-1.0375-1.125T2.175 16.75l1.8-3.9V19.8Zm4 2.2q-.825 0-1.4125-.5875T5.975 20V14l2.65 7.35q.075.175.15.3375t.2.3125h-1Zm5.15-.1q-.8.3-1.55-.075t-1.05-1.175L6.075 8.45q-.3-.8.05-1.5625T7.275 5.85l7.55-2.75q.8-.3 1.55.075t1.05 1.175l4.45 12.2q.3.8-.05 1.5625T20.675 19.15l-7.55 2.75ZM10.975 10q.425 0 .7125-.2875T11.975 9q0-.425-.2875-.7125T10.975 8t-.7125.2875T9.975 9t.2875.7125T10.975 10Z"),
TEXTURE("M4.4 21q-.475-.1-.8875-.5125T3 19.6L19.6 3q.525.125.9.5125t.525.8875L4.4 21ZM3 14.7v-2.8L11.9 3h2.8L3 14.7ZM3 7V5q0-.825.5875-1.4125T5 3h2L3 7Zm14 14 4-4v2q0 .825-.5875 1.4125T19 21h-2Zm-7.7 0L21 9.3v2.8L12.1 21H9.3Z"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,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.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;
Expand Down Expand Up @@ -84,6 +79,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
private final TabHeader.Tab<DownloadListPage> resourcePackTab = new TabHeader.Tab<>("resourcePackTab");
private final TabHeader.Tab<DownloadListPage> shaderTab = new TabHeader.Tab<>("shaderTab");
private final TabHeader.Tab<DownloadListPage> worldTab = new TabHeader.Tab<>("worldTab");
private final TabHeader.Tab<AddonFavoritesListPage> favoritesTab = new TabHeader.Tab<>("favoritesTab");
private final TransitionPane transitionPane = new TransitionPane();
private final DownloadNavigator versionPageNavigator = new DownloadNavigator();

Expand Down Expand Up @@ -111,7 +107,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);
favoritesTab.setNodeSupplier(loadVersionFor(AddonFavoritesListPage::new));
tab = new TabHeader(transitionPane, newGameTab, modpackTab, modTab, resourcePackTab, shaderTab, worldTab, favoritesTab);

GameDirectoryManager.registerVersionsListener(this::loadVersions);

Expand All @@ -125,7 +122,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, favoritesTab, i18n("addon.favorites"), SVG.STAR, SVG.STAR_FILL);
FXUtils.setLimitWidth(sideBar, 200);
setLeft(sideBar);

Expand All @@ -137,6 +135,8 @@ private static <T extends Node> Supplier<T> loadVersionFor(Supplier<T> nodeSuppl
T node = nodeSupplier.get();
if (node instanceof DownloadListPage page) {
page.loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository()));
} else if (node instanceof AddonFavoritesListPage page) {
page.loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository()));
}
return node;
};
Expand Down Expand Up @@ -199,6 +199,9 @@ private void loadVersions(HMCLGameRepository repository) {
if (worldTab.isInitialized()) {
worldTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
}
if (favoritesTab.isInitialized()) {
favoritesTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
}
}));
}
});
Expand Down
Loading
Loading