diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/common/preferences/AccountPreferences.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/common/preferences/AccountPreferences.kt index 2a149788..12c35ea6 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/common/preferences/AccountPreferences.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/common/preferences/AccountPreferences.kt @@ -5,17 +5,16 @@ package io.askimo.ui.common.preferences import io.askimo.core.logging.logger +import io.askimo.core.util.AskimoHome import java.time.Duration import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter -import java.util.prefs.Preferences /** * Per-account preferences scoped to a specific authenticated user. * - * Stored under the Java Preferences path: - * `io/askimo/app/accounts//` + * Stored under `/prefs/accounts/.properties`. * * Keeps sync cursors, default model, plan inputs, and user-behavior tracking * fully isolated between users on the same machine. These are NOT reset when @@ -23,24 +22,21 @@ import java.util.prefs.Preferences * * Obtain via [AccountPreferences.forAccount]. */ -class AccountPreferences private constructor(private val prefs: Preferences) { +class AccountPreferences private constructor(private val prefs: PropertyFilePreferences) { companion object { - private const val ROOT_NODE = "io/askimo/app/accounts" - private const val MINIMUM_DAYS_BEFORE_PROMPT = 7L private const val MINIMUM_MESSAGES_BEFORE_PROMPT = 20 private const val SNOOZE_DAYS = 30L /** * Returns [AccountPreferences] scoped to [accountId] (typically the user's email, - * lowercased and sanitized for use as a Preferences path segment). - * Safe to call multiple times — Preferences nodes are singletons per path. + * lowercased and sanitized for use as a file name). */ fun forAccount(accountId: String): AccountPreferences { val safeId = accountId.trim().lowercase() .replace(Regex("[^a-z0-9._@-]"), "_") - return AccountPreferences(Preferences.userRoot().node("$ROOT_NODE/$safeId")) + return AccountPreferences(PropertyFilePreferences(AskimoHome.base().resolve("prefs/accounts/$safeId.properties"))) } /** @@ -48,7 +44,7 @@ class AccountPreferences private constructor(private val prefs: Preferences) { * that must persist even before the user logs in (e.g. first-use date, * launch count, analytics consent). */ - fun device(): AccountPreferences = AccountPreferences(Preferences.userRoot().node("$ROOT_NODE/__device__")) + fun device(): AccountPreferences = AccountPreferences(PropertyFilePreferences(AskimoHome.base().resolve("prefs/accounts/__device__.properties"))) } private val log = logger() @@ -61,26 +57,17 @@ class AccountPreferences private constructor(private val prefs: Preferences) { .onFailure { log.warn("AccountPreferences.get failed key='$key': ${it.message}") } .getOrDefault(default) - private fun safePutBoolean(key: String, value: Boolean) = runCatching { prefs.putBoolean(key, value) } - .onFailure { log.warn("AccountPreferences.putBoolean failed key='$key': ${it.message}") } + private fun safePutBoolean(key: String, value: Boolean) = safePut(key, value.toString()) - private fun safeGetBoolean(key: String, default: Boolean): Boolean = runCatching { prefs.getBoolean(key, default) } - .onFailure { log.warn("AccountPreferences.getBoolean failed key='$key': ${it.message}") } - .getOrDefault(default) + private fun safeGetBoolean(key: String, default: Boolean): Boolean = safeGet(key, null)?.toBooleanStrictOrNull() ?: default - private fun safePutInt(key: String, value: Int) = runCatching { prefs.putInt(key, value) } - .onFailure { log.warn("AccountPreferences.putInt failed key='$key': ${it.message}") } + private fun safePutInt(key: String, value: Int) = safePut(key, value.toString()) - private fun safeGetInt(key: String, default: Int): Int = runCatching { prefs.getInt(key, default) } - .onFailure { log.warn("AccountPreferences.getInt failed key='$key': ${it.message}") } - .getOrDefault(default) + private fun safeGetInt(key: String, default: Int): Int = safeGet(key, null)?.toIntOrNull() ?: default - private fun safePutLong(key: String, value: Long) = runCatching { prefs.putLong(key, value) } - .onFailure { log.warn("AccountPreferences.putLong failed key='$key': ${it.message}") } + private fun safePutLong(key: String, value: Long) = safePut(key, value.toString()) - private fun safeGetLong(key: String, default: Long): Long = runCatching { prefs.getLong(key, default) } - .onFailure { log.warn("AccountPreferences.getLong failed key='$key': ${it.message}") } - .getOrDefault(default) + private fun safeGetLong(key: String, default: Long): Long = safeGet(key, null)?.toLongOrNull() ?: default // ── Conversation sync ───────────────────────────────────────────────────── @@ -132,11 +119,7 @@ class AccountPreferences private constructor(private val prefs: Preferences) { } safePut( "plan.inputs.$planId", - if (raw.length > Preferences.MAX_VALUE_LENGTH) { - raw.substring(0, Preferences.MAX_VALUE_LENGTH) - } else { - raw - }, + raw, ) } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/common/preferences/ApplicationPreferences.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/common/preferences/ApplicationPreferences.kt index 40add881..3c1e767b 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/common/preferences/ApplicationPreferences.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/common/preferences/ApplicationPreferences.kt @@ -5,44 +5,38 @@ package io.askimo.ui.common.preferences import io.askimo.core.logging.logger -import java.util.prefs.Preferences -import kotlin.jvm.java +import io.askimo.core.util.AskimoHome /** * Resettable application preferences — UI layout, onboarding state, and device identity. */ object ApplicationPreferences { - private val prefs = Preferences.userNodeForPackage(ApplicationPreferences::class.java) private val log = logger() + private fun prefs() = PropertyFilePreferences(AskimoHome.base().resolve("prefs/app.properties")) + // ── Safe preference accessors ───────────────────────────────────────────── private fun safePut(key: String, value: String) { - runCatching { prefs.put(key, value) } + runCatching { prefs().put(key, value) } .onFailure { log.warn("Preferences.put failed for key='$key': ${it.message}") } } - private fun safeGet(key: String, default: String?): String? = runCatching { prefs.get(key, default) } + private fun safeGet(key: String, default: String?): String? = runCatching { prefs().get(key, default) } .onFailure { log.warn("Preferences.get failed for key='$key': ${it.message}") } .getOrDefault(default) private fun safePutBoolean(key: String, value: Boolean) { - runCatching { prefs.putBoolean(key, value) } - .onFailure { log.warn("Preferences.putBoolean failed for key='$key': ${it.message}") } + safePut(key, value.toString()) } - private fun safeGetBoolean(key: String, default: Boolean): Boolean = runCatching { prefs.getBoolean(key, default) } - .onFailure { log.warn("Preferences.getBoolean failed for key='$key': ${it.message}") } - .getOrDefault(default) + private fun safeGetBoolean(key: String, default: Boolean): Boolean = safeGet(key, null)?.toBooleanStrictOrNull() ?: default private fun safePutInt(key: String, value: Int) { - runCatching { prefs.putInt(key, value) } - .onFailure { log.warn("Preferences.putInt failed for key='$key': ${it.message}") } + safePut(key, value.toString()) } - private fun safeGetInt(key: String, default: Int): Int = runCatching { prefs.getInt(key, default) } - .onFailure { log.warn("Preferences.getInt failed for key='$key': ${it.message}") } - .getOrDefault(default) + private fun safeGetInt(key: String, default: Int): Int = safeGet(key, null)?.toIntOrNull() ?: default // ============================================================ // TUTORIAL & ONBOARDING (resettable — re-shows on clearAll) @@ -136,7 +130,7 @@ object ApplicationPreferences { * update dismissals) are stored in [AccountPreferences] and are NOT affected. */ fun clearAll() { - runCatching { prefs.clear() } + runCatching { prefs().clear() } .onFailure { log.warn("Preferences.clear() failed: ${it.message}") } log.info("All resettable application preferences cleared") } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/common/preferences/PropertyFilePreferences.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/common/preferences/PropertyFilePreferences.kt new file mode 100644 index 00000000..352cb023 --- /dev/null +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/common/preferences/PropertyFilePreferences.kt @@ -0,0 +1,70 @@ +/* SPDX-License-Identifier: AGPLv3 + * + * Copyright (c) 2026 Askimo + */ +package io.askimo.ui.common.preferences + +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.Properties +import java.util.concurrent.ConcurrentHashMap + +/** Small thread-safe preference store backed by a plain Java properties file. */ +internal class PropertyFilePreferences(private val path: Path) { + private val lock = locks.computeIfAbsent(path.toAbsolutePath().normalize()) { Any() } + + fun get(key: String, default: String?): String? = synchronized(lock) { + load().getProperty(key, default) + } + + fun put(key: String, value: String) = synchronized(lock) { + val properties = load() + properties.setProperty(key, value) + save(properties) + } + + fun remove(key: String) = synchronized(lock) { + val properties = load() + properties.remove(key) + save(properties) + } + + fun clear() = synchronized(lock) { + save(Properties()) + } + + private fun load(): Properties = Properties().also { properties -> + if (Files.isRegularFile(path)) { + Files.newInputStream(path).buffered().use(properties::load) + } + } + + private fun save(properties: Properties) { + val parent = path.parent + Files.createDirectories(parent) + val temporary = Files.createTempFile(parent, ".${path.fileName}", ".tmp") + try { + Files.newOutputStream(temporary).buffered().use { output -> + properties.store(output, "Askimo preferences") + } + try { + Files.move( + temporary, + path, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING) + } + } finally { + Files.deleteIfExists(temporary) + } + } + + private companion object { + val locks = ConcurrentHashMap() + } +} diff --git a/desktop-shared/src/test/kotlin/io/askimo/ui/common/preferences/PreferencesStorageTest.kt b/desktop-shared/src/test/kotlin/io/askimo/ui/common/preferences/PreferencesStorageTest.kt new file mode 100644 index 00000000..132e6afa --- /dev/null +++ b/desktop-shared/src/test/kotlin/io/askimo/ui/common/preferences/PreferencesStorageTest.kt @@ -0,0 +1,53 @@ +/* SPDX-License-Identifier: AGPLv3 + * + * Copyright (c) 2026 Askimo + */ +package io.askimo.ui.common.preferences + +import io.askimo.core.util.AskimoHome +import java.nio.file.Files +import java.util.Properties +import kotlin.io.path.inputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PreferencesStorageTest { + @Test + fun `application preferences use Askimo home property file`() { + val root = Files.createTempDirectory("askimo-app-prefs") + + AskimoHome.withTestBase(root).use { + ApplicationPreferences.clearAll() + ApplicationPreferences.setProjectSidePanelWidth(512) + + val file = AskimoHome.base().resolve("prefs/app.properties") + assertTrue(Files.isRegularFile(file)) + assertEquals("512", load(file).getProperty("ui.project_side_panel_width")) + assertEquals(512, ApplicationPreferences.getProjectSidePanelWidth()) + } + } + + @Test + fun `account and device preferences use isolated property files`() { + val root = Files.createTempDirectory("askimo-account-prefs") + + AskimoHome.withTestBase(root).use { + val account = AccountPreferences.forAccount("User Name@example.com") + account.saveLastSyncSeq(42) + AccountPreferences.device().setHardwareAccelerationEnabled(false) + + val accountsDir = AskimoHome.base().resolve("prefs/accounts") + val accountFile = accountsDir.resolve("user_name@example.com.properties") + val deviceFile = accountsDir.resolve("__device__.properties") + assertEquals("42", load(accountFile).getProperty("sync.last_seq")) + assertEquals("false", load(deviceFile).getProperty("perf.hardware_acceleration_enabled")) + assertFalse(AccountPreferences.device().getHardwareAccelerationEnabled()) + } + } + + private fun load(path: java.nio.file.Path): Properties = Properties().also { properties -> + path.inputStream().use(properties::load) + } +}