diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java index 23fd12c0c3c..8a185725cf8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java @@ -83,6 +83,7 @@ public enum SVG { INFO("M11 17H13V11H11V17ZM12 9Q12.425 9 12.7125 8.7125T13 8Q13 7.575 12.7125 7.2875T12 7Q11.575 7 11.2875 7.2875T11 8Q11 8.425 11.2875 8.7125T12 9ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"), INFO_FILL("M12 22q2.075-0 3.9-.7875t3.175-2.1375q1.35-1.35 2.1375-3.175T22 12q-0-2.075-.7875-3.9T19.075 4.925q-1.35-1.35-3.175-2.1375T12 2q-2.075 0-3.9.7875T4.925 4.925Q3.575 6.275 2.7875 8.1T2 12q0 2.075.7875 3.9T4.925 19.075q1.35 1.35 3.175 2.1375T12 22ZM12 9q-.425 0-.7125-.2875T11 8t.2875-.7125T12 7q.425-0 .7125.2875T13 8t-.2875.7125T12 9Zm-1 8V11h2v6H11Z"), KEEP("m16 12l2 2v2h-5v6l-1 1l-1-1v-6H6v-2l2-2V5H7V3h10v2h-1zm-7.15 2h6.3L14 12.85V5h-4v7.85zM12 14"), + KEEP_FILL("m16 12l2 2v2h-5v6l-1 1l-1-1v-6H6v-2l2-2V5H7V3h10v2h-1z"), KEYBOARD_ARROW_DOWN("M12 15.4 6 9.4 7.4 8 12 12.6 16.6 8 18 9.4 12 15.4Z"), KEYBOARD_ARROW_UP("M12 10.8 7.4 15.4 6 14 12 8 18 14 16.6 15.4 12 10.8Z"), LANDSCAPE("M1 18l6-8 4.5 6H19L14 9.35l-2.5 3.3L10.25 11 14 6l9 12H1Zm13.025-2ZM5 16H9L7 13.325 5 16ZH9 5Z"), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java index 75e02adb294..8d1b27f224c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java @@ -99,6 +99,9 @@ public final class ModListPage extends ListPageBase i private final ReentrantLock lock = new ReentrantLock(); private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); + /// Notified after the pin state of any mod changed, so the skin refreshes its toolbar button. + private @Nullable Runnable pinnedStateChanged; + private ModManager modManager; private @Nullable HMCLGameInstance gameInstance; @@ -156,7 +159,9 @@ private void loadMods(ModManager modManager) { lock.lock(); try { modManager.refresh(); - return modManager.getLocalFiles().stream().map(ModInfoObject::new).toList(); + return modManager.getLocalFiles().stream() + .map(file -> new ModInfoObject(file, modManager.isPinned(file.getFile()))) + .toList(); } catch (IOException e) { throw new UncheckedIOException(e); } finally { @@ -257,18 +262,91 @@ public void openModFolder() { } } + /// Pins or unpins a mod, so that the update check skips it. + /// + /// @param item the mod to change + /// @param pinned whether the mod should stay at its current version + void setPinned(ModInfoObject item, boolean pinned) { + setPinned(List.of(item), pinned); + } + + /// Pins or unpins several mods at once, saving them with a single write. + /// + /// @param items the mods to change + /// @param pinned whether the mods should stay at their current versions + void setSelectedPinned(ObservableList items, boolean pinned) { + setPinned(items, pinned); + } + + /// Applies a pin change to the list immediately, then saves it in the background. + /// + /// @param items the mods to change + /// @param pinned whether the mods should stay at their current versions + private void setPinned(Collection items, boolean pinned) { + ModManager manager = modManager; + // While the list reloads, the items still belong to the previous mod directory. + if (manager == null || isLoading()) { + return; + } + + List files = items.stream() + .map(item -> item.getModInfo().getFile()) + .toList(); + for (ModInfoObject item : items) { + item.setPinned(pinned); + } + firePinnedStateChanged(); + + Task.runAsync(() -> manager.setPinned(files, pinned)) + .whenComplete(Schedulers.javafx(), exception -> { + if (exception != null) { + LOG.warning("Unable to save pinned mods", exception); + Controllers.showToast(i18n("message.failed")); + } + }) + .start(); + } + + /// Notifies [#pinnedStateChanged] that the pin state of some mod changed. + private void firePinnedStateChanged() { + Runnable listener = pinnedStateChanged; + if (listener != null) { + listener.run(); + } + } + + /// Returns whether the mod stays at its current version during update checks. + /// + /// @param file the mod file + /// @return true if the update check skips the mod + boolean isPinned(LocalModFile file) { + ModManager manager = modManager; + return manager != null && manager.isPinned(file.getFile()); + } + + /// Checks for updates of the given mods, skipping the ones pinned to their current version. + /// + /// @param mods the mods to check public void checkUpdates(Collection mods) { Objects.requireNonNull(mods); if (isLoading() || gameInstance == null) { return; } + List targets = mods.stream() + .filter(mod -> !isPinned(mod)) + .toList(); + if (targets.isEmpty()) { + Controllers.dialog(i18n("mods.pin.nothing")); + return; + } + HMCLGameInstance gameInstance = this.gameInstance; Runnable action = () -> Controllers.taskDialog(Task .composeAsync(() -> { GameVersionNumber version = gameInstance.getVersion(); if (version != GameVersionNumber.unknown()) { - return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), version.toString(), mods); + return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), version.toString(), targets); } else { LOG.warning("Failed to check for updates, due to unable to get instance game version"); return null; @@ -335,6 +413,9 @@ private static final class ModListPageSkin extends SkinBase { private final HBox toolbarNormal; private final HBox toolbarSelecting; + /// Toolbar button of [#toolbarSelecting] that pins or unpins the selected mods. + private final JFXButton pinSelected; + private final JFXListView listView; /// Whether the search mechanism is currently active. @@ -415,9 +496,17 @@ public ModListPageSkin(ModListPage skinnable) { // reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker var selectAll = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size())); + // Pins or unpins every selected mod at once, the batch equivalent of the pin icon of a row. + pinSelected = createToolbarButton2(i18n("mods.pin"), SVG.KEEP, () -> { + skinnable.setSelectedPinned(listView.getSelectionModel().getSelectedItems(), !allPinned()); + updatePinSelectedButton(); + }); + skinnable.pinnedStateChanged = this::updatePinSelectedButton; + ListChangeListener listener = change -> { selectAll.setDisable(!listView.getItems().isEmpty() && listView.getSelectionModel().getSelectedItems().size() == listView.getItems().size()); + updatePinSelectedButton(); }; listView.getSelectionModel().getSelectedItems().addListener(listener); @@ -433,6 +522,7 @@ public ModListPageSkin(ModListPage skinnable) { skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true)), createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () -> skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false)), + pinSelected, createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () -> skinnable.checkUpdates( listView.getSelectionModel().getSelectedItems().stream() @@ -528,6 +618,21 @@ private void changeToolbar(HBox newToolbar) { } } + /// Returns whether every selected mod is already pinned, so [#pinSelected] unpins instead of pins. + /// + /// @return true if the selection is not empty and all of its mods are pinned + private boolean allPinned() { + ObservableList selected = listView.getSelectionModel().getSelectedItems(); + return !selected.isEmpty() && selected.stream().allMatch(ModListPage.ModInfoObject::isPinned); + } + + /// Makes the text and glyph of [#pinSelected] describe what the next click will do. + private void updatePinSelectedButton() { + boolean allPinned = allPinned(); + pinSelected.setText(i18n(allPinned ? "mods.unpin" : "mods.pin")); + pinSelected.setGraphic((allPinned ? SVG.KEEP_FILL : SVG.KEEP).createIcon(20)); + } + private void search() { isSearching.set(true); @@ -570,11 +675,15 @@ public static final class ModInfoObject { /// Matching project metadata, or null if the local mod cannot be identified. private final @Nullable ModProjectInfo projectInfo; + /// Whether this mod stays at its current version during update checks. + private final SimpleBooleanProperty pinned; + private final ItemPropertyAsyncCache iconCache; - ModInfoObject(LocalModFile localModFile) { + ModInfoObject(LocalModFile localModFile, boolean pinned) { this.localModFile = localModFile; this.projectInfo = AddonProjectInfoRegistry.MOD.getInfo(localModFile.getId(), localModFile.getName()); + this.pinned = new SimpleBooleanProperty(this, "pinned", pinned); this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon); } @@ -583,6 +692,27 @@ public LocalModFile getModInfo() { return localModFile; } + /// Returns whether the mod stays at its current version when checking for updates. + /// + /// @return true if the mod is pinned + public boolean isPinned() { + return pinned.get(); + } + + /// Returns the observable pin state, so list cells follow the changes made elsewhere. + /// + /// @return the read-only pin state + public ReadOnlyBooleanProperty pinnedProperty() { + return pinned; + } + + /// Records the pin state, which [#setPinned(ModInfoObject, boolean)] keeps in sync with the file. + /// + /// @param pinned the new pin state + private void setPinned(boolean pinned) { + this.pinned.set(pinned); + } + /// Returns matching project metadata, or null if no project was found. public @Nullable ModProjectInfo getProjectInfo() { return projectInfo; @@ -744,6 +874,9 @@ private static final class ModInfoListCell extends MDListCell { private final JFXCheckBox checkBox = new JFXCheckBox(); private final ImageContainer imageContainer = new ImageContainer(32); private final TwoLineListItem content = new TwoLineListItem(); + private final JFXButton pinButton = FXUtils.newToggleButton4(SVG.KEEP); + /// Shared tooltip of [#pinButton], whose text follows the pin state of the displayed mod. + private final Tooltip pinTooltip = new Tooltip(i18n("mods.pin")); private final JFXButton restoreButton = FXUtils.newToggleButton4(SVG.RESTORE); private final JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO); private final JFXButton revealButton = FXUtils.newToggleButton4(SVG.FOLDER); @@ -752,6 +885,10 @@ private static final class ModInfoListCell extends MDListCell { private final WeakInvalidationListener weakActiveListener = new WeakInvalidationListener(activeListener); private @Nullable ReadOnlyBooleanProperty observedActiveProperty; + private final InvalidationListener pinnedListener = obs -> applyPinState(getItem()); + private final WeakInvalidationListener weakPinnedListener = new WeakInvalidationListener(pinnedListener); + private @Nullable ReadOnlyBooleanProperty observedPinnedProperty; + private Tooltip warningTooltip; ModInfoListCell(JFXListView listView, ModListPage page) { @@ -769,9 +906,10 @@ private static final class ModInfoListCell extends MDListCell { imageContainer.setImage(GameInstanceIconType.COMMAND.getIcon()); + FXUtils.installFastTooltip(pinButton, pinTooltip); FXUtils.installFastTooltip(restoreButton, i18n("mods.restore")); - container.getChildren().setAll(checkBox, imageContainer, content, restoreButton, revealButton, infoButton); + container.getChildren().setAll(checkBox, imageContainer, content, restoreButton, pinButton, revealButton, infoButton); StackPane.setMargin(container, new Insets(8)); getContainer().getChildren().setAll(container); @@ -781,6 +919,8 @@ private static final class ModInfoListCell extends MDListCell { protected void updateControl(ModInfoObject dataItem, boolean empty) { if (observedActiveProperty != null) observedActiveProperty.removeListener(weakActiveListener); + if (observedPinnedProperty != null) + observedPinnedProperty.removeListener(weakPinnedListener); pseudoClassStateChanged(WARNING, false); if (warningTooltip != null) { @@ -855,7 +995,10 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) { checkBox.setSelected(modInfo.isActive()); } }); - restoreButton.setVisible(!modInfo.getMod().getOldFiles().isEmpty()); + // Managed as well, so the pin icon keeps the same place whether or not the mod has an old file to restore. + boolean hasOldFiles = !modInfo.getMod().getOldFiles().isEmpty(); + restoreButton.setVisible(hasOldFiles); + restoreButton.setManaged(hasOldFiles); restoreButton.setOnAction(e -> { menu.get().getContent().setAll(modInfo.getMod().getOldFiles().stream() .map(localModFile -> new IconedMenuItem(null, localModFile.getVersion(), @@ -869,6 +1012,11 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) { revealButton.setOnAction(e -> FXUtils.showFileInExplorer(modInfo.getFile())); infoButton.setOnAction(e -> Controllers.dialog(new ModInfoDialog(dataItem))); + pinButton.setDisable(modInfo.isOld()); + (observedPinnedProperty = dataItem.pinnedProperty()).addListener(weakPinnedListener); + applyPinState(dataItem); + pinButton.setOnAction(e -> page.setPinned(dataItem, !dataItem.isPinned())); + if (!warning.isEmpty()) { pseudoClassStateChanged(WARNING, true); @@ -879,5 +1027,24 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) { FXUtils.installFastTooltip(this, warningTooltip); } } + + /// Rebuilds the pin icon, tag and tooltip of the displayed mod. + /// + /// @param item the mod shown by this cell, or null when the cell is empty + private void applyPinState(@Nullable ModInfoObject item) { + if (item == null) { + return; + } + + String text = i18n("mods.pinned"); + content.getTags().removeIf(label -> text.equals(label.getText())); + if (item.isPinned()) { + content.addTag(text); + } + // A pinned mod shows the filled glyph, so the state is visible without reading the tag. + pinButton.setGraphic((item.isPinned() ? SVG.KEEP_FILL : SVG.KEEP).createIcon()); + pinTooltip.setText(i18n(item.isPinned() ? "mods.unpin" : "mods.pin")); + } + } } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index b82188a6b0b..e337eb86d35 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1189,6 +1189,10 @@ mods.mcmod=MCMod mods.mcmod.page=MCMod Page mods.mcmod.search=Search in MCMod mods.name=Name +mods.pin=Pin to current version +mods.pin.nothing=All selected mods are pinned, so there is nothing to update. +mods.pinned=Pinned +mods.unpin=Unpin (allow update) mods.restore=Restore mods.toggle.failed=Failed to enable or disable mod(s). The mod file(s) may be in use by another program. mods.url=Official Page diff --git a/HMCL/src/main/resources/assets/lang/I18N_ar.properties b/HMCL/src/main/resources/assets/lang/I18N_ar.properties index 42905afc83c..bd6b59abaa3 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ar.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ar.properties @@ -1189,6 +1189,10 @@ mods.mcmod=MCMod mods.mcmod.page=صفحة MCMod mods.mcmod.search=البحث في MCMod mods.name=الاسم +mods.pin=تثبيت على الإصدار الحالي +mods.pin.nothing=جميع المودات المحددة مثبتة بالفعل، لا يوجد ما يمكن تحديثه. +mods.pinned=مثبَّت +mods.unpin=إلغاء التثبيت (السماح بالتحديث) mods.restore=استعادة # mods.toggle.failed= mods.url=الصفحة الرسمية diff --git a/HMCL/src/main/resources/assets/lang/I18N_de.properties b/HMCL/src/main/resources/assets/lang/I18N_de.properties index b82645d9071..88cf7b7a768 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_de.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_de.properties @@ -1188,6 +1188,10 @@ mods.mcmod=MCMod mods.mcmod.page=MCMod-Seite mods.mcmod.search=In MCMod suchen mods.name=Name +mods.pin=Auf aktuelle Version festlegen +mods.pin.nothing=Alle ausgewählten Mods sind bereits festgelegt, es gibt nichts zu aktualisieren. +mods.pinned=Festgelegt +mods.unpin=Festlegung aufheben (Updates erlauben) mods.restore=Wiederherstellen # mods.toggle.failed= mods.url=Offizielle Seite diff --git a/HMCL/src/main/resources/assets/lang/I18N_es.properties b/HMCL/src/main/resources/assets/lang/I18N_es.properties index 20b856ba6ad..f3be9fdb6c9 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_es.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_es.properties @@ -1181,6 +1181,10 @@ mods.mcmod=MCMod mods.mcmod.page=Página de MCMod mods.mcmod.search=Búsqueda en MCMod mods.name=Nombre +mods.pin=Fijar a la versión actual +mods.pin.nothing=Todos los mods seleccionados ya están fijados, no hay nada que actualizar. +mods.pinned=Fijado +mods.unpin=Desfijar (permitir la actualización) mods.restore=Restaurar # mods.toggle.failed= mods.url=Página oficial diff --git a/HMCL/src/main/resources/assets/lang/I18N_ja.properties b/HMCL/src/main/resources/assets/lang/I18N_ja.properties index 34222788946..84a4ac7987c 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ja.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ja.properties @@ -989,6 +989,10 @@ mods.mcmod=MCMOD mods.mcmod.page=MCMOD mods.mcmod.search=MCMODで検索 mods.name=Name +mods.pin=現在のバージョンに固定 +mods.pin.nothing=選択した Mod はすべて固定済みのため、更新する項目はありません。 +mods.pinned=固定中 +mods.unpin=固定を解除(更新を許可) mods.restore=Restore # mods.toggle.failed= mods.url=公式ページ diff --git a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties index 45de1099a28..781425111e3 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties @@ -1001,6 +1001,10 @@ mods.mcmod=礦藝改囊典 mods.mcmod.page=礦藝改囊典頁 mods.mcmod.search=礦藝改囊典查 mods.name=名 +mods.pin=錮之(不與更迭) +mods.pin.nothing=所擇之囊皆已錮,無可更者。 +mods.pinned=已錮 +mods.unpin=解錮(復其更迭) mods.restore=復 # mods.toggle.failed= mods.url=改囊官網 diff --git a/HMCL/src/main/resources/assets/lang/I18N_ru.properties b/HMCL/src/main/resources/assets/lang/I18N_ru.properties index e2fd5fb42e5..c47e94e0419 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ru.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ru.properties @@ -1121,6 +1121,10 @@ mods.mcmod=MCMod mods.mcmod.page=Страница MCMod mods.mcmod.search=Искать на MCMod mods.name=Название +mods.pin=Закрепить текущую версию +mods.pin.nothing=Все выбранные моды уже закреплены, обновлять нечего. +mods.pinned=Закреплён +mods.unpin=Открепить (разрешить обновление) mods.restore=Восстановить # mods.toggle.failed= mods.url=Официальная страница diff --git a/HMCL/src/main/resources/assets/lang/I18N_uk.properties b/HMCL/src/main/resources/assets/lang/I18N_uk.properties index ae9bb1ccdc6..4f1c0262498 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_uk.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_uk.properties @@ -1144,6 +1144,10 @@ mods.mcmod=MCMod mods.mcmod.page=Сторінка MCMod mods.mcmod.search=Пошук в MCMod mods.name=Назва +mods.pin=Закріпити поточну версію +mods.pin.nothing=Усі вибрані моди вже закріплено, оновлювати нічого. +mods.pinned=Закріплено +mods.unpin=Скасувати закріплення (дозволити оновлення) mods.restore=Відновити # mods.toggle.failed= mods.url=Офіційна сторінка diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties index cdd99616e3e..d7452daeefd 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties @@ -1005,6 +1005,10 @@ mods.mcmod=MC 百科 mods.mcmod.page=MC 百科页面 mods.mcmod.search=MC 百科搜索 mods.name=名称 +mods.pin=钉住(不参与更新) +mods.pin.nothing=所选模组都已钉住,没有需要更新的模组 +mods.pinned=已钉住 +mods.unpin=取消钉住(恢复更新) mods.restore=回退 mods.toggle.failed=无法启用或禁用模组,模组文件可能已被其他程序占用 mods.url=官方页面 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties index ab3ee371916..f36a8b718b4 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties @@ -999,6 +999,10 @@ mods.mcmod=MC 百科 mods.mcmod.page=MC 百科頁面 mods.mcmod.search=MC 百科檢索 mods.name=名稱 +mods.pin=釘住(不參與更新) +mods.pin.nothing=所選模組都已釘住,沒有需要更新的模組 +mods.pinned=已釘住 +mods.unpin=取消釘住(恢復更新) mods.restore=回退 mods.toggle.failed=無法啟用或停用模組,模組檔案可能已被其他程式佔用 mods.url=官方頁面 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java index a1513a6016b..7bf6323052a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java @@ -30,9 +30,11 @@ import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.tree.ZipFileTree; +import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Unmodifiable; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.util.*; @@ -221,6 +223,9 @@ public void refresh() throws IOException { localFiles.clear(); localMods.clear(); + // Re-read the pin file as well, so edits made outside this manager are picked up. + pinnedFiles = null; + analyze(); boolean supportSubfolders = analyzer.has(GameComponentType.FORGE) @@ -400,6 +405,116 @@ public boolean isDisabled(Path file) { return FileUtils.getName(file).endsWith(DISABLED_EXTENSION); } + /// Name of the file in the mods directory that lists the mods pinned to their current version, one per line. + /// + /// The file has no mod extension, so [#refresh()] skips it. + public static final String PINNED_FILE_NAME = ".hmcl-pinned"; + + /// Pinned mod file names, or null until they are read from [#PINNED_FILE_NAME]. + private @Nullable Set pinnedFiles; + + /// Returns whether the mod at the given path stays at its current version during update checks. + /// + /// @param file the mod file + /// @return true if the mod should not be updated + public boolean isPinned(Path file) { + lock.lock(); + try { + return getPinnedFiles().contains(getLocalAddonName(file)); + } finally { + lock.unlock(); + } + } + + /// Pins or unpins the given mods, writing [#PINNED_FILE_NAME] at most once. + /// + /// A name is stored without the `.disabled` or `.old` suffix, so pinning survives enabling, + /// disabling and rolling back the mod. The in-memory pins are updated only after the file was + /// written, so a failed write leaves both the file and the memory unchanged. + /// + /// @param files the mod files to change + /// @param pinned whether they should stay at their current versions + /// @throws IOException if the pin file cannot be written + public void setPinned(Collection files, boolean pinned) throws IOException { + lock.lock(); + try { + var names = new HashSet<>(getPinnedFiles()); + boolean changed = false; + for (Path file : files) { + if (pinned ? names.add(getLocalAddonName(file)) : names.remove(getLocalAddonName(file))) { + changed = true; + } + } + + if (changed) { + Set newPins = Set.copyOf(names); + savePinnedFiles(newPins); + pinnedFiles = newPins; + } + } finally { + lock.unlock(); + } + } + + /// Returns the pinned mod names, reading [#PINNED_FILE_NAME] on first access. + /// + /// @return the pinned mod file names, empty when nothing is pinned + private @Unmodifiable Set getPinnedFiles() { + if (pinnedFiles == null) { + pinnedFiles = loadPinnedFiles(); + } + return pinnedFiles; + } + + /// Reads [#PINNED_FILE_NAME] from the mods directory. + /// + /// A missing or unreadable file yields an empty set, so it never hides the mod list. + /// + /// @return the pinned mod file names + private @Unmodifiable Set loadPinnedFiles() { + Path file = getDirectory().resolve(PINNED_FILE_NAME); + if (!Files.isRegularFile(file)) { + return Set.of(); + } + + try { + Set names = new HashSet<>(); + for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { + String name = line.trim(); + if (!name.isEmpty()) { + names.add(name); + } + } + return Set.copyOf(names); + } catch (IOException e) { + LOG.warning("Unable to read pinned mods from " + file, e); + return Set.of(); + } + } + + /// Writes the pinned mod names to [#PINNED_FILE_NAME], sorted to keep the file stable. + /// + /// The names go to a temporary file that then replaces the pin file, so an interrupted write + /// cannot leave a truncated file and lose every pin. + /// + /// @param names the pinned mod file names + /// @throws IOException if the file cannot be written + private void savePinnedFiles(Collection names) throws IOException { + Path file = getDirectory().resolve(PINNED_FILE_NAME); + Path temp = Files.createTempFile(getDirectory(), PINNED_FILE_NAME, ".tmp"); + try { + Files.write(temp, names.stream().sorted().toList(), StandardCharsets.UTF_8); + try { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + // Some file systems cannot replace atomically, but the content is identical either way. + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temp); + } + } + public static boolean isFileNameMod(Path file) { String name = getLocalAddonName(file); return MOD_EXTENSIONS.contains(FileUtils.getExtension(name).toLowerCase(Locale.ROOT));