package com.faforever.client.preferences;
import com.faforever.client.game.Faction;
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.notification.Severity;
import com.faforever.client.os.OperatingSystem;
import com.faforever.client.preferences.gson.ColorTypeAdapter;
import com.faforever.client.preferences.gson.PathTypeAdapter;
import com.faforever.client.preferences.gson.PropertyTypeAdapter;
import com.faforever.client.remote.gson.FactionTypeAdapter;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.sun.jna.platform.win32.Shell32Util;
import com.sun.jna.platform.win32.ShlObj;
import javafx.beans.property.Property;
import javafx.scene.paint.Color;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.bridge.SLF4JBridgeHandler;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.lang.invoke.MethodHandles;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.CompletionStage;
import static java.nio.charset.StandardCharsets.US_ASCII;
public class PreferencesService {
/**
* Points to the FAF data directory where log files, config files and others are held. The returned value varies
* depending on the operating system.
*/
private static final Path FAF_DATA_DIRECTORY;
private static final long STORE_DELAY = 1000;
private static final Charset CHARSET = StandardCharsets.UTF_8;
private static final String PREFS_FILE_NAME = "client.prefs";
private static final String APP_DATA_SUB_FOLDER = "Forged Alliance Forever";
private static final String USER_HOME_SUB_FOLDER = ".faforever";
private static final String REPLAYS_SUB_FOLDER = "replays";
private static final String CORRUPTED_REPLAYS_SUB_FOLDER = "corrupt";
private static final String CACHE_SUB_FOLDER = "cache";
private static final String CACHE_STYLESHEETS_SUB_FOLDER = Paths.get(CACHE_SUB_FOLDER, "stylesheets").toString();
private static final Collection<Path> USUAL_GAME_PATHS = Arrays.asList(
Paths.get(System.getenv("ProgramFiles") + "\\THQ\\Gas Powered Games\\Supreme Commander - Forged Alliance"),
Paths.get(System.getenv("ProgramFiles") + " (x86)\\THQ\\Gas Powered Games\\Supreme Commander - Forged Alliance"),
Paths.get(System.getenv("ProgramFiles") + "\\Steam\\steamapps\\common\\supreme commander forged alliance"),
Paths.get(System.getenv("ProgramFiles") + "\\Supreme Commander - Forged Alliance")
);
private static final String FORGED_ALLIANCE_EXE = "ForgedAlliance.exe";
private static final String SUPREME_COMMANDER_EXE = "SupremeCommander.exe";
private static final Logger logger;
static {
switch (OperatingSystem.current()) {
case WINDOWS:
FAF_DATA_DIRECTORY = Paths.get(Shell32Util.getFolderPath(ShlObj.CSIDL_COMMON_APPDATA), "FAForever");
break;
default:
FAF_DATA_DIRECTORY = Paths.get(System.getProperty("user.home")).resolve(USER_HOME_SUB_FOLDER);
}
}
static {
System.setProperty("logDirectory", PreferencesService.FAF_DATA_DIRECTORY.resolve("logs").toString());
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
logger.debug("Logger initialized");
}
private final Path preferencesFilePath;
private final Gson gson;
/**
* @see #storeInBackground()
*/
private final Timer timer;
private final Collection<PreferenceUpdateListener> updateListeners;
@Resource
I18n i18n;
@Resource
NotificationService notificationService;
private Preferences preferences;
private TimerTask storeInBackgroundTask;
private OnChooseGameDirectoryListener onChooseGameDirectoryListener;
public PreferencesService() {
updateListeners = new ArrayList<>();
this.preferencesFilePath = getPreferencesDirectory().resolve(PREFS_FILE_NAME);
timer = new Timer("PrefTimer", true);
gson = new GsonBuilder()
.setPrettyPrinting()
.registerTypeHierarchyAdapter(Property.class, PropertyTypeAdapter.INSTANCE)
.registerTypeHierarchyAdapter(Path.class, PathTypeAdapter.INSTANCE)
.registerTypeAdapter(Color.class, new ColorTypeAdapter())
.registerTypeAdapter(Faction.class, FactionTypeAdapter.INSTANCE)
.create();
}
public static void configureLogging() {
// This method call causes the class to be initialized (static initializers) which in turn causes the logger to initialize.
}
public Path getPreferencesDirectory() {
switch (OperatingSystem.current()) {
case WINDOWS:
return Paths.get(System.getenv("APPDATA")).resolve(APP_DATA_SUB_FOLDER);
default:
return Paths.get(System.getProperty("user.home")).resolve(USER_HOME_SUB_FOLDER);
}
}
@PostConstruct
void postConstruct() throws IOException {
if (Files.exists(preferencesFilePath)) {
deleteFileIfEmpty();
readExistingFile(preferencesFilePath);
} else {
preferences = new Preferences();
}
Path gamePrefs = preferences.getForgedAlliance().getPreferencesFile();
if (Files.notExists(gamePrefs)) {
logger.info("Initializing game preferences file: {}", gamePrefs);
Files.createDirectories(gamePrefs.getParent());
Files.copy(getClass().getResourceAsStream("/game.prefs"), gamePrefs);
}
Path path = preferences.getForgedAlliance().getPath();
if (path == null || Files.notExists(path)) {
logger.info("Game path is not specified or non-existent, trying to detect");
detectGamePath();
} else {
createFaPathLua(path);
}
}
private void detectGamePath() {
for (Path path : USUAL_GAME_PATHS) {
if (storeGamePathIfValid(path)) {
return;
}
}
logger.info("Game path could not be detected");
notifyMissingGamePath();
}
private void notifyMissingGamePath() {
List<Action> actions = Collections.singletonList(
new Action(i18n.get("missingGamePath.locate"), event -> letUserChooseGameDirectory())
);
notificationService.addNotification(new PersistentNotification(i18n.get("missingGamePath.notification"), Severity.WARN, actions));
}
/**
* Completes the returned future with the game path selected by the user. Returns {@code null} if the user selected no
* path.
*/
public CompletionStage<Path> letUserChooseGameDirectory() {
if (onChooseGameDirectoryListener == null) {
throw new IllegalStateException("No listener has been specified");
}
return onChooseGameDirectoryListener.onChooseGameDirectory().thenApply(path -> {
if (path == null) {
return null;
}
boolean isPathValid = storeGamePathIfValid(path);
if (!isPathValid) {
throw new RuntimeException("Invalid game path selected");
}
return path;
}).exceptionally(throwable -> {
logger.warn("Unexpected exception", throwable);
return null;
});
}
/**
* Checks whether the specified path contains a ForgedAlliance.exe (either directly if the user selected the "bin"
* directory, or in the "bin" subfolder). If the path is valid, it is stored in the preferences.
*
* @return {@code true} if the game path is valid, {@code false} otherwise.
*/
private boolean storeGamePathIfValid(Path path) {
if (path == null || !Files.isDirectory(path)) {
return false;
}
if (!Files.isRegularFile(path.resolve(FORGED_ALLIANCE_EXE)) && !Files.isRegularFile(path.resolve(SUPREME_COMMANDER_EXE))) {
return storeGamePathIfValid(path.resolve("bin"));
}
// At this point, path points to the "bin" directory
Path gamePath = path.getParent();
logger.info("Found game path at {}", gamePath);
preferences.getForgedAlliance().setPath(gamePath);
storeInBackground();
createFaPathLua(gamePath);
return true;
}
private void createFaPathLua(Path gamePath) {
Path faPathFile = getFafDataDirectory().resolve("fa_path.lua");
try {
Files.createDirectories(faPathFile.getParent());
Files.write(faPathFile, String.format("fa_path = '%s'\n",
gamePath.toAbsolutePath().toString().replace("\\", "\\\\")).getBytes(US_ASCII));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
/**
* It may happen that the file is empty when the process is forcibly killed, so remove the file if that happened.
*/
private void deleteFileIfEmpty() throws IOException {
if (Files.size(preferencesFilePath) == 0) {
Files.delete(preferencesFilePath);
}
}
public Path getFafBinDirectory() {
return getFafDataDirectory().resolve("bin");
}
public Path getFafDataDirectory() {
return FAF_DATA_DIRECTORY;
}
public Path getFafReposDirectory() {
return getFafDataDirectory().resolve("repos");
}
private void readExistingFile(Path path) {
if (preferences != null) {
throw new IllegalStateException("Preferences have already been initialized");
}
try (Reader reader = Files.newBufferedReader(path, CHARSET)) {
logger.debug("Reading preferences file {}", preferencesFilePath.toAbsolutePath());
preferences = gson.fromJson(reader, Preferences.class);
} catch (IOException e) {
logger.warn("Preferences file " + path.toAbsolutePath() + " could not be read", e);
}
}
public Preferences getPreferences() {
return preferences;
}
public void store() {
Path parent = preferencesFilePath.getParent();
try {
if (!Files.exists(parent)) {
Files.createDirectories(parent);
}
} catch (IOException e) {
logger.warn("Could not create directory " + parent.toAbsolutePath(), e);
return;
}
try (Writer writer = Files.newBufferedWriter(preferencesFilePath, CHARSET)) {
logger.debug("Writing preferences file {}", preferencesFilePath.toAbsolutePath());
gson.toJson(preferences, writer);
} catch (IOException e) {
logger.warn("Preferences file " + preferencesFilePath.toAbsolutePath() + " could not be written", e);
}
}
/**
* Stores the preferences in background, with a delay of {@link #STORE_DELAY}. Each subsequent call to this method
* during that delay causes the delay to be reset. This ensures that the prefs file is written only once if multiple
* calls occur within a short time.
*/
public void storeInBackground() {
if (storeInBackgroundTask != null) {
storeInBackgroundTask.cancel();
}
storeInBackgroundTask = new TimerTask() {
@Override
public void run() {
store();
for (PreferenceUpdateListener updateListener : updateListeners) {
updateListener.onPreferencesUpdated(preferences);
}
}
};
timer.schedule(storeInBackgroundTask, STORE_DELAY);
}
/**
* Adds a listener to be notified whenever the preferences have been updated (that is, stored to file).
*/
public void addUpdateListener(PreferenceUpdateListener listener) {
updateListeners.add(listener);
}
public Path getCorruptedReplaysDirectory() {
return getReplaysDirectory().resolve(CORRUPTED_REPLAYS_SUB_FOLDER);
}
public Path getReplaysDirectory() {
return getFafDataDirectory().resolve(REPLAYS_SUB_FOLDER);
}
public void setOnChooseGameDirectoryListener(OnChooseGameDirectoryListener onChooseGameDirectoryListener) {
this.onChooseGameDirectoryListener = onChooseGameDirectoryListener;
}
public Path getCacheDirectory() {
return getFafDataDirectory().resolve(CACHE_SUB_FOLDER);
}
public Path getFafLogDirectory() {
return getFafDataDirectory().resolve("logs");
}
public Path getThemesDirectory() {
return getFafDataDirectory().resolve("themes");
}
public boolean isGamePathValid() {
Path binPath = preferences.getForgedAlliance().getPath().resolve("bin");
return preferences.getForgedAlliance().getPath() != null
&& (Files.isRegularFile(binPath.resolve(FORGED_ALLIANCE_EXE))
|| Files.isRegularFile(binPath.resolve(SUPREME_COMMANDER_EXE))
);
}
public Path getCacheStylesheetsDirectory() {
return getFafDataDirectory().resolve(CACHE_STYLESHEETS_SUB_FOLDER);
}
}