package com.faforever.client.mod; import com.faforever.client.config.CacheNames; import com.faforever.client.fx.PlatformService; import com.faforever.client.i18n.I18n; import com.faforever.client.notification.Action; import com.faforever.client.notification.NotificationService; import com.faforever.client.notification.PersistentNotification; import com.faforever.client.preferences.PreferencesService; import com.faforever.client.remote.FafService; import com.faforever.client.task.CompletableTask; import com.faforever.client.task.TaskService; import com.faforever.client.util.ConcurrentUtil; import javafx.beans.property.DoubleProperty; import javafx.beans.property.StringProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.concurrent.Task; import javafx.scene.image.Image; import org.apache.commons.lang3.StringUtils; import org.apache.lucene.analysis.Analyzer; import org.apache.lucene.search.suggest.analyzing.AnalyzingInfixSuggester; import org.apache.lucene.store.Directory; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.luaj.vm2.LuaError; import org.luaj.vm2.LuaValue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.cache.annotation.Cacheable; import org.springframework.context.ApplicationContext; import javax.annotation.PostConstruct; import javax.annotation.Resource; import java.io.IOException; import java.lang.invoke.MethodHandles; import java.net.URL; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.WatchEvent; import java.nio.file.WatchKey; import java.nio.file.WatchService; import java.util.Comparator; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; import static com.faforever.client.notification.Severity.WARN; import static com.faforever.client.util.LuaUtil.loadFile; import static com.github.nocatch.NoCatch.noCatch; import static java.nio.charset.StandardCharsets.US_ASCII; import static java.nio.file.Files.createDirectories; import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE; import static java.util.Collections.singletonList; public class ModServiceImpl implements ModService { private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); private static final Pattern ACTIVE_MODS_PATTERN = Pattern.compile("active_mods\\s*=\\s*\\{.*?}", Pattern.DOTALL); private static final Pattern ACTIVE_MOD_PATTERN = Pattern.compile("\\['(.*?)']\\s*=\\s*(true|false)", Pattern.DOTALL); private static final Lock LOOKUP_LOCK = new ReentrantLock(); @Resource FafService fafService; @Resource PreferencesService preferencesService; @Resource TaskService taskService; @Resource ApplicationContext applicationContext; @Resource ThreadPoolExecutor threadPoolExecutor; @Resource Analyzer analyzer; @Resource Directory directory; @Resource NotificationService notificationService; @Resource I18n i18n; @Resource PlatformService platformService; private Path modsDirectory; private Map<Path, ModInfoBean> pathToMod; private ObservableList<ModInfoBean> installedMods; private ObservableList<ModInfoBean> readOnlyInstalledMods; private AnalyzingInfixSuggester suggester; public ModServiceImpl() { pathToMod = new HashMap<>(); installedMods = FXCollections.observableArrayList(); readOnlyInstalledMods = FXCollections.unmodifiableObservableList(installedMods); } private static Path extractIconPath(Path path, LuaValue luaValue) { String icon = luaValue.get("icon").toString(); if ("nil".equals(icon) || StringUtils.isEmpty(icon)) { return null; } if (icon.startsWith("/")) { icon = icon.substring(1); } Path iconPath = Paths.get(icon); // FIXME try-catch until I know exactly what's the value that causes #228 try { // mods/BlackOpsUnleashed/icons/yoda_icon.bmp -> icons/yoda_icon.bmp iconPath = iconPath.subpath(2, iconPath.getNameCount()); } catch (IllegalArgumentException e) { logger.warn("Can't load icon for mod: {}, icon path: {}", path, iconPath); return null; } return path.resolve(iconPath); } @PostConstruct void postConstruct() throws IOException { modsDirectory = preferencesService.getPreferences().getForgedAlliance().getModsDirectory(); preferencesService.getPreferences().getForgedAlliance().modsDirectoryProperty().addListener((observable, oldValue, newValue) -> { if (newValue != null) { onModDirectoryReady(); } }); if (modsDirectory != null) { onModDirectoryReady(); } suggester = new AnalyzingInfixSuggester(directory, analyzer); } private void onModDirectoryReady() { try { createDirectories(modsDirectory); startDirectoryWatcher(modsDirectory); } catch (IOException | InterruptedException e) { logger.warn("Could not start mod directory watcher", e); // TODO notify user } loadInstalledMods(); } private void startDirectoryWatcher(Path modsDirectory) throws IOException, InterruptedException { ConcurrentUtil.executeInBackground(new Task<Void>() { @Override protected Void call() throws Exception { WatchService watcher = modsDirectory.getFileSystem().newWatchService(); modsDirectory.register(watcher, ENTRY_DELETE); //noinspection InfiniteLoopStatement while (true) { WatchKey key = watcher.take(); for (WatchEvent<?> event : key.pollEvents()) { if (event.kind() == ENTRY_DELETE) { removeMod(modsDirectory.resolve((Path) event.context())); } } key.reset(); } } }); } @Override public void loadInstalledMods() { try (DirectoryStream<Path> directoryStream = Files.newDirectoryStream(modsDirectory, entry -> Files.isDirectory(entry))) { for (Path path : directoryStream) { addMod(path); } } catch (IOException e) { logger.warn("Mods could not be read from: " + modsDirectory, e); } } @Override public ObservableList<ModInfoBean> getInstalledMods() { return readOnlyInstalledMods; } @Override public CompletionStage<Void> downloadAndInstallMod(URL url) { return downloadAndInstallMod(url, null, null); } @Override public CompletionStage<Void> downloadAndInstallMod(URL url, @Nullable DoubleProperty progressProperty, @Nullable StringProperty titleProperty) { InstallModTask task = applicationContext.getBean(InstallModTask.class); task.setUrl(url); if (progressProperty != null) { progressProperty.bind(task.progressProperty()); } if (titleProperty != null) { titleProperty.bind(task.titleProperty()); } return taskService.submitTask(task).getFuture() .thenAccept(aVoid -> loadInstalledMods()); } @Override public CompletionStage<Void> downloadAndInstallMod(ModInfoBean modInfoBean, @Nullable DoubleProperty progressProperty, StringProperty titleProperty) { return downloadAndInstallMod(modInfoBean.getDownloadUrl(), progressProperty, titleProperty); } @Override public Set<String> getInstalledModUids() { return getInstalledMods().stream() .map(ModInfoBean::getId) .collect(Collectors.toSet()); } @Override public Set<String> getInstalledUiModsUids() { return getInstalledMods().stream() .filter(ModInfoBean::getUiOnly) .map(ModInfoBean::getId) .collect(Collectors.toSet()); } @Override public void enableSimMods(Set<String> simMods) throws IOException { Map<String, Boolean> modStates = readModStates(); Set<String> installedUiMods = getInstalledUiModsUids(); for (Map.Entry<String, Boolean> entry : modStates.entrySet()) { String uid = entry.getKey(); if (!installedUiMods.contains(uid)) { // Only disable it if it's a sim mod; because it has not been selected entry.setValue(false); } } for (String simModUid : simMods) { modStates.put(simModUid, true); } writeModStates(modStates); } @Override public boolean isModInstalled(String uid) { return getInstalledUiModsUids().contains(uid) || getInstalledModUids().contains(uid); } @Override public CompletionStage<Void> uninstallMod(ModInfoBean mod) { UninstallModTask task = applicationContext.getBean(UninstallModTask.class); task.setMod(mod); return taskService.submitTask(task).getFuture(); } @Override public Path getPathForMod(ModInfoBean mod) { for (Map.Entry<Path, ModInfoBean> entry : pathToMod.entrySet()) { ModInfoBean modInfoBean = entry.getValue(); if (mod.getId().equals(modInfoBean.getId())) { return entry.getKey(); } } return null; } @Override public CompletionStage<List<ModInfoBean>> getAvailableMods() { return CompletableFuture.supplyAsync(() -> { List<ModInfoBean> availableMods = fafService.getMods(); try { ModInfoBeanIterator iterator = new ModInfoBeanIterator(availableMods.iterator()); suggester.build(iterator); return availableMods; } catch (IOException e) { throw new RuntimeException(e); } } , threadPoolExecutor); } @Override public CompletionStage<List<ModInfoBean>> getMostDownloadedMods(int count) { return getTopElements(ModInfoBean.DOWNLOADS_COMPARATOR.reversed(), count); } @Override public CompletionStage<List<ModInfoBean>> getMostLikedMods(int count) { return getTopElements(ModInfoBean.LIKES_COMPARATOR.reversed(), count); } @Override public CompletionStage<List<ModInfoBean>> getMostPlayedMods(int count) { return getTopElements(ModInfoBean.TIMES_PLAYED_COMPARATOR.reversed(), count); } @Override public CompletionStage<List<ModInfoBean>> getNewestMods(int count) { return getTopElements(ModInfoBean.PUBLISH_DATE_COMPARATOR.reversed(), count); } @Override public CompletionStage<List<ModInfoBean>> getMostLikedUiMods(int count) { return getAvailableMods().thenApply(modInfoBeans -> modInfoBeans.stream() .filter(ModInfoBean::getUiOnly) .sorted(ModInfoBean.LIKES_COMPARATOR.reversed()) .limit(count) .collect(Collectors.toList())); } @Override public CompletionStage<List<ModInfoBean>> lookupMod(String string, int maxResults) { return CompletableFuture.supplyAsync(() -> { try { LOOKUP_LOCK.lock(); ModInfoBeanIterator iterator = new ModInfoBeanIterator(fafService.getMods().iterator()); suggester.build(iterator); return suggester.lookup(string, maxResults, true, false).stream() .map(lookupResult -> iterator.deserialize(lookupResult.payload.bytes)) .collect(Collectors.toList()); } catch (IOException e) { throw new RuntimeException(e); } finally { LOOKUP_LOCK.unlock(); } }, threadPoolExecutor).exceptionally(throwable -> { logger.warn("Lookup failed", throwable); return null; }); } @NotNull public ModInfoBean extractModInfo(Path path) { ModInfoBean modInfoBean = new ModInfoBean(); Path modInfoLua = path.resolve("mod_info.lua"); if (Files.notExists(modInfoLua)) { throw new ModLoadException("Missing mod_info.lua in: " + path.toAbsolutePath()); } logger.debug("Reading mod {}", path); try { LuaValue luaValue = noCatch(() -> loadFile(modInfoLua), ModLoadException.class); modInfoBean.setId(luaValue.get("uid").toString()); modInfoBean.setName(luaValue.get("name").toString()); modInfoBean.setDescription(luaValue.get("description").toString()); modInfoBean.setAuthor(luaValue.get("author").toString()); modInfoBean.setVersion(luaValue.get("version").toString()); modInfoBean.setSelectable(luaValue.get("selectable").toboolean()); modInfoBean.setUiOnly(luaValue.get("ui_only").toboolean()); modInfoBean.setImagePath(extractIconPath(path, luaValue)); } catch (LuaError e) { throw new ModLoadException(e); } return modInfoBean; } @Override public CompletableTask<Void> uploadMod(Path modPath) { ModUploadTask modUploadTask = applicationContext.getBean(ModUploadTask.class); modUploadTask.setModPath(modPath); return taskService.submitTask(modUploadTask); } @Override @Cacheable(value = CacheNames.MOD_THUMBNAIL, unless = "#result == null") public Image loadThumbnail(ModInfoBean mod) { String url = mod.getThumbnailUrl(); if (url == null) { return null; } logger.debug("Fetching thumbnail for mod {} from {}", mod.getName(), url); return new Image(url, true); } @Override public void evictModsCache() { fafService.evictModsCache(); } private CompletionStage<List<ModInfoBean>> getTopElements(Comparator<? super ModInfoBean> comparator, int count) { return getAvailableMods().thenApply(modInfoBeans -> modInfoBeans.stream() .sorted(comparator) .limit(count) .collect(Collectors.toList())); } private Map<String, Boolean> readModStates() throws IOException { Path preferencesFile = preferencesService.getPreferences().getForgedAlliance().getPreferencesFile(); Map<String, Boolean> mods = new HashMap<>(); String preferencesContent = new String(Files.readAllBytes(preferencesFile), US_ASCII); Matcher matcher = ACTIVE_MODS_PATTERN.matcher(preferencesContent); if (matcher.find()) { Matcher activeModMatcher = ACTIVE_MOD_PATTERN.matcher(matcher.group(0)); while (activeModMatcher.find()) { String modUid = activeModMatcher.group(1); boolean enabled = Boolean.parseBoolean(activeModMatcher.group(2)); mods.put(modUid, enabled); } } return mods; } private void writeModStates(Map<String, Boolean> modStates) throws IOException { Path preferencesFile = preferencesService.getPreferences().getForgedAlliance().getPreferencesFile(); String preferencesContent = new String(Files.readAllBytes(preferencesFile), US_ASCII); String currentActiveModsContent = null; Matcher matcher = ACTIVE_MODS_PATTERN.matcher(preferencesContent); if (matcher.find()) { currentActiveModsContent = matcher.group(0); } StringBuilder newActiveModsContentBuilder = new StringBuilder("active_mods = {"); Iterator<Map.Entry<String, Boolean>> iterator = modStates.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry<String, Boolean> entry = iterator.next(); if (!entry.getValue()) { continue; } newActiveModsContentBuilder.append("\n ['"); newActiveModsContentBuilder.append(entry.getKey()); newActiveModsContentBuilder.append("'] = true"); if (iterator.hasNext()) { newActiveModsContentBuilder.append(","); } } newActiveModsContentBuilder.append("\n}"); if (currentActiveModsContent != null) { preferencesContent = preferencesContent.replace(currentActiveModsContent, newActiveModsContentBuilder); } else { preferencesContent += newActiveModsContentBuilder.toString(); } Files.write(preferencesFile, preferencesContent.getBytes(US_ASCII)); } private void removeMod(Path path) throws IOException { installedMods.remove(pathToMod.remove(path)); } private void addMod(Path path) { try { ModInfoBean modInfoBean = extractModInfo(path); pathToMod.put(path, modInfoBean); if (!installedMods.contains(modInfoBean)) { installedMods.add(modInfoBean); } } catch (ModLoadException e) { logger.debug("Corrupt mod: " + path, e); notificationService.addNotification(new PersistentNotification(i18n.get("corruptedMods.notification", path.getFileName()), WARN, singletonList( new Action(i18n.get("corruptedMods.show"), event -> platformService.reveal(path)) ))); } } }