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 {}