From 8b869fe4e92d74989ce9b436ec66ee0140efad77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beatrice=20Dellac=C3=A0?= Date: Thu, 1 Oct 2026 23:30:19 +0200 Subject: [PATCH] add a persistent settings store --- .../autosqueal/config/AutoSquealConfig.java | 113 ++++++++++++ .../autosqueal/config/ConfigStore.java | 163 ++++++++++++++++++ .../autosqueal/config/ScreenArea.java | 13 ++ .../beatrice/autosqueal/util/SystemUtil.java | 22 +++ .../autosqueal/config/ConfigStoreTest.java | 125 ++++++++++++++ 5 files changed, 436 insertions(+) create mode 100644 src/main/java/wtf/beatrice/autosqueal/config/AutoSquealConfig.java create mode 100644 src/main/java/wtf/beatrice/autosqueal/config/ConfigStore.java create mode 100644 src/main/java/wtf/beatrice/autosqueal/config/ScreenArea.java create mode 100644 src/test/java/wtf/beatrice/autosqueal/config/ConfigStoreTest.java diff --git a/src/main/java/wtf/beatrice/autosqueal/config/AutoSquealConfig.java b/src/main/java/wtf/beatrice/autosqueal/config/AutoSquealConfig.java new file mode 100644 index 0000000..3413e05 --- /dev/null +++ b/src/main/java/wtf/beatrice/autosqueal/config/AutoSquealConfig.java @@ -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)); + } +} \ No newline at end of file diff --git a/src/main/java/wtf/beatrice/autosqueal/config/ConfigStore.java b/src/main/java/wtf/beatrice/autosqueal/config/ConfigStore.java new file mode 100644 index 0000000..65a5742 --- /dev/null +++ b/src/main/java/wtf/beatrice/autosqueal/config/ConfigStore.java @@ -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 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 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 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 > void readEnum(Properties properties, String key, Class type, Consumer 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); + } + } +} \ No newline at end of file diff --git a/src/main/java/wtf/beatrice/autosqueal/config/ScreenArea.java b/src/main/java/wtf/beatrice/autosqueal/config/ScreenArea.java new file mode 100644 index 0000000..c697f1d --- /dev/null +++ b/src/main/java/wtf/beatrice/autosqueal/config/ScreenArea.java @@ -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 +} \ No newline at end of file diff --git a/src/main/java/wtf/beatrice/autosqueal/util/SystemUtil.java b/src/main/java/wtf/beatrice/autosqueal/util/SystemUtil.java index 3fe43d0..9b99fdf 100644 --- a/src/main/java/wtf/beatrice/autosqueal/util/SystemUtil.java +++ b/src/main/java/wtf/beatrice/autosqueal/util/SystemUtil.java @@ -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")) { diff --git a/src/test/java/wtf/beatrice/autosqueal/config/ConfigStoreTest.java b/src/test/java/wtf/beatrice/autosqueal/config/ConfigStoreTest.java new file mode 100644 index 0000000..4f04210 --- /dev/null +++ b/src/test/java/wtf/beatrice/autosqueal/config/ConfigStoreTest.java @@ -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)); + } +} \ No newline at end of file