add a persistent settings store

This commit is contained in:
bea
2026-10-01 23:30:41 +02:00
parent a3c750c479
commit 8b869fe4e9
5 changed files with 436 additions and 0 deletions
@@ -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));
}
}