package com.faforever.client.theme; import com.faforever.client.config.CacheNames; import com.faforever.client.preferences.PreferencesService; import javafx.application.Platform; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.collections.FXCollections; import javafx.collections.MapChangeListener; import javafx.collections.ObservableMap; import javafx.embed.swing.SwingFXUtils; import javafx.scene.Scene; import javafx.scene.image.Image; import javafx.scene.image.WritableImage; import javafx.scene.web.WebView; import javafx.stage.Stage; import org.apache.commons.compress.utils.IOUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.Cacheable; import org.springframework.core.io.ClassPathResource; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import javax.annotation.Resource; import java.awt.Color; import java.awt.Font; import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.image.BufferedImage; import java.io.IOException; import java.io.InputStream; import java.io.Reader; import java.lang.invoke.MethodHandles; import java.net.URL; import java.nio.file.ClosedWatchServiceException; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.WatchEvent; import java.nio.file.WatchKey; import java.nio.file.WatchService; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Locale; import java.util.Map; import java.util.Properties; import java.util.Set; import java.util.concurrent.ThreadPoolExecutor; import static com.faforever.client.io.FileUtils.deleteRecursively; import static com.faforever.client.preferences.Preferences.DEFAULT_THEME_NAME; import static com.github.nocatch.NoCatch.noCatch; import static java.awt.RenderingHints.KEY_ANTIALIASING; import static java.awt.RenderingHints.VALUE_ANTIALIAS_ON; import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE; import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE; import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; import static javax.imageio.ImageIO.read; public class ThemeServiceImpl implements ThemeService { public static final int BADGE_ICON_PADDING = 1; public static final int BADGE_ICON_SIZE = 18; private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); /** * This value needs to be updated whenever theme-breaking changes were made to the client. */ private static final int THEME_VERSION = 1; private static final String METADATA_FILE_NAME = "theme.properties"; private static final String DEFAULT_BASE_URL = "/theme/"; private final Set<Scene> scenes; private final Set<WebView> webViews; @Resource PreferencesService preferencesService; @Resource ThreadPoolExecutor threadPoolExecutor; @Resource Locale locale; @Resource CacheManager cacheManager; @Resource ThemeService themeService; private WatchService watchService; private ObservableMap<String, Theme> themesByFolderName; private Map<Theme, String> folderNamesByTheme; private Map<Path, WatchKey> watchKeys; private ObjectProperty<Theme> currentTheme; private Path currentTempStyleSheet; public ThemeServiceImpl() { scenes = Collections.synchronizedSet(new HashSet<>()); webViews = new HashSet<>(); watchKeys = new HashMap<>(); currentTheme = new SimpleObjectProperty<>(DEFAULT_THEME); folderNamesByTheme = new HashMap<>(); themesByFolderName = FXCollections.observableHashMap(); themesByFolderName.addListener((MapChangeListener<String, Theme>) change -> { if (change.wasRemoved()) { folderNamesByTheme.remove(change.getValueRemoved()); } if (change.wasAdded()) { folderNamesByTheme.put(change.getValueAdded(), change.getKey()); } }); } @PostConstruct void postConstruct() throws IOException, InterruptedException { Path themesDirectory = preferencesService.getThemesDirectory(); startWatchService(themesDirectory); Path cacheStylesheetsDirectory = preferencesService.getCacheStylesheetsDirectory(); if (Files.exists(cacheStylesheetsDirectory)) { deleteRecursively(cacheStylesheetsDirectory); } loadThemes(); String storedTheme = preferencesService.getPreferences().getThemeName(); setTheme(themesByFolderName.get(storedTheme)); } private void startWatchService(Path themesDirectory) throws IOException, InterruptedException { watchService = themesDirectory.getFileSystem().newWatchService(); threadPoolExecutor.execute(() -> { try { while (!Thread.interrupted()) { WatchKey key = watchService.take(); onWatchEvent(key); key.reset(); } } catch (InterruptedException | ClosedWatchServiceException e) { logger.debug("Watcher service terminated"); } catch (IOException e) { logger.debug("Exception while watching directories", e); } }); } private void addThemeDirectory(Path path) { Path metadataFile = path.resolve(METADATA_FILE_NAME); if (Files.notExists(metadataFile)) { return; } try (Reader reader = Files.newBufferedReader(metadataFile)) { String folderName = path.getFileName().toString(); themesByFolderName.put(folderName, readTheme(reader)); } catch (IOException e) { logger.warn("Theme could not be read: " + metadataFile.toAbsolutePath(), e); } } private Theme readTheme(Reader reader) throws IOException { Properties properties = new Properties(); properties.load(reader); return Theme.fromProperties(properties); } @PreDestroy void preDestroy() { IOUtils.closeQuietly(watchService); } private void stopWatchingTheme(Theme theme) { Path path = getThemeDirectory(theme); if (watchKeys.containsKey(path)) { watchKeys.remove(path).cancel(); } } /** * Watches all contents in the specified theme for changes and reloads the theme if a change is detected. */ private void watchTheme(Theme theme) { Path themePath = getThemeDirectory(theme); logger.debug("Watching theme directory for changes: {}", themePath.toAbsolutePath()); noCatch(() -> Files.walkFileTree(themePath, new DirectoryVisitor(path -> watchDirectory(themePath, watchService)))); } private void onWatchEvent(WatchKey key) throws IOException { for (WatchEvent<?> watchEvent : key.pollEvents()) { Path path = (Path) watchEvent.context(); if (watchEvent.kind() == ENTRY_CREATE && Files.isDirectory(path)) { watchDirectory(path, watchService); } else if (watchEvent.kind() == ENTRY_DELETE && Files.isDirectory(path)) { watchKeys.remove(path); } } reloadStylesheet(); } private void watchDirectory(Path directory, WatchService watchService) { if (watchKeys.containsKey(directory)) { return; } logger.debug("Watching directory: {}", directory.toAbsolutePath()); noCatch(() -> watchKeys.put(directory, directory.register(watchService, ENTRY_MODIFY, ENTRY_CREATE, ENTRY_DELETE))); } private void reloadStylesheet() { String styleSheet = getSceneStyleSheet(); logger.debug("Changes detected, reloading stylesheet: {}", styleSheet); scenes.forEach(scene -> setSceneStyleSheet(scene, styleSheet)); setAndCreateWebViewsStyleSheet(getWebViewStyleSheet()); } private void setSceneStyleSheet(Scene scene, String styleSheet) { Platform.runLater(() -> scene.getStylesheets().setAll(styleSheet)); } private String getSceneStyleSheet() { return getThemeFile(STYLE_CSS); } @Override public String getThemeFile(String relativeFile) { Path externalFile = getThemeDirectory(currentTheme.get()).resolve(relativeFile); if (Files.notExists(externalFile)) { return noCatch(() -> new ClassPathResource(DEFAULT_BASE_URL + relativeFile).getURL().toString()); } return noCatch(() -> externalFile.toUri().toURL().toString()); } @Override @Cacheable(CacheNames.THEME_IMAGES) public Image getThemeImage(String relativeImage) { return new Image(getThemeFile(relativeImage), true); } @Override public URL getThemeFileUrl(String relativeFile) { String themeFile = getThemeFile(relativeFile); if (themeFile.startsWith("file:") || themeFile.startsWith("jar:")) { return noCatch(() -> new URL(themeFile)); } return noCatch(() -> new ClassPathResource(getThemeFile(relativeFile)).getURL()); } @Override public void setTheme(Theme theme) { stopWatchingTheme(theme); if (theme == DEFAULT_THEME) { preferencesService.getPreferences().setThemeName(DEFAULT_THEME_NAME); } else { watchTheme(theme); preferencesService.getPreferences().setThemeName(getThemeDirectory(theme).getFileName().toString()); } preferencesService.storeInBackground(); reloadStylesheet(); currentTheme.set(theme); cacheManager.getCache(CacheNames.THEME_IMAGES).clear(); } @Override public void unregisterScene(Scene scene) { scenes.remove(scene); } @Override public void registerScene(Scene scene) { scenes.add(scene); scene.getWindow().showingProperty().addListener((observable, oldValue, newValue) -> { if (!newValue) { unregisterScene(scene); } else { registerScene(scene); } }); scene.getStylesheets().setAll(getSceneStyleSheet()); } @Override public void registerWebView(WebView webView) { webViews.add(webView); if (currentTempStyleSheet == null) { setAndCreateWebViewsStyleSheet(getWebViewStyleSheet()); } else { Platform.runLater(() -> webView.getEngine().setUserStyleSheetLocation(getWebViewStyleSheet())); } } @Override public void loadThemes() { themesByFolderName.clear(); themesByFolderName.put(DEFAULT_THEME_NAME, DEFAULT_THEME); noCatch(() -> { Files.createDirectories(preferencesService.getThemesDirectory()); try (DirectoryStream<Path> directoryStream = Files.newDirectoryStream(preferencesService.getThemesDirectory())) { directoryStream.forEach(this::addThemeDirectory); } }); } @Override public Collection<Theme> getAvailableThemes() { return new ArrayList<>(themesByFolderName.values()); } @Override // TODO make this supporting multiple resolutions public void setApplicationIconBadgeNumber(Stage stage, int number) { if (number == 0) { Platform.runLater(() -> stage.getIcons().set(0, themeService.getThemeImage(TRAY_ICON))); return; } BufferedImage appIcon = noCatch(() -> read(getThemeFileUrl(TRAY_ICON))); Graphics2D appIconGraphics = appIcon.createGraphics(); appIconGraphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, (int) (BADGE_ICON_SIZE * .8))); appIconGraphics.setRenderingHints(new RenderingHints(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON)); appIconGraphics.setColor(new Color(255, 102, 102)); int badgeIconSize = BADGE_ICON_SIZE + 2 * BADGE_ICON_PADDING; appIconGraphics.fillOval( appIcon.getWidth() - BADGE_ICON_SIZE - 2 * BADGE_ICON_PADDING, appIcon.getHeight() - BADGE_ICON_SIZE - 2 * BADGE_ICON_PADDING, badgeIconSize, badgeIconSize ); String numberText; if (number < 10) { numberText = String.format(locale, "%d", number); } else { numberText = String.format(locale, "%d+", 9); } int numberX = appIcon.getWidth() - badgeIconSize / 2 - appIconGraphics.getFontMetrics().stringWidth(numberText) / 2; int numberY = appIcon.getHeight() - badgeIconSize / 2 + (appIconGraphics.getFontMetrics().getAscent() - appIconGraphics.getFontMetrics().getDescent()) / 2; appIconGraphics.setColor(Color.WHITE); appIconGraphics.drawString(numberText, numberX, numberY); WritableImage fxIcon = new WritableImage(appIcon.getWidth(), appIcon.getHeight()); SwingFXUtils.toFXImage(appIcon, fxIcon); Platform.runLater(() -> stage.getIcons().set(0, fxIcon)); } @Override public ReadOnlyObjectProperty<Theme> currentThemeProperty() { return currentTheme; } private Path getThemeDirectory(Theme theme) { return preferencesService.getThemesDirectory().resolve(folderNamesByTheme.get(theme)); } private String getWebViewStyleSheet() { return getThemeFileUrl(WEBVIEW_CSS_FILE).toString(); } private void setAndCreateWebViewsStyleSheet(String styleSheetUrl) { // Always copy to a new file since WebView locks the loaded one Path stylesheetsCacheDirectory = preferencesService.getCacheStylesheetsDirectory(); noCatch(() -> { Files.createDirectories(stylesheetsCacheDirectory); Path newTempStyleSheet = Files.createTempFile(stylesheetsCacheDirectory, "style-webview", ".css"); try (InputStream inputStream = new URL(styleSheetUrl).openStream()) { Files.copy(inputStream, newTempStyleSheet, StandardCopyOption.REPLACE_EXISTING); } String newStyleSheetUrl = newTempStyleSheet.toUri().toURL().toString(); webViews.forEach(webView -> Platform.runLater(() -> webView.getEngine().setUserStyleSheetLocation(newStyleSheetUrl))); logger.debug("{} created and applied to all web views", newTempStyleSheet.getFileName()); if (currentTempStyleSheet != null) { Files.delete(currentTempStyleSheet); } currentTempStyleSheet = newTempStyleSheet; }); } }