diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 73f9311..f1f6bd9 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -112,6 +112,7 @@ val buildWithProjectModules by tasks.registering(Jar::class) {
// include contents of that jar, but drop the generated BuildConfig to avoid conflicts
from({ project.zipTree(jarProvider.flatMap { it.archiveFile }) }) {
exclude("com/spoiligaming/explorer/build/BuildConfig.class")
+ exclude("com/spoiligaming/explorer/build/PlatformDirs.class")
}
}
}
diff --git a/app/src/main/kotlin/com/spoiligaming/explorer/LogStorage.kt b/app/src/main/kotlin/com/spoiligaming/explorer/LogStorage.kt
deleted file mode 100644
index 422e262..0000000
--- a/app/src/main/kotlin/com/spoiligaming/explorer/LogStorage.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * This file is part of Server List Explorer.
- * Copyright (C) 2025 SpoilerRules
- *
- * Server List Explorer is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * Server List Explorer is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with Server List Explorer. If not, see .
- */
-
-package com.spoiligaming.explorer
-
-import com.spoiligaming.explorer.build.BuildConfig
-import java.io.File
-
-internal object LogStorage {
- private const val APP_DIR = "ServerListExplorer"
-
- private val isPortableWindows
- get() = isWindows && BuildConfig.DISTRIBUTION.contains("portable", ignoreCase = true)
-
- private val osName
- get() = System.getProperty("os.name")?.lowercase().orEmpty()
-
- private val isWindows
- get() = osName.contains("win")
-
- private val isMac
- get() = osName.contains("mac")
-
- val logsDir
- get() =
- if (isPortableWindows) {
- File("logs")
- } else {
- when {
- isWindows -> windowsLogs()
- isMac -> macLogs()
- else -> linuxLogs()
- }
- }
-
- private fun windowsLogs(): File {
- val local = System.getenv("LOCALAPPDATA")?.takeIf { it.isNotBlank() }
- val base =
- local?.let { File(it, APP_DIR) }
- ?: File(System.getProperty("user.home"), "AppData/Local/$APP_DIR")
- return base.resolve("logs")
- }
-
- private fun macLogs() = File(System.getProperty("user.home"), "Library/Logs/$APP_DIR")
-
- private fun linuxLogs(): File {
- val state = System.getenv("XDG_STATE_HOME")?.takeIf { it.isNotBlank() }
- val base =
- state?.let { File(it, APP_DIR) }
- ?: File(System.getProperty("user.home"), ".local/state/$APP_DIR")
- return base.resolve("logs")
- }
-}
diff --git a/app/src/main/kotlin/com/spoiligaming/explorer/Main.kt b/app/src/main/kotlin/com/spoiligaming/explorer/Main.kt
index 7ff2258..9994c2d 100644
--- a/app/src/main/kotlin/com/spoiligaming/explorer/Main.kt
+++ b/app/src/main/kotlin/com/spoiligaming/explorer/Main.kt
@@ -27,7 +27,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging
fun main(args: Array) {
val env = if (System.getProperty("env") == "dev") "dev" else "prod"
- val logsDir = LogStorage.logsDir
+ val logsDir = StartupAppDataMigration.migrateBeforeLogging()
logsDir.mkdirs()
System.setProperty("log4j2.configurationFile", "log4j2-$env.xml")
System.setProperty("app.logs.dir", logsDir.absolutePath)
diff --git a/app/src/main/kotlin/com/spoiligaming/explorer/StartupAppDataMigration.kt b/app/src/main/kotlin/com/spoiligaming/explorer/StartupAppDataMigration.kt
new file mode 100644
index 0000000..225229c
--- /dev/null
+++ b/app/src/main/kotlin/com/spoiligaming/explorer/StartupAppDataMigration.kt
@@ -0,0 +1,221 @@
+/*
+ * This file is part of Server List Explorer.
+ * Copyright (C) 2026 SpoilerRules
+ *
+ * Server List Explorer is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Server List Explorer is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Server List Explorer. If not, see .
+ */
+
+package com.spoiligaming.explorer
+
+import com.spoiligaming.explorer.settings.util.AppStoragePaths
+import com.spoiligaming.explorer.util.FirstRunManager
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+
+internal object StartupAppDataMigration {
+ private const val RETRY_COUNT = 40
+ private const val RETRY_DELAY_MS = 150L
+
+ private data class MigrationSpec(
+ val name: String,
+ val sourceDir: File,
+ val targetDir: File,
+ val sourceFilter: (File) -> Boolean = { true },
+ val deleteSourceDirectoryWhenFinished: Boolean = true,
+ )
+
+ fun migrateBeforeLogging(): File {
+ if (AppStoragePaths.isPortableWindows) {
+ return AppStoragePaths.logsDir
+ }
+
+ val specs =
+ listOf(
+ MigrationSpec(
+ name = "LegacyLocalConfigJsonToPlatformSettings",
+ sourceDir = AppStoragePaths.legacyConfigDir,
+ targetDir = AppStoragePaths.platformSettingsDir,
+ sourceFilter = { it.isFile && it.extension.equals("json", ignoreCase = true) },
+ deleteSourceDirectoryWhenFinished = false,
+ ),
+ MigrationSpec(
+ name = "LegacyLocalConfigMarkerToPlatformConfigRoot",
+ sourceDir = AppStoragePaths.legacyConfigDir,
+ targetDir = AppStoragePaths.platformConfigRootDir,
+ sourceFilter = { it.isFile && it.name.equals(FirstRunManager.MARKER_NAME, ignoreCase = true) },
+ deleteSourceDirectoryWhenFinished = false,
+ ),
+ MigrationSpec(
+ name = "LegacyLocalConfigRemainderToPlatformConfigRoot",
+ sourceDir = AppStoragePaths.legacyConfigDir,
+ targetDir = AppStoragePaths.platformConfigRootDir,
+ sourceFilter = {
+ it.isFile &&
+ !it.extension.equals("json", ignoreCase = true) &&
+ !it.name.equals(FirstRunManager.MARKER_NAME, ignoreCase = true)
+ },
+ ),
+ MigrationSpec(
+ name = "LegacyLocalLogsToPlatformLogs",
+ sourceDir = AppStoragePaths.legacyLogsDir,
+ targetDir = AppStoragePaths.platformLogsDir,
+ ),
+ MigrationSpec(
+ name = "LegacyNamedPlatformConfigRootToPreferredConfigRoot",
+ sourceDir = AppStoragePaths.legacyNamedPlatformConfigRootDir,
+ targetDir = AppStoragePaths.platformConfigRootDir,
+ ),
+ MigrationSpec(
+ name = "LegacyNamedPlatformLogsRootToPreferredLogsRoot",
+ sourceDir = AppStoragePaths.legacyNamedPlatformLogsRootDir,
+ targetDir = AppStoragePaths.platformLogsRootDir,
+ ),
+ )
+
+ specs.forEach(::runMigration)
+ ensureDirectoryExists(AppStoragePaths.platformLogsDir, "platform logs")
+ return AppStoragePaths.platformLogsDir
+ }
+
+ private fun runMigration(spec: MigrationSpec) {
+ val source = spec.sourceDir
+ if (!source.exists() || !source.isDirectory) return
+ if (source.absoluteFile == spec.targetDir.absoluteFile) return
+
+ println("Starting migration ${spec.name}: ${source.absolutePath} -> ${spec.targetDir.absolutePath}")
+
+ val merged =
+ runCatching {
+ ensureDirectoryExists(spec.targetDir, spec.name)
+ mergeDirectory(source, spec.targetDir, spec.sourceFilter)
+ true
+ }.getOrElse { e ->
+ System.err.println(
+ "Migration ${spec.name} failed while copying to ${spec.targetDir.absolutePath}: ${e.message.orEmpty()}",
+ )
+ false
+ }
+
+ if (!merged) return
+
+ if (!spec.deleteSourceDirectoryWhenFinished) {
+ tryDeleteDirectoryIfEmpty(source, spec.name)
+ return
+ }
+
+ if (tryDeleteRecursively(source)) {
+ println("Migration ${spec.name} completed and removed ${source.absolutePath}")
+ return
+ }
+
+ System.err.println(
+ "Migration ${spec.name} copied files but could not delete ${source.absolutePath}. " +
+ "This is likely a temporary file lock.",
+ )
+ }
+
+ private fun ensureDirectoryExists(
+ dir: File,
+ context: String,
+ ) = runCatching { Files.createDirectories(dir.toPath()) }.onFailure { e ->
+ System.err.println("Failed to create directory for $context at ${dir.absolutePath}: ${e.message.orEmpty()}")
+ }
+
+ private fun mergeDirectory(
+ sourceDir: File,
+ targetDir: File,
+ sourceFilter: (File) -> Boolean,
+ ) = sourceDir
+ .walkTopDown()
+ .forEach { source ->
+ if (source.absoluteFile == sourceDir.absoluteFile) return@forEach
+ if (!sourceFilter(source)) return@forEach
+
+ val relativePath = sourceDir.toPath().relativize(source.toPath())
+ val destination = targetDir.toPath().resolve(relativePath)
+
+ if (source.isDirectory) {
+ Files.createDirectories(destination)
+ return@forEach
+ }
+
+ if (!destination.toFile().exists()) {
+ moveOrCopyFile(source, destination.toFile())
+ return@forEach
+ }
+
+ if (source.exists()) {
+ runCatching { Files.deleteIfExists(source.toPath()) }
+ .onFailure { e ->
+ System.err.println(
+ "Could not remove duplicate file ${source.absolutePath}: ${e.message.orEmpty()}",
+ )
+ }
+ }
+ }
+
+ private fun moveOrCopyFile(
+ source: File,
+ destination: File,
+ ) = runCatching {
+ destination.parentFile?.let { Files.createDirectories(it.toPath()) }
+ Files.move(source.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING)
+ }.recoverCatching {
+ destination.parentFile?.let { Files.createDirectories(it.toPath()) }
+ Files.copy(source.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING)
+ Files.deleteIfExists(source.toPath())
+ }.onFailure { e ->
+ System.err.println(
+ "Could not migrate ${source.absolutePath} to ${destination.absolutePath}: ${e.message.orEmpty()}",
+ )
+ }
+
+ private fun tryDeleteDirectoryIfEmpty(
+ dir: File,
+ context: String,
+ ) {
+ if (!dir.exists() || !dir.isDirectory) return
+ val entries = dir.listFiles().orEmpty()
+ if (entries.isNotEmpty()) return
+
+ val deleted = tryDeleteRecursively(dir)
+ if (!deleted) {
+ System.err.println("Could not delete empty directory after $context: ${dir.absolutePath}")
+ }
+ }
+
+ private fun tryDeleteRecursively(file: File): Boolean {
+ if (!file.exists()) return true
+
+ repeat(RETRY_COUNT) {
+ if (deleteRecursivelySinglePass(file)) {
+ return true
+ }
+ Thread.sleep(RETRY_DELAY_MS)
+ }
+
+ return !file.exists()
+ }
+
+ private fun deleteRecursivelySinglePass(file: File): Boolean {
+ var allDeleted = true
+ file.walkBottomUp().forEach { entry ->
+ if (entry.exists() && !entry.delete()) {
+ allDeleted = false
+ }
+ }
+ return allDeleted
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index a9f8e71..cdc0578 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,6 +1,6 @@
/*
* This file is part of Server List Explorer.
- * Copyright (C) 2025 SpoilerRules
+ * Copyright (C) 2025-2026 SpoilerRules
*
* Server List Explorer is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -145,6 +145,11 @@ allprojects {
buildConfigField("VERSION", appVersion)
buildConfigField("DISTRIBUTION", appDistribution)
+
+ forClass("PlatformDirs") {
+ buildConfigField("WINDOWS_MACOS_APP_DIR_NAME", "Server List Explorer")
+ buildConfigField("LINUX_APP_DIR_NAME", "server-list-explorer")
+ }
}
}
}
diff --git a/core/src/main/kotlin/com/spoiligaming/explorer/serverlist/bookmarks/ServerListFileBookmarksManager.kt b/core/src/main/kotlin/com/spoiligaming/explorer/serverlist/bookmarks/ServerListFileBookmarksManager.kt
index feffc87..9d7adc7 100644
--- a/core/src/main/kotlin/com/spoiligaming/explorer/serverlist/bookmarks/ServerListFileBookmarksManager.kt
+++ b/core/src/main/kotlin/com/spoiligaming/explorer/serverlist/bookmarks/ServerListFileBookmarksManager.kt
@@ -21,8 +21,8 @@
package com.spoiligaming.explorer.serverlist.bookmarks
import com.spoiligaming.explorer.multiplayer.repository.ServerListRepository
+import com.spoiligaming.explorer.settings.util.AppStoragePaths
import com.spoiligaming.explorer.settings.util.LegacyMultiplayerSettingsReader
-import com.spoiligaming.explorer.settings.util.SettingsStorage
import com.spoiligaming.explorer.util.canonicalize
import com.spoiligaming.explorer.util.serverListBookmarkKey
import io.github.oshai.kotlinlogging.KotlinLogging
@@ -40,7 +40,7 @@ import kotlin.uuid.Uuid
object ServerListFileBookmarksManager {
private val mutex = Mutex()
- private val store = ServerListFileBookmarksStore(SettingsStorage.platformConfigDir.toPath())
+ private val store = ServerListFileBookmarksStore(AppStoragePaths.platformSettingsDir.toPath())
private val _entries = MutableStateFlow>(emptyList())
val entries = _entries.asStateFlow()
private val _activePath = MutableStateFlow(null)
diff --git a/core/src/main/kotlin/com/spoiligaming/explorer/util/FirstRunManager.kt b/core/src/main/kotlin/com/spoiligaming/explorer/util/FirstRunManager.kt
index 27c9d73..f85248a 100644
--- a/core/src/main/kotlin/com/spoiligaming/explorer/util/FirstRunManager.kt
+++ b/core/src/main/kotlin/com/spoiligaming/explorer/util/FirstRunManager.kt
@@ -1,6 +1,6 @@
/*
* This file is part of Server List Explorer.
- * Copyright (C) 2025 SpoilerRules
+ * Copyright (C) 2025-2026 SpoilerRules
*
* Server List Explorer is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -18,45 +18,17 @@
package com.spoiligaming.explorer.util
-import com.spoiligaming.explorer.build.BuildConfig
+import com.spoiligaming.explorer.settings.util.AppStoragePaths
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
-import java.io.File
import java.nio.file.Files
-import java.nio.file.Path
-import java.nio.file.StandardCopyOption
object FirstRunManager {
- private const val APP_DIR = "ServerListExplorer"
- private const val MARKER_NAME = "first-run.marker"
-
- private val isPortableWindows
- get() =
- OSUtils.isWindows &&
- BuildConfig.DISTRIBUTION.contains("portable", ignoreCase = true)
-
- private val platformDir
- get() =
- when {
- OSUtils.isWindows -> windowsDir()
- OSUtils.isMacOS -> macDir()
- else -> linuxDir()
- }
-
- private val legacyDir = File("config")
-
- val configDir
- get() =
- if (isPortableWindows) {
- legacyDir
- } else {
- migrateLegacyMarkerIfNeeded()
- platformDir
- }
+ const val MARKER_NAME = "first-run.marker"
private val markerFile
- get() = configDir.resolve(MARKER_NAME)
+ get() = AppStoragePaths.firstRunConfigDir.resolve(MARKER_NAME)
private val _isFirstRun = MutableStateFlow(!markerFile.exists())
val isFirstRun = _isFirstRun.asStateFlow()
@@ -65,7 +37,7 @@ object FirstRunManager {
if (_isFirstRun.value.not()) return // already handled
runCatching {
- Files.createDirectories(configDir.toPath())
+ Files.createDirectories(AppStoragePaths.firstRunConfigDir.toPath())
markerFile.createNewFile()
}.onFailure { e ->
logger.error(e) { "Failed to create first-run marker at ${markerFile.absolutePath}" }
@@ -74,70 +46,6 @@ object FirstRunManager {
logger.info { "First-run marker created at: ${markerFile.absolutePath}" }
}
}
-
- private fun migrateLegacyMarkerIfNeeded() {
- val legacyMarker = legacyDir.resolve(MARKER_NAME)
- if (!legacyMarker.exists()) return
-
- val target = platformDir
- val newMarker = target.resolve(MARKER_NAME)
-
- if (newMarker.exists()) {
- runCatching { legacyMarker.delete() }
- return
- }
-
- runCatching { Files.createDirectories(target.toPath()) }
- .onFailure { e ->
- logger.error(e) {
- "Could not create directory for first-run marker: ${target.absolutePath}"
- }
- return
- }
-
- val moved =
- moveOrCopyFile(
- legacyMarker.toPath(),
- newMarker.toPath(),
- )
-
- if (moved) {
- logger.info { "Migrated first-run marker to ${newMarker.absolutePath}" }
- }
- }
-
- private fun moveOrCopyFile(
- src: Path,
- dest: Path,
- ) = runCatching {
- Files.createDirectories(dest.parent)
- Files.move(src, dest, StandardCopyOption.REPLACE_EXISTING)
- true
- }.recoverCatching {
- Files.copy(src, dest, StandardCopyOption.REPLACE_EXISTING)
- Files.deleteIfExists(src)
- true
- }.onFailure { e ->
- logger.error(e) { "Failed to migrate first-run marker from $src to $dest" }
- }.getOrDefault(false)
-
- private fun windowsDir(): File {
- val appData = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
- val base =
- appData?.let { File(it, APP_DIR) }
- ?: File(System.getProperty("user.home"), "AppData/Roaming/$APP_DIR")
- return base
- }
-
- private fun macDir() = File(System.getProperty("user.home"), "Library/Application Support/$APP_DIR")
-
- private fun linuxDir(): File {
- val xdg = System.getenv("XDG_CONFIG_HOME")?.takeIf { it.isNotBlank() }
- val base =
- xdg?.let { File(it, APP_DIR) }
- ?: File(System.getProperty("user.home"), ".config/$APP_DIR")
- return base
- }
}
private val logger = KotlinLogging.logger {}
diff --git a/settings/src/main/kotlin/com/spoiligaming/explorer/settings/util/AppStoragePaths.kt b/settings/src/main/kotlin/com/spoiligaming/explorer/settings/util/AppStoragePaths.kt
new file mode 100644
index 0000000..7b3ab05
--- /dev/null
+++ b/settings/src/main/kotlin/com/spoiligaming/explorer/settings/util/AppStoragePaths.kt
@@ -0,0 +1,112 @@
+/*
+ * This file is part of Server List Explorer.
+ * Copyright (C) 2026 SpoilerRules
+ *
+ * Server List Explorer is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Server List Explorer is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Server List Explorer. If not, see .
+ */
+
+package com.spoiligaming.explorer.settings.util
+
+import com.spoiligaming.explorer.build.BuildConfig
+import com.spoiligaming.explorer.build.PlatformDirs
+import java.io.File
+
+object AppStoragePaths {
+ private const val LEGACY_CONFIG_DIR_NAME = "config"
+ private const val LOGS_DIR_NAME = "logs"
+ private const val SETTINGS_DIR_NAME = "config"
+ const val LEGACY_APP_DIR_NAME = "ServerListExplorer"
+
+ private val osName = System.getProperty("os.name")?.lowercase().orEmpty()
+ private val isMac = osName.contains("mac")
+ private val isWindows = osName.contains("win")
+ val isPortableWindows = isWindows && BuildConfig.DISTRIBUTION.contains("portable", ignoreCase = true)
+
+ val preferredAppDirName =
+ if (isWindows || isMac) {
+ PlatformDirs.WINDOWS_MACOS_APP_DIR_NAME
+ } else {
+ PlatformDirs.LINUX_APP_DIR_NAME
+ }
+
+ val homeDir = File(System.getProperty("user.home"))
+
+ val windowsRoamingDir =
+ System
+ .getenv("APPDATA")
+ ?.trim()
+ ?.takeIf { it.isNotEmpty() }
+ ?.let(::File)
+ ?: homeDir.resolve("AppData/Roaming")
+
+ val windowsLocalDir =
+ System
+ .getenv("LOCALAPPDATA")
+ ?.trim()
+ ?.takeIf { it.isNotEmpty() }
+ ?.let(::File)
+ ?: homeDir.resolve("AppData/Local")
+
+ val xdgConfigDir =
+ System
+ .getenv("XDG_CONFIG_HOME")
+ ?.trim()
+ ?.takeIf { it.isNotEmpty() }
+ ?.let(::File)
+ ?: homeDir.resolve(".config")
+
+ val xdgStateDir =
+ System
+ .getenv("XDG_STATE_HOME")
+ ?.trim()
+ ?.takeIf { it.isNotEmpty() }
+ ?.let(::File)
+ ?: homeDir.resolve(".local/state")
+
+ val configParentDir =
+ when {
+ isWindows -> windowsRoamingDir
+ isMac -> homeDir.resolve("Library/Application Support")
+ else -> xdgConfigDir
+ }
+
+ val logsParentDir =
+ when {
+ isWindows -> windowsLocalDir
+ isMac -> homeDir.resolve("Library/Logs")
+ else -> xdgStateDir
+ }
+
+ val legacyConfigDir = File(LEGACY_CONFIG_DIR_NAME)
+
+ val legacyLogsDir = File(LOGS_DIR_NAME)
+
+ val platformConfigRootDir = configParentDir.resolve(preferredAppDirName)
+
+ val platformLogsRootDir = logsParentDir.resolve(preferredAppDirName)
+
+ val legacyNamedPlatformConfigRootDir = configParentDir.resolve(LEGACY_APP_DIR_NAME)
+
+ val legacyNamedPlatformLogsRootDir = logsParentDir.resolve(LEGACY_APP_DIR_NAME)
+
+ val platformSettingsDir = platformConfigRootDir.resolve(SETTINGS_DIR_NAME)
+
+ val platformLogsDir = platformLogsRootDir.resolve(LOGS_DIR_NAME)
+
+ val settingsDir = if (isPortableWindows) legacyConfigDir else platformSettingsDir
+
+ val firstRunConfigDir = if (isPortableWindows) legacyConfigDir else platformConfigRootDir
+
+ val logsDir = if (isPortableWindows) legacyLogsDir else platformLogsDir
+}
diff --git a/settings/src/main/kotlin/com/spoiligaming/explorer/settings/util/LegacyMultiplayerSettingsReader.kt b/settings/src/main/kotlin/com/spoiligaming/explorer/settings/util/LegacyMultiplayerSettingsReader.kt
index c8aa64b..2d90cb5 100644
--- a/settings/src/main/kotlin/com/spoiligaming/explorer/settings/util/LegacyMultiplayerSettingsReader.kt
+++ b/settings/src/main/kotlin/com/spoiligaming/explorer/settings/util/LegacyMultiplayerSettingsReader.kt
@@ -1,6 +1,6 @@
/*
* This file is part of Server List Explorer.
- * Copyright (C) 2025 SpoilerRules
+ * Copyright (C) 2025-2026 SpoilerRules
*
* Server List Explorer is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -33,7 +33,7 @@ object LegacyMultiplayerSettingsReader {
}
fun readServerListFile(): Path? {
- val file = SettingsStorage.settingsDir.resolve(SETTINGS_FILE_NAME)
+ val file = AppStoragePaths.settingsDir.resolve(SETTINGS_FILE_NAME)
if (!file.exists() || file.length() == 0L) return null
return runCatching {
diff --git a/settings/src/main/kotlin/com/spoiligaming/explorer/settings/util/SettingsFile.kt b/settings/src/main/kotlin/com/spoiligaming/explorer/settings/util/SettingsFile.kt
index 7841b55..e138eea 100644
--- a/settings/src/main/kotlin/com/spoiligaming/explorer/settings/util/SettingsFile.kt
+++ b/settings/src/main/kotlin/com/spoiligaming/explorer/settings/util/SettingsFile.kt
@@ -26,7 +26,7 @@ import kotlinx.serialization.json.Json
import java.io.File
internal class SettingsFile(
- private val fileName: String,
+ fileName: String,
private val serializer: KSerializer,
private val defaultValueProvider: () -> T,
) {
@@ -37,21 +37,20 @@ internal class SettingsFile(
ignoreUnknownKeys = true
}
- private val settingsFile
- get() = SettingsStorage.settingsDir.resolve(fileName)
+ private val settingsDir = AppStoragePaths.settingsDir
+ private val settingsFile = settingsDir.resolve(fileName)
val lastModifiedMillis
get() = settingsFile.takeIf { it.exists() }?.lastModified()
suspend fun read() =
withContext(Dispatchers.IO) {
- val dir = SettingsStorage.settingsDir
val file = settingsFile
logger.debug { "Attempting to read settings from: ${file.absolutePath}" }
if (!file.exists()) {
- ensureDirExists(dir)
+ ensureDirExists(settingsDir)
val defaultObj = defaultValueProvider()
file.writeText(json.encodeToString(serializer, defaultObj))
@@ -78,10 +77,9 @@ internal class SettingsFile(
data: T,
onComplete: (() -> Unit)? = null,
) = withContext(Dispatchers.IO) {
- val dir = SettingsStorage.settingsDir
val file = settingsFile
- ensureDirExists(dir)
+ ensureDirExists(settingsDir)
val serialized = json.encodeToString(serializer, data)
file.writeText(serialized)
diff --git a/settings/src/main/kotlin/com/spoiligaming/explorer/settings/util/SettingsStorage.kt b/settings/src/main/kotlin/com/spoiligaming/explorer/settings/util/SettingsStorage.kt
deleted file mode 100644
index df2eebd..0000000
--- a/settings/src/main/kotlin/com/spoiligaming/explorer/settings/util/SettingsStorage.kt
+++ /dev/null
@@ -1,157 +0,0 @@
-/*
- * This file is part of Server List Explorer.
- * Copyright (C) 2025 SpoilerRules
- *
- * Server List Explorer is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * Server List Explorer is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with Server List Explorer. If not, see .
- */
-
-package com.spoiligaming.explorer.settings.util
-
-import com.spoiligaming.explorer.build.BuildConfig
-import io.github.oshai.kotlinlogging.KotlinLogging
-import java.io.File
-import java.nio.file.Files
-import java.nio.file.Path
-import java.nio.file.StandardCopyOption
-import java.util.concurrent.atomic.AtomicBoolean
-
-object SettingsStorage {
- private const val APP_DIR = "ServerListExplorer"
-
- private val migrated = AtomicBoolean(false)
-
- private val isPortableWindows
- get() = isWindows && BuildConfig.DISTRIBUTION.contains("portable", ignoreCase = true)
-
- private val osName
- get() = System.getProperty("os.name")?.lowercase().orEmpty()
-
- private val isWindows
- get() = osName.contains("win")
-
- private val isMac
- get() = osName.contains("mac")
-
- internal val settingsDir
- get() =
- if (isPortableWindows) {
- legacyDir
- } else {
- migrateLegacyIfNeeded()
- platformConfigDir
- }
-
- private val legacyDir
- get() = File("config")
-
- val platformConfigDir
- get() =
- when {
- isWindows -> windowsDir()
- isMac -> macDir()
- else -> linuxDir()
- }
-
- private fun windowsDir(): File {
- val appData = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
- val base =
- appData?.let { File(it, APP_DIR) }
- ?: File(System.getProperty("user.home"), "AppData/Roaming/$APP_DIR")
- return base.resolve("config")
- }
-
- private fun macDir() = File(System.getProperty("user.home"), "Library/Application Support/$APP_DIR/config")
-
- private fun linuxDir(): File {
- val xdg = System.getenv("XDG_CONFIG_HOME")?.takeIf { it.isNotBlank() }
- val base =
- xdg?.let { File(it, APP_DIR) }
- ?: File(System.getProperty("user.home"), ".config/$APP_DIR")
- return base.resolve("config")
- }
-
- private fun migrateLegacyIfNeeded() {
- if (!migrated.compareAndSet(false, true)) return
-
- val legacy = legacyDir
- if (!legacy.exists() || !legacy.isDirectory) {
- logger.debug { "No legacy settings directory found at ${legacy.absolutePath}" }
- return
- }
-
- val files =
- legacy
- .listFiles()
- ?.asSequence()
- ?.filter { it.isFile }
- ?.filter { it.extension.equals("json", ignoreCase = true) }
- ?.toList()
- .orEmpty()
-
- if (files.isEmpty()) {
- logger.info { "Legacy settings directory is empty at ${legacy.absolutePath}" }
- return
- }
-
- val target = platformConfigDir
- runCatching { Files.createDirectories(target.toPath()) }
- .onFailure { e ->
- logger.error(e) { "Could not create new settings directory at ${target.absolutePath}" }
- return
- }
-
- var movedAny = false
-
- files.forEach { src ->
- if (src.length() == 0L) {
- logger.warn { "Skipping empty legacy settings file: ${src.absolutePath}" }
- return@forEach
- }
-
- val dest = target.resolve(src.name)
-
- if (dest.exists() && dest.length() > 0L) {
- logger.debug { "New settings file already exists, skipping legacy: ${dest.absolutePath}" }
- return@forEach
- }
-
- movedAny = moveOrCopyFile(src.toPath(), dest.toPath()) || movedAny
- }
-
- if (movedAny) {
- logger.info {
- "Migrated settings from ${legacy.absolutePath} to ${target.absolutePath}"
- }
- } else {
- logger.debug { "No legacy settings files required migration." }
- }
- }
-
- private fun moveOrCopyFile(
- src: Path,
- dest: Path,
- ) = runCatching {
- Files.createDirectories(dest.parent)
- Files.move(src, dest, StandardCopyOption.REPLACE_EXISTING)
- true
- }.recoverCatching {
- Files.copy(src, dest, StandardCopyOption.REPLACE_EXISTING)
- Files.deleteIfExists(src)
- true
- }.onFailure { e ->
- logger.error(e) { "Failed to migrate settings file from $src to $dest" }
- }.getOrDefault(false)
-}
-
-private val logger = KotlinLogging.logger {}