add a persistent settings store
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
package wtf.beatrice.autosqueal.config;
|
||||
|
||||
/**
|
||||
* The user-editable settings of the app.
|
||||
*
|
||||
* A single instance is shared between the components, which read the
|
||||
* current values while running: a change is picked up by the next poll or
|
||||
* movement without a restart. Values are clamped to sane ranges when set,
|
||||
* so that no matter what ends up in the settings file, the automation
|
||||
* always gets workable numbers.
|
||||
*/
|
||||
public class AutoSquealConfig {
|
||||
|
||||
public static final int AWAY_THRESHOLD_MIN_SECONDS = 5;
|
||||
public static final int AWAY_THRESHOLD_MAX_SECONDS = 600;
|
||||
|
||||
public static final int MOVE_INTERVAL_MIN_SECONDS = 1;
|
||||
public static final int MOVE_INTERVAL_MAX_SECONDS = 3600;
|
||||
|
||||
public static final int STEP_DELAY_MIN_MILLISECONDS = 1;
|
||||
public static final int STEP_DELAY_MAX_MILLISECONDS = 50;
|
||||
|
||||
public static final int CLICK_EVERY_MIN_MOVES = 1;
|
||||
public static final int CLICK_EVERY_MAX_MOVES = 100;
|
||||
|
||||
private volatile int awayThresholdSeconds = 30;
|
||||
private volatile int moveIntervalSeconds = 10;
|
||||
private volatile int stepDelayMilliseconds = 2;
|
||||
private volatile boolean clickEnabled = true;
|
||||
private volatile int clickEveryNMoves = 5;
|
||||
private volatile ScreenArea movementScreen = ScreenArea.PRIMARY;
|
||||
private volatile boolean startAutomatically = true;
|
||||
private volatile boolean hotkeyEnabled = true;
|
||||
|
||||
/** Seconds without user activity after which the user is considered away. */
|
||||
public int getAwayThresholdSeconds() {
|
||||
return awayThresholdSeconds;
|
||||
}
|
||||
|
||||
public void setAwayThresholdSeconds(int seconds) {
|
||||
awayThresholdSeconds = clamp(seconds, AWAY_THRESHOLD_MIN_SECONDS, AWAY_THRESHOLD_MAX_SECONDS);
|
||||
}
|
||||
|
||||
/** Seconds between two movements. */
|
||||
public int getMoveIntervalSeconds() {
|
||||
return moveIntervalSeconds;
|
||||
}
|
||||
|
||||
public void setMoveIntervalSeconds(int seconds) {
|
||||
moveIntervalSeconds = clamp(seconds, MOVE_INTERVAL_MIN_SECONDS, MOVE_INTERVAL_MAX_SECONDS);
|
||||
}
|
||||
|
||||
/** Pause between two cursor steps, in milliseconds: the lower, the faster. */
|
||||
public int getStepDelayMilliseconds() {
|
||||
return stepDelayMilliseconds;
|
||||
}
|
||||
|
||||
public void setStepDelayMilliseconds(int milliseconds) {
|
||||
stepDelayMilliseconds = clamp(milliseconds, STEP_DELAY_MIN_MILLISECONDS, STEP_DELAY_MAX_MILLISECONDS);
|
||||
}
|
||||
|
||||
/** Whether the corner double click is performed at all. */
|
||||
public boolean isClickEnabled() {
|
||||
return clickEnabled;
|
||||
}
|
||||
|
||||
public void setClickEnabled(boolean enabled) {
|
||||
clickEnabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* One movement every N is a corner double click, so that the
|
||||
* notification area gets exercised too.
|
||||
*/
|
||||
public int getClickEveryNMoves() {
|
||||
return clickEveryNMoves;
|
||||
}
|
||||
|
||||
public void setClickEveryNMoves(int moves) {
|
||||
clickEveryNMoves = clamp(moves, CLICK_EVERY_MIN_MOVES, CLICK_EVERY_MAX_MOVES);
|
||||
}
|
||||
|
||||
/** Which screens the cursor may travel to. */
|
||||
public ScreenArea getMovementScreen() {
|
||||
return movementScreen;
|
||||
}
|
||||
|
||||
public void setMovementScreen(ScreenArea screenArea) {
|
||||
movementScreen = screenArea == null ? ScreenArea.PRIMARY : screenArea;
|
||||
}
|
||||
|
||||
/** Whether the automation starts as soon as the window opens. */
|
||||
public boolean isStartAutomatically() {
|
||||
return startAutomatically;
|
||||
}
|
||||
|
||||
public void setStartAutomatically(boolean automatically) {
|
||||
startAutomatically = automatically;
|
||||
}
|
||||
|
||||
/** Whether the global toggle hotkey is active. */
|
||||
public boolean isHotkeyEnabled() {
|
||||
return hotkeyEnabled;
|
||||
}
|
||||
|
||||
public void setHotkeyEnabled(boolean enabled) {
|
||||
hotkeyEnabled = enabled;
|
||||
}
|
||||
|
||||
private static int clamp(int value, int min, int max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package wtf.beatrice.autosqueal.config;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import wtf.beatrice.autosqueal.util.SystemUtil;
|
||||
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.TreeMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.IntConsumer;
|
||||
|
||||
/**
|
||||
* Loads and saves the settings, as a properties file in the user's config
|
||||
* directory.
|
||||
*
|
||||
* The whole store is defensive: a missing file, garbage values or an
|
||||
* unwritable directory degrade to the defaults instead of breaking the
|
||||
* app, because the automation must be able to start no matter what.
|
||||
* Writing goes through a temp file and an atomic move, so that a crash
|
||||
* mid-save can never leave a truncated settings file behind.
|
||||
*/
|
||||
public class ConfigStore {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(ConfigStore.class);
|
||||
|
||||
static final String KEY_AWAY_THRESHOLD_SECONDS = "away.threshold.seconds";
|
||||
static final String KEY_MOVE_INTERVAL_SECONDS = "move.interval.seconds";
|
||||
static final String KEY_STEP_DELAY_MILLISECONDS = "move.step.delay.milliseconds";
|
||||
static final String KEY_CLICK_ENABLED = "click.enabled";
|
||||
static final String KEY_CLICK_EVERY_N_MOVES = "click.every.n.moves";
|
||||
static final String KEY_MOVEMENT_SCREEN = "move.screen";
|
||||
static final String KEY_START_AUTOMATICALLY = "automation.start.automatically";
|
||||
static final String KEY_HOTKEY_ENABLED = "hotkey.enabled";
|
||||
|
||||
private final Path file;
|
||||
|
||||
/** A store reading the standard settings file of the host platform. */
|
||||
public ConfigStore() {
|
||||
this(SystemUtil.getAppConfigDir().resolve("autosqueal.properties"));
|
||||
}
|
||||
|
||||
/** A store reading the given file; used by the tests. */
|
||||
public ConfigStore(Path file) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
/** Reads the settings, falling back to the defaults for anything missing or broken. */
|
||||
public AutoSquealConfig load() {
|
||||
AutoSquealConfig config = new AutoSquealConfig();
|
||||
|
||||
if (!Files.isRegularFile(file)) {
|
||||
LOGGER.info("No settings file yet, using the defaults");
|
||||
return config;
|
||||
}
|
||||
|
||||
Properties properties = new Properties();
|
||||
try (InputStream input = Files.newInputStream(file)) {
|
||||
properties.load(input);
|
||||
} catch (IOException ex) {
|
||||
LOGGER.warn("Could not read the settings file, using the defaults", ex);
|
||||
return config;
|
||||
}
|
||||
|
||||
readInt(properties, KEY_AWAY_THRESHOLD_SECONDS, config::setAwayThresholdSeconds);
|
||||
readInt(properties, KEY_MOVE_INTERVAL_SECONDS, config::setMoveIntervalSeconds);
|
||||
readInt(properties, KEY_STEP_DELAY_MILLISECONDS, config::setStepDelayMilliseconds);
|
||||
readInt(properties, KEY_CLICK_EVERY_N_MOVES, config::setClickEveryNMoves);
|
||||
readBoolean(properties, KEY_CLICK_ENABLED, config::setClickEnabled);
|
||||
readBoolean(properties, KEY_START_AUTOMATICALLY, config::setStartAutomatically);
|
||||
readBoolean(properties, KEY_HOTKEY_ENABLED, config::setHotkeyEnabled);
|
||||
readEnum(properties, KEY_MOVEMENT_SCREEN, ScreenArea.class, config::setMovementScreen);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Writes the settings atomically, so that readers never see a half-written file. */
|
||||
public void save(AutoSquealConfig config) {
|
||||
Map<String, String> entries = new TreeMap<>();
|
||||
entries.put(KEY_AWAY_THRESHOLD_SECONDS, String.valueOf(config.getAwayThresholdSeconds()));
|
||||
entries.put(KEY_MOVE_INTERVAL_SECONDS, String.valueOf(config.getMoveIntervalSeconds()));
|
||||
entries.put(KEY_STEP_DELAY_MILLISECONDS, String.valueOf(config.getStepDelayMilliseconds()));
|
||||
entries.put(KEY_CLICK_ENABLED, String.valueOf(config.isClickEnabled()));
|
||||
entries.put(KEY_CLICK_EVERY_N_MOVES, String.valueOf(config.getClickEveryNMoves()));
|
||||
entries.put(KEY_MOVEMENT_SCREEN, String.valueOf(config.getMovementScreen()));
|
||||
entries.put(KEY_START_AUTOMATICALLY, String.valueOf(config.isStartAutomatically()));
|
||||
entries.put(KEY_HOTKEY_ENABLED, String.valueOf(config.isHotkeyEnabled()));
|
||||
|
||||
try {
|
||||
Path directory = file.toAbsolutePath().getParent();
|
||||
Files.createDirectories(directory);
|
||||
Path tempFile = Files.createTempFile(directory, "autosqueal-", ".tmp");
|
||||
|
||||
try (BufferedWriter writer = Files.newBufferedWriter(tempFile)) {
|
||||
writer.write("# autosqueal settings, rewritten by the app when the settings change");
|
||||
writer.newLine();
|
||||
for (Map.Entry<String, String> entry : entries.entrySet()) {
|
||||
writer.write(entry.getKey());
|
||||
writer.write("=");
|
||||
writer.write(entry.getValue());
|
||||
writer.newLine();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Files.move(tempFile, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException ex) {
|
||||
Files.move(tempFile, file, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
LOGGER.error("Could not save the settings", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void readInt(Properties properties, String key, IntConsumer setter) {
|
||||
String value = properties.getProperty(key);
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setter.accept(Integer.parseInt(value.trim()));
|
||||
} catch (NumberFormatException ex) {
|
||||
LOGGER.warn("Ignoring invalid value for {}: {}", key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static void readBoolean(Properties properties, String key, Consumer<Boolean> setter) {
|
||||
String value = properties.getProperty(key);
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String normalized = value.trim();
|
||||
if ("true".equalsIgnoreCase(normalized)) {
|
||||
setter.accept(true);
|
||||
} else if ("false".equalsIgnoreCase(normalized)) {
|
||||
setter.accept(false);
|
||||
} else {
|
||||
LOGGER.warn("Ignoring invalid value for {}: {}", key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static <E extends Enum<E>> void readEnum(Properties properties, String key, Class<E> type, Consumer<E> setter) {
|
||||
String value = properties.getProperty(key);
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setter.accept(Enum.valueOf(type, value.trim()));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
LOGGER.warn("Ignoring unknown value for {}: {}", key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package wtf.beatrice.autosqueal.config;
|
||||
|
||||
/**
|
||||
* Which screens the automation is allowed to move the cursor to.
|
||||
*/
|
||||
public enum ScreenArea {
|
||||
|
||||
/** The main screen only, as the app has always used. */
|
||||
PRIMARY,
|
||||
|
||||
/** Every connected screen, treated as one big virtual screen. */
|
||||
ALL
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package wtf.beatrice.autosqueal.util;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Locale;
|
||||
|
||||
public class SystemUtil
|
||||
@@ -9,6 +10,27 @@ public class SystemUtil
|
||||
return fromOsName(System.getProperty("os.name"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-user directory where the app keeps its settings, following
|
||||
* the conventions of the host platform.
|
||||
*/
|
||||
public static Path getAppConfigDir() {
|
||||
String home = System.getProperty("user.home", ".");
|
||||
String appData = System.getenv("APPDATA");
|
||||
String xdgConfigHome = System.getenv("XDG_CONFIG_HOME");
|
||||
|
||||
return switch (getHostSystem()) {
|
||||
case MAC_OS -> Path.of(home, "Library", "Application Support", "autosqueal");
|
||||
case WINDOWS -> appData != null && !appData.isBlank()
|
||||
? Path.of(appData, "autosqueal")
|
||||
: Path.of(home, ".autosqueal");
|
||||
case LINUX -> xdgConfigHome != null && !xdgConfigHome.isBlank()
|
||||
? Path.of(xdgConfigHome, "autosqueal")
|
||||
: Path.of(home, ".config", "autosqueal");
|
||||
default -> Path.of(home, ".autosqueal");
|
||||
};
|
||||
}
|
||||
|
||||
public static OperatingSystem fromOsName(String osName) {
|
||||
String normalizedOsName = osName.toLowerCase(Locale.ENGLISH);
|
||||
if (normalizedOsName.contains("win")) {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package wtf.beatrice.autosqueal.config;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ConfigStoreTest
|
||||
{
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private ConfigStore store;
|
||||
|
||||
@BeforeEach
|
||||
void createStore() {
|
||||
store = new ConfigStore(tempDir.resolve("autosqueal.properties"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingFileYieldsTheDefaults() {
|
||||
AutoSquealConfig config = store.load();
|
||||
|
||||
assertEquals(30, config.getAwayThresholdSeconds());
|
||||
assertEquals(10, config.getMoveIntervalSeconds());
|
||||
assertEquals(2, config.getStepDelayMilliseconds());
|
||||
assertTrue(config.isClickEnabled());
|
||||
assertEquals(5, config.getClickEveryNMoves());
|
||||
assertEquals(ScreenArea.PRIMARY, config.getMovementScreen());
|
||||
assertTrue(config.isStartAutomatically());
|
||||
assertTrue(config.isHotkeyEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void savedSettingsLoadBack() {
|
||||
AutoSquealConfig config = new AutoSquealConfig();
|
||||
config.setAwayThresholdSeconds(45);
|
||||
config.setMoveIntervalSeconds(20);
|
||||
config.setStepDelayMilliseconds(4);
|
||||
config.setClickEnabled(false);
|
||||
config.setClickEveryNMoves(7);
|
||||
config.setMovementScreen(ScreenArea.ALL);
|
||||
config.setStartAutomatically(false);
|
||||
config.setHotkeyEnabled(false);
|
||||
|
||||
store.save(config);
|
||||
AutoSquealConfig loaded = store.load();
|
||||
|
||||
assertEquals(45, loaded.getAwayThresholdSeconds());
|
||||
assertEquals(20, loaded.getMoveIntervalSeconds());
|
||||
assertEquals(4, loaded.getStepDelayMilliseconds());
|
||||
assertFalse(loaded.isClickEnabled());
|
||||
assertEquals(7, loaded.getClickEveryNMoves());
|
||||
assertEquals(ScreenArea.ALL, loaded.getMovementScreen());
|
||||
assertFalse(loaded.isStartAutomatically());
|
||||
assertFalse(loaded.isHotkeyEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void outOfRangeValuesAreClamped() throws IOException {
|
||||
writeFile(ConfigStore.KEY_AWAY_THRESHOLD_SECONDS + "=0",
|
||||
ConfigStore.KEY_MOVE_INTERVAL_SECONDS + "=99999999",
|
||||
ConfigStore.KEY_STEP_DELAY_MILLISECONDS + "=-3",
|
||||
ConfigStore.KEY_CLICK_EVERY_N_MOVES + "=1000");
|
||||
|
||||
AutoSquealConfig config = store.load();
|
||||
|
||||
assertEquals(5, config.getAwayThresholdSeconds());
|
||||
assertEquals(3600, config.getMoveIntervalSeconds());
|
||||
assertEquals(1, config.getStepDelayMilliseconds());
|
||||
assertEquals(100, config.getClickEveryNMoves());
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidValuesKeepTheDefaults() throws IOException {
|
||||
writeFile(ConfigStore.KEY_AWAY_THRESHOLD_SECONDS + "=soon",
|
||||
ConfigStore.KEY_CLICK_ENABLED + "=banana",
|
||||
ConfigStore.KEY_MOVEMENT_SCREEN + "=THE_MOON",
|
||||
ConfigStore.KEY_MOVE_INTERVAL_SECONDS + "=15");
|
||||
|
||||
AutoSquealConfig config = store.load();
|
||||
|
||||
assertEquals(30, config.getAwayThresholdSeconds());
|
||||
assertTrue(config.isClickEnabled());
|
||||
assertEquals(ScreenArea.PRIMARY, config.getMovementScreen());
|
||||
// the one valid value still loads
|
||||
assertEquals(15, config.getMoveIntervalSeconds());
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsSettersClampToo() {
|
||||
AutoSquealConfig config = new AutoSquealConfig();
|
||||
|
||||
config.setAwayThresholdSeconds(-10);
|
||||
assertEquals(5, config.getAwayThresholdSeconds());
|
||||
|
||||
config.setAwayThresholdSeconds(10_000);
|
||||
assertEquals(600, config.getAwayThresholdSeconds());
|
||||
|
||||
config.setMovementScreen(null);
|
||||
assertEquals(ScreenArea.PRIMARY, config.getMovementScreen());
|
||||
}
|
||||
|
||||
@Test
|
||||
void savingNeverLeavesTempFilesBehind() throws IOException {
|
||||
store.save(new AutoSquealConfig());
|
||||
|
||||
assertTrue(Files.isRegularFile(tempDir.resolve("autosqueal.properties")));
|
||||
try (var files = Files.list(tempDir)) {
|
||||
assertEquals(1, files.count());
|
||||
}
|
||||
}
|
||||
|
||||
private void writeFile(String... lines) throws IOException {
|
||||
Files.write(tempDir.resolve("autosqueal.properties"), List.of(lines));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user